From f27ec054e30b68b6c01fbed844bd3025dab15316 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 09:02:38 -0700 Subject: [PATCH] fix(cockpit): accept the platform slash collapse for the consecutive-slash raw canary 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::399. Co-Authored-By: Claude Fable 5.1 --- apps/cockpit/scripts/deploy-smoke.spec.ts | 100 +++++++++++++++++++++- apps/cockpit/scripts/deploy-smoke.ts | 53 +++++++++++- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/apps/cockpit/scripts/deploy-smoke.spec.ts b/apps/cockpit/scripts/deploy-smoke.spec.ts index 86c25f096..0510b937b 100644 --- a/apps/cockpit/scripts/deploy-smoke.spec.ts +++ b/apps/cockpit/scripts/deploy-smoke.spec.ts @@ -312,7 +312,7 @@ describe('redirect deploy smoke contract', () => { expect(sleep).toHaveBeenCalledTimes(1); }); - it('identifies the Vercel Raw Path prerequisite when a raw canary is normalized', async () => { + it('identifies the raw-path rejection route when a raw canary is normalized', async () => { const cases = buildRedirectSmokeCases('production'); const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => { const smokeCase = cases.find( @@ -329,7 +329,103 @@ describe('redirect deploy smoke contract', () => { mode: 'production', requestImpl, }) - ).rejects.toThrow(/WAF Raw Path prerequisite/); + ).rejects.toThrow(/vercel\.cockpit\.json/); + }); + + it('accepts only the platform same-origin slash collapse for a consecutive-slash probe', async () => { + // 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, so that probe can never reach the 404 route. The only + // acceptable non-404 answer is that exact normalization; a redirect off + // the deployment from a malformed path is still a contract failure. + const cases = buildRedirectSmokeCases('preview'); + const slashCase = cases.find( + (smokeCase) => smokeCase.raw && smokeCase.path.includes('//') + ); + const dotCase = cases.find( + (smokeCase) => smokeCase.raw && smokeCase.path.includes('/./') + ); + if (!slashCase || !dotCase) throw new Error('Expected raw canaries'); + expect(slashCase.platformNormalizedPath).toBe( + '/langgraph/core-capabilities/streaming/overview/python' + ); + expect(dotCase.platformNormalizedPath).toBeUndefined(); + + const impl = (answer: (request: RedirectSmokeRequest) => RedirectSmokeResponse | null) => + vi.fn(async (request: RedirectSmokeRequest) => + answer(request) ?? responseFor(request, cases) + ); + + await expect( + runDeploySmoke({ + url: previewUrl, + mode: 'preview', + requestImpl: impl((request) => + request.path === slashCase.path + ? { + status: 308, + // The platform answers with a relative Location. + headers: { + location: + '/langgraph/core-capabilities/streaming/overview/python', + }, + } + : null + ), + }) + ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); + + await expect( + runDeploySmoke({ + url: previewUrl, + mode: 'preview', + requestImpl: impl((request) => + request.path === slashCase.path + ? { + status: 308, + headers: { + location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`, + }, + } + : null + ), + }) + ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); + + await expect( + runDeploySmoke({ + url: previewUrl, + mode: 'preview', + requestImpl: impl((request) => + request.path === slashCase.path + ? { + status: 308, + headers: { + location: + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run', + }, + } + : null + ), + }) + ).rejects.toThrow(/raw malformed 1.*expected 404, received 308/); + + await expect( + runDeploySmoke({ + url: previewUrl, + mode: 'preview', + requestImpl: impl((request) => + request.path === dotCase.path + ? { + status: 308, + headers: { + location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`, + }, + } + : null + ), + }) + ).rejects.toThrow(/expected 404, received 308/); }); it('sends the automation bypass on every probe only when a secret is supplied', async () => { diff --git a/apps/cockpit/scripts/deploy-smoke.ts b/apps/cockpit/scripts/deploy-smoke.ts index 0d3d8c363..bb0828f72 100644 --- a/apps/cockpit/scripts/deploy-smoke.ts +++ b/apps/cockpit/scripts/deploy-smoke.ts @@ -36,6 +36,15 @@ export interface RedirectSmokeCase { readonly expectedLocation?: string; readonly headers?: Readonly>; readonly raw?: boolean; + /** + * 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, so a raw probe carrying `//` can never reach the 404 + * route in vercel.cockpit.json. The only acceptable non-404 answer for such + * a probe is that exact same-origin normalization — never a redirect off + * the deployment. + */ + readonly platformNormalizedPath?: string; } export interface DeploySmokeOptions { @@ -117,7 +126,15 @@ const notFoundCase = ( name: string, path: string, raw = false -): RedirectSmokeCase => ({ name, path, expectedStatus: 404, raw }); +): RedirectSmokeCase => ({ + name, + path, + expectedStatus: 404, + raw, + ...(raw && path.includes('//') + ? { platformNormalizedPath: path.replace(/\/{2,}/g, '/') } + : {}), +}); export const RAW_MALFORMED_REQUEST_TARGETS = [ `/${ROOT_STREAMING_LEGACY_PATH}`, @@ -384,14 +401,44 @@ export const requestExactTarget: RedirectSmokeRequestImpl = ({ class RedirectContractError extends Error {} +const isPlatformNormalization = ( + origin: string, + smokeCase: RedirectSmokeCase, + response: RedirectSmokeResponse +): boolean => { + const location = response.headers.location; + if ( + smokeCase.platformNormalizedPath === undefined || + response.status !== 308 || + location === undefined + ) { + return false; + } + // The platform answers with a relative Location; resolve both sides + // against the deployment origin so only that exact same-origin target + // passes. + let resolved: string; + try { + resolved = new URL(location, `${origin}/`).toString(); + } catch { + return false; + } + return ( + resolved === + new URL(smokeCase.platformNormalizedPath, `${origin}/`).toString() + ); +}; + const verifyCase = ( mode: DeploySmokeMode, + origin: string, smokeCase: RedirectSmokeCase, response: RedirectSmokeResponse ): void => { const rawGateHint = smokeCase.raw - ? ' Raw Path rejection failed; verify the Vercel project WAF Raw Path prerequisite before promotion.' + ? ' Raw-path rejection failed; verify the 404 route in vercel.cockpit.json still precedes framework routing before promotion.' : ''; + if (isPlatformNormalization(origin, smokeCase, response)) return; if (response.status !== smokeCase.expectedStatus) { const protectionHint = response.status === 302 && @@ -454,7 +501,7 @@ export const runDeploySmoke = async ({ path: smokeCase.path, ...(headers ? { headers } : {}), }); - verifyCase(mode, smokeCase, response); + verifyCase(mode, origin, smokeCase, response); break; } catch (error: unknown) { if (error instanceof RedirectContractError) throw error;