@@ -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,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+
700868func 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