Skip to content

Commit 3a43cf0

Browse files
authored
fix(@astrojs/vercel): improve internal ISR route handling (#17370)
* fix(@astrojs/vercel): improve internal ISR route handling * fix(@astrojs/vercel): allow-list ISR path token query param The ISR route rewrite carries the path token via x_astro_path_token, but that param was missing from the prerender config's allowQuery. Vercel strips any query param not in allowQuery before invoking the function, so the token never reached the entrypoint and every ISR route 404'd. Add the token to allowQuery and add a regression test asserting every param used in the ISR rewrite is allow-listed.
1 parent dec7692 commit 3a43cf0

4 files changed

Lines changed: 118 additions & 8 deletions

File tree

.changeset/modern-otters-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/vercel': patch
3+
---
4+
5+
Improves internal ISR route handling

packages/integrations/vercel/src/index.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ const PACKAGE_NAME = '@astrojs/vercel';
4444
export const ASTRO_PATH_HEADER = 'x-astro-path';
4545
export const ASTRO_PATH_PARAM = 'x_astro_path';
4646

47+
/**
48+
* ISR functions receive the target path through the `x_astro_path` query
49+
* parameter instead of a header. Because that parameter travels on the URL, it
50+
* is accompanied by this token so the entrypoint can confirm the path override
51+
* came from Astro's own build-time route rewrite rather than from an arbitrary
52+
* caller. The value is the per-build `middlewareSecret`.
53+
*/
54+
export const ASTRO_PATH_TOKEN_PARAM = 'x_astro_path_token';
55+
4756
/**
4857
* The edge function calls the node server at /_render,
4958
* with the locals serialized into this header.
@@ -60,7 +69,10 @@ const MIDDLEWARE_PATH = '_middleware';
6069
// This isn't documented by vercel anywhere, but unlike serverless
6170
// and edge functions, isr functions are not passed the original path.
6271
// Instead, we have to use $0 to refer to the regex match from "src".
63-
const ISR_PATH = `/_isr?${ASTRO_PATH_PARAM}=$0`;
72+
// The path token is appended so the entrypoint can verify the rewrite
73+
// originated from this build's route table and not from an external caller.
74+
const getIsrPath = (pathToken: string) =>
75+
`/_isr?${ASTRO_PATH_PARAM}=$0&${ASTRO_PATH_TOKEN_PARAM}=${pathToken}`;
6476

6577
// https://vercel.com/docs/concepts/functions/serverless-functions/runtimes/node-js#node.js-version
6678
const SUPPORTED_NODE_VERSIONS: Record<
@@ -487,7 +499,7 @@ export default function vercelAdapter({
487499
const dest =
488500
src.startsWith('^\\/_image') || src.startsWith('^\\/_server-islands')
489501
? NODE_PATH
490-
: ISR_PATH;
502+
: getIsrPath(middlewareSecret);
491503
if (!route.isPrerendered)
492504
routeDefinitions.push({
493505
src,
@@ -724,7 +736,7 @@ class VercelBuilder {
724736
await writeJson(prerenderConfig, {
725737
expiration: isr.expiration ?? false,
726738
bypassToken: isr.bypassToken,
727-
allowQuery: [ASTRO_PATH_PARAM],
739+
allowQuery: [ASTRO_PATH_PARAM, ASTRO_PATH_TOKEN_PARAM],
728740
passQuery: true,
729741
});
730742
}

packages/integrations/vercel/src/serverless/entrypoint.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ASTRO_MIDDLEWARE_SECRET_HEADER,
55
ASTRO_PATH_HEADER,
66
ASTRO_PATH_PARAM,
7+
ASTRO_PATH_TOKEN_PARAM,
78
} from '../index.js';
89
import { middlewareSecret, skewProtection } from 'virtual:astro-vercel:config';
910
import { createApp } from 'astro/app/entrypoint';
@@ -21,11 +22,18 @@ export default {
2122
let realPath = undefined;
2223
if (hasValidMiddlewareSecret) {
2324
realPath = request.headers.get(ASTRO_PATH_HEADER);
24-
} else if (request.headers.get('x-vercel-isr') === '1') {
25+
} else if (url.searchParams.get(ASTRO_PATH_TOKEN_PARAM) === middlewareSecret) {
26+
// ISR functions only receive the target path via the URL, so the route
27+
// rewrite carries the build's path token alongside it. Only honor the
28+
// override when that token matches, otherwise the path is ignored and
29+
// the request is served as `/_isr`.
2530
realPath = url.searchParams.get(ASTRO_PATH_PARAM);
2631
}
2732
if (typeof realPath === 'string') {
2833
url.pathname = realPath;
34+
// Remove the internal routing params so they never reach user code.
35+
url.searchParams.delete(ASTRO_PATH_PARAM);
36+
url.searchParams.delete(ASTRO_PATH_TOKEN_PARAM);
2937
request = new Request(url.toString(), {
3038
method: request.method,
3139
headers: request.headers,

packages/integrations/vercel/test/isr.test.ts

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,26 @@ describe('ISR', () => {
1919
assert.deepEqual(vcConfig, {
2020
expiration: 120,
2121
bypassToken: '1c9e601d-9943-4e7c-9575-005556d774a8',
22-
allowQuery: ['x_astro_path'],
22+
allowQuery: ['x_astro_path', 'x_astro_path_token'],
2323
passQuery: true,
2424
});
2525
});
2626

2727
it('generates expected routes', { timeout: 30000 }, async () => {
2828
const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json'));
29+
// The path token embedded in the ISR rewrites is a random per-build value,
30+
// so normalize it to a stable placeholder before comparing.
31+
const routes = deploymentConfig.routes.slice(2).map((route: Record<string, unknown>) => {
32+
if (typeof route.dest === 'string') {
33+
return {
34+
...route,
35+
dest: route.dest.replace(/x_astro_path_token=[^&]+/, 'x_astro_path_token=$TOKEN'),
36+
};
37+
}
38+
return route;
39+
});
2940
// the first two are /_astro/*, and filesystem routes
30-
assert.deepEqual(deploymentConfig.routes.slice(2), [
41+
assert.deepEqual(routes, [
3142
{
3243
src: '^/two$',
3344
dest: '_render',
@@ -58,11 +69,11 @@ describe('ISR', () => {
5869
},
5970
{
6071
src: '^/one/?$',
61-
dest: '/_isr?x_astro_path=$0',
72+
dest: '/_isr?x_astro_path=$0&x_astro_path_token=$TOKEN',
6273
},
6374
{
6475
src: '^/404/?$',
65-
dest: '/_isr?x_astro_path=$0',
76+
dest: '/_isr?x_astro_path=$0&x_astro_path_token=$TOKEN',
6677
},
6778
{
6879
dest: '_render',
@@ -71,4 +82,78 @@ describe('ISR', () => {
7182
},
7283
]);
7384
});
85+
86+
it('allow-lists every query param used in the ISR rewrite', { timeout: 30000 }, async () => {
87+
// Vercel strips any query param not present in `allowQuery` before invoking
88+
// the ISR function. If the rewrite `dest` references a param that isn't
89+
// allow-listed (e.g. the path token), that param never reaches the
90+
// entrypoint and every ISR route 404s. Assert the two stay in sync.
91+
const prerenderConfig = JSON.parse(
92+
await fixture.readFile('../.vercel/output/functions/_isr.prerender-config.json'),
93+
);
94+
const token = await readPathToken();
95+
const isrRoute = new URL(
96+
`https://example.com/_isr?x_astro_path=/one&x_astro_path_token=${token}`,
97+
);
98+
for (const param of isrRoute.searchParams.keys()) {
99+
assert.ok(
100+
prerenderConfig.allowQuery.includes(param),
101+
`ISR rewrite param "${param}" must be present in allowQuery`,
102+
);
103+
}
104+
});
105+
106+
async function loadIsrFunction() {
107+
const functionConfig = JSON.parse(
108+
await fixture.readFile('../.vercel/output/functions/_isr.func/.vc-config.json'),
109+
);
110+
const functionEntry = new URL(
111+
`../.vercel/output/functions/_isr.func/${functionConfig.handler}`,
112+
fixture.config.outDir,
113+
);
114+
return import(functionEntry.href);
115+
}
116+
117+
async function readPathToken() {
118+
const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json'));
119+
const isrRoute = deploymentConfig.routes.find(
120+
(route: { dest?: string }) =>
121+
typeof route.dest === 'string' && route.dest.startsWith('/_isr?'),
122+
);
123+
return new URL(isrRoute.dest, 'https://example.com').searchParams.get('x_astro_path_token');
124+
}
125+
126+
it('ignores x_astro_path without a valid path token', { timeout: 30000 }, async () => {
127+
const isrFunction = await loadIsrFunction();
128+
const response = await isrFunction.default.fetch(
129+
new Request('https://example.com/_isr?x_astro_path=/one'),
130+
);
131+
// Without the build's token the override is ignored, so `/_isr` matches no
132+
// route and does not render the target page.
133+
assert.equal(response.status, 404);
134+
assert.equal((await response.text()).includes('<h1>One</h1>'), false);
135+
});
136+
137+
it('ignores x_astro_path when only the x-vercel-isr header is set', {
138+
timeout: 30000,
139+
}, async () => {
140+
const isrFunction = await loadIsrFunction();
141+
const response = await isrFunction.default.fetch(
142+
new Request('https://example.com/_isr?x_astro_path=/one', {
143+
headers: { 'x-vercel-isr': '1' },
144+
}),
145+
);
146+
assert.equal(response.status, 404);
147+
assert.equal((await response.text()).includes('<h1>One</h1>'), false);
148+
});
149+
150+
it('honors x_astro_path when the valid path token is present', { timeout: 30000 }, async () => {
151+
const isrFunction = await loadIsrFunction();
152+
const token = await readPathToken();
153+
const response = await isrFunction.default.fetch(
154+
new Request(`https://example.com/_isr?x_astro_path=/one&x_astro_path_token=${token}`),
155+
);
156+
assert.equal(response.status, 200);
157+
assert.equal((await response.text()).includes('<h1>One</h1>'), true);
158+
});
74159
});

0 commit comments

Comments
 (0)