Skip to content

Commit b58d06c

Browse files
authored
Merge pull request #157 from browser-use/fetch-use-endpoint
feat(bcode-browser): allow overriding the fetch-use endpoint
2 parents 0a2ccc3 + d9d571e commit b58d06c

2 files changed

Lines changed: 146 additions & 2 deletions

File tree

‎packages/bcode-browser/src/fetch-use.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { Context, Effect, Layer } from "effect"
77
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
88

9-
const ENDPOINT = "https://fetch.browser-use.com/fetch"
9+
const DEFAULT_ENDPOINT = "https://fetch.browser-use.com/fetch"
1010

1111
export interface FetchResult {
1212
readonly body: ArrayBuffer
@@ -32,11 +32,12 @@ export const layer = Layer.effect(
3232
Effect.gen(function* () {
3333
const http = yield* HttpClient.HttpClient
3434
const apiKey = process.env.BROWSER_USE_API_KEY ?? ""
35+
const endpoint = resolveEndpoint()
3536
return Service.of({
3637
enabled: apiKey.length > 0,
3738
fetch: (url, { timeoutMs }) =>
3839
Effect.gen(function* () {
39-
const request = yield* HttpClientRequest.post(ENDPOINT).pipe(
40+
const request = yield* HttpClientRequest.post(endpoint).pipe(
4041
HttpClientRequest.setHeaders({ "Content-Type": "application/json", "X-Browser-Use-API-Key": apiKey }),
4142
HttpClientRequest.bodyJson({ url, timeout_ms: timeoutMs }),
4243
)
@@ -56,4 +57,42 @@ export const layer = Layer.effect(
5657
}),
5758
)
5859

60+
// Overridable so a caller can mediate the request and keep the real key out of
61+
// this process entirely. The default endpoint is a general-purpose URL fetcher,
62+
// so anything holding the key can send it to an arbitrary host -- an untrusted
63+
// or injectable agent should be given a mediating endpoint and a throwaway
64+
// credential instead of the real one.
65+
//
66+
// Every rejection below is an operator mistake at startup, and each one would
67+
// otherwise put X-Browser-Use-API-Key somewhere it should not go. Set-but-empty
68+
// is a mistake rather than a default, because the default is the direct fetcher
69+
// -- the exact path someone setting this variable is trying to leave.
70+
function resolveEndpoint() {
71+
const configured = process.env.BCODE_FETCH_USE_ENDPOINT
72+
if (configured === undefined) return DEFAULT_ENDPOINT
73+
if (configured.trim() === "")
74+
throw new Error("BCODE_FETCH_USE_ENDPOINT is set but empty; unset it to use the default fetcher")
75+
// The messages below name the variable and at most the destination's origin,
76+
// never the value: it can carry userinfo or a token in its query, and writing
77+
// that to stderr is the same leak this override exists to close. The operator
78+
// can read back their own environment variable.
79+
if (!URL.canParse(configured)) throw new Error("BCODE_FETCH_USE_ENDPOINT is not a valid url")
80+
const url = new URL(configured)
81+
// Checked before the loopback exemption below, which would otherwise wave
82+
// through ftp://localhost and defer the failure to the first webfetch.
83+
if (url.protocol !== "https:" && url.protocol !== "http:")
84+
throw new Error(`BCODE_FETCH_USE_ENDPOINT must be http or https, not ${url.protocol}`)
85+
if (url.protocol !== "https:" && !LOOPBACK.test(url.hostname))
86+
throw new Error(
87+
`BCODE_FETCH_USE_ENDPOINT must use https outside loopback; refusing to send the api key in cleartext to ${url.origin}`,
88+
)
89+
return configured
90+
}
91+
92+
// All of 127.0.0.0/8 is loopback rather than 127.0.0.1 alone, a trailing dot is
93+
// the same name in its rooted form, and URL reports the IPv6 literal with its
94+
// brackets, so "::1" would never match. Anchored and numeric so a DNS name like
95+
// 127.example.com is not mistaken for the subnet.
96+
const LOOPBACK = /^(localhost\.?|\[::1\]|127(\.\d{1,3}){3})$/
97+
5998
export * as FetchUse from "./fetch-use"

‎packages/bcode-browser/test/fetch-use.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
23128
test.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

Comments
 (0)