Input validation / Deployment token enforcement in test-harness for Turbopack static assets

MEDIUM
vercel/next.js
Commit: 1c1550ca7bb6
Affected: <=16.2.1
2026-04-04 07:50 UTC

Description

The commit adds a runtime input-validation hardening in test-mode for Turbopack to ensure static asset requests include a deployment token (?dpl=...). Specifically, in resolve-routes.ts, when NEXT_TEST_MODE and IS_TURBOPACK_TEST are true and the asset is a nextStaticFolder with a deploymentId, the server now validates that the incoming query parameter dpl matches the deploymentId; if not, it halts processing (404-like behavior). Additionally, next-server.ts forwards any dpl value from the original _next/image request to the internal static asset request so the validation can be applied consistently. Tests are updated to account for this behavior (and some test infra toggles like disableAutoSkewProtection). The change is primarily a test-infra/runtime hardening intended to prevent serving assets with an invalid or missing deployment token in Turbopack test mode, reducing risk of misrouting or leakage in test environments.

Proof of Concept

Prereq: Run Next.js test server with Turbopack test mode enabled and a deployment id set (e.g., NEXT_DEPLOYMENT_ID=dpl_123456).\n\n1) Without dpl parameter:\ncurl -sS http://localhost:3000/_next/static/media/test.png\nExpected: 404 due to deployment token validation implemented by the patch.\n\n2) With a mismatched dpl parameter:\ncurl -sS 'http://localhost:3000/_next/static/media/test.png?dpl=wrong'\nExpected: 404 (token must match deploymentId).\n\n3) With the correct dpl parameter:\ncurl -sS 'http://localhost:3000/_next/static/media/test.png?dpl=dpl_123456'\nExpected: asset served as normal (assuming other conditions are met).\n\nNote: This PoC illustrates the behavior change introduced by the patch; it assumes the server is configured with __NEXT_TEST_MODE and IS_TURBOPACK_TEST and that a deploymentId is supplied via NEXT_DEPLOYMENT_ID. In production builds, this code path should be inactive.

Commit Details

Author: Niklas Mischkulnig

Date: 2026-02-27 09:06 UTC

Message:

tests: Assert dpl query string in all tests for Turbopack (#90592) Enable skew protection for all start-mode tests when Turbopack is enabled (i.e. not dev). This works by setting `NEXT_DEPLOYMENT_ID` in the test infra - When running the tests, `packages/next/src/server/lib/router-utils/resolve-routes.ts` now validates that all static asset requests have the correct `?dpl=...` query param value, and returns a 404 if missing. - It can be disabled with `disableAutoSkewProtection: true` in the test options - There were various tests that were asserting URLs and were missing the optional `?dpl` at the end. - `next.getDeploymentIdQuery()` can be used to get the `?dpl=dpl_123912js` string (or an empty string) - `next.getAssetQuery()` currently behaves like `getDeploymentIdQuery`, but will switch to prefer the immutable static token in #88607

Triage Assessment

Vulnerability Type: Input validation

Confidence: MEDIUM

Reasoning:

The change adds a runtime check to ensure static asset requests in Turbopack test mode include the correct deployment token (?dpl=...), returning a 404 if missing. This is an input validation hardening to prevent serving assets with an invalid deployment token, which could otherwise lead to misconfigured or leaked asset handling during testing. While the change is test-infra oriented, it directly guards asset resolution logic against tampered/incorrect query parameters.

Verification Assessment

Vulnerability Type: Input validation / Deployment token enforcement in test-harness for Turbopack static assets

Confidence: MEDIUM

Affected Versions: <=16.2.1

Code Diff

diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 62713fb587086..c8c793d45d3b1 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -474,6 +474,27 @@ export function getResolveRoutes( if (output.locale) { addRequestMeta(req, 'locale', output.locale) } + + if ( + process.env.__NEXT_TEST_MODE && + process.env.IS_TURBOPACK_TEST && + output.type === 'nextStaticFolder' && + config.deploymentId + ) { + const expectedToken = config.deploymentId + if (parsedUrl.query.dpl !== expectedToken) { + console.error( + `Invalid dpl query param: ${req.url}, expected: ${expectedToken}` + ) + return { + finished: true, + parsedUrl, + resHeaders, + matchedOutput: null, + } + } + } + return { parsedUrl, resHeaders, diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 65825723b735e..2479adf388749 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -700,9 +700,26 @@ export default class NextNodeServer extends BaseServer< return } - const { isAbsolute, href } = paramsResult + let { href } = paramsResult - const imageUpstream = isAbsolute + if ( + process.env.__NEXT_TEST_MODE && + process.env.IS_TURBOPACK_TEST && + !paramsResult.isAbsolute + ) { + // Forward the dpl query param from the original /_next/image request to the + // internal static file request so that the static file validation in + // resolve-routes.ts can verify it. + const dpl = + typeof req.url === 'string' + ? new URL(req.url, 'http://n').searchParams.get('dpl') + : undefined + if (dpl) { + href += `${href.includes('?') ? '&' : '?'}dpl=${dpl}` + } + } + + const imageUpstream = paramsResult.isAbsolute ? await fetchExternalImage( href, this.nextConfig.images.dangerouslyAllowLocalIP, diff --git a/test/e2e/app-dir-export/test/utils.ts b/test/e2e/app-dir-export/test/utils.ts index dce6f50178080..30494b1335f5d 100644 --- a/test/e2e/app-dir-export/test/utils.ts +++ b/test/e2e/app-dir-export/test/utils.ts @@ -210,6 +210,7 @@ export function runTests({ files: join(__dirname, '..'), skipDeployment: true, skipStart: true, + disableAutoSkewProtection: true, }) if (skipped) { return diff --git a/test/e2e/app-dir/app-esm-js/standalone.test.ts b/test/e2e/app-dir/app-esm-js/standalone.test.ts index ee46e10683129..cf06510ac86d6 100644 --- a/test/e2e/app-dir/app-esm-js/standalone.test.ts +++ b/test/e2e/app-dir/app-esm-js/standalone.test.ts @@ -52,6 +52,7 @@ if (!(globalThis as any).isNextStart) { /- Local:/, { ...process.env, + ...next.env, PORT: appPort.toString(), }, undefined, diff --git a/test/e2e/app-dir/app/standalone-gsp.test.ts b/test/e2e/app-dir/app/standalone-gsp.test.ts index be82a5f7136b7..ce7f8254489e6 100644 --- a/test/e2e/app-dir/app/standalone-gsp.test.ts +++ b/test/e2e/app-dir/app/standalone-gsp.test.ts @@ -69,6 +69,7 @@ if (!(globalThis as any).isNextStart) { /- Local:/, { ...process.env, + ...next.env, PORT: appPort.toString(), }, undefined, diff --git a/test/e2e/app-dir/app/standalone.test.ts b/test/e2e/app-dir/app/standalone.test.ts index 7330c74e5edc6..3769ff2c36a45 100644 --- a/test/e2e/app-dir/app/standalone.test.ts +++ b/test/e2e/app-dir/app/standalone.test.ts @@ -73,6 +73,7 @@ if (!(globalThis as any).isNextStart) { /- Local:/, { ...process.env, + ...next.env, PORT: appPort.toString(), }, undefined, diff --git a/test/e2e/app-dir/mdx/mdx.test.ts b/test/e2e/app-dir/mdx/mdx.test.ts index 19ab369aea2db..e4fa6a12d1d8c 100644 --- a/test/e2e/app-dir/mdx/mdx.test.ts +++ b/test/e2e/app-dir/mdx/mdx.test.ts @@ -61,7 +61,7 @@ for (const type of ['with-mdx-rs', 'without-mdx-rs']) { it('should work with next/image', async () => { const $ = await next.render$('/image') expect($('img').attr('src')).toBe( - '/_next/image?url=%2Ftest.jpg&w=384&q=75' + `/_next/image?url=%2Ftest.jpg&w=384&q=75${next.getDeploymentIdQuery(true)}` ) }) diff --git a/test/e2e/app-dir/next-image/next-image-proxy.test.ts b/test/e2e/app-dir/next-image/next-image-proxy.test.ts index fdbe842f10d20..48eb689c4835d 100644 --- a/test/e2e/app-dir/next-image/next-image-proxy.test.ts +++ b/test/e2e/app-dir/next-image/next-image-proxy.test.ts @@ -1,5 +1,5 @@ import { join } from 'path' -import { findPort, check } from 'next-test-utils' +import { findPort, retry } from 'next-test-utils' import https from 'https' import httpProxy from 'http-proxy' import fs from 'fs' @@ -77,32 +77,21 @@ describe('next-image-proxy', () => { }) const local = await browser.elementByCss('#app-page').getAttribute('src') - - if (process.env.IS_TURBOPACK_TEST) { - expect(local).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=90"` - ) - } else { - expect(local).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=90"` - ) - } + expect(local.replace(/test\.[0-9a-f]{8,}\.png/g, 'test.HASH.png')).toEqual( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=90${next.getAssetQuery(true)}` + ) const remote = await browser .elementByCss('#remote-app-page') .getAttribute('src') - if (process.env.IS_TURBOPACK_TEST) { - expect(remote).toMatchInlineSnapshot( - `"/_next/image?url=https%3A%2F%2Fimage-optimization-test.vercel.app%2Ftest.jpg&w=640&q=90"` - ) - } else { - expect(remote).toMatchInlineSnapshot( - `"/_next/image?url=https%3A%2F%2Fimage-optimization-test.vercel.app%2Ftest.jpg&w=640&q=90"` - ) - } + expect(remote).toEqual( + `/_next/image?url=https%3A%2F%2Fimage-optimization-test.vercel.app%2Ftest.jpg&w=640&q=90` + ) - const expected = JSON.stringify({ fulfilledCount: 4, failCount: 0 }) - await check(() => JSON.stringify({ fulfilledCount, failCount }), expected) + await retry(() => { + expect(fulfilledCount).toBe(4) + expect(failCount).toBe(0) + }) await browser.close() }) diff --git a/test/e2e/app-dir/next-image/next-image.test.ts b/test/e2e/app-dir/next-image/next-image.test.ts index 2a01785be7acc..217b5cda7bb4a 100644 --- a/test/e2e/app-dir/next-image/next-image.test.ts +++ b/test/e2e/app-dir/next-image/next-image.test.ts @@ -1,151 +1,106 @@ -import { nextTestSetup } from 'e2e-utils' +import { isNextDeploy, nextTestSetup } from 'e2e-utils' import fs from 'fs-extra' import { join } from 'path' describe('app dir - next-image', () => { - const { next, skipped } = nextTestSetup({ + const { next } = nextTestSetup({ files: __dirname, - skipDeployment: true, }) - if (skipped) { - return - } - describe('ssr content', () => { - it('should handle HEAD requests for uncached images', async () => { - const imagesDir = join(next.testDir, '.next/cache/images') - await fs.remove(imagesDir).catch(() => {}) - - const $ = await next.render$('/') - const imageUrl = $('#app-layout').attr('src') - - const headRes = await next.fetch(imageUrl, { method: 'HEAD' }) - expect(headRes.status).toBe(200) - expect(headRes.headers.get('content-type')).toMatch(/^image\//) - expect(headRes.headers.get('X-Nextjs-Cache')).toBe('MISS') - - const contentLength = headRes.headers.get('content-length') - expect(Number(contentLength || '0')).toBeGreaterThan(0) - const headBody = await headRes.arrayBuffer() - expect(headBody.byteLength).toBe(0) - - const getRes = await next.fetch(imageUrl) - expect(getRes.status).toBe(200) - expect(getRes.headers.get('content-type')).toMatch(/^image\//) - expect(getRes.headers.get('X-Nextjs-Cache')).toBe('HIT') - - const getContentLength = getRes.headers.get('content-length') - expect(Number(getContentLength || '0')).toBeGreaterThan(0) - - const getBody = await getRes.arrayBuffer() - expect(getBody.byteLength).toBeGreaterThan(0) - expect(getBody.byteLength).toBe(Number(getContentLength)) - }) + if (!isNextDeploy) { + it('should handle HEAD requests for uncached images', async () => { + const imagesDir = join(next.testDir, '.next/cache/images') + await fs.remove(imagesDir).catch(() => {}) + + const $ = await next.render$('/') + const imageUrl = $('#app-layout').attr('src') + + const headRes = await next.fetch(imageUrl, { method: 'HEAD' }) + expect(headRes.status).toBe(200) + expect(headRes.headers.get('content-type')).toMatch(/^image\//) + expect(headRes.headers.get('X-Nextjs-Cache')).toBe('MISS') + + const contentLength = headRes.headers.get('content-length') + expect(Number(contentLength || '0')).toBeGreaterThan(0) + const headBody = await headRes.arrayBuffer() + expect(headBody.byteLength).toBe(0) + + const getRes = await next.fetch(imageUrl) + expect(getRes.status).toBe(200) + expect(getRes.headers.get('content-type')).toMatch(/^image\//) + expect(getRes.headers.get('X-Nextjs-Cache')).toBe('HIT') + + const getContentLength = getRes.headers.get('content-length') + expect(Number(getContentLength || '0')).toBeGreaterThan(0) + + const getBody = await getRes.arrayBuffer() + expect(getBody.byteLength).toBeGreaterThan(0) + expect(getBody.byteLength).toBe(Number(getContentLength)) + }) + } it('should render images on / route', async () => { const $ = await next.render$('/') const layout = $('#app-layout') - - if (process.env.IS_TURBOPACK_TEST) { - expect(layout.attr('src')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=85"` - ) - } else { - expect(layout.attr('src')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=85"` - ) - } - - if (process.env.IS_TURBOPACK_TEST) { - expect(layout.attr('srcset')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=640&q=85 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=85 2x"` - ) - } else { - expect(layout.attr('srcset')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=640&q=85 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=85 2x"` - ) - } + expect(stripTestHash(layout.attr('src'))).toBe( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=85${next.getAssetQuery(true)}` + ) + expect(stripTestHash(layout.attr('srcset'))).toBe( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=640&q=85${next.getAssetQuery(true)} 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=85${next.getAssetQuery(true)} 2x` + ) const page = $('#app-page') - - if (process.env.IS_TURBOPACK_TEST) { - expect(page.attr('src')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=90"` - ) - } else { - expect(page.attr('src')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=90"` - ) - } - - if (process.env.IS_TURBOPACK_TEST) { - expect(page.attr('srcset')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=640&q=90 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=90 2x"` - ) - } else { - expect(page.attr('srcset')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=640&q=90 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=90 2x"` - ) - } + expect(stripTestHash(page.attr('src'))).toBe( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=90${next.getAssetQuery(true)}` + ) + expect(stripTestHash(page.attr('srcset'))).toBe( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=640&q=90${next.getAssetQuery(true)} 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=90${next.getAssetQuery(true)} 2x` + ) const comp = $('#app-comp') - - if (process.env.IS_TURBOPACK_TEST) { - expect(comp.attr('src')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=80"` - ) - } else { - expect(comp.attr('src')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=80"` - ) - } - - if (process.env.IS_TURBOPACK_TEST) { - expect(comp.attr('srcset')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=640&q=80 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.c5ae911e.png&w=828&q=80 2x"` - ) - } else { - expect(comp.attr('srcset')).toMatchInlineSnapshot( - `"/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=640&q=80 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.3f1a293b.png&w=828&q=80 2x"` - ) - } + expect(stripTestHash(comp.attr('src'))).toBe( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=80${next.getAssetQuery(true)}` + ) + expect(stripTestHash(comp.attr('srcset'))).toBe( + `/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=640&q=80${next.getAssetQuery(true)} 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.HASH.png&w=828&q=80${next.getAssetQuery(true)} 2x` + ) }) it('should render images on /client route', async () => { const $ = await next.render$('/client') const root = $('#app-layout') - expect(root.attr('src')).toMatch( - ... [truncated]
← Back to Alerts View on GitHub →