Skip to content

Commit 6a3a964

Browse files
committed
Harden OAuth discovery and token endpoints
- Scheme-validate the discovery authorization_endpoint (https, or http on loopback) before it's dispatched to the OS browser launcher, so a hostile discovery doc can't hand the OS a file:// (or other) URL. - Enforce RequireSecureURL on the DCR registration_endpoint. - Set CheckRedirect on the OAuth HTTP client so token exchange/refresh POST bodies (auth code, refresh_token) aren't replayed to a redirect target. Idempotent GET/HEAD (OAuth discovery) may still follow redirects, so we don't break discovery or force an unintended Launchpad fallback.
1 parent dd63a13 commit 6a3a964

3 files changed

Lines changed: 154 additions & 3 deletions

File tree

internal/appctx/context.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,24 @@ func (a *authAdapter) AccessToken(ctx context.Context) (string, error) {
8383

8484
// NewApp creates a new App with the given configuration.
8585
func NewApp(cfg *config.Config) *App {
86-
// Create HTTP client for auth manager (OAuth discovery, token refresh)
87-
httpClient := &http.Client{Timeout: 30 * time.Second}
86+
// Create HTTP client for auth manager (OAuth discovery, token refresh).
87+
// Refuse to follow redirects for non-idempotent requests: RFC 6749 token
88+
// endpoints don't legitimately 3xx-redirect POSTs, and because the
89+
// exchange/refresh requests set GetBody, Go would replay the auth code /
90+
// refresh_token to the redirect target (only the initial endpoint is
91+
// origin-validated). Idempotent GET/HEAD requests (e.g. OAuth discovery)
92+
// carry no credential body, so they may follow redirects normally —
93+
// blocking those would needlessly fail discovery and force the Launchpad
94+
// fallback.
95+
httpClient := &http.Client{
96+
Timeout: 30 * time.Second,
97+
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
98+
if len(via) > 0 && via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
99+
return http.ErrUseLastResponse
100+
}
101+
return nil
102+
},
103+
}
88104
authMgr := auth.NewManager(cfg, httpClient)
89105

90106
// Create observability components

internal/auth/auth.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,20 @@ func (m *Manager) loadBC3Client() (*ClientCredentials, error) {
622622
}
623623

624624
func (m *Manager) registerBC3Client(ctx context.Context, registrationEndpoint string, opts *LoginOptions) (*ClientCredentials, error) {
625+
// The registration endpoint comes from the server-controlled discovery
626+
// document. Restrict it to https (or http on loopback for local
627+
// development) so a hostile discovery doc can't hand the DCR POST a
628+
// file:// (or other) scheme that RequireSecureURL would let through.
629+
// Mirrors buildAuthURL's scheme whitelist.
630+
u, err := url.Parse(registrationEndpoint)
631+
if err != nil {
632+
return nil, output.ErrAuth(fmt.Sprintf("invalid registration endpoint %q: %v", registrationEndpoint, err))
633+
}
634+
secureEndpoint := u.Scheme == "https" || (u.Scheme == "http" && hostutil.IsLocalhost(u.Host))
635+
if !secureEndpoint {
636+
return nil, output.ErrAuth(fmt.Sprintf("invalid registration endpoint %q: scheme must be https (or http on loopback)", registrationEndpoint))
637+
}
638+
625639
customRedirect := opts.RedirectURI != defaultRedirectURI
626640
regReq := map[string]any{
627641
"client_name": "basecamp-cli",
@@ -643,7 +657,19 @@ func (m *Manager) registerBC3Client(ctx context.Context, registrationEndpoint st
643657
}
644658
req.Header.Set("Content-Type", "application/json")
645659

646-
resp, err := m.httpClient.Do(req)
660+
// Use a dedicated client without the manager's CheckRedirect guard. The DCR
661+
// POST body carries only client metadata (no auth code or refresh token), so
662+
// following a proxy-canonicalized 3xx redirect is safe — and necessary, since
663+
// the guarded client would silently fail first-time login on such redirects.
664+
var dcrClient *http.Client
665+
if m.httpClient != nil {
666+
c := *m.httpClient // http.Client has no locks; value copy is safe
667+
c.CheckRedirect = nil // DCR POST body carries only client metadata; safe to follow redirects
668+
dcrClient = &c
669+
} else {
670+
dcrClient = &http.Client{Timeout: 30 * time.Second}
671+
}
672+
resp, err := dcrClient.Do(req)
647673
if err != nil {
648674
return nil, output.ErrNetwork(err)
649675
}
@@ -704,6 +730,15 @@ func (m *Manager) buildAuthURL(cfg *oauth.Config, oauthType, scope, state, codeC
704730
return "", err
705731
}
706732

733+
// The authorization endpoint comes from the server-controlled discovery
734+
// document and is later dispatched to the OS browser handler (xdg-open /
735+
// open). Restrict it to https (or http on loopback for local development)
736+
// so a hostile discovery doc can't hand the OS a file:// (or other) URL.
737+
secureEndpoint := u.Scheme == "https" || (u.Scheme == "http" && hostutil.IsLocalhost(u.Host))
738+
if !secureEndpoint {
739+
return "", output.ErrAuth(fmt.Sprintf("invalid authorization endpoint %q: scheme must be https (or http on loopback)", cfg.AuthorizationEndpoint))
740+
}
741+
707742
q := u.Query()
708743
q.Set("response_type", "code")
709744
q.Set("client_id", clientID)

internal/auth/auth_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,42 @@ func TestBuildAuthURL_UsesResolvedRedirectURI(t *testing.T) {
592592
assert.Contains(t, authURL, "redirect_uri=http%3A%2F%2Flocalhost%3A9999%2Fmy-callback")
593593
}
594594

595+
// TestBuildAuthURL_RejectsUnsafeScheme guards against a hostile discovery
596+
// document handing the OS browser launcher a non-https authorization endpoint
597+
// (e.g. file://). https and http-on-loopback are accepted; everything else
598+
// must error before reaching OpenBrowser.
599+
func TestBuildAuthURL_RejectsUnsafeScheme(t *testing.T) {
600+
m := &Manager{cfg: config.Default(), httpClient: http.DefaultClient}
601+
opts := &LoginOptions{RedirectURI: "http://localhost:9999/callback"}
602+
603+
accepted := []string{
604+
"https://auth.example.com/authorize",
605+
"http://localhost:3000/authorize",
606+
"http://127.0.0.1:3000/authorize",
607+
}
608+
for _, endpoint := range accepted {
609+
t.Run("accepts "+endpoint, func(t *testing.T) {
610+
oauthCfg := &oauth.Config{AuthorizationEndpoint: endpoint}
611+
_, err := m.buildAuthURL(oauthCfg, "bc3", "read", "state", "challenge", "cid", opts)
612+
require.NoError(t, err)
613+
})
614+
}
615+
616+
rejected := []string{
617+
"file:///etc/passwd",
618+
"http://evil.example.com/authorize",
619+
"javascript:alert(1)",
620+
"-flag",
621+
}
622+
for _, endpoint := range rejected {
623+
t.Run("rejects "+endpoint, func(t *testing.T) {
624+
oauthCfg := &oauth.Config{AuthorizationEndpoint: endpoint}
625+
_, err := m.buildAuthURL(oauthCfg, "bc3", "read", "state", "challenge", "cid", opts)
626+
require.Error(t, err)
627+
})
628+
}
629+
}
630+
595631
func TestExchangeCode_UsesResolvedRedirectURI(t *testing.T) {
596632
// Capture the request body sent to the token endpoint
597633
var receivedBody string
@@ -697,6 +733,70 @@ func TestRegisterBC3Client_DefaultRedirectPersisted(t *testing.T) {
697733
assert.NoError(t, statErr, "client.json should be written for default redirect URI")
698734
}
699735

736+
// TestRegisterBC3Client_RejectsUnsafeScheme guards against a hostile discovery
737+
// document handing the DCR POST a non-https registration endpoint (e.g.
738+
// file://). https and http-on-loopback are accepted; everything else must error
739+
// before any request is made. Mirrors buildAuthURL's scheme whitelist.
740+
func TestRegisterBC3Client_RejectsUnsafeScheme(t *testing.T) {
741+
m := &Manager{cfg: config.Default(), httpClient: http.DefaultClient}
742+
opts := &LoginOptions{RedirectURI: defaultRedirectURI}
743+
744+
rejected := []string{
745+
"file:///etc/passwd",
746+
"http://evil.example.com/register",
747+
"ftp://evil.example.com/register",
748+
"javascript:alert(1)",
749+
"data:text/html,foo",
750+
}
751+
for _, endpoint := range rejected {
752+
t.Run("rejects "+endpoint, func(t *testing.T) {
753+
_, err := m.registerBC3Client(context.Background(), endpoint, opts)
754+
require.Error(t, err)
755+
assert.Contains(t, err.Error(), "registration endpoint")
756+
})
757+
}
758+
}
759+
760+
// TestRegisterBC3Client_FollowsRedirect verifies the DCR POST uses a client
761+
// without the manager's CheckRedirect guard, so a proxy-canonicalized 3xx on
762+
// the registration endpoint is followed rather than silently failing. The DCR
763+
// body carries only client metadata, so following the redirect is safe.
764+
func TestRegisterBC3Client_FollowsRedirect(t *testing.T) {
765+
mux := http.NewServeMux()
766+
mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
767+
http.Redirect(w, r, "/register-canonical", http.StatusTemporaryRedirect)
768+
})
769+
mux.HandleFunc("/register-canonical", func(w http.ResponseWriter, r *http.Request) {
770+
w.Header().Set("Content-Type", "application/json")
771+
fmt.Fprint(w, `{"client_id":"dcr-id","client_secret":"dcr-secret"}`)
772+
})
773+
srv := httptest.NewServer(mux)
774+
defer srv.Close()
775+
776+
tmpDir := t.TempDir()
777+
t.Setenv("XDG_CONFIG_HOME", tmpDir)
778+
779+
// Manager carries a guarded client (as appctx wires it) to prove the DCR
780+
// path uses its own unguarded client rather than m.httpClient.
781+
guarded := srv.Client()
782+
guarded.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
783+
if len(via) > 0 && via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
784+
return http.ErrUseLastResponse
785+
}
786+
return nil
787+
}
788+
m := &Manager{
789+
cfg: config.Default(),
790+
httpClient: guarded,
791+
store: newTestStore(t, tmpDir),
792+
}
793+
opts := &LoginOptions{RedirectURI: "http://localhost:7777/cb"}
794+
795+
creds, err := m.registerBC3Client(context.Background(), srv.URL+"/register", opts)
796+
require.NoError(t, err)
797+
assert.Equal(t, "dcr-id", creds.ClientID)
798+
}
799+
700800
func TestLoadClientCredentials_BC3_CustomRedirect_SkipsStoredClient(t *testing.T) {
701801
// DCR server
702802
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)