From 30f8b646a2a1e4a0b5a072cfe591510080c66883 Mon Sep 17 00:00:00 2001 From: Rowan Smith Date: Tue, 30 Jun 2026 02:29:11 +1000 Subject: [PATCH 1/7] fix(coderd): enforce required external auth on workspace create (#26314) (#26791) backport of #26314 for the 2.33 branch. Required external auth (`optional = false`) was only enforced by client-side preflight checks, so creating a workspace via the REST API succeeded even when the owner had never authenticated, producing a broken workspace. `createWorkspace` now validates the workspace owner's external auth server-side and returns 403 before any row is inserted or prebuild is claimed. The owner (not the initiator) is checked because build-time token injection uses their links, so this also covers admin-on-behalf-of creates and prebuild claims. Use `optional = true` to allow pre-provisioning for unauthenticated users. Fixes PLAT-241. > This PR was generated by Coder Agents on behalf of @dylanhuff-at-coder. Co-authored-by: dylanhuff-at-coder --- coderd/templateversions.go | 35 +++-- coderd/workspacebuilds_test.go | 7 +- coderd/workspaces.go | 66 +++++++++ coderd/workspaces_test.go | 241 +++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 14 deletions(-) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 6490165782179..449ab92cfe9bf 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -31,6 +31,7 @@ import ( "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" @@ -337,14 +338,28 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ templateVersion = httpmw.TemplateVersionParam(r) ) + providers, err := api.templateVersionExternalAuthForUser(ctx, templateVersion, apiKey.UserID) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, providers) +} + +// templateVersionExternalAuthForUser returns the external auth providers +// referenced by the template version, with Authenticated reporting whether +// the given user has a usable token for each provider. Failures are returned +// as httperror response errors suitable for writing directly to an API +// response. +func (api *API) templateVersionExternalAuthForUser(ctx context.Context, templateVersion database.TemplateVersion, userID uuid.UUID) ([]codersdk.TemplateVersionExternalAuth, error) { var rawProviders []database.ExternalAuthProvider err := json.Unmarshal(templateVersion.ExternalAuthProviders, &rawProviders) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading auth config from database", Detail: err.Error(), }) - return } providers := make([]codersdk.TemplateVersionExternalAuth, 0) @@ -357,21 +372,19 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ } } if config == nil { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{ Message: fmt.Sprintf("The template version references a Git auth provider %q that no longer exists.", rawProvider.ID), Detail: "You'll need to update the template version to use a different provider.", }) - return } // This is the URL that will redirect the user with a state token. redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID)) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse access URL.", Detail: err.Error(), }) - return } provider := codersdk.TemplateVersionExternalAuth{ @@ -385,7 +398,7 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ ProviderID: config.ID, - UserID: apiKey.UserID, + UserID: userID, }) // If there isn't an auth link, then the user just isn't authenticated. if errors.Is(err, sql.ErrNoRows) { @@ -393,27 +406,25 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ continue } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching external auth link.", Detail: err.Error(), }) - return } _, err = config.RefreshToken(ctx, api.Database, authLink) if err != nil && !externalauth.IsInvalidTokenError(err) { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to refresh external auth token.", Detail: err.Error(), }) - return } provider.Authenticated = err == nil providers = append(providers, provider) } - httpapi.Write(ctx, rw, http.StatusOK, providers) + return providers, nil } // @Summary Get template variables by template version diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 800076eaffc12..96a70a43dae49 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1334,7 +1334,10 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - require.Equal(t, 1, validateCalls) // Ensure the external link is working + // Ensure the external link is working. Workspace creation validates the + // owner's required external auth, and the build's token injection + // validates it again. + require.Equal(t, 2, validateCalls) // Suspend the user ctx := testutil.Context(t, testutil.WaitLong) @@ -1348,7 +1351,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { }) require.NoError(t, err) build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, build.ID) - require.Equal(t, 2, validateCalls) + require.Equal(t, 3, validateCalls) require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 961db5b6ced16..66513c41a86d0 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -9,6 +9,7 @@ import ( "net/http" "slices" "strconv" + "strings" "time" "github.com/dustin/go-humanize" @@ -595,6 +596,27 @@ func createWorkspace( }) } + // Required external auth is otherwise only enforced by client-side preflight + // checks in the CLI and UI, so API-created workspaces must be validated here + // before any workspace row is inserted or prebuilt workspace is claimed. + templateVersionID := req.TemplateVersionID + if templateVersionID == uuid.Nil { + templateVersionID = template.ActiveVersionID + } + templateVersion, err := api.Database.GetTemplateVersionByID(ctx, templateVersionID) + if err != nil { + if httpapi.Is404Error(err) { + return codersdk.Workspace{}, httperror.ErrResourceNotFound + } + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template version.", + Detail: err.Error(), + }) + } + if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil { + return codersdk.Workspace{}, err + } + dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule) if err != nil { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ @@ -892,6 +914,50 @@ func createWorkspace( return w, nil } +// requireWorkspaceOwnerExternalAuth returns a 403 response error when the +// workspace owner has not authenticated with every required (non-optional) +// external auth provider referenced by the template version. Token injection +// at build time uses the owner's external auth links, so the owner is the +// subject of the check even when another user initiates the build. +func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error { + //nolint:gocritic // System access is required to validate the workspace owner's external auth links because admins and API clients may create workspaces for other users. + providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsSystemRestricted(ctx), templateVersion, ownerID) + if err != nil { + return err + } + + var ( + missingNames []string + validations []codersdk.ValidationError + ) + for _, provider := range providers { + if provider.Optional || provider.Authenticated { + continue + } + name := provider.DisplayName + if name == "" { + name = provider.ID + } + missingNames = append(missingNames, name) + validations = append(validations, codersdk.ValidationError{ + Field: "external_auth", + Detail: provider.ID, + }) + } + if len(missingNames) == 0 { + return nil + } + + return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ + Message: "External authentication is required to create a workspace with this template.", + Detail: fmt.Sprintf( + "The workspace owner must authenticate with the following external auth providers: %s.", + strings.Join(missingNames, ", "), + ), + Validations: validations, + }) +} + func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) { // If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it. templateID := req.TemplateID diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 7f6b2559d232d..175a61da83cb1 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -8,6 +8,8 @@ import ( "fmt" "math" "net/http" + "net/http/httptest" + "regexp" "slices" "strings" "testing" @@ -31,6 +33,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -1466,6 +1469,244 @@ func TestPostWorkspacesByOrganization(t *testing.T) { }) } +func TestCreateWorkspaceExternalAuth(t *testing.T) { + t.Parallel() + + // The expected 403 message returned by createWorkspace when the workspace + // owner is missing required external auth. + const externalAuthRequiredMessage = "External authentication is required to create a workspace with this template." + + // externalAuthVersion returns echo responses for a template version whose + // graph references the given external auth providers. + externalAuthVersion := func(providers ...*proto.ExternalAuthProviderResource) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: providers, + }, + }, + }}, + } + } + + t.Run("RequiredAuthMissing", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + } + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Equal(t, "The workspace owner must authenticate with the following external auth providers: GitHub.", apiErr.Detail) + require.Equal(t, []codersdk.ValidationError{{ + Field: "external_auth", + Detail: "github", + }}, apiErr.Validations) + + // The rejection must happen before any workspace row is inserted. + _, err = memberClient.WorkspaceByOwnerAndName(ctx, codersdk.Me, req.Name, codersdk.WorkspaceOptions{}) + apiErr = nil + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + + // Authenticating with the provider lifts the rejection. + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("OwnerVsInitiator", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // The initiating admin is authenticated with the provider, but the + // workspace owner (the member) is not. Token injection at build time + // uses the owner's links, so the owner's auth state is what matters. + resp := coderdtest.RequestExternalAuthCallback(t, "github", client) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + } + _, err := client.CreateUserWorkspace(ctx, member.Username, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + + // Once the owner authenticates, the same create succeeds. + resp = coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + workspace, err := client.CreateUserWorkspace(ctx, member.Username, req) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("OptionalProvider", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github", Optional: true})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Optional providers must not block creation even when the owner has + // never authenticated with them. + workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("InvalidToken", func(t *testing.T) { + t.Parallel() + // The validation endpoint always reports the token as revoked. The + // external auth callback stores the link without validating it, so the + // link row exists but RefreshToken classifies it as invalid. + validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(validateSrv.Close) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + ValidateURL: validateSrv.URL, + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + // Create the external auth link for the owner. + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + ctx := testutil.Context(t, testutil.WaitLong) + + // A link that fails validation counts as unauthenticated. + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Equal(t, []codersdk.ValidationError{{ + Field: "external_auth", + Detail: "github", + }}, apiErr.Validations) + }) + + t.Run("DisplayNameFallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "fallback-provider", + Regex: regexp.MustCompile(`fallback\.example\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "fallback-provider"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Without a DisplayName, the response falls back to the provider ID. + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Contains(t, apiErr.Detail, "fallback-provider") + require.Len(t, apiErr.Validations, 1) + require.Equal(t, "external_auth", apiErr.Validations[0].Field) + require.Equal(t, "fallback-provider", apiErr.Validations[0].Detail) + }) +} + func TestWorkspaceByOwnerAndName(t *testing.T) { t.Parallel() t.Run("NotFound", func(t *testing.T) { From 0cfa936944054f5813a6ea52a6f8110b5a6e9788 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:30:58 -0400 Subject: [PATCH 2/7] feat: add INSECURE oidc email fallback flag for IdP brokers (#26751) (#26819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26751 Original PR: #26751 — feat: add INSECURE oidc email fallback flag for IdP brokers Merge commit: ad355aeaa9a10d44c8b910236d6e293e321e9d2f Requested by: @uzair-coder07 > [!WARNING] > The automatic cherry-pick had conflicts. > Please resolve manually by cherry-picking the original merge commit: > > ``` > git fetch origin backport/26751-to-2.33 > git checkout backport/26751-to-2.33 > git reset --hard origin/release/2.33 > git cherry-pick -x -m1 ad355aeaa9a10d44c8b910236d6e293e321e9d2f > # resolve conflicts, then push > ``` --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Steven Masley --- cli/server.go | 1 + cli/testdata/server-config.yaml.golden | 7 + coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/userauth.go | 40 +++++- coderd/userauth_test.go | 170 +++++++++++++++++++++++++ codersdk/deployment.go | 21 +++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 4 + site/src/api/typesGenerated.ts | 8 ++ 10 files changed, 254 insertions(+), 6 deletions(-) diff --git a/cli/server.go b/cli/server.go index 136d67fac04c8..9a94e94134dd0 100644 --- a/cli/server.go +++ b/cli/server.go @@ -278,6 +278,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De IconURL: vals.OIDC.IconURL.String(), IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), PKCEMethods: pkceSupport.CodeChallengeMethodsSupported, + EmailFallback: vals.OIDC.EmailFallback.Value(), }, nil } diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 3f70ce8c274ef..392195beaf9f2 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -434,6 +434,13 @@ oidc: # next login. # (default: true, type: bool) oidc-repair-links: true + # INSECURE: Allow OIDC logins to fall back to email-based matching when the + # linked_id (issuer+subject) does not match an existing user link. Required for + # IdP brokers that do not issue a stable 'sub' for the same user across + # connections. The existing user_link's linked_id is preserved on fallback. Only + # enable if you understand and accept the risk. + # (default: , type: bool) + dangerousOidcEmailFallback: false # Telemetry is critical to our ability to improve Coder. We strip all personal # information before sending data to our servers. Please only disable telemetry # when required by your organization's security policy. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8aa530b460ae6..80cf508c9d839 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19637,6 +19637,10 @@ const docTemplate = `{ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the ` + "`" + `linked_id` + "`" + ` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable ` + "`" + `sub` + "`" + ` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 73151ef1c0909..23311f50d0622 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17930,6 +17930,10 @@ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the `linked_id` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable `sub` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, diff --git a/coderd/userauth.go b/coderd/userauth.go index ec1907af479d8..bd96bafdf96f7 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1037,7 +1037,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { }) return } - user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail()) + user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, false, verifiedEmail.GetEmail()) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", verifiedEmail.GetEmail()), @@ -1187,6 +1187,12 @@ type OIDCConfig struct { // SignupsDisabledText is the text do display on the static error page. SignupsDisabledText string PKCEMethods []promoauth.Oauth2PKCEChallengeMethod + // EmailFallback, when true, allows OIDC logins to fall back to + // email-based user matching when the linked_id (issuer+subject) does + // not match an existing user link. INSECURE: weakens the linked_id + // check. Used for IdP brokers that do not issue a stable `sub` for the + // same user across connections. + EmailFallback bool } // PKCESupported is to prevent nil pointer dereference. @@ -1458,7 +1464,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name)) - user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email) + user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, api.OIDCConfig.EmailFallback, email) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", email), @@ -1526,6 +1532,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { UserInfoClaims: supplementaryClaims, MergedClaims: mergedClaims, }, + AllowInsecureLinkedIDMismatch: api.OIDCConfig.EmailFallback, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) }) @@ -1679,6 +1686,13 @@ type oauthLoginParams struct { // It is used to save the user's claims on login. UserClaims database.UserLinkClaims + // AllowInsecureLinkedIDMismatch, when true, allows the login to proceed + // when the existing user_link's linked_id differs from LinkedID. The + // existing linked_id is preserved (no overwrite). INSECURE: opt-in + // escape hatch for IdP brokers that emit different subjects for the + // same user across connections. + AllowInsecureLinkedIDMismatch bool + commitLock sync.Mutex initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User] commits []func() @@ -1911,7 +1925,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Defense-in-depth: if a concurrent transaction backfilled // linked_id between findLinkedUser and this point, reject the // login with a 403 instead of letting it bubble up as a 500. - if link.LinkedID != "" && link.LinkedID != params.LinkedID { + // The INSECURE AllowInsecureLinkedIDMismatch escape hatch + // preserves the existing linked_id and lets the login proceed; + // the warning was already emitted by the caller. + if link.LinkedID != "" && link.LinkedID != params.LinkedID && !params.AllowInsecureLinkedIDMismatch { return &idpsync.HTTPError{ Code: http.StatusForbidden, Msg: "Account already linked", @@ -2162,7 +2179,16 @@ var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a d // legacy links (empty linked_id) only. If the user found by email // already has a link with a different linked_id, errLinkedIDAlreadyBound // is returned to prevent account takeover via IdP email reuse. -func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) { +// +// When allowInsecureLinkedIDMismatch is true, the linked_id mismatch +// check is skipped and the email fallback resolves the login even when +// the existing link's linked_id differs from the current login's. The +// existing linked_id is left intact (no overwrite). This is an INSECURE +// opt-in for IdP brokers that do not issue a stable `sub` for the same +// user across connections. +// +//nolint:revive // allowInsecureLinkedIDMismatch is intentionally a control flag; it gates an INSECURE opt-in. +func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, allowInsecureLinkedIDMismatch bool, emails ...string) (database.User, database.UserLink, error) { var ( user database.User link database.UserLink @@ -2215,8 +2241,10 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, log // Block email fallback when an existing link has a different linked_id. // Prevents account takeover via IdP email reuse; first-time and legacy - // (empty linked_id) links pass through. - if err == nil && link.LinkedID != "" && link.LinkedID != linkedID { + // (empty linked_id) links pass through. The INSECURE + // allowInsecureLinkedIDMismatch escape hatch keeps the existing link + // (and its original linked_id) and lets the login proceed. + if err == nil && link.LinkedID != "" && link.LinkedID != linkedID && !allowInsecureLinkedIDMismatch { return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound } diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index d9f5dabf49e4f..3833dc6423323 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -2002,6 +2002,176 @@ func TestUserOIDC(t *testing.T) { "linked_id must not be modified when the login is blocked") }) + // Tests the INSECURE OIDC email fallback escape hatch. When the + // deployment flag is set, an OIDC login whose subject differs from an + // existing user_link's linked_id but whose email matches must be + // allowed through. The original linked_id is preserved (no overwrite), + // so the user can keep logging in with either subject. + t.Run("OIDCInsecureEmailFallbackAllowed", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed a user whose link records the IdP's first connection. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + originalLinkedID := fake.IssuerURL().String() + "||" + "first-connection-sub" + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Login with a different subject (the broker emitted a new `sub` + // for the same user) but the same email. With EmailFallback + // enabled the email match resolves the login despite the linked_id + // mismatch. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode, + "insecure email fallback must allow login with a mismatched subject") + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID, + "should authenticate as the existing user") + + // The original linked_id must be preserved, not overwritten by the + // new subject. This keeps the next login from the original subject + // (which hits the primary linked_id path) working. + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must be preserved on insecure email fallback") + }) + + // Tests that with the INSECURE OIDC email fallback enabled, the + // original subject's login still resolves via the primary linked_id + // path after a fallback login from a different subject. The fallback + // path does not overwrite the link, so the original subject keeps + // matching directly. + t.Run("OIDCInsecureEmailFallbackPreservesOriginalLogin", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + const originalSub = "first-connection-sub" + originalLinkedID := fake.IssuerURL().String() + "||" + originalSub + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Fallback login with a different subject. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Subsequent login with the original subject must hit the primary + // linked_id match and succeed. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": originalSub, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID) + + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must stay anchored to the original subject") + }) + + // Tests that the INSECURE OIDC email fallback does NOT extend to + // signups: an attacker logging in with a brand-new email (no existing + // user) still goes through the normal signup gate. The escape hatch is + // only about resolving subject-mismatch on existing accounts. + t.Run("OIDCInsecureEmailFallbackDoesNotCreateUsers", func(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = false + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed an existing user so the deployment's user count is > 0; + // otherwise the first signup is always allowed. + dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + + // New email, no existing user. Signups are disabled, so the + // fallback flag must not let the login through. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": "stranger@example.com", + "sub": "stranger-subject", + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "insecure email fallback must not bypass the signup gate") + }) + t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 894928ecefb0e..bdbe9f8fceee3 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -927,6 +927,13 @@ type OIDCConfig struct { RedirectURL serpent.URL `json:"redirect_url" typescript:",notnull"` AutoRepairLinks serpent.Bool `json:"auto_repair_links" typescript:",notnull"` + + // EmailFallback allows OIDC logins to fall back to email-based matching + // when the `linked_id` (issuer+subject) does not match an existing user + // link. INSECURE: weakens the linked_id check. It exists for IdP + // brokers that do not issue a stable `sub` for the same user across + // connections. + EmailFallback serpent.Bool `json:"email_fallback" typescript:",notnull"` } type TelemetryConfig struct { @@ -2592,6 +2599,20 @@ func (c *DeploymentValues) Options() serpent.OptionSet { // as a flag as an escape hatch for now. Hidden: true, }, + { + Name: "OIDC Insecure Email Fallback (DANGEROUS)", + Description: "INSECURE: Allow OIDC logins to fall back to email-based matching when " + + "the linked_id (issuer+subject) does not match an existing user link. " + + "Required for IdP brokers that do not issue a stable 'sub' for the same user across connections. " + + "The existing user_link's linked_id is preserved on fallback. " + + "Only enable if you understand and accept the risk.", + Flag: "dangerous-oidc-email-fallback", + Env: "CODER_DANGEROUS_OIDC_EMAIL_FALLBACK", + YAML: "dangerousOidcEmailFallback", + Value: &c.OIDC.EmailFallback, + Group: &deploymentGroupOIDC, + Hidden: true, + }, // Telemetry settings telemetryEnable, { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 2be5a290a47f3..06cc7a37cc21c 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -427,6 +427,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ec98281a354a9..33cf41b724f57 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5429,6 +5429,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -6020,6 +6021,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -8463,6 +8465,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -8532,6 +8535,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | | `client_secret` | string | false | | | | `email_domain` | array of string | false | | | +| `email_fallback` | boolean | false | | Email fallback allows OIDC logins to fall back to email-based matching when the `linked_id` (issuer+subject) does not match an existing user link. INSECURE: weakens the linked_id check. It exists for IdP brokers that do not issue a stable `sub` for the same user across connections. | | `email_field` | string | false | | | | `group_allow_list` | array of string | false | | | | `group_auto_create` | boolean | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0309d2b970b9c..19dce823b9f41 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5306,6 +5306,14 @@ export interface OIDCConfig { */ readonly redirect_url: string; readonly auto_repair_links: boolean; + /** + * EmailFallback allows OIDC logins to fall back to email-based matching + * when the `linked_id` (issuer+subject) does not match an existing user + * link. INSECURE: weakens the linked_id check. It exists for IdP + * brokers that do not issue a stable `sub` for the same user across + * connections. + */ + readonly email_fallback: boolean; } // From codersdk/parameters.go From fcf3f58cd8981e2f2b2ef609ef4387953dc3bbbf Mon Sep 17 00:00:00 2001 From: Denis Afonso Date: Wed, 1 Jul 2026 18:33:34 +0100 Subject: [PATCH 3/7] fix: correct gvisor replace directive to match module path (#26822) (#26923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26822 Original PR: #26822 — fix: correct gvisor replace directive to match module path Merge commit: f77d0065ed368df51a82172adf674b9b77d01ea8 Requested by: @denisra --- *Generated by [Coder Agents](https://coder.com/agents) on behalf of @denisra* --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3db52a9d9014d..561bb1caaf7ae 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ replace github.com/tailscale/wireguard-go => github.com/coder/wireguard-go v0.0. // We use a fork to fix an integer overflow issue that causes occasional crashes in workspace agents. // See https://github.com/coder/coder/issues/20885 -replace gvisor.dev => github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 +replace gvisor.dev/gvisor => github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 // Switch to our fork that imports fixes from http://github.com/tailscale/ssh. // See: https://github.com/coder/coder/issues/3371 diff --git a/go.sum b/go.sum index c5bca779a65c2..c292165653a5a 100644 --- a/go.sum +++ b/go.sum @@ -332,6 +332,8 @@ github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc= github.com/coder/guts v1.6.1 h1:bMVBtDNP/1gW58NFRBdzStAQzXlveMrLAnORpwE9tYo= github.com/coder/guts v1.6.1/go.mod h1:FaECwB632JE8nYi7nrKfO0PVjbOl4+hSWupKO2Z99JI= +github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 h1:j7tyq3rv0ZXkbyy/BE5K3lYiIqEsV8sDTiTjpLLxjiw= +github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= github.com/coder/paralleltestctx v0.0.1 h1:eauyehej1XYTGwgzGWMTjeRIVgOpU6XLPNVb2oi6kDs= github.com/coder/paralleltestctx v0.0.1/go.mod h1:q/wi6cmlBOhrJKjUtouTn4J9xZlRhK0MbgHvJNdGW3w= github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7R7n+wsCqNve7Brdvj0F1rDnU= @@ -1558,8 +1560,6 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc h1:DXLLFYv/k/xr0rWcwVEvWme1GR36Oc4kNMspg38JeiE= -gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= From 21d96c332dc169709ade577bffd85b463c64781b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:46:48 +0200 Subject: [PATCH 4/7] fix(site): redirect to new organization after create (#26890) (#26931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26890 Original PR: #26890 — fix(site): redirect to new organization after create Merge commit: 3e0875d236340612cf88bde5fb3d167fcbc080da Requested by: @aslilac Co-authored-by: McKayla はな --- .../CreateOrganizationPage.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx index 2da4aafba5bfd..51cdccf5ab5b9 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx @@ -27,14 +27,11 @@ const CreateOrganizationPage: FC = () => { error={error} isEntitled={feats.multiple_organizations} onSubmit={async (values) => { - await createOrganizationMutation.mutateAsync(values, { - onSuccess: () => { - toast.success( - `Organization "${values.name}" created successfully.`, - ); - navigate(`/organizations/${values.name}`); - }, - }); + await createOrganizationMutation.mutateAsync(values); + toast.success( + `Organization "${values.name}" created successfully.`, + ); + navigate(`/organizations/${values.name}`); }} /> From 37ef2e583ed2abb4a9d6f31320c8177cbe843e69 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 16 Jul 2026 00:05:21 -0700 Subject: [PATCH 5/7] fix: resolve client IP from the rightmost untrusted X-Forwarded-For entry (backport 2.33) (#27237) Backports #26646 to release/2.33 (Security Support). A client could spoof its X-Forwarded-For value by prepending a fake IP; `getRemoteAddress` took the leftmost comma-delimited token, so appending proxies (nginx, ALB, Cloudflare) never overrode the spoofed value. This fed `httpmw.RateLimit` (per-IP login throttling) and audit log `IPAddress` fields, enabling rate-limit bypass and audit falsification. This is the security fix tracked in PLAT-258 / coder/security-disclosures#9 (SEC-FC61DF2BF7). It already shipped in mainline (v2.35.0); this PR brings it to the Security Support line. Clean cherry-pick, no conflicts. `coderd/httpmw/realip_test.go` covers the spoofing scenario. Co-authored-by: Jon Ayers --- coderd/httpmw/realip.go | 59 ++++++++++++++++++++--- coderd/httpmw/realip_test.go | 91 ++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index f428e15fcf43a..b3e460b9e9d67 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -70,7 +70,17 @@ func ExtractRealIPAddress(config *RealIPConfig, req *http.Request) (net.IP, erro } for _, trustedHeader := range config.TrustedHeaders { - addr := getRemoteAddress(req.Header.Get(trustedHeader)) + // X-Forwarded-For is a list-valued header. Per RFC 7230, multiple + // field lines with the same name are equivalent to a single + // comma-separated value. Join them so a client cannot hide a spoofed + // address in the first field line, which Header.Get would return on + // its own. Other forwarding headers carry a single edge-proxy value, + // so use Header.Get to preserve their first-value semantics. + value := req.Header.Get(trustedHeader) + if http.CanonicalHeaderKey(trustedHeader) == headerXForwardedFor { + value = strings.Join(req.Header.Values(trustedHeader), ",") + } + addr := extractForwardedAddress(config, value) if addr != nil { return addr, nil } @@ -101,6 +111,14 @@ func FilterUntrustedOriginHeaders(config *RealIPConfig, req *http.Request) { } for _, header := range config.TrustedHeaders { + // X-Forwarded-For is a list-valued header whose field lines are + // equivalent to a single comma-separated value (RFC 7230 section + // 3.2.2). Join them so later hops are not dropped when collapsing to a + // single line. Other forwarding headers carry a single value. + if http.CanonicalHeaderKey(header) == headerXForwardedFor { + req.Header.Set(header, strings.Join(req.Header.Values(header), ",")) + continue + } req.Header.Set(header, req.Header.Get(header)) } } @@ -185,12 +203,15 @@ func EnsureXForwardedForHeader(req *http.Request) error { return nil } -// getRemoteAddress extracts the IP address from the given string. If -// the string contains commas, it assumes that the first part is the -// original address. +// getRemoteAddress extracts a single IP address from the given string, +// stripping a port if present. If the string contains commas, only the +// portion before the first comma is parsed. This helper does not select the +// real client from a multi-hop X-Forwarded-For chain; use +// extractForwardedAddress for that, which accounts for client-supplied values. func getRemoteAddress(address string) net.IP { - // X-Forwarded-For may contain multiple addresses, in case the - // proxies are chained; the first value is the client address + // A value may contain a port and, for a raw X-Forwarded-For value, more + // than one comma-separated address. Parse only the part before the first + // comma. i := strings.IndexByte(address, ',') if i == -1 { i = len(address) @@ -206,6 +227,32 @@ func getRemoteAddress(address string) net.IP { return net.ParseIP(host) } +// extractForwardedAddress parses a comma-separated forwarding header value and +// returns the rightmost address that is not a trusted origin. Reverse proxies +// append the peer that connected to them, so when every trusted proxy hop is +// listed in TrustedOrigins, the rightmost untrusted address is the real client; +// any values a client prepends to spoof its address sit to the left of the +// addresses inserted by trusted proxies and are ignored. If every parsed address +// is a trusted origin, the leftmost address is returned. It returns nil when no +// address can be parsed. +func extractForwardedAddress(config *RealIPConfig, value string) net.IP { + parts := strings.Split(value, ",") + var leftmost net.IP + for i := len(parts) - 1; i >= 0; i-- { + ip := getRemoteAddress(strings.TrimSpace(parts[i])) + if ip == nil { + continue + } + // Iterating right-to-left, so the last assignment is the leftmost + // valid address, used as the fallback when all hops are trusted. + leftmost = ip + if !isContainedIn(config.TrustedOrigins, ip) { + return ip + } + } + return leftmost +} + // isContainedIn checks that the given address is contained in the given // network. func isContainedIn(networks []*net.IPNet, address net.IP) bool { diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index caa1fe98496c7..cce7445bf6812 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -82,6 +82,72 @@ func TestExtractAddress(t *testing.T) { }, ExpectedRemoteAddr: "10.24.1.1", }, + { + // A chain of trusted proxies appends each hop. The rightmost + // untrusted address (the real client) wins, skipping the trusted + // inner-proxy hop. + Name: "picks-rightmost-untrusted", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4, 203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, + { + // When every parsed hop is a trusted origin, there is no untrusted + // client to select, so the leftmost address is used. + Name: "all-trusted-falls-back-to-leftmost", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"10.0.0.1, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "10.0.0.1", + }, + { + // A proxy may append its hop as a separate header line. Per + // RFC 7230 section 3.2.2 these are equivalent to a single + // comma-joined value, so the spoofed first line must not be + // trusted on its own. + Name: "x-forwarded-for-set-multiple-times", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4", "203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, { Name: "single-real-ip", Config: &httpmw.RealIPConfig{ @@ -456,6 +522,31 @@ func TestFilterUntrusted(t *testing.T) { }, ExpectedRemoteAddr: "1.2.3.4", }, + { + // For a trusted origin, multiple X-Forwarded-For field lines are + // joined into one comma-separated value rather than collapsed to + // the first line, so later hops are preserved. + Name: "trusted-origin-joins-multiple-x-forwarded-for", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4", "203.0.113.5, 10.0.0.2"}, + }, + RemoteAddr: "10.0.0.1", + ExpectedHeader: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4,203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "10.0.0.1", + }, } for _, test := range tests { From 5bdaf6bd26795ac0cc63792483b1b6fc9f6502c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:26:28 +0200 Subject: [PATCH 6/7] fix(coderd): harden oauth2 redirect validation (#27274) (#27462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27274 Original PR: #27274 — fix(coderd): harden oauth2 redirect validation Merge commit: 2f879910af7d0017736ea35bfa0f34eef557dd66 Requested by: @aslilac Co-authored-by: McKayla はな --- coderd/externalauth.go | 12 +-------- coderd/httpapi/redirect.go | 31 +++++++++++++++++++++ coderd/httpapi/redirect_test.go | 48 +++++++++++++++++++++++++++++++++ coderd/httpmw/oauth2.go | 12 +-------- coderd/userauth.go | 4 +-- 5 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 coderd/httpapi/redirect.go create mode 100644 coderd/httpapi/redirect_test.go diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 95978a5ac8b76..a0aa3157ebdc3 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "github.com/sqlc-dev/pqtype" "golang.org/x/sync/errgroup" @@ -331,7 +330,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht // FE know not to enter the authentication loop again, and instead display an error. redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID) } - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } @@ -429,12 +428,3 @@ func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvi CodeChallengeMethodsSupported: slice.ToStrings(cfg.CodeChallengeMethodsSupported), } } - -func uriFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil { - return "/" - } - - return uri.RequestURI() -} diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go new file mode 100644 index 0000000000000..6c1c49e39e389 --- /dev/null +++ b/coderd/httpapi/redirect.go @@ -0,0 +1,31 @@ +package httpapi + +import ( + "net/url" + "strings" +) + +// SafeRedirectPath reduces a redirect URL down to a safe, relative path. The +// scheme and host are dropped to prevent redirecting to another origin. Opaque +// URLs (e.g. `javascript:`, `data:`) are rejected outright and default to /. +func SafeRedirectPath(u string) string { + uri, err := url.Parse(u) + if err != nil || uri.Opaque != "" { + return "/" + } + + // A path with 2 or more leading slashes (e.g. "//evil.com") is interpreted as + // protocol-relative, so make sure there is exactly one. + path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") + if uri.RawQuery != "" { + path += "?" + uri.RawQuery + } + // We're specifically checking Fragment instead of RawFragment here because + // RawFragment is only populated when the parser needs to preserve a + // non-default escaping, so it is empty for plain-alphanumeric fragments like + // "#wooble". EscapedFragment handles escaping correctly in either case. + if uri.Fragment != "" { + path += "#" + uri.EscapedFragment() + } + return path +} diff --git a/coderd/httpapi/redirect_test.go b/coderd/httpapi/redirect_test.go new file mode 100644 index 0000000000000..7bb9b88d99c89 --- /dev/null +++ b/coderd/httpapi/redirect_test.go @@ -0,0 +1,48 @@ +package httpapi_test + +import ( + "testing" + + "github.com/coder/coder/v2/coderd/httpapi" +) + +func TestSafeRedirectPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", "/"}, + {"simple path", "/foo/bar", "/foo/bar"}, + {"path with query", "/foo/bar?baz=qux", "/foo/bar?baz=qux"}, + {"path with fragment", "/foo/bar#wooble", "/foo/bar#wooble"}, + {"path with query+fragment", "/foo/bar?wibble=wobble#wooble", "/foo/bar?wibble=wobble#wooble"}, + {"no leading slash", "foo/bar", "/foo/bar"}, + {"malformed", "http://[::1]:namedport", "/"}, + // Ensure backslashes aren't a blindspot. + {"backslash after slash", `/\evil.example.com`, "/%5Cevil.example.com"}, + {"leading double backslash", `\\evil.example.com`, "/%5C%5Cevil.example.com"}, + {"backslash then slash", `\/evil.example.com`, "/%5C/evil.example.com"}, + {"mixed slash backslash", `/\/evil.example.com`, "/%5C/evil.example.com"}, + {"scheme with backslash", `https:/\evil.example.com`, "/%5Cevil.example.com"}, + // Cure53 CDM-02-009: triple-slash open redirect. + {"protocol relative triple slash", "///evil.example.com", "/evil.example.com"}, + {"protocol relative double slash", "//evil.example.com", "/"}, + {"absolute url with host", "http://evil.example.com/path", "/path"}, + {"absolute url with host and query", "https://evil.example.com/path?a=b", "/path?a=b"}, + // Cure53 CDM-02-009: javascript: scheme bypassing CSP. + {"javascript scheme", "javascript:alert(origin)", "/"}, + {"nested javascript scheme", "javascript:javascript:javascript:alert(origin)", "/"}, + {"data scheme", "data:text/html,", "/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := httpapi.SafeRedirectPath(tt.in); got != tt.want { + t.Errorf("SafeRedirectPath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 5f12543887a09..fe91b49a5eafe 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "net/url" "reflect" "slices" @@ -100,7 +99,7 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg // the host of the AccessURL but ultimately as long as our redirect // url omits a host we're ensuring that we're routing to a path // local to the application. - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) } if code == "" { @@ -415,12 +414,3 @@ func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.H }) } } - -func uriFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil { - return "/" - } - - return uri.RequestURI() -} diff --git a/coderd/userauth.go b/coderd/userauth.go index bd96bafdf96f7..7bbf95593f02c 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1139,7 +1139,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { http.SetCookie(rw, cookie) } - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the redirect is handled client-side. httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{ @@ -1560,7 +1560,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { redirect := state.Redirect // Strip the host if it exists on the URL to prevent // any nefarious redirects. - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } From 572f9269feec66ff37ec39f9f64446bea450cebf Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Mon, 3 Aug 2026 19:25:02 +0200 Subject: [PATCH 7/7] fix(coderd): reject workspace proxy hostname prefixes (#27544) (#27792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27544 to `release/2.33`. Original PR: #27544 — fix(coderd): reject workspace proxy hostname prefixes Merge commit: 8cc7f2bb0e0b33199393cc3773c7d691bd2fbf6b `release/2.33` is in the **Security Support** channel per the [release schedule](https://coder.com/docs/install/releases#release-schedule) and was the only in-scope branch still missing this security fix (2.29, 2.34, 2.35, and 2.36 were already backported via #27614, #27616, #27615, and #27613). Refs: https://linear.app/codercom/issue/PLAT-384 --- _This backport PR was created by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: George K Co-authored-by: Bobby Ho --- coderd/database/querier_test.go | 52 +++++++++++++++++++++++++++++ coderd/database/queries.sql.go | 2 +- coderd/database/queries/proxies.sql | 2 +- coderd/workspaceapps_test.go | 9 +++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index be4784fa1c729..1cd8feaff7073 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1562,6 +1562,16 @@ func TestProxyByHostname(t *testing.T) { accessURL: "https://two.coder.com", wildcardHostname: "*--suffix.two.coder.com", }, + { + name: "three", + accessURL: "https://three.coder.com:8443", + wildcardHostname: "*.wildcard.three.coder.com", + }, + { + name: "four", + accessURL: "https://four.coder.com/", + wildcardHostname: "*.wildcard.four.coder.com", + }, } for _, p := range proxies { dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{ @@ -1592,6 +1602,34 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "MatchAccessURLWithPort", + testHostname: "three.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "three", + }, + { + name: "MatchAccessURLWithTrailingSlash", + testHostname: "four.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "four", + }, + { + name: "RejectAccessURLPrefix", + testHostname: "one.coder", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, + { + name: "RejectAccessURLTLDPrefix", + testHostname: "one.coder.co", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, { name: "MatchWildcard", testHostname: "something.wildcard.one.coder.com", @@ -1599,6 +1637,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "RejectWildcardHostnamePrefix", + testHostname: "something.wildcard.one.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "MatchSuffix", testHostname: "something--suffix.two.coder.com", @@ -1606,6 +1651,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "two", }, + { + name: "RejectSuffixHostnamePrefix", + testHostname: "something--suffix.two.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "ValidateHostname/1", testHostname: ".*ne.coder.com", diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4fb0f6bead75c..13d3237c8e281 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -19773,7 +19773,7 @@ WHERE ( ( $2 :: bool = true AND - url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]%)*' ) OR ( $3 :: bool = true AND diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index df59d3baf107f..cac44de84d4c8 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -120,7 +120,7 @@ WHERE ( ( @allow_access_url :: bool = true AND - url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]%)*' ) OR ( @allow_wildcard_hostname :: bool = true AND diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8db2858e01e32..db6f77ab95142 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -115,6 +115,15 @@ func TestWorkspaceApplicationAuth(t *testing.T) { redirectURI: "https://proxy.test.coder.com/path", expectRedirect: "https://proxy.test.coder.com/path", }, + { + name: "RejectProxyAccessURLPrefix", + accessURL: "https://test.coder.com", + appHostname: "*.test.coder.com", + proxyURL: "https://proxy.test.coder.com", + proxyAppHostname: "*.proxy.test.coder.com", + redirectURI: "https://proxy.test.coder/path", + expectRedirect: "", + }, { name: "ProxySubdomainOK", accessURL: "https://test.coder.com",