Skip to content

Commit 159b0ef

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 c7b69c7 commit 159b0ef

3 files changed

Lines changed: 238 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: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,28 @@ func (m *Manager) loadBC3Client() (*ClientCredentials, error) {
621621
return &creds, nil
622622
}
623623

624+
// isSecureEndpointURL reports whether u uses a scheme safe for OAuth endpoints
625+
// derived from the server-controlled discovery document: https, or http only on
626+
// loopback for local development. Centralizing the rule keeps the registration,
627+
// authorization, and redirect-following checks consistent.
628+
func isSecureEndpointURL(u *url.URL) bool {
629+
return u.Scheme == "https" || (u.Scheme == "http" && hostutil.IsLocalhost(u.Host))
630+
}
631+
624632
func (m *Manager) registerBC3Client(ctx context.Context, registrationEndpoint string, opts *LoginOptions) (*ClientCredentials, error) {
633+
// The registration endpoint comes from the server-controlled discovery
634+
// document. Restrict it to https (or http on loopback for local
635+
// development) so a hostile discovery doc can't hand the DCR POST a
636+
// file:// (or other) scheme that RequireSecureURL would let through.
637+
// Mirrors buildAuthURL's scheme whitelist.
638+
u, err := url.Parse(registrationEndpoint)
639+
if err != nil {
640+
return nil, output.ErrAuth(fmt.Sprintf("invalid registration endpoint %q: %v", registrationEndpoint, err))
641+
}
642+
if !isSecureEndpointURL(u) {
643+
return nil, output.ErrAuth(fmt.Sprintf("invalid registration endpoint %q: scheme must be https (or http on loopback)", registrationEndpoint))
644+
}
645+
625646
customRedirect := opts.RedirectURI != defaultRedirectURI
626647
regReq := map[string]any{
627648
"client_name": "basecamp-cli",
@@ -643,7 +664,29 @@ func (m *Manager) registerBC3Client(ctx context.Context, registrationEndpoint st
643664
}
644665
req.Header.Set("Content-Type", "application/json")
645666

646-
resp, err := m.httpClient.Do(req)
667+
// Use a dedicated client with its own CheckRedirect guard. The DCR POST body
668+
// carries only client metadata (no auth code or refresh token), so following a
669+
// proxy-canonicalized 3xx redirect is safe — and necessary, since the manager's
670+
// guarded client would silently fail first-time login on such redirects. But Go
671+
// replays the POST body on 307/308, so re-validate EACH hop's target with the
672+
// same scheme rule applied to the registration endpoint; otherwise a hostile
673+
// server could 307 the body to a file:// or non-loopback http:// URL, escaping
674+
// the whitelist that only covered the original endpoint.
675+
checkRedirect := func(req *http.Request, _ []*http.Request) error {
676+
if !isSecureEndpointURL(req.URL) {
677+
return output.ErrAuth(fmt.Sprintf("refusing DCR redirect to %q: scheme must be https (or http on loopback)", req.URL.String()))
678+
}
679+
return nil
680+
}
681+
var dcrClient *http.Client
682+
if m.httpClient != nil {
683+
c := *m.httpClient // http.Client has no locks; value copy is safe
684+
c.CheckRedirect = checkRedirect
685+
dcrClient = &c
686+
} else {
687+
dcrClient = &http.Client{Timeout: 30 * time.Second, CheckRedirect: checkRedirect}
688+
}
689+
resp, err := dcrClient.Do(req)
647690
if err != nil {
648691
return nil, output.ErrNetwork(err)
649692
}
@@ -704,6 +747,14 @@ func (m *Manager) buildAuthURL(cfg *oauth.Config, oauthType, scope, state, codeC
704747
return "", err
705748
}
706749

750+
// The authorization endpoint comes from the server-controlled discovery
751+
// document and is later dispatched to the OS browser handler (xdg-open /
752+
// open). Restrict it to https (or http on loopback for local development)
753+
// so a hostile discovery doc can't hand the OS a file:// (or other) URL.
754+
if !isSecureEndpointURL(u) {
755+
return "", output.ErrAuth(fmt.Sprintf("invalid authorization endpoint %q: scheme must be https (or http on loopback)", cfg.AuthorizationEndpoint))
756+
}
757+
707758
q := u.Query()
708759
q.Set("response_type", "code")
709760
q.Set("client_id", clientID)

internal/auth/auth_test.go

Lines changed: 168 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,138 @@ 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 follows a
761+
// proxy-canonicalized 3xx on the registration endpoint (rather than silently
762+
// failing under the manager's GET-only guard) when the redirect target stays
763+
// within the secure-endpoint whitelist — here a loopback http:// hop. The DCR
764+
// body carries only client metadata, so following such a redirect is safe.
765+
func TestRegisterBC3Client_FollowsRedirect(t *testing.T) {
766+
mux := http.NewServeMux()
767+
mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
768+
http.Redirect(w, r, "/register-canonical", http.StatusTemporaryRedirect)
769+
})
770+
mux.HandleFunc("/register-canonical", func(w http.ResponseWriter, r *http.Request) {
771+
w.Header().Set("Content-Type", "application/json")
772+
fmt.Fprint(w, `{"client_id":"dcr-id","client_secret":"dcr-secret"}`)
773+
})
774+
srv := httptest.NewServer(mux)
775+
defer srv.Close()
776+
777+
tmpDir := t.TempDir()
778+
t.Setenv("XDG_CONFIG_HOME", tmpDir)
779+
780+
// Manager carries a guarded client (as appctx wires it) to prove the DCR
781+
// path uses its own unguarded client rather than m.httpClient.
782+
guarded := srv.Client()
783+
guarded.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
784+
if len(via) > 0 && via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
785+
return http.ErrUseLastResponse
786+
}
787+
return nil
788+
}
789+
m := &Manager{
790+
cfg: config.Default(),
791+
httpClient: guarded,
792+
store: newTestStore(t, tmpDir),
793+
}
794+
opts := &LoginOptions{RedirectURI: "http://localhost:7777/cb"}
795+
796+
creds, err := m.registerBC3Client(context.Background(), srv.URL+"/register", opts)
797+
require.NoError(t, err)
798+
assert.Equal(t, "dcr-id", creds.ClientID)
799+
}
800+
801+
// TestRegisterBC3Client_FollowsHTTPSRedirect verifies an https redirect hop is
802+
// followed: the scheme stays within the secure-endpoint whitelist, so the
803+
// re-validation in CheckRedirect must not reject it.
804+
func TestRegisterBC3Client_FollowsHTTPSRedirect(t *testing.T) {
805+
mux := http.NewServeMux()
806+
mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
807+
http.Redirect(w, r, "/register-canonical", http.StatusTemporaryRedirect)
808+
})
809+
mux.HandleFunc("/register-canonical", func(w http.ResponseWriter, r *http.Request) {
810+
w.Header().Set("Content-Type", "application/json")
811+
fmt.Fprint(w, `{"client_id":"dcr-id","client_secret":"dcr-secret"}`)
812+
})
813+
srv := httptest.NewTLSServer(mux)
814+
defer srv.Close()
815+
816+
tmpDir := t.TempDir()
817+
t.Setenv("XDG_CONFIG_HOME", tmpDir)
818+
819+
m := &Manager{
820+
cfg: config.Default(),
821+
httpClient: srv.Client(), // trusts the test server's TLS cert
822+
store: newTestStore(t, tmpDir),
823+
}
824+
opts := &LoginOptions{RedirectURI: "http://localhost:7777/cb"}
825+
826+
creds, err := m.registerBC3Client(context.Background(), srv.URL+"/register", opts)
827+
require.NoError(t, err)
828+
assert.Equal(t, "dcr-id", creds.ClientID)
829+
}
830+
831+
// TestRegisterBC3Client_RejectsUnsafeRedirect guards against a hostile server
832+
// 307/308-redirecting the DCR POST (body and all) to a scheme/host outside the
833+
// secure-endpoint whitelist that was only enforced on the original endpoint.
834+
// Each redirect hop must be re-validated, so file:// and non-loopback http://
835+
// targets are rejected before the body is replayed.
836+
func TestRegisterBC3Client_RejectsUnsafeRedirect(t *testing.T) {
837+
targets := map[string]string{
838+
"file scheme": "file:///etc/passwd",
839+
"non-loopback http": "http://evil.example.com/register",
840+
"other scheme": "ftp://evil.example.com/register",
841+
}
842+
for name, target := range targets {
843+
t.Run(name, func(t *testing.T) {
844+
mux := http.NewServeMux()
845+
mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
846+
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
847+
})
848+
srv := httptest.NewServer(mux)
849+
defer srv.Close()
850+
851+
tmpDir := t.TempDir()
852+
t.Setenv("XDG_CONFIG_HOME", tmpDir)
853+
854+
m := &Manager{
855+
cfg: config.Default(),
856+
httpClient: srv.Client(),
857+
store: newTestStore(t, tmpDir),
858+
}
859+
opts := &LoginOptions{RedirectURI: "http://localhost:7777/cb"}
860+
861+
_, err := m.registerBC3Client(context.Background(), srv.URL+"/register", opts)
862+
require.Error(t, err)
863+
assert.Contains(t, err.Error(), "redirect")
864+
})
865+
}
866+
}
867+
700868
func TestLoadClientCredentials_BC3_CustomRedirect_SkipsStoredClient(t *testing.T) {
701869
// DCR server
702870
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)