Skip to content

Commit e643849

Browse files
bloveclaude
andauthored
fix(cockpit): accept the platform slash collapse for the consecutive-slash raw canary (#987)
With the cockpit bypass secret in place, the exhaustive preview smoke ran against the immutable artifact for the first time and stopped on its first raw canary: [preview] raw malformed 1: //langgraph/...: expected 404, received 308. Vercel's CDN collapses consecutive slashes and answers 308 to the single-slash path on the same origin before any route, rewrite, or function runs, and there is no setting to turn that off. The 404 route in vercel.cockpit.json therefore never sees a `//` request; it does reject the other seven raw targets, all verified live. The "WAF Raw Path prerequisite" the hint named does not exist. The consecutive-slash canary now accepts exactly one non-404 answer: a 308 whose Location resolves to the same-origin single-slash path. A redirect off the deployment from a malformed path, or a normalization of any other raw target, is still a contract failure. The hint now points at the real prerequisite, the vercel.cockpit.json route. Verified live with the bypass: pass:preview:<immutable artifact>:399. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5e420e7 commit e643849

2 files changed

Lines changed: 148 additions & 5 deletions

File tree

apps/cockpit/scripts/deploy-smoke.spec.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ describe('redirect deploy smoke contract', () => {
312312
expect(sleep).toHaveBeenCalledTimes(1);
313313
});
314314

315-
it('identifies the Vercel Raw Path prerequisite when a raw canary is normalized', async () => {
315+
it('identifies the raw-path rejection route when a raw canary is normalized', async () => {
316316
const cases = buildRedirectSmokeCases('production');
317317
const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => {
318318
const smokeCase = cases.find(
@@ -329,7 +329,103 @@ describe('redirect deploy smoke contract', () => {
329329
mode: 'production',
330330
requestImpl,
331331
})
332-
).rejects.toThrow(/WAF Raw Path prerequisite/);
332+
).rejects.toThrow(/vercel\.cockpit\.json/);
333+
});
334+
335+
it('accepts only the platform same-origin slash collapse for a consecutive-slash probe', async () => {
336+
// Vercel's CDN collapses consecutive slashes and answers 308 to the
337+
// single-slash path on the same origin before any route, rewrite, or
338+
// function runs, so that probe can never reach the 404 route. The only
339+
// acceptable non-404 answer is that exact normalization; a redirect off
340+
// the deployment from a malformed path is still a contract failure.
341+
const cases = buildRedirectSmokeCases('preview');
342+
const slashCase = cases.find(
343+
(smokeCase) => smokeCase.raw && smokeCase.path.includes('//')
344+
);
345+
const dotCase = cases.find(
346+
(smokeCase) => smokeCase.raw && smokeCase.path.includes('/./')
347+
);
348+
if (!slashCase || !dotCase) throw new Error('Expected raw canaries');
349+
expect(slashCase.platformNormalizedPath).toBe(
350+
'/langgraph/core-capabilities/streaming/overview/python'
351+
);
352+
expect(dotCase.platformNormalizedPath).toBeUndefined();
353+
354+
const impl = (answer: (request: RedirectSmokeRequest) => RedirectSmokeResponse | null) =>
355+
vi.fn(async (request: RedirectSmokeRequest) =>
356+
answer(request) ?? responseFor(request, cases)
357+
);
358+
359+
await expect(
360+
runDeploySmoke({
361+
url: previewUrl,
362+
mode: 'preview',
363+
requestImpl: impl((request) =>
364+
request.path === slashCase.path
365+
? {
366+
status: 308,
367+
// The platform answers with a relative Location.
368+
headers: {
369+
location:
370+
'/langgraph/core-capabilities/streaming/overview/python',
371+
},
372+
}
373+
: null
374+
),
375+
})
376+
).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`);
377+
378+
await expect(
379+
runDeploySmoke({
380+
url: previewUrl,
381+
mode: 'preview',
382+
requestImpl: impl((request) =>
383+
request.path === slashCase.path
384+
? {
385+
status: 308,
386+
headers: {
387+
location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`,
388+
},
389+
}
390+
: null
391+
),
392+
})
393+
).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`);
394+
395+
await expect(
396+
runDeploySmoke({
397+
url: previewUrl,
398+
mode: 'preview',
399+
requestImpl: impl((request) =>
400+
request.path === slashCase.path
401+
? {
402+
status: 308,
403+
headers: {
404+
location:
405+
'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run',
406+
},
407+
}
408+
: null
409+
),
410+
})
411+
).rejects.toThrow(/raw malformed 1.*expected 404, received 308/);
412+
413+
await expect(
414+
runDeploySmoke({
415+
url: previewUrl,
416+
mode: 'preview',
417+
requestImpl: impl((request) =>
418+
request.path === dotCase.path
419+
? {
420+
status: 308,
421+
headers: {
422+
location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`,
423+
},
424+
}
425+
: null
426+
),
427+
})
428+
).rejects.toThrow(/expected 404, received 308/);
333429
});
334430

335431
it('sends the automation bypass on every probe only when a secret is supplied', async () => {

apps/cockpit/scripts/deploy-smoke.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export interface RedirectSmokeCase {
3636
readonly expectedLocation?: string;
3737
readonly headers?: Readonly<Record<string, string>>;
3838
readonly raw?: boolean;
39+
/**
40+
* Vercel's CDN collapses consecutive slashes and answers 308 to the
41+
* single-slash path on the same origin before any route, rewrite, or
42+
* function runs, so a raw probe carrying `//` can never reach the 404
43+
* route in vercel.cockpit.json. The only acceptable non-404 answer for such
44+
* a probe is that exact same-origin normalization — never a redirect off
45+
* the deployment.
46+
*/
47+
readonly platformNormalizedPath?: string;
3948
}
4049

4150
export interface DeploySmokeOptions {
@@ -117,7 +126,15 @@ const notFoundCase = (
117126
name: string,
118127
path: string,
119128
raw = false
120-
): RedirectSmokeCase => ({ name, path, expectedStatus: 404, raw });
129+
): RedirectSmokeCase => ({
130+
name,
131+
path,
132+
expectedStatus: 404,
133+
raw,
134+
...(raw && path.includes('//')
135+
? { platformNormalizedPath: path.replace(/\/{2,}/g, '/') }
136+
: {}),
137+
});
121138

122139
export const RAW_MALFORMED_REQUEST_TARGETS = [
123140
`/${ROOT_STREAMING_LEGACY_PATH}`,
@@ -384,14 +401,44 @@ export const requestExactTarget: RedirectSmokeRequestImpl = ({
384401

385402
class RedirectContractError extends Error {}
386403

404+
const isPlatformNormalization = (
405+
origin: string,
406+
smokeCase: RedirectSmokeCase,
407+
response: RedirectSmokeResponse
408+
): boolean => {
409+
const location = response.headers.location;
410+
if (
411+
smokeCase.platformNormalizedPath === undefined ||
412+
response.status !== 308 ||
413+
location === undefined
414+
) {
415+
return false;
416+
}
417+
// The platform answers with a relative Location; resolve both sides
418+
// against the deployment origin so only that exact same-origin target
419+
// passes.
420+
let resolved: string;
421+
try {
422+
resolved = new URL(location, `${origin}/`).toString();
423+
} catch {
424+
return false;
425+
}
426+
return (
427+
resolved ===
428+
new URL(smokeCase.platformNormalizedPath, `${origin}/`).toString()
429+
);
430+
};
431+
387432
const verifyCase = (
388433
mode: DeploySmokeMode,
434+
origin: string,
389435
smokeCase: RedirectSmokeCase,
390436
response: RedirectSmokeResponse
391437
): void => {
392438
const rawGateHint = smokeCase.raw
393-
? ' Raw Path rejection failed; verify the Vercel project WAF Raw Path prerequisite before promotion.'
439+
? ' Raw-path rejection failed; verify the 404 route in vercel.cockpit.json still precedes framework routing before promotion.'
394440
: '';
441+
if (isPlatformNormalization(origin, smokeCase, response)) return;
395442
if (response.status !== smokeCase.expectedStatus) {
396443
const protectionHint =
397444
response.status === 302 &&
@@ -454,7 +501,7 @@ export const runDeploySmoke = async ({
454501
path: smokeCase.path,
455502
...(headers ? { headers } : {}),
456503
});
457-
verifyCase(mode, smokeCase, response);
504+
verifyCase(mode, origin, smokeCase, response);
458505
break;
459506
} catch (error: unknown) {
460507
if (error instanceof RedirectContractError) throw error;

0 commit comments

Comments
 (0)