@@ -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+
595631func 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+
700800func 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