diff --git a/coderd/oauth2_security_test.go b/coderd/oauth2_security_test.go index 17c092fd7aaf1..4c9780911267a 100644 --- a/coderd/oauth2_security_test.go +++ b/coderd/oauth2_security_test.go @@ -267,6 +267,19 @@ func TestOAuth2PrivilegeEscalation(t *testing.T) { ClientName: fmt.Sprintf("native-app-3-%d", time.Now().UnixNano()), TokenEndpointAuthMethod: "none", // Required for public clients }, + { + // Bare custom schemes (no reverse-domain notation) are the + // schemes real native apps register with the OS, and PKCE, + // not the scheme's spelling, is what secures the redirect. + RedirectURIs: []string{"vscode://coder.authenticate"}, + ClientName: fmt.Sprintf("native-app-vscode-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + { + RedirectURIs: []string{"jetbrains://coder-callback"}, + ClientName: fmt.Sprintf("native-app-jetbrains-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, } for i, req := range validCustomSchemeRequests { @@ -312,6 +325,54 @@ func TestOAuth2PrivilegeEscalation(t *testing.T) { require.Contains(t, err.Error(), "dangerous scheme") }) } + + // mailto, tel, and sms are not in the dangerous-scheme blocklist + // above: they hand off to a mail client, dialer, or SMS app rather + // than injecting content, so they are harmless for a confidential + // client's redirect. A public client has no secret, so the redirect + // URI's scheme is its only mechanism for regaining control, and + // none of these three return control to it the way a real redirect + // scheme does. They are rejected for public clients specifically, + // with a distinct error from the dangerous-scheme case above. + publicClientDisallowedSchemeRequests := []struct { + req codersdk.OAuth2ClientRegistrationRequest + scheme string + }{ + { + req: codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"mailto:user@example.com"}, + ClientName: fmt.Sprintf("native-app-mailto-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + scheme: "mailto", + }, + { + req: codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"tel:+15555550100"}, + ClientName: fmt.Sprintf("native-app-tel-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + scheme: "tel", + }, + { + req: codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"sms:+15555550100"}, + ClientName: fmt.Sprintf("native-app-sms-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + scheme: "sms", + }, + } + + for _, test := range publicClientDisallowedSchemeRequests { + t.Run(fmt.Sprintf("PublicClientDisallowedScheme_%s", test.scheme), func(t *testing.T) { + t.Parallel() + + _, err := client.PostOAuth2ClientRegistration(ctx, test.req) + require.Error(t, err) + require.Contains(t, err.Error(), "public clients may not use the "+test.scheme+" scheme") + }) + } }) } diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 4c6ca0faa855e..34d0e37665ec5 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -163,17 +163,31 @@ func validateRedirectURIs(uris []string, tokenEndpointAuthMethod OAuth2TokenEndp } } } - } else { - // Custom scheme validation for public clients (RFC 8252 section 7.1) - if isPublicClient { - // For public clients, custom schemes should follow RFC 8252 recommendations - // Should be reverse domain notation based on domain under their control - if !isValidCustomScheme(uri.Scheme) { - return xerrors.Errorf("redirect URI at index %d: custom scheme %s should use reverse domain notation (e.g. com.example.app)", i, uri.Scheme) - } + } else if isPublicClient { + // mailto, tel, and sms hand off to a mail client, dialer, or SMS + // app rather than returning control to the application that + // started the flow. A public client has no other way to obtain + // its authorization code, so registering one of these would + // produce a client that can never complete authorization. + // + // This check runs only for public clients because that is how + // custom-scheme validation was scoped before this change, not + // because these three schemes are known to be safe for a + // confidential client's redirect; confidential clients were + // never subject to any scheme-shape check beyond validateScheme + // and remain so here. + switch uri.Scheme { + case "mailto", "tel", "sms": + return xerrors.Errorf("redirect URI at index %d: public clients may not use the %s scheme", i, uri.Scheme) } - // For confidential clients, custom schemes are less common but allowed } + // Beyond that, custom schemes need no further check: validateScheme + // already blocked the ones that are dangerous in a redirect context, + // and RFC 8252 ยง7.1 only recommends reverse-domain notation rather + // than requiring it. Rejecting bare schemes such as vscode:// or + // jetbrains:// would penalize the native and CLI apps this client + // type exists for; PKCE, not the scheme's spelling, is what secures + // the redirect. // Prevent URI fragments (RFC 6749 section 3.1.2) if uri.Fragment != "" || strings.Contains(uriStr, "#") { @@ -295,22 +309,3 @@ func isLoopbackAddress(hostname string) bool { hostname == "127.0.0.1" || hostname == "::1" } - -// isValidCustomScheme validates custom schemes for public clients (RFC 8252) -func isValidCustomScheme(scheme string) bool { - // For security and RFC compliance, require reverse domain notation - // Should contain at least one period and not be a well-known scheme - if !strings.Contains(scheme, ".") { - return false - } - - // Block schemes that look like well-known protocols - wellKnownSchemes := []string{"http", "https", "ftp", "mailto", "tel", "sms"} - for _, wellKnown := range wellKnownSchemes { - if strings.EqualFold(scheme, wellKnown) { - return false - } - } - - return true -} diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 1cbb17a3e1d0f..73f69725474eb 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -333,12 +333,25 @@ application's callback URL to a valid scheme (see Verify that the `code_verifier` used in the token request matches the one used to generate the `code_challenge`. +### "public clients may not use the mailto/tel/sms scheme" + +This error appears during client registration when a public client +(`token_endpoint_auth_method: none`) registers a redirect URI using the +`mailto:`, `tel:`, or `sms:` scheme. These schemes hand off to a mail +client, dialer, or SMS app instead of returning control to the +application that started the flow, so a public client registered with +one of them could never complete authorization. Register a redirect URI +the client can actually receive control on instead, such as a custom +scheme (`myapp://callback`) or a loopback HTTP address. + ## Callback URL schemes Custom URI schemes (`myapp://`, `vscode://`, `jetbrains://`, etc.) are fully supported for native and desktop applications. The OS routes the redirect back to the registered application without requiring a running HTTP server. The following schemes are blocked for security reasons: `javascript:`, `data:`, `file:`, `ftp:`. +Public clients (`token_endpoint_auth_method: none`) additionally cannot register `mailto:`, `tel:`, or `sms:` redirect URIs, since those schemes hand off to another app rather than returning an authorization code to the client. Confidential clients are not subject to this restriction. + ## Security Considerations - **Use HTTPS**: Always use HTTPS in production to protect tokens in transit @@ -346,7 +359,8 @@ The following schemes are blocked for security reasons: `javascript:`, `data:`, (public and confidential) - **Validate redirect URLs**: Only register trusted redirect URIs. Dangerous schemes (`javascript:`, `data:`, `file:`, `ftp:`) are blocked by the server, - but custom URI schemes for native apps (`myapp://`) are permitted + custom URI schemes for native apps (`myapp://`) are permitted, and public + clients additionally cannot use `mailto:`, `tel:`, or `sms:` - **Rotate secrets**: Periodically rotate client secrets using the management API ## Limitations