Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -5697,6 +5697,16 @@ func (q *querier) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]datab
return q.db.GetWorkspacesForWorkspaceMetrics(ctx)
}

func (q *querier) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Context, arg database.HasTemplateVersionsUsingCachedModuleFileInOrgParams) (bool, error) {
// This query authorizes provisioner module-file downloads. The caller
// must be able to read files in the target organization; the actual
// tenant isolation comes from the organization_id filter in the query.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFile.InOrg(arg.OrganizationID)); err != nil {
return false, err
}
return q.db.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, arg)
}

func (q *querier) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
// System-level operation: an agent context push fans hydration out
// across every not-yet-pinned chat for the agent, so it authorizes at
Expand Down
5 changes: 5 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2674,6 +2674,11 @@ func (s *MethodTestSuite) TestTemplate() {
dbm.EXPECT().GetTemplateVersionTerraformValues(gomock.Any(), tv.ID).Return(val, nil).AnyTimes()
check.Args(tv.ID).Asserts(t, policy.ActionRead)
}))
s.Run("HasTemplateVersionsUsingCachedModuleFileInOrg", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.HasTemplateVersionsUsingCachedModuleFileInOrgParams{FileID: uuid.New(), OrganizationID: uuid.New()}
dbm.EXPECT().HasTemplateVersionsUsingCachedModuleFileInOrg(gomock.Any(), arg).Return(true, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceFile.InOrg(arg.OrganizationID), policy.ActionRead).Returns(true)
}))
s.Run("GetTemplateVersionVariables", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
t1 := testutil.Fake(s.T(), faker, database.Template{})
tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}})
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions coderd/database/queries/templateversionterraformvalues.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ VALUES
@updated_at,
@provisionerd_version
);

-- name: HasTemplateVersionsUsingCachedModuleFileInOrg :one
-- Reports whether the given file is referenced as cached module files by any
-- template version in the given organization. Used to authorize provisioner
-- module-file downloads so a daemon cannot read another organization's cached
-- Terraform module source.
SELECT EXISTS (
SELECT 1
FROM template_version_terraform_values tvtv
JOIN template_versions tv
ON tv.id = tvtv.template_version_id
WHERE tvtv.cached_module_files = @file_id::uuid
Comment thread
sreya marked this conversation as resolved.
AND tv.organization_id = @organization_id::uuid
);
20 changes: 20 additions & 0 deletions coderd/provisionerdserver/provisionerdserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,26 @@ func (s *server) DownloadFile(request *proto.FileRequest, stream proto.DRPCProvi
if file.CreatedBy != uuid.Nil || file.Mimetype != tarMimeType {
return fail(xerrors.Errorf("file %s is not a modules file", fid))
}
// Ensure the requested module file belongs to a template version in
// this provisioner daemon's organization. Without this, any
// authenticated provisioner could download cached module archives
// (Terraform source) belonging to other organizations (ANT-2026-22440).
ok, err := s.Database.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, database.HasTemplateVersionsUsingCachedModuleFileInOrgParams{
FileID: fid,
OrganizationID: s.OrganizationID,
})
if err != nil {
return fail(xerrors.Errorf("authorize module file: %w", err))
}
if !ok {
s.Logger.Warn(ctx, "module file download rejected: file not referenced by any template version in daemon org",
slog.F("file_id", fid),
slog.F("organization_id", s.OrganizationID),
)
// Use the same error as the metadata check above so the handler
// does not confirm the existence of files in other organizations.
return fail(xerrors.Errorf("file %s is not a modules file", fid))
Comment thread
sreya marked this conversation as resolved.
}
default:
return fail(xerrors.Errorf("unsupported file upload type: %s", request.UploadType))
}
Expand Down
142 changes: 142 additions & 0 deletions coderd/provisionerdserver/provisionerdserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package provisionerdserver_test

import (
"context"
crand "crypto/rand"
"database/sql"
"encoding/json"
"io"
Expand All @@ -25,6 +26,8 @@ import (
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/timestamppb"
"storj.io/drpc"
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"

"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
Expand Down Expand Up @@ -52,6 +55,7 @@ import (
"github.com/coder/coder/v2/coderd/usage/usagetypes"
"github.com/coder/coder/v2/coderd/wspubsub"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/drpcsdk"
"github.com/coder/coder/v2/provisionerd/proto"
"github.com/coder/coder/v2/provisionersdk"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
Expand Down Expand Up @@ -5420,3 +5424,141 @@ func newFakeUsageInserter() (*coderdtest.UsageInserter, *atomic.Pointer[usage.In
poitr.Store(&inserter)
return fake, poitr
}

// serveProvisionerDaemon serves the provisioner daemon server over an
// in-memory pipe and returns a connected client, mirroring how coderd serves
// in-memory provisioner daemons. This exercises the real DRPC streaming path
// instead of a hand-rolled mock stream.
func serveProvisionerDaemon(t *testing.T, srv proto.DRPCProvisionerDaemonServer) proto.DRPCProvisionerDaemonClient {
t.Helper()
clientPipe, serverPipe := drpcsdk.MemTransportPipe()
t.Cleanup(func() {
_ = clientPipe.Close()
_ = serverPipe.Close()
})
mux := drpcmux.New()
require.NoError(t, proto.DRPCRegisterProvisionerDaemon(mux, srv))
server := drpcserver.NewWithOptions(mux, drpcserver.Options{
Manager: drpcsdk.DefaultDRPCOptions(nil),
})
ctx, cancel := context.WithCancel(context.Background())
closed := make(chan struct{})
go func() {
defer close(closed)
_ = server.Serve(ctx, serverPipe)
}()
t.Cleanup(func() {
cancel()
<-closed
})
return proto.NewDRPCProvisionerDaemonClient(clientPipe)
}

// insertModuleFile inserts a system-created (CreatedBy=uuid.Nil) tar file and
// links it as the cached module files of a template version in the given
// organization, returning the file.
func insertModuleFile(t *testing.T, db database.Store, orgID uuid.UUID, data []byte) database.File {
t.Helper()
ctx := testutil.Context(t, testutil.WaitShort)

user := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
OrganizationID: orgID,
CreatedBy: user.ID,
})
jobID := uuid.New()
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: orgID,
CreatedBy: user.ID,
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
JobID: jobID,
})
// Insert the file directly rather than via dbgen.File: the helper treats a
// zero CreatedBy as "unset" and replaces it with a random UUID, but module
// files must be system-created (CreatedBy=uuid.Nil) to match the handler's
// metadata check.
file, err := db.InsertFile(ctx, database.InsertFileParams{
ID: uuid.New(),
Hash: uuid.NewString(),
CreatedAt: dbtime.Now(),
CreatedBy: uuid.Nil,
Mimetype: "application/x-tar",
Data: data,
})
require.NoError(t, err)
err = db.InsertTemplateVersionTerraformValuesByJobID(ctx, database.InsertTemplateVersionTerraformValuesByJobIDParams{
JobID: version.JobID,
CachedPlan: []byte("{}"),
CachedModuleFiles: uuid.NullUUID{UUID: file.ID, Valid: true},
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
return file
}

// TestDownloadFile verifies that a provisioner daemon cannot download cached
// module archives belonging to other organizations (ANT-2026-22440), while
// still being able to download module files from its own organization.
func TestDownloadFile(t *testing.T) {
t.Parallel()

t.Run("RejectsOtherOrgModuleFile", func(t *testing.T) {
t.Parallel()

// The server is scoped to the default organization (org A).
srv, db, _, daemon := setup(t, false, &overrides{
externalAuthConfigs: []*externalauth.Config{{}},
})
ctx := testutil.Context(t, testutil.WaitMedium)
client := serveProvisionerDaemon(t, srv)

// Create a module file belonging to a different organization (org B).
otherOrg := dbgen.Organization(t, db, database.Organization{})
require.NotEqual(t, daemon.OrganizationID, otherOrg.ID)

moduleData := make([]byte, sdkproto.ChunkSize*2)
// crand.Read never returns an error as of Go 1.24.
_, _ = crand.Read(moduleData)
file := insertModuleFile(t, db, otherOrg.ID, moduleData)

stream, err := client.DownloadFile(ctx, &proto.FileRequest{
FileId: file.ID.String(),
UploadType: sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES,
})
require.NoError(t, err)

// The handler must reject the cross-org download with an error rather
// than streaming the file's contents.
_, err = provisionersdk.HandleReceivingDataUpload(stream)
require.Error(t, err)
require.ErrorContains(t, err, "is not a modules file")
})

t.Run("AllowsSameOrgModuleFile", func(t *testing.T) {
t.Parallel()

// The server is scoped to the default organization (org A).
srv, db, _, daemon := setup(t, false, &overrides{
externalAuthConfigs: []*externalauth.Config{{}},
})
ctx := testutil.Context(t, testutil.WaitMedium)
client := serveProvisionerDaemon(t, srv)

moduleData := make([]byte, sdkproto.ChunkSize*2+512)
// crand.Read never returns an error as of Go 1.24.
_, _ = crand.Read(moduleData)
file := insertModuleFile(t, db, daemon.OrganizationID, moduleData)

stream, err := client.DownloadFile(ctx, &proto.FileRequest{
FileId: file.ID.String(),
UploadType: sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES,
})
require.NoError(t, err)

builder, err := provisionersdk.HandleReceivingDataUpload(stream)
require.NoError(t, err)
data, err := builder.Complete()
require.NoError(t, err)
require.Equal(t, moduleData, data)
})
}
Loading