Skip to content

Commit 988da79

Browse files
committed
refactor: update fetch handling to allow selective redirects including different paths, query parameters, and hash fragments on the same domain
1 parent ae5b182 commit 988da79

File tree

2 files changed

+73
-35
lines changed

2 files changed

+73
-35
lines changed

lib/fetch.test.ts

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -271,64 +271,102 @@ describe("lib/fetch", () => {
271271
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
272272
});
273273

274-
it("does NOT follow redirect to different path", async () => {
274+
it("follows redirect to different path on same domain", async () => {
275275
const redirectRes = createResponse({
276276
status: 301,
277277
headers: new Headers({ location: "https://example.com/other-path" }),
278278
});
279+
const finalRes = createResponse({ ok: true, status: 200 });
279280

280-
globalThis.fetch = vi.fn(
281-
async () => redirectRes,
282-
) as unknown as typeof fetch;
281+
const mock = vi
282+
.fn()
283+
.mockResolvedValueOnce(redirectRes)
284+
.mockResolvedValueOnce(finalRes);
285+
globalThis.fetch = mock as unknown as typeof fetch;
283286

284287
const out = await fetchWithSelectiveRedirects(
285288
"https://example.com/",
286289
{},
287290
{ timeoutMs: 50 },
288291
);
289-
expect(out).toBe(redirectRes);
290-
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
292+
expect(out).toBe(finalRes);
293+
expect(mock).toHaveBeenCalledTimes(2);
291294
});
292295

293-
it("does NOT follow redirect with query params", async () => {
296+
it("follows redirect with query params", async () => {
294297
const redirectRes = createResponse({
295298
status: 301,
296299
headers: new Headers({
297300
location: "https://example.com/?utm_source=test",
298301
}),
299302
});
303+
const finalRes = createResponse({ ok: true, status: 200 });
300304

301-
globalThis.fetch = vi.fn(
302-
async () => redirectRes,
303-
) as unknown as typeof fetch;
305+
const mock = vi
306+
.fn()
307+
.mockResolvedValueOnce(redirectRes)
308+
.mockResolvedValueOnce(finalRes);
309+
globalThis.fetch = mock as unknown as typeof fetch;
304310

305311
const out = await fetchWithSelectiveRedirects(
306312
"https://example.com/",
307313
{},
308314
{ timeoutMs: 50 },
309315
);
310-
expect(out).toBe(redirectRes);
311-
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
316+
expect(out).toBe(finalRes);
317+
expect(mock).toHaveBeenCalledTimes(2);
312318
});
313319

314-
it("handles relative redirect URLs", async () => {
320+
it("follows redirect with hash fragment", async () => {
315321
const redirectRes = createResponse({
316322
status: 301,
317-
headers: new Headers({ location: "/" }),
323+
headers: new Headers({
324+
location: "https://example.com/#section",
325+
}),
318326
});
327+
const finalRes = createResponse({ ok: true, status: 200 });
319328

320-
globalThis.fetch = vi.fn(
321-
async () => redirectRes,
322-
) as unknown as typeof fetch;
329+
const mock = vi
330+
.fn()
331+
.mockResolvedValueOnce(redirectRes)
332+
.mockResolvedValueOnce(finalRes);
333+
globalThis.fetch = mock as unknown as typeof fetch;
323334

324335
const out = await fetchWithSelectiveRedirects(
325-
"https://www.example.com/path",
336+
"https://example.com/",
326337
{},
327338
{ timeoutMs: 50 },
328339
);
329-
// Relative redirect to "/" changes the path (/path -> /), so should NOT be followed
330-
expect(out).toBe(redirectRes);
331-
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
340+
expect(out).toBe(finalRes);
341+
expect(mock).toHaveBeenCalledTimes(2);
342+
});
343+
344+
it("follows relative redirect URLs to different path", async () => {
345+
const redirectRes = createResponse({
346+
status: 301,
347+
headers: new Headers({ location: "/main" }),
348+
});
349+
const finalRes = createResponse({ ok: true, status: 200 });
350+
351+
const mock = vi
352+
.fn()
353+
.mockResolvedValueOnce(redirectRes)
354+
.mockResolvedValueOnce(finalRes);
355+
globalThis.fetch = mock as unknown as typeof fetch;
356+
357+
const out = await fetchWithSelectiveRedirects(
358+
"https://www.example.com/",
359+
{},
360+
{ timeoutMs: 50 },
361+
);
362+
expect(out).toBe(finalRes);
363+
expect(mock).toHaveBeenCalledTimes(2);
364+
// Second call should use the absolute URL
365+
expect(mock).toHaveBeenNthCalledWith(
366+
2,
367+
"https://www.example.com/main",
368+
expect.objectContaining({ redirect: "manual" }),
369+
);
332370
});
333371

334372
it("throws on too many redirects", async () => {

lib/fetch.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,16 @@ export async function headThenGet(
5454
}
5555

5656
/**
57-
* Checks if a redirect from one URL to another is allowed.
58-
* Only allows redirects between apex/www or http/https versions of the same domain.
57+
* Determines if a redirect should be followed based on selective redirect rules.
58+
* Allows redirects:
59+
* 1. Between apex/www versions (e.g., example.com -> www.example.com)
60+
* 2. Between http/https schemes
61+
* 3. To different paths on the same domain (e.g., www.example.com -> www.example.com/homepage)
62+
* 4. With different query parameters (e.g., example.com -> example.com/?ref=social)
63+
* 5. With different hash fragments (e.g., example.com -> example.com/#content)
64+
*
65+
* Blocks redirects:
66+
* - To different domains (after normalizing www)
5967
*/
6068
function isAllowedRedirect(fromUrl: string, toUrl: string): boolean {
6169
try {
@@ -72,25 +80,17 @@ function isAllowedRedirect(fromUrl: string, toUrl: string): boolean {
7280
return false;
7381
}
7482

75-
// Allow if path, search, and hash are the same (only scheme or www prefix changed)
76-
const isSchemeLike =
77-
from.pathname === to.pathname &&
78-
from.search === to.search &&
79-
from.hash === to.hash;
80-
if (isSchemeLike) {
81-
return true;
82-
}
83-
84-
return false;
83+
// Allow: same domain, any path, query params, or hash
84+
return true;
8585
} catch {
8686
// If URL parsing fails, don't allow redirect
8787
return false;
8888
}
8989
}
9090

9191
/**
92-
* Fetch with manual redirect handling that only follows redirects between
93-
* apex/www or http/https versions of the same domain.
92+
* Fetch with manual redirect handling that only follows redirects to the same domain
93+
* (allowing apex/www, http/https, path, query param, and hash changes).
9494
*/
9595
export async function fetchWithSelectiveRedirects(
9696
input: RequestInfo | URL,

0 commit comments

Comments
 (0)