@@ -20,6 +20,111 @@ test("layer constructs and exposes `enabled` reflecting env", async () => {
2020 expect ( enabled ) . toBe ( haveKey )
2121} )
2222
23+ test ( "BCODE_FETCH_USE_ENDPOINT redirects the request and forwards the target url" , async ( ) => {
24+ // A real server, so this pins the wire behaviour a mediating proxy depends on:
25+ // the override must be used AND the target url must arrive in the body, or the
26+ // proxy has nothing to forward.
27+ const seen : { url ?: string ; key ?: string } = { }
28+ const server = Bun . serve ( {
29+ port : 0 ,
30+ fetch : async ( req ) => {
31+ seen . url = ( ( await req . json ( ) ) as { url : string } ) . url
32+ seen . key = req . headers . get ( "X-Browser-Use-API-Key" ) ?? undefined
33+ return Response . json ( { status_code : 200 , body : "ok" , headers : { "content-type" : [ "text/plain" ] } } )
34+ } ,
35+ } )
36+ const realKey = process . env . BROWSER_USE_API_KEY
37+ process . env . BCODE_FETCH_USE_ENDPOINT = `http://localhost:${ server . port } /fetch`
38+ process . env . BROWSER_USE_API_KEY = "sentinel-not-a-real-key"
39+ try {
40+ const result = await Effect . gen ( function * ( ) {
41+ return yield * ( yield * FetchUse . Service ) . fetch ( "https://example.com/page" , { timeoutMs : 30_000 } )
42+ } ) . pipe ( Effect . provide ( FetchUse . layer . pipe ( Layer . provide ( FetchHttpClient . layer ) ) ) , Effect . runPromise )
43+
44+ expect ( seen . url ) . toBe ( "https://example.com/page" )
45+ expect ( seen . key ) . toBe ( "sentinel-not-a-real-key" )
46+ expect ( new TextDecoder ( ) . decode ( result . body ) ) . toBe ( "ok" )
47+ } finally {
48+ server . stop ( true )
49+ delete process . env . BCODE_FETCH_USE_ENDPOINT
50+ if ( realKey === undefined ) delete process . env . BROWSER_USE_API_KEY
51+ else process . env . BROWSER_USE_API_KEY = realKey
52+ }
53+ } )
54+
55+ // The endpoint carries the api key, so a bad value leaks a credential rather
56+ // than merely failing. Loopback http is allowed because a mediating proxy on the
57+ // same host is the normal local arrangement.
58+ test . each ( [
59+ [ "" , "set but empty" ] ,
60+ [ " " , "set but empty" ] ,
61+ [ "not-a-url" , "not a valid url" ] ,
62+ [ "http://evil.example/fetch" , "https outside loopback" ] ,
63+ // Would otherwise pass startup and fail at the first webfetch instead.
64+ [ "ftp://localhost/fetch" , "must be http or https" ] ,
65+ ] ) ( "rejects BCODE_FETCH_USE_ENDPOINT=%p" , ( value , reason ) => {
66+ process . env . BCODE_FETCH_USE_ENDPOINT = value
67+ try {
68+ expect ( ( ) =>
69+ Effect . runSync (
70+ Effect . gen ( function * ( ) {
71+ return ( yield * FetchUse . Service ) . enabled
72+ } ) . pipe ( Effect . provide ( FetchUse . layer . pipe ( Layer . provide ( FetchHttpClient . layer ) ) ) ) ,
73+ ) ,
74+ ) . toThrow ( new RegExp ( reason . replace ( / / g, "\\s" ) ) )
75+ } finally {
76+ delete process . env . BCODE_FETCH_USE_ENDPOINT
77+ }
78+ } )
79+
80+ test ( "rejection does not echo credentials carried in the endpoint value" , ( ) => {
81+ // An endpoint can embed userinfo or a token in its query. Writing that into
82+ // stderr on a typo is the same log leak this override exists to close, so the
83+ // message may name the origin and nothing more.
84+ process . env . BCODE_FETCH_USE_ENDPOINT = "http://user:[email protected] :8080/f?token=SECRET" 85+ try {
86+ let message = ""
87+ try {
88+ Effect . runSync (
89+ Effect . gen ( function * ( ) {
90+ return ( yield * FetchUse . Service ) . enabled
91+ } ) . pipe ( Effect . provide ( FetchUse . layer . pipe ( Layer . provide ( FetchHttpClient . layer ) ) ) ) ,
92+ )
93+ } catch ( e ) {
94+ message = String ( e )
95+ }
96+ expect ( message ) . toContain ( "https outside loopback" )
97+ expect ( message ) . toContain ( "evil.example:8080" )
98+ expect ( message ) . not . toContain ( "hunter2" )
99+ expect ( message ) . not . toContain ( "SECRET" )
100+ } finally {
101+ delete process . env . BCODE_FETCH_USE_ENDPOINT
102+ }
103+ } )
104+
105+ test . each ( [
106+ "https://proxy.example/fetch" ,
107+ "http://127.0.0.1:7461/fetch" ,
108+ "http://[::1]:7461/fetch" ,
109+ // The whole 127/8 is loopback, and a rooted name is the same name.
110+ "http://127.0.0.2:9/fetch" ,
111+ "http://localhost./fetch" ,
112+ ] ) ( "accepts BCODE_FETCH_USE_ENDPOINT=%p" , ( value ) => {
113+ process . env . BCODE_FETCH_USE_ENDPOINT = value
114+ try {
115+ expect ( ( ) =>
116+ Effect . runSync (
117+ Effect . gen ( function * ( ) {
118+ return ( yield * FetchUse . Service ) . enabled
119+ } ) . pipe ( Effect . provide ( FetchUse . layer . pipe ( Layer . provide ( FetchHttpClient . layer ) ) ) ) ,
120+ ) ,
121+ ) . not . toThrow ( )
122+ } finally {
123+ delete process . env . BCODE_FETCH_USE_ENDPOINT
124+ }
125+ } ,
126+ )
127+
23128test . skipIf ( ! haveKey ) ( "live: fetches httpbin and returns body + content-type" , async ( ) => {
24129 const result = await Effect . gen ( function * ( ) {
25130 return yield * ( yield * FetchUse . Service ) . fetch ( "https://httpbin.org/get" , { timeoutMs : 30_000 } )
0 commit comments