From 3bbc6ec12b41379a51bf1108726426c64b00c07d Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 6 Sep 2026 13:42:59 -0700 Subject: [PATCH 1/9] feat(codersdk): add a loopback-aware redirect URI comparator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8252 §7.3 requires an authorization server to accept any port on a loopback redirect URI, because a native app binds an ephemeral port at runtime and cannot know it at registration. Coder compares redirect URIs by exact string equality with no such exception, so a public client that registers http://127.0.0.1/callback can never match at authorize time. This adds the comparison rule without wiring it in yet: - RedirectURIMatches returns true on exact string equality (OAuth 2.1 §2.3.1), or when the registered URI is http to a loopback host and the two URIs are equal with the port removed. Every other component, including query and userinfo, must still match. The exception is decided by the registered URI, so a client cannot opt in by presenting a loopback host the app never registered. - isLoopbackAddress is exported as IsLoopbackAddress so registration and comparison share one definition of loopback. Its one caller is updated. A follow-up commit swaps the comparison in httpapi.QueryParamParser.RedirectURL. No behavior changes in this commit. Part of PLAT-488. --- codersdk/oauth2_test.go | 53 +++++++++++++++++++++++++++++++++++ codersdk/oauth2_validation.go | 24 ++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/codersdk/oauth2_test.go b/codersdk/oauth2_test.go index e75a5e3b525ff..f8c9da14566fd 100644 --- a/codersdk/oauth2_test.go +++ b/codersdk/oauth2_test.go @@ -1,6 +1,7 @@ package codersdk_test import ( + "net/url" "testing" "github.com/stretchr/testify/require" @@ -86,3 +87,55 @@ func TestOAuth2ClientRegistrationRequest_DetermineClientType(t *testing.T) { }) } } + +func TestIsLoopbackAddress(t *testing.T) { + t.Parallel() + + for _, host := range []string{"localhost", "127.0.0.1", "::1"} { + require.True(t, codersdk.IsLoopbackAddress(host), host) + } + // Callers pass url.URL.Hostname(), which strips IPv6 brackets. + for _, host := range []string{"", "[::1]", "app.localhost", "127.0.0.2", "0.0.0.0", "example.com"} { + require.False(t, codersdk.IsLoopbackAddress(host), host) + } +} + +func TestRedirectURIMatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + registered string + presented string + want bool + }{ + {"ExactMatch", "https://app.example.com/callback", "https://app.example.com/callback", true}, + {"CustomSchemeExact", "cursor://anysphere.cursor-mcp/oauth/callback", "cursor://anysphere.cursor-mcp/oauth/callback", true}, + {"LoopbackIPv4PortDiffers", "http://127.0.0.1/callback", "http://127.0.0.1:53219/callback", true}, + {"LoopbackIPv6PortDiffers", "http://[::1]/callback", "http://[::1]:53219/callback", true}, + {"LocalhostPortDiffers", "http://localhost/callback", "http://localhost:53219/callback", true}, + {"RegisteredPortDoesNotPin", "http://localhost:9876/callback", "http://localhost:53219/callback", true}, + {"PresentedWithoutPort", "http://127.0.0.1:53219/callback", "http://127.0.0.1/callback", true}, + {"LoopbackPathDiffers", "http://127.0.0.1/callback", "http://127.0.0.1:53219/other", false}, + {"LoopbackSchemeDiffers", "http://127.0.0.1/callback", "https://127.0.0.1:53219/callback", false}, + {"LoopbackHostSubstitution", "http://127.0.0.1/callback", "http://localhost:53219/callback", false}, + {"LoopbackQueryDiffers", "http://127.0.0.1/callback", "http://127.0.0.1:53219/callback?next=x", false}, + {"LoopbackUserinfoDiffers", "http://127.0.0.1/callback", "http://user@127.0.0.1:53219/callback", false}, + {"LocalhostSubdomain", "http://app.localhost/callback", "http://app.localhost:53219/callback", false}, + {"OtherLoopbackIP", "http://127.0.0.2/callback", "http://127.0.0.2:53219/callback", false}, + {"HTTPSLoopbackPortDiffers", "https://127.0.0.1/callback", "https://127.0.0.1:53219/callback", false}, + {"NonLoopbackPortDiffers", "https://app.example.com/callback", "https://app.example.com:8443/callback", false}, + {"LoopbackPresentedAgainstNonLoopback", "https://app.example.com/callback", "http://127.0.0.1:53219/callback", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + registered, err := url.Parse(tt.registered) + require.NoError(t, err) + presented, err := url.Parse(tt.presented) + require.NoError(t, err) + require.Equal(t, tt.want, codersdk.RedirectURIMatches(presented, registered)) + }) + } +} diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 9c61739c797ab..5f8e01bfaad9d 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -95,6 +95,23 @@ func ValidateRedirectURIScheme(u *url.URL) error { return validateScheme(u) } +// RedirectURIMatches reports whether a redirect_uri a client presented may be +// used in place of one the app registered. The rule is exact string equality +// (OAuth 2.1 §2.3.1). The one exception is a registered http URI to a loopback +// host, where the port is ignored (RFC 8252 §7.3). +func RedirectURIMatches(presented, registered *url.URL) bool { + if presented.String() == registered.String() { + return true + } + if registered.Scheme != "http" || !IsLoopbackAddress(registered.Hostname()) { + return false + } + // Drop the port from both sides. Every other component must still match. + p, r := *presented, *registered + p.Host, r.Host = p.Hostname(), r.Hostname() + return p.String() == r.String() +} + func validateScheme(u *url.URL) error { if u.Scheme == "" { return xerrors.New("redirect URI must have a scheme") @@ -157,7 +174,7 @@ func validateRedirectURIs(uris []string, clientType OAuth2ClientType) error { if uri.Scheme == "http" { if isPublicClient { // For public clients, only allow loopback (RFC 8252) - if !isLoopbackAddress(uri.Hostname()) { + if !IsLoopbackAddress(uri.Hostname()) { return xerrors.Errorf("redirect URI at index %d: public clients may only use http with loopback addresses (127.0.0.1, ::1, localhost)", i) } } else { @@ -307,8 +324,9 @@ func isLocalhost(hostname string) bool { strings.HasSuffix(hostname, ".localhost") } -// isLoopbackAddress checks if hostname is a strict loopback address (RFC 8252) -func isLoopbackAddress(hostname string) bool { +// IsLoopbackAddress reports whether hostname is one of the loopback hosts +// RFC 8252 §7.3 names: localhost, 127.0.0.1, or ::1. +func IsLoopbackAddress(hostname string) bool { return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" From 81e2fde4f36919e1566937486880c67796300620 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 6 Sep 2026 13:46:31 -0700 Subject: [PATCH 2/9] fix(coderd/httpapi): allow any port on loopback redirect URIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedirectURL rejected any redirect_uri that was not byte-for-byte equal to the registered one. RFC 8252 §7.3 requires the port of a loopback redirect URI to be accepted whatever the client bound at runtime, so a public client registered with http://127.0.0.1/callback could never pass this check. The comparison now goes through codersdk.RedirectURIMatches, which keeps exact matching for every URI except a registered http URI to a loopback host, where the port is ignored. Both callers, the authorize handlers and the token endpoint, pick up the change through this one function. The default when redirect_uri is absent, the unparsable-input path, and the error text are unchanged. The code-vs-token redirect_uri check in the token endpoint is unchanged too: RFC 6749 §4.1.3 requires those two values to be identical, and a client presents the same port at both steps. Part of PLAT-488. --- coderd/httpapi/queryparams.go | 5 ++- coderd/httpapi/queryparams_test.go | 61 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 8b563520d2a93..dceb8a4d06aec 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -232,8 +232,9 @@ func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryPara return base } - // OAuth 2.1 requires exact redirect URI matching. - if v.String() != base.String() { + // OAuth 2.1 §2.3.1 requires an exact match. RFC 8252 §7.3 excepts the port + // of a loopback redirect URI; the comparator owns that rule. + if !codersdk.RedirectURIMatches(v, base) { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must exactly match %s", queryParam, base), diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index 44cc66d09c7b4..286901a56d425 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -636,4 +636,65 @@ func TestRedirectURL(t *testing.T) { require.Contains(t, parser.Errors[0].Detail, "must be a valid url") } }) + + // RFC 8252 §7.3: a loopback redirect URI may present any port. The returned + // URL is the presented one, since that is where the response must go. + t.Run("LoopbackPortDiffers", func(t *testing.T) { + t.Parallel() + for _, host := range []string{"127.0.0.1", "[::1]", "localhost"} { + registered, err := url.Parse("http://" + host + "/callback") + require.NoError(t, err) + presented := "http://" + host + ":53219/callback" + + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{presented}} + got := parser.RedirectURL(vals, registered, "redirect_uri") + require.Empty(t, parser.Errors, "host %s", host) + require.Equal(t, presented, got.String(), "host %s", host) + } + }) + + t.Run("LoopbackRegisteredWithPort", func(t *testing.T) { + t.Parallel() + registered, err := url.Parse("http://localhost:9876/callback") + require.NoError(t, err) + presented := "http://localhost:53219/callback" + + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{presented}} + got := parser.RedirectURL(vals, registered, "redirect_uri") + require.Empty(t, parser.Errors) + require.Equal(t, presented, got.String()) + }) + + // Only the port is excepted. Any other difference is still a mismatch. + t.Run("LoopbackOtherComponentDiffers", func(t *testing.T) { + t.Parallel() + registered, err := url.Parse("http://127.0.0.1/callback") + require.NoError(t, err) + for name, presented := range map[string]string{ + "path": "http://127.0.0.1:53219/other", + "scheme": "https://127.0.0.1:53219/callback", + "host": "http://localhost:53219/callback", + "query": "http://127.0.0.1:53219/callback?next=x", + "userinfo": "http://user@127.0.0.1:53219/callback", + } { + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{presented}} + parser.RedirectURL(vals, registered, "redirect_uri") + require.Len(t, parser.Errors, 1, "%s differs", name) + require.Equal(t, "redirect_uri", parser.Errors[0].Field, "%s differs", name) + require.Contains(t, parser.Errors[0].Detail, "must exactly match", "%s differs", name) + } + }) + + // A non-loopback registration keeps the exact match, port included. + t.Run("NonLoopbackPortDiffers", func(t *testing.T) { + t.Parallel() + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{"https://app.example.com:8443/callback"}} + parser.RedirectURL(vals, base, "redirect_uri") + require.Len(t, parser.Errors, 1) + require.Contains(t, parser.Errors[0].Detail, "must exactly match") + }) } From ac9f591089e4b40c020ec8195bceae360498ee34 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 6 Sep 2026 13:54:55 -0700 Subject: [PATCH 3/9] test(coderd/oauth2provider): cover the loopback redirect port exception end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparator change in httpapi is exercised here through both handlers. A public client registers http:///callback with no port and authorizes with port 53219 for 127.0.0.1, [::1], and localhost. The consent page's cancel link, the code redirect, and the token exchange all use the presented port. Two cases pin what the exception does not do. An exchange from a different port than the code was issued to is refused with invalid_grant (RFC 6749 §4.1.3), and the code stays redeemable from the right port. A code issued with no redirect_uri, which leaves nothing on the code to compare against, still exchanges from a loopback port, so the token endpoint's own registration check is covered on its own. Part of PLAT-488. --- .../oauth2provider/authorize_internal_test.go | 20 +++++ coderd/oauth2provider/authorize_test.go | 88 +++++++++++++++++++ coderd/oauth2provider/tokens_test.go | 24 +++++ 3 files changed, 132 insertions(+) diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 04e172dd9d1de..6b759c2693988 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -576,6 +576,26 @@ func TestNewAuthorizeResponse(t *testing.T) { require.Empty(t, p.Errors) require.False(t, response.canRedirect()) }) + + // RFC 8252 §7.3: the port of a loopback redirect URI is not compared, and + // the response goes to the port the client presented. + t.Run("LoopbackPortDiffersIsADestination", func(t *testing.T) { + t.Parallel() + + const presented = "http://127.0.0.1:53219/callback" + p := httpapi.NewQueryParamParser() + response, err := newAuthorizeResponse(p, url.Values{ + "redirect_uri": {presented}, + "state": {"abc123"}, + }, "http://127.0.0.1/callback") + + require.NoError(t, err) + require.Empty(t, p.Errors) + require.True(t, response.canRedirect()) + require.Equal(t, presented, response.callbackURL()) + errorURL := response.errorURL(codersdk.OAuth2ErrorCodeAccessDenied, "denied") + require.Equal(t, "127.0.0.1:53219", errorURL.Host, "error redirects must keep the presented port") + }) } // TestAuthorizeResponseZeroValue pins the zero value as inert, since it is what diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index 1cbbeed6cd24d..20f5740261abf 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -1032,3 +1032,91 @@ func readBody(t *testing.T, resp *http.Response) string { require.NoError(t, err) return string(body) } + +// RFC 8252 §7.3: a native app registers a loopback redirect URI without a port +// and presents whichever port it bound at runtime. +func TestOAuth2AuthorizeLoopbackRedirectPort(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + + const port = "53219" + + // Registers a public client on http:///callback and authorizes with + // http://:53219/callback. Returns what the exchange needs. + authorize := func(ctx context.Context, t *testing.T, host string) (clientID, presented, code, verifier string) { + t.Helper() + + app := oauth2providertest.RegisterPublicClient(t, client, "loopback", "http://"+host+"/callback") + presented = "http://" + host + ":" + port + "/callback" + + verifier, challenge := oauth2providertest.GeneratePKCE(t) + query := authorizeQuery(t, app.ClientID, "") + query.Set("code_challenge", challenge) + query.Set("redirect_uri", presented) + + get := sendAuthorizeRequest(ctx, t, client, http.MethodGet, query) + defer get.Body.Close() + body := readBody(t, get) + require.Equal(t, http.StatusOK, get.StatusCode, body) + require.Equal(t, host+":"+port, cancelLinkFromConsentPage(t, body).Host, + "the cancel link must go to the presented port") + + post := sendAuthorizeRequest(ctx, t, client, http.MethodPost, query) + defer post.Body.Close() + require.Equal(t, http.StatusFound, post.StatusCode, readBody(t, post)) + location, err := url.Parse(post.Header.Get("Location")) + require.NoError(t, err) + require.Equal(t, host+":"+port, location.Host, "the code must go to the presented port") + require.Equal(t, "/callback", location.Path) + require.Equal(t, authorizeState, location.Query().Get("state")) + code = location.Query().Get("code") + require.NotEmpty(t, code, "authorization did not issue a code") + return app.ClientID, presented, code, verifier + } + + exchange := func(ctx context.Context, t *testing.T, clientID, code, verifier, redirectURI string) (int, string) { + t.Helper() + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", clientID) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("code_verifier", verifier) + return postTokenRequest(ctx, t, client, form) + } + + for name, host := range map[string]string{ + "IPv4": "127.0.0.1", + "IPv6": "[::1]", + "Localhost": "localhost", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + clientID, presented, code, verifier := authorize(ctx, t, host) + status, body := exchange(ctx, t, clientID, code, verifier, presented) + requireTokenResponse(t, status, body) + }) + } + + // RFC 6749 §4.1.3: the redirect_uri at the exchange must be identical to + // the one the code was issued to. The loopback exception does not apply + // here, and a refused exchange does not consume the code. + t.Run("ExchangeFromAnotherPortIsRefused", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + clientID, presented, code, verifier := authorize(ctx, t, "127.0.0.1") + + status, body := exchange(ctx, t, clientID, code, verifier, "http://127.0.0.1:53220/callback") + requireTokenGrantError(t, status, body) + + status, body = exchange(ctx, t, clientID, code, verifier, presented) + requireTokenResponse(t, status, body) + }) +} diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go index 0ff3e70922c34..8b9f09f863daa 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -450,3 +450,27 @@ func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refre require.NoError(t, err) return key.Scopes } + +// The token endpoint compares redirect_uri against the registration on its +// own. Authorizing without redirect_uri leaves nothing on the code to compare +// against, so this check is the only one the exchange runs. +func TestOAuth2TokenExchangeLoopbackRedirectPort(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + oauth2providertest.EnableDCR(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + app := oauth2providertest.RegisterPublicClient(t, client, "loopback", "http://127.0.0.1/callback") + code, verifier := authorizeCode(ctx, t, client, app.ClientID, "") + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", app.ClientID) + form.Set("code", code) + form.Set("redirect_uri", "http://127.0.0.1:53219/callback") + form.Set("code_verifier", verifier) + status, body := postTokenRequest(ctx, t, client, form) + requireTokenResponse(t, status, body) +} From 61d4bd4c4a72f6a689f0bb89730f793b7c3c0116 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 6 Sep 2026 13:58:49 -0700 Subject: [PATCH 4/9] docs(admin/integrations): describe the loopback redirect port exception The OAuth2 provider page said redirect URIs must match exactly and listed exact matching among the OAuth 2.1 requirements Coder enforces. Both are now qualified: the port of a loopback http redirect URI is not compared, as RFC 8252 requires for native apps that choose a port at runtime. The loopback note under Client Authentication Methods says how to register such a URI, the "Invalid redirect_uri" troubleshooting entry names the exception and points at that note, and the Standards Compliance paragraph links RFC 8252. Paragraphs touched are reflowed to one sentence per line, per the docs style guide. Part of PLAT-488. --- docs/admin/integrations/oauth2-provider.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 4676a4ccc2ebe..644a4921936a0 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -127,11 +127,11 @@ Public clients suit native, mobile, and CLI applications that cannot keep a secr If you use Dynamic Client Registration (RFC 7591) and omit `token_endpoint_auth_method`, clients default to `client_secret_basic`. To request `client_secret_post`, set `token_endpoint_auth_method` to `client_secret_post` in the registration request. To register a public client, set it to `none`: Coder issues no `client_secret`, and the registration response omits that field entirely. > [!IMPORTANT] -> A public client may use `http://` only with a loopback host -> (`localhost`, `127.0.0.1`, `[::1]`). An `http://` redirect URI to any -> other host is rejected, so use `https://` instead. A confidential -> client has the same restriction but also accepts `.localhost` -> subdomains over `http://`. +> A public client may use `http://` only with a loopback host (`localhost`, `127.0.0.1`, `[::1]`). +> An `http://` redirect URI to any other host is rejected, so use `https://` instead. +> A confidential client has the same restriction but also accepts `.localhost` subdomains over `http://`. +> The port of a loopback redirect URI is not compared, as RFC 8252 requires for native apps that choose a port at runtime. +> Register `http://127.0.0.1/callback` and present whichever port the client is listening on. > > Which schemes a redirect URI may use is a separate restriction that > also differs by client type. See @@ -397,6 +397,8 @@ Add `oauth2` to your experiment flags: `coder server --experiments oauth2` ### "Invalid redirect_uri" Ensure the redirect URI in your request exactly matches the one registered for your application. +The one exception is the port of a loopback `http://` redirect URI (`localhost`, `127.0.0.1`, `[::1]`), which may differ from the registered one. +Refer to the note under [Client Authentication Methods](#client-authentication-methods). ### "Invalid Callback URL" on the consent page @@ -583,13 +585,8 @@ As an experimental feature, the current implementation has limitations: ## Standards Compliance -This implementation follows established OAuth2 standards including -[RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core), -[RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the -[OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12). -Coder enforces OAuth 2.1 requirements including mandatory PKCE for all -authorization code grants, exact redirect URI string matching, rejection -of the implicit grant, and CSRF protections on consent pages. +This implementation follows established OAuth2 standards including [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core), [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12). +Coder enforces OAuth 2.1 requirements including mandatory PKCE for all authorization code grants, exact redirect URI string matching with the [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) loopback port exception, rejection of the implicit grant, and CSRF protections on consent pages. ## Next Steps From 182b465d7759ac3cbff819e5d6cc25276f772994 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 7 Sep 2026 10:27:16 -0700 Subject: [PATCH 5/9] docs(codersdk): cite RFC 8252 correctly in the IsLoopbackAddress comment --- codersdk/oauth2_validation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 5f8e01bfaad9d..7c0eeccff0759 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -324,8 +324,8 @@ func isLocalhost(hostname string) bool { strings.HasSuffix(hostname, ".localhost") } -// IsLoopbackAddress reports whether hostname is one of the loopback hosts -// RFC 8252 §7.3 names: localhost, 127.0.0.1, or ::1. +// IsLoopbackAddress reports whether hostname is a loopback host. RFC 8252 §7.3 +// names 127.0.0.1 and ::1. Coder also accepts localhost as its own policy. func IsLoopbackAddress(hostname string) bool { return hostname == "localhost" || hostname == "127.0.0.1" || From 6cf371f962e6b850d341464e5928f5f56a8b5dba Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 7 Sep 2026 10:35:25 -0700 Subject: [PATCH 6/9] docs(admin/integrations): say which hosts and clients get the loopback port exception --- docs/admin/integrations/oauth2-provider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 644a4921936a0..bdb42652abb8c 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -130,7 +130,7 @@ If you use Dynamic Client Registration (RFC 7591) and omit `token_endpoint_auth_ > A public client may use `http://` only with a loopback host (`localhost`, `127.0.0.1`, `[::1]`). > An `http://` redirect URI to any other host is rejected, so use `https://` instead. > A confidential client has the same restriction but also accepts `.localhost` subdomains over `http://`. -> The port of a loopback redirect URI is not compared, as RFC 8252 requires for native apps that choose a port at runtime. +> Coder ignores the port of an `http://` redirect URI to one of those three loopback hosts, for public and confidential clients alike. RFC 8252 requires this for `127.0.0.1` and `[::1]` so that native apps can choose a port at runtime. Coder applies it to `localhost` too. A `.localhost` subdomain still requires an exact port match. > Register `http://127.0.0.1/callback` and present whichever port the client is listening on. > > Which schemes a redirect URI may use is a separate restriction that From 6fa2fc66e8529465d992977235ac83f53e61299c Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 7 Sep 2026 10:35:25 -0700 Subject: [PATCH 7/9] refactor(codersdk): unexport isLoopbackAddress and clarify RedirectURIMatches comments --- codersdk/oauth2_test.go | 12 ------------ codersdk/oauth2_validation.go | 13 ++++++++----- codersdk/oauth2_validation_internal_test.go | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 codersdk/oauth2_validation_internal_test.go diff --git a/codersdk/oauth2_test.go b/codersdk/oauth2_test.go index f8c9da14566fd..f4ed0af197627 100644 --- a/codersdk/oauth2_test.go +++ b/codersdk/oauth2_test.go @@ -88,18 +88,6 @@ func TestOAuth2ClientRegistrationRequest_DetermineClientType(t *testing.T) { } } -func TestIsLoopbackAddress(t *testing.T) { - t.Parallel() - - for _, host := range []string{"localhost", "127.0.0.1", "::1"} { - require.True(t, codersdk.IsLoopbackAddress(host), host) - } - // Callers pass url.URL.Hostname(), which strips IPv6 brackets. - for _, host := range []string{"", "[::1]", "app.localhost", "127.0.0.2", "0.0.0.0", "example.com"} { - require.False(t, codersdk.IsLoopbackAddress(host), host) - } -} - func TestRedirectURIMatches(t *testing.T) { t.Parallel() diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 7c0eeccff0759..70d36b4a74e41 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -98,15 +98,18 @@ func ValidateRedirectURIScheme(u *url.URL) error { // RedirectURIMatches reports whether a redirect_uri a client presented may be // used in place of one the app registered. The rule is exact string equality // (OAuth 2.1 §2.3.1). The one exception is a registered http URI to a loopback -// host, where the port is ignored (RFC 8252 §7.3). +// host, where the port is ignored (RFC 8252 §7.3). The exception depends on the +// registered URI alone, not on the client type. func RedirectURIMatches(presented, registered *url.URL) bool { if presented.String() == registered.String() { return true } - if registered.Scheme != "http" || !IsLoopbackAddress(registered.Hostname()) { + if registered.Scheme != "http" || !isLoopbackAddress(registered.Hostname()) { return false } // Drop the port from both sides. Every other component must still match. + // Hostname() also strips IPv6 brackets, so both strings are built the same + // way and stay comparable. p, r := *presented, *registered p.Host, r.Host = p.Hostname(), r.Hostname() return p.String() == r.String() @@ -174,7 +177,7 @@ func validateRedirectURIs(uris []string, clientType OAuth2ClientType) error { if uri.Scheme == "http" { if isPublicClient { // For public clients, only allow loopback (RFC 8252) - if !IsLoopbackAddress(uri.Hostname()) { + if !isLoopbackAddress(uri.Hostname()) { return xerrors.Errorf("redirect URI at index %d: public clients may only use http with loopback addresses (127.0.0.1, ::1, localhost)", i) } } else { @@ -324,9 +327,9 @@ func isLocalhost(hostname string) bool { strings.HasSuffix(hostname, ".localhost") } -// IsLoopbackAddress reports whether hostname is a loopback host. RFC 8252 §7.3 +// isLoopbackAddress reports whether hostname is a loopback host. RFC 8252 §7.3 // names 127.0.0.1 and ::1. Coder also accepts localhost as its own policy. -func IsLoopbackAddress(hostname string) bool { +func isLoopbackAddress(hostname string) bool { return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" diff --git a/codersdk/oauth2_validation_internal_test.go b/codersdk/oauth2_validation_internal_test.go new file mode 100644 index 0000000000000..e13cf8b72676c --- /dev/null +++ b/codersdk/oauth2_validation_internal_test.go @@ -0,0 +1,19 @@ +package codersdk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsLoopbackAddress(t *testing.T) { + t.Parallel() + + for _, host := range []string{"localhost", "127.0.0.1", "::1"} { + require.True(t, isLoopbackAddress(host), host) + } + // Callers pass url.URL.Hostname(), which strips IPv6 brackets. + for _, host := range []string{"", "[::1]", "app.localhost", "127.0.0.2", "0.0.0.0", "example.com"} { + require.False(t, isLoopbackAddress(host), host) + } +} From 2eeee6c4a1d542e2b52cfa5927ba719572edc791 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 7 Sep 2026 10:35:25 -0700 Subject: [PATCH 8/9] fix(coderd): say the loopback port may differ in the redirect_uri mismatch error --- coderd/httpapi/queryparams.go | 2 +- coderd/httpapi/queryparams_test.go | 6 +++--- coderd/oauth2provider/authorize_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index dceb8a4d06aec..cce69bc634b37 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -237,7 +237,7 @@ func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryPara if !codersdk.RedirectURIMatches(v, base) { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, - Detail: fmt.Sprintf("Query param %q must exactly match %s", queryParam, base), + Detail: fmt.Sprintf("Query param %q must match %s; only the port of a loopback URI may differ", queryParam, base), }) } diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index 286901a56d425..3d8b612632533 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -616,7 +616,7 @@ func TestRedirectURL(t *testing.T) { vals := url.Values{"redirect_uri": []string{"https://evil.example.com/steal"}} parser.RedirectURL(vals, base, "redirect_uri") require.Len(t, parser.Errors, 1) - require.Contains(t, parser.Errors[0].Detail, "must exactly match") + require.Contains(t, parser.Errors[0].Detail, "must match") }) // url.Parse returns a nil URL alongside its error for these, so a caller @@ -684,7 +684,7 @@ func TestRedirectURL(t *testing.T) { parser.RedirectURL(vals, registered, "redirect_uri") require.Len(t, parser.Errors, 1, "%s differs", name) require.Equal(t, "redirect_uri", parser.Errors[0].Field, "%s differs", name) - require.Contains(t, parser.Errors[0].Detail, "must exactly match", "%s differs", name) + require.Contains(t, parser.Errors[0].Detail, "must match", "%s differs", name) } }) @@ -695,6 +695,6 @@ func TestRedirectURL(t *testing.T) { vals := url.Values{"redirect_uri": []string{"https://app.example.com:8443/callback"}} parser.RedirectURL(vals, base, "redirect_uri") require.Len(t, parser.Errors, 1) - require.Contains(t, parser.Errors[0].Detail, "must exactly match") + require.Contains(t, parser.Errors[0].Detail, "must match") }) } diff --git a/coderd/oauth2provider/authorize_test.go b/coderd/oauth2provider/authorize_test.go index 20f5740261abf..5fac477efdc8a 100644 --- a/coderd/oauth2provider/authorize_test.go +++ b/coderd/oauth2provider/authorize_test.go @@ -361,7 +361,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) { "%s: the user must not be redirected to a URI the app did not register", method) // The request also carries an invalid scope, so this pins which // guard rejected it first. - require.Contains(t, readBody(t, resp), "must exactly match", + require.Contains(t, readBody(t, resp), "must match", "%s: the rejection must come from redirect_uri validation", method) } From e521650366f65408d22d898c8a234377ae3e758e Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 7 Sep 2026 10:35:25 -0700 Subject: [PATCH 9/9] test(coderd/httpapi): collapse the loopback component mismatch cases --- coderd/httpapi/queryparams_test.go | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index 3d8b612632533..592f43550c64a 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -667,25 +667,18 @@ func TestRedirectURL(t *testing.T) { require.Equal(t, presented, got.String()) }) - // Only the port is excepted. Any other difference is still a mismatch. + // Only the port is excepted. codersdk.TestRedirectURIMatches covers each + // other component; this checks the wrapper reports the mismatch. t.Run("LoopbackOtherComponentDiffers", func(t *testing.T) { t.Parallel() registered, err := url.Parse("http://127.0.0.1/callback") require.NoError(t, err) - for name, presented := range map[string]string{ - "path": "http://127.0.0.1:53219/other", - "scheme": "https://127.0.0.1:53219/callback", - "host": "http://localhost:53219/callback", - "query": "http://127.0.0.1:53219/callback?next=x", - "userinfo": "http://user@127.0.0.1:53219/callback", - } { - parser := httpapi.NewQueryParamParser() - vals := url.Values{"redirect_uri": []string{presented}} - parser.RedirectURL(vals, registered, "redirect_uri") - require.Len(t, parser.Errors, 1, "%s differs", name) - require.Equal(t, "redirect_uri", parser.Errors[0].Field, "%s differs", name) - require.Contains(t, parser.Errors[0].Detail, "must match", "%s differs", name) - } + parser := httpapi.NewQueryParamParser() + vals := url.Values{"redirect_uri": []string{"http://127.0.0.1:53219/other"}} + parser.RedirectURL(vals, registered, "redirect_uri") + require.Len(t, parser.Errors, 1) + require.Equal(t, "redirect_uri", parser.Errors[0].Field) + require.Contains(t, parser.Errors[0].Detail, "must match") }) // A non-loopback registration keeps the exact match, port included.