Skip to content

Commit aaf6a4a

Browse files
netanelgiladclaude
andcommitted
fetchWithAuth: validate the path is relative instead of resolving it
Review feedback: don't resolve the path against the document and don't gate the method on a browser — Nitro dispatches a relative path to another server route in-process, and the SDK should not stand in the way of that. The origin guarantee now comes from the path shape rather than from a comparison: one leading slash cannot carry a scheme, and rejecting "//host" and "/\host" covers the two forms that reach another origin. The check runs on the string a URL parser would see — tabs and newlines removed, leading C0/space trimmed — because "/<tab>/evil.example" reads as protocol-relative by the time the request is built and would otherwise pass a prefix test. The path then goes to fetch untouched, so a server-side client works: the token on such a client is the caller's own, from createClientFromRequest. Only Authorization is added — a callee building its own client from the request still needs the platform headers, which the JSDoc now says. A bare relative path ("api/orders") is no longer accepted; a leading slash is also what an in-process dispatcher expects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014tTNp16fczTqi3KQ7mGEKS
1 parent d74bc8a commit aaf6a4a

3 files changed

Lines changed: 55 additions & 41 deletions

File tree

‎src/client.types.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,16 @@ export interface Base44Client {
152152
*
153153
* Base44 keeps the user's access token in the browser's local storage, so a plain `fetch()` to your app's server routes arrives without it and the route sees an anonymous caller. `fetchWithAuth()` is the same `fetch()` with the `Authorization: Bearer <token>` header added, which is what lets a server route act on behalf of the signed-in user.
154154
*
155-
* Requests are restricted to your app's own origin so the token is never sent to a third party: pass a path such as `/api/orders`, not a full URL. An absolute URL, a protocol-relative path, or anything else that resolves to another origin throws. To call a Base44 backend function, use {@linkcode FunctionsModule.fetch | functions.fetch()}; for another origin, use plain `fetch()`.
155+
* Requests are restricted to your app's own origin so the token is never sent to a third party: pass a relative path beginning with a single `/`, such as `/api/orders`. An absolute URL, a protocol-relative `//host`, or anything else that a URL parser would read as another origin throws. To call a Base44 backend function, use {@linkcode FunctionsModule.fetch | functions.fetch()}; for another origin, use plain `fetch()`.
156156
*
157-
* When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working.
157+
* The path is passed to `fetch` unchanged, so this also works in server code, where the runtime's `fetch` decides what a relative path means — a server-side client from {@linkcode createClientFromRequest | createClientFromRequest()} carries the caller's own token. Note that only the `Authorization` header is added: a route that builds its own client from the incoming request also needs the platform's `Base44-App-Id` and `Base44-Api-Url`, which a request you construct yourself does not have.
158158
*
159-
* This method is browser-only. In server code, read the caller's token from the incoming request instead.
159+
* When no user is signed in the request is sent without an `Authorization` header, so routes that allow anonymous access keep working.
160160
*
161-
* @param path - A path on your app's own origin, such as `/api/orders`.
161+
* @param path - A relative path on your app's own origin, such as `/api/orders`.
162162
* @param init - Optional [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) options such as `method`, `headers`, `body`, and `signal`. The auth header is added automatically; an `Authorization` header you set yourself is kept.
163163
* @returns Promise resolving to a native [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
164-
* @throws {Error} When `path` resolves to a different origin, or when called outside the browser.
164+
* @throws {Error} When `path` is not a relative path on your app's own origin.
165165
*
166166
* @example
167167
* ```typescript

‎src/utils/fetch-with-auth.ts‎

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import type { AxiosInstance } from "axios";
22

33
/**
44
* Builds the client's `fetchWithAuth`: a `fetch` that attaches the signed-in
5-
* user's access token, restricted to the app's own origin.
5+
* user's access token to a request for the app's own origin.
66
*
77
* @param axios - The user-scoped axios instance. Its `Authorization` default is
88
* the live token: it follows `setToken()` and is deleted on `logout()`, so a
9-
* request never carries a token the user no longer has.
9+
* request never carries a token the user no longer has. In a server-side client
10+
* from `createClientFromRequest()` it holds the caller's own token.
1011
* @internal
1112
*/
1213
export function createFetchWithAuth(axios: AxiosInstance) {
@@ -22,47 +23,43 @@ export function createFetchWithAuth(axios: AxiosInstance) {
2223
path: string,
2324
init: RequestInit = {}
2425
): Promise<Response> {
25-
const url = resolveSameOriginUrl(path);
26+
assertOwnOriginPath(path);
27+
2628
const headers = new Headers(init.headers);
2729
const token = currentToken();
2830

2931
if (token && !headers.has("Authorization")) {
3032
headers.set("Authorization", `Bearer ${token}`);
3133
}
3234

33-
return fetch(url, { ...init, headers });
35+
// Passed through untouched: resolving it here would need a document, and a
36+
// root-relative path is already what a runtime that dispatches in-process
37+
// (Nitro's `fetch`) expects.
38+
return fetch(path, { ...init, headers });
3439
};
3540
}
3641

37-
function resolveSameOriginUrl(path: string): string {
42+
function assertOwnOriginPath(path: string): void {
3843
if (typeof path !== "string" || path === "") {
3944
throw new Error("fetchWithAuth() requires a path, such as '/api/orders'.");
4045
}
4146

42-
const location = typeof window !== "undefined" ? window.location : undefined;
43-
if (!location?.href) {
44-
throw new Error(
45-
"fetchWithAuth() is only available in the browser. In server code, read the caller's token from the request instead — see createClientFromRequest()."
46-
);
47-
}
47+
// Check what a URL parser would see, not the raw string: it drops every ASCII
48+
// tab/newline anywhere in the input and trims leading C0/space, so
49+
// "/<tab>/evil.example" would pass a naive prefix check and then resolve to
50+
// another host.
51+
const asParsed = path.replace(/[\t\n\r]/g, "").replace(/^[\x00-\x20]+/, "");
4852

49-
let pageUrl: URL;
50-
let resolved: URL;
51-
try {
52-
pageUrl = new URL(location.href);
53-
resolved = new URL(path, pageUrl);
54-
} catch {
55-
throw new Error(`fetchWithAuth() received an invalid path: "${path}".`);
56-
}
57-
58-
// Resolving before comparing is what makes this safe: a protocol-relative
59-
// path ("//evil.example"), a backslash ("/\\evil.example") and an absolute URL
60-
// all land on another origin here, and are rejected the same way.
61-
if (resolved.origin !== pageUrl.origin) {
53+
// One leading slash is the whole rule: it cannot carry a scheme, and it rules
54+
// out the two forms that reach another origin — "//host" and, since URL
55+
// parsing treats a backslash as a slash, "/\host".
56+
if (
57+
!asParsed.startsWith("/") ||
58+
asParsed.startsWith("//") ||
59+
asParsed.startsWith("/\\")
60+
) {
6261
throw new Error(
63-
`fetchWithAuth() only sends requests to your app's own origin, so the access token never reaches a third party. "${path}" resolves to ${resolved.origin}. Use base44.functions.fetch() to call a Base44 backend function, or plain fetch() for another origin.`
62+
`fetchWithAuth() only sends requests to your app's own origin, so the access token never reaches a third party. "${path}" is not a path on it — pass a relative path such as '/api/orders'. Use base44.functions.fetch() to call a Base44 backend function, or plain fetch() for another origin.`
6463
);
6564
}
66-
67-
return resolved.toString();
6865
}

‎tests/unit/fetch-with-auth.test.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe("fetchWithAuth", () => {
6868
await base44.fetchWithAuth("/api/orders");
6969

7070
const { url, headers } = lastCall();
71-
expect(url).toBe(`${origin}/api/orders`);
71+
expect(url).toBe("/api/orders");
7272
expect(headers.get("Authorization")).toBe("Bearer user-token");
7373
});
7474

@@ -133,20 +133,26 @@ describe("fetchWithAuth", () => {
133133
expect(headers.get("Authorization")).toBe("Bearer caller-token");
134134
});
135135

136-
test("resolves a path relative to the current page", async () => {
136+
test("passes the path through untouched", async () => {
137137
stubBrowser();
138138
const base44 = createTestClient("user-token");
139139

140-
await base44.fetchWithAuth("api/orders");
140+
await base44.fetchWithAuth("/api/orders?status=open#top");
141141

142-
expect(lastCall().url).toBe(`${origin}/api/orders`);
142+
expect(lastCall().url).toBe("/api/orders?status=open#top");
143143
});
144144

145145
test.each([
146146
["an absolute URL", "https://evil.example/steal"],
147147
["a protocol-relative path", "//evil.example/steal"],
148148
["a backslash-prefixed path", "/\\evil.example/steal"],
149149
["an absolute URL on another port", `${origin}:8443/api/orders`],
150+
["a bare relative path", "api/orders"],
151+
// A URL parser drops tabs/newlines and trims leading space, so these read
152+
// as "//evil.example" by the time the request is built.
153+
["a tab-split protocol-relative path", "/\t/evil.example/steal"],
154+
["a newline-split protocol-relative path", "/\n/evil.example/steal"],
155+
["a space-padded protocol-relative path", " //evil.example/steal"],
150156
])("rejects %s", async (_label, path) => {
151157
stubBrowser();
152158
const base44 = createTestClient("user-token");
@@ -165,12 +171,23 @@ describe("fetchWithAuth", () => {
165171
expect(fetchMock).not.toHaveBeenCalled();
166172
});
167173

168-
test("throws outside the browser", async () => {
169-
const base44 = createTestClient("user-token");
174+
test("works with no document, as in a server route", async () => {
175+
// No stubBrowser(): window is undefined here, the way it is on a worker.
176+
const base44 = createTestClient("caller-token");
170177

171-
await expect(base44.fetchWithAuth("/api/orders")).rejects.toThrow(
172-
/only available in the browser/
173-
);
178+
await base44.fetchWithAuth("/api/orders");
179+
180+
const { url, headers } = lastCall();
181+
expect(url).toBe("/api/orders");
182+
expect(headers.get("Authorization")).toBe("Bearer caller-token");
183+
});
184+
185+
test("rejects another origin with no document too", async () => {
186+
const base44 = createTestClient("caller-token");
187+
188+
await expect(
189+
base44.fetchWithAuth("https://evil.example/steal")
190+
).rejects.toThrow(/only sends requests to your app's own origin/);
174191
expect(fetchMock).not.toHaveBeenCalled();
175192
});
176193
});

0 commit comments

Comments
 (0)