diff --git a/.github/workflows/graphite_ci_optimizer.yml b/.github/workflows/graphite_ci_optimizer.yml index 722492300ef2..81aef7c55a72 100644 --- a/.github/workflows/graphite_ci_optimizer.yml +++ b/.github/workflows/graphite_ci_optimizer.yml @@ -36,10 +36,14 @@ jobs: name: Graphite CI Optimizer runs-on: ubuntu-latest outputs: + # `== 'true'` comparison: If `step.check-skip` fails (`continue-on-error`), that output will + # be an empty string, which sets our `skip` output to `'false'`. skip: ${{ env.HAS_BYPASS_LABEL == 'false' && steps.check-skip.outputs.skip == 'true' }} steps: - name: Optimize CI id: check-skip + # Graphite's action is designed to fail open, but still sometimes fails if GH's infra flakes + continue-on-error: true uses: withgraphite/graphite-ci-action@ee395f3a78254c006d11339669c6cabddf196f72 # main with: graphite_token: ${{ secrets.GRAPHITE_TOKEN }} diff --git a/docs/01-app/03-api-reference/04-functions/unstable_io.mdx b/docs/01-app/03-api-reference/04-functions/io.mdx similarity index 50% rename from docs/01-app/03-api-reference/04-functions/unstable_io.mdx rename to docs/01-app/03-api-reference/04-functions/io.mdx index f05ed3dbe62e..5f0181f80f1d 100644 --- a/docs/01-app/03-api-reference/04-functions/unstable_io.mdx +++ b/docs/01-app/03-api-reference/04-functions/io.mdx @@ -1,22 +1,22 @@ --- -title: unstable_io -description: API Reference for the unstable_io function. +title: io +description: API Reference for the io function. version: draft --- -`unstable_io()` informs Next.js that an IO operation will follow this call. When [Cache Components](/docs/app/api-reference/config/next-config-js/cacheComponents) is not enabled or when rendering in Pages Router, signifying IO in this way is not meaningful and the call will always resolve immediately. When Cache Components is enabled, you may be required to add `await unstable_io()` preceding synchronous IO that is encountered while prerendering pages (`new Date()` for example). Additionally, if you want to avoid having genuine uncached IO invoked while prerendering, you shield it by preceeding it with `await unstable_io()`. +`io()` informs Next.js that an IO operation will follow this call. When [Cache Components](/docs/app/api-reference/config/next-config-js/cacheComponents) is not enabled or when rendering in Pages Router, signifying IO in this way is not meaningful and the call will always resolve immediately. When Cache Components is enabled, you may be required to add `await io()` preceding synchronous IO that is encountered while prerendering pages (`new Date()` for example). Additionally, if you want to avoid having genuine uncached IO invoked while prerendering, you shield it by preceeding it with `await io()`. ```ts filename="app/page.tsx" switcher -import { unstable_io } from 'next/cache' +import { io } from 'next/cache' import { db } from '@/lib/db' export default async function Page() { // Synchronous IO: new Date() would fail during prerender without this - await unstable_io() + await io() const now = new Date().toISOString() // Async IO: the query would run and be discarded during prerender; - // unstable_io() above lets Next.js skip it entirely + // io() above lets Next.js skip it entirely const orders = await db.query('SELECT * FROM orders LIMIT 10') return ( @@ -33,16 +33,16 @@ export default async function Page() { ``` ```js filename="app/page.js" switcher -import { unstable_io } from 'next/cache' +import { io } from 'next/cache' import { db } from '@/lib/db' export default async function Page() { // Synchronous IO: new Date() would fail during prerender without this - await unstable_io() + await io() const now = new Date().toISOString() // Async IO: the query would run and be discarded during prerender; - // unstable_io() above lets Next.js skip it entirely + // io() above lets Next.js skip it entirely const orders = await db.query('SELECT * FROM orders LIMIT 10') return ( @@ -62,16 +62,16 @@ export default async function Page() { [`connection()`](/docs/app/api-reference/functions/connection) requires an active HTTP request context and signals that the component needs request-specific data. It is imported from `next/server`. -`unstable_io()` does not require a request context. It can be used inside `"use cache"` scopes, client components, and anywhere you perform IO that should not be included in a static prerender. It is imported from `next/cache`. +`io()` does not require a request context. It can be used inside `"use cache"` scopes, client components, and anywhere you perform IO that should not be included in a static prerender. It is imported from `next/cache`. -Use `connection()` when you need the request itself (cookies, headers, etc.). Use `unstable_io()` when you perform IO that is independent of the request but should still prevent static prerendering. +Use `connection()` when you need the request itself (cookies, headers, etc.). Use `io()` when you perform IO that is independent of the request but should still prevent static prerendering. ## Reference ### Type ```ts -function unstable_io(): Promise +function io(): Promise ``` ### Parameters @@ -84,12 +84,12 @@ function unstable_io(): Promise ## Good to know -- `unstable_io()` is imported from `next/cache`, not `next/server`. -- Inside `"use cache"` scopes, `unstable_io()` resolves immediately. The cache captures the IO result at fill time. -- In client components, `unstable_io()` resolves immediately since there is no prerender context in the browser. +- `io()` is imported from `next/cache`, not `next/server`. +- Inside `"use cache"` scopes, `io()` resolves immediately. The cache captures the IO result at fill time. +- In client components, `io()` resolves immediately since there is no prerender context in the browser. ### Version History -| Version | Changes | -| --------- | -------------------- | -| `v16.x.x` | `unstable_io` added. | +| Version | Changes | +| --------- | ----------- | +| `v16.x.x` | `io` added. | diff --git a/lerna.json b/lerna.json index 57fd330c0f3b..22dafe4e2d77 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.0-canary.14" + "version": "16.3.0-canary.16" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index e80964eeb7fc..136b7bd70668 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 5a345b2fd7ac..0f316ab182ce 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.0-canary.14", + "@next/eslint-plugin-next": "16.3.0-canary.16", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 1bb5097910ee..8d21dfefc980 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index a575959aae04..a9488918943b 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index e804f2672921..e96f16be3ef4 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 2cac73f0253a..6b4ea5626d97 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index fe5aa6197c95..c94d9dcda3f2 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 2b49ecbf1253..ab03bd5ec2b3 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index ae15231701da..533fe43c34dc 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 7df5603c4fbf..5383d20fdeb8 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 01e47424f4d6..706d149aa7ce 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 3f64a3d32ed1..7a8896237837 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index fea6fe6c9dc3..6e778a00dfa3 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index f42ea9c3aa8b..4e2112ffe7bc 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index d8f302b9182a..681582e1689b 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 240a72ce406b..e6eb2f8dfdad 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "private": true, "files": [ "native/" diff --git a/packages/next/cache.d.ts b/packages/next/cache.d.ts index f72d6142983c..014e3c117eea 100644 --- a/packages/next/cache.d.ts +++ b/packages/next/cache.d.ts @@ -9,7 +9,7 @@ export { export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store' -export { unstable_io } from 'next/dist/server/request/io' +export { io } from 'next/dist/server/request/io' import { cacheTag } from 'next/dist/server/use-cache/cache-tag' diff --git a/packages/next/cache.js b/packages/next/cache.js index 17993a62d75d..c978a57cbd4d 100644 --- a/packages/next/cache.js +++ b/packages/next/cache.js @@ -17,7 +17,7 @@ if (process.env.NEXT_RUNTIME === '') { } }, unstable_noStore: function unstable_noStore() {}, - unstable_io: require('next/dist/client/request/io.browser').unstable_io, + io: require('next/dist/client/request/io.browser').io, updateTag: notAvailableInClient('updateTag'), revalidateTag: notAvailableInClient('revalidateTag'), @@ -46,7 +46,7 @@ if (process.env.NEXT_RUNTIME === '') { unstable_noStore: require('next/dist/server/web/spec-extension/unstable-no-store') .unstable_noStore, - unstable_io: require('next/dist/server/request/io').unstable_io, + io: require('next/dist/server/request/io').io, cacheLife: require('next/dist/server/use-cache/cache-life').cacheLife, cacheTag: require('next/dist/server/use-cache/cache-tag').cacheTag, } @@ -94,4 +94,4 @@ exports.unstable_cacheLife = cacheExports.unstable_cacheLife exports.cacheTag = cacheExports.cacheTag exports.unstable_cacheTag = cacheExports.unstable_cacheTag exports.refresh = cacheExports.refresh -exports.unstable_io = cacheExports.unstable_io +exports.io = cacheExports.io diff --git a/packages/next/package.json b/packages/next/package.json index c799202b8638..39c8efc6b79c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.0-canary.14", + "version": "16.3.0-canary.16", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -101,7 +101,7 @@ ] }, "dependencies": { - "@next/env": "16.3.0-canary.14", + "@next/env": "16.3.0-canary.16", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -165,11 +165,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.3.0-canary.14", - "@next/polyfill-module": "16.3.0-canary.14", - "@next/polyfill-nomodule": "16.3.0-canary.14", - "@next/react-refresh-utils": "16.3.0-canary.14", - "@next/swc": "16.3.0-canary.14", + "@next/font": "16.3.0-canary.16", + "@next/polyfill-module": "16.3.0-canary.16", + "@next/polyfill-nomodule": "16.3.0-canary.16", + "@next/react-refresh-utils": "16.3.0-canary.16", + "@next/swc": "16.3.0-canary.16", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.58.2", "@rspack/core": "1.6.7", diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index f5a63b6669ad..0f0341b8b12a 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -29,6 +29,7 @@ import { NodeNextResponse, } from '../../server/base-http/node' with { 'turbopack-transition': 'next-server-utility' } import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr' with { 'turbopack-transition': 'next-server-utility' } +import { isRSCRequestHeader } from '../../server/lib/is-rsc-request' with { 'turbopack-transition': 'next-server-utility' } import { getFallbackRouteParams, getPlaceholderFallbackRouteParams, @@ -294,7 +295,8 @@ export async function handler( // NOTE: Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later const isRSCRequest = - getRequestMeta(req, 'isRSCRequest') ?? Boolean(req.headers[RSC_HEADER]) + getRequestMeta(req, 'isRSCRequest') ?? + isRSCRequestHeader(req.headers[RSC_HEADER]) const isPossibleServerAction = getIsPossibleServerAction(req) @@ -426,7 +428,7 @@ export async function handler( const isInstantNavigationTest = exposeTestingApi && (req.headers[NEXT_INSTANT_PREFETCH_HEADER] === '1' || - (req.headers[RSC_HEADER] === undefined && + (!isRSCRequestHeader(req.headers[RSC_HEADER]) && typeof req.headers.cookie === 'string' && req.headers.cookie.includes(NEXT_INSTANT_TEST_COOKIE + '='))) diff --git a/packages/next/src/client/request/io.browser.ts b/packages/next/src/client/request/io.browser.ts index e63daa71af65..43827d084cd6 100644 --- a/packages/next/src/client/request/io.browser.ts +++ b/packages/next/src/client/request/io.browser.ts @@ -5,9 +5,9 @@ const resolvedIOPromise: Promise = Promise.resolve(undefined) ;(resolvedIOPromise as any).value = undefined /** - * Browser implementation of unstable_io(). On the client there is no + * Browser implementation of io(). On the client there is no * prerender context so we always resolve immediately. */ -export function unstable_io(): Promise { +export function io(): Promise { return resolvedIOPromise } diff --git a/packages/next/src/client/script.tsx b/packages/next/src/client/script.tsx index e28724e894cc..d1bde2e38207 100644 --- a/packages/next/src/client/script.tsx +++ b/packages/next/src/client/script.tsx @@ -6,6 +6,7 @@ import type { ScriptHTMLAttributes } from 'react' import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime' import { setAttributesFromProps } from './set-attributes-from-props' import { requestIdleCallback } from './request-idle-callback' +import { htmlEscapeJsonString } from '../shared/lib/htmlescape' const ScriptCache = new Map() const LoadCache = new Set() @@ -327,10 +328,9 @@ function Script(props: ScriptProps): JSX.Element | null { "'`) + ).toBeUndefined() + expect( + getScriptNonceFromHeader(`script-src 'nonce-" onerror="alert(1)'`) + ).toBeUndefined() + }) + + it('skips malformed nonce values and keeps looking for a valid one', () => { + expect( + getScriptNonceFromHeader( + `script-src 'nonce-" onerror="alert(1)' 'nonce-cmFuZG9tCg=='` + ) + ).toBe('cmFuZG9tCg==') + }) +}) diff --git a/packages/next/src/server/app-render/get-script-nonce-from-header.tsx b/packages/next/src/server/app-render/get-script-nonce-from-header.tsx index 63b20a005fc3..45ce83c00ae0 100644 --- a/packages/next/src/server/app-render/get-script-nonce-from-header.tsx +++ b/packages/next/src/server/app-render/get-script-nonce-from-header.tsx @@ -1,4 +1,4 @@ -import { ESCAPE_REGEX } from '../htmlescape' +const CSP_NONCE_SOURCE_REGEX = /^'nonce-([A-Za-z0-9+/_-]+={0,2})'$/ export function getScriptNonceFromHeader( cspHeaderValue: string @@ -19,35 +19,13 @@ export function getScriptNonceFromHeader( return } - // Extract the nonce from the directive - const nonce = directive - .split(' ') - // Remove the 'strict-src'/'default-src' string, this can't be the nonce. - .slice(1) - .map((source) => source.trim()) - // Find the first source with the 'nonce-' prefix. - .find( - (source) => - source.startsWith("'nonce-") && - source.length > 8 && - source.endsWith("'") - ) - // Grab the nonce by trimming the 'nonce-' prefix. - ?.slice(7, -1) + // Extract the first valid nonce from the directive. Malformed nonces are + // ignored so the request can continue without a nonce instead of failing. + for (const source of directive.split(/\s+/).slice(1)) { + const match = source.trim().match(CSP_NONCE_SOURCE_REGEX) - // If we could't find the nonce, then we're done. - if (!nonce) { - return - } - - // Don't accept the nonce value if it contains HTML escape characters. - // Technically, the spec requires a base64'd value, but this is just an - // extra layer. - if (ESCAPE_REGEX.test(nonce)) { - throw new Error( - 'Nonce value from Content-Security-Policy contained HTML escape characters.\nLearn more: https://nextjs.org/docs/messages/nonce-contained-invalid-characters' - ) + if (match) { + return match[1] + } } - - return nonce } diff --git a/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts new file mode 100644 index 000000000000..f6b386763d03 --- /dev/null +++ b/packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.test.ts @@ -0,0 +1,12 @@ +import { createServerInsertedMetadata } from './create-server-inserted-metadata' + +describe('createServerInsertedMetadata', () => { + it('escapes nonce attribute values in raw HTML output', async () => { + const getServerInsertedMetadata = + createServerInsertedMetadata(`" onerror="alert(1)`) + + await expect(getServerInsertedMetadata()).resolves.toContain( + '` + return `${REINSERT_ICON_SCRIPT}` } } diff --git a/packages/next/src/server/app-render/stream-ops.node.ts b/packages/next/src/server/app-render/stream-ops.node.ts index 554246405494..5e4e80b26253 100644 --- a/packages/next/src/server/app-render/stream-ops.node.ts +++ b/packages/next/src/server/app-render/stream-ops.node.ts @@ -31,7 +31,10 @@ import { import { indexOfUint8Array } from '../stream-utils/uint8array-helpers' import { ENCODED_TAGS } from '../stream-utils/encoded-tags' import { MISSING_ROOT_TAGS_ERROR } from '../../shared/lib/errors/constants' -import { htmlEscapeJsonString } from '../htmlescape' +import { + htmlEscapeAttributeString, + htmlEscapeJsonString, +} from '../../shared/lib/htmlescape' import { createInlinedDataReadableStream } from './use-flight-response' import type { AnyStream as AnyStreamType } from './app-render-prerender-utils' import { DetachedPromise } from '../../lib/detached-promise' @@ -937,7 +940,7 @@ export function createNodeInlinedDataStream( formState: unknown | null ): AnyStream { const startScriptTag = nonce - ? `` breaks out of the script element at the +// HTML tokenizer level, executes, and the fingerprint it leaves on `window` +// is picked up by . +export default function Page() { + return ( +
+ " + > + {/* Same idea, exercising `>` and `&&` (contains `&`). */} + {`window.__escapeProofInlineChildren = 2 > 1 && 3 > 2;console.log('running children script');`} + + ` break-out. +// 2. Whether each legitimate beforeInteractive `` inside terminates the inline __next_s push + // script at the HTML tokenizer level and the trailing ' + ) + expect(html).not.toContain( + '' + ) + expect(html).not.toContain('') + + // The fixture's exposes each result as a `data-*` + // attribute on `[data-testid="xss-status"]`. `data-ready="true"` flips + // after the effect populates the results. + // + // - `data-xss-*` must all be "false" (no injected script ran) + // - `data-escape-proof-*` must all be "true" for the inline scripts — + // their bodies evaluate a `<`/`>`/`&&` expression, so `true` proves + // both "the script ran" and "the HTML-escape round-trip didn't + // mangle the source" + // - `data-loaded-src` must be "true" (external script fetched and ran) + const browser = await next.browser('/') + const getAttr = (name: string) => + browser.elementByCss('[data-testid="xss-status"]').getAttribute(name) + + await retry(async () => { + expect(await getAttr('data-ready')).toBe('true') + }) + + expect(await getAttr('data-xss-inline-innerhtml')).toBe('false') + expect(await getAttr('data-xss-inline-children')).toBe('false') + expect(await getAttr('data-xss-src')).toBe('false') + expect(await getAttr('data-escape-proof-inline-innerhtml')).toBe('true') + expect(await getAttr('data-escape-proof-inline-children')).toBe('true') + expect(await getAttr('data-loaded-src')).toBe('true') + }) +}) diff --git a/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts b/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts index aabbb2919acc..65d116bf3991 100644 --- a/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts +++ b/test/e2e/app-dir/segment-cache/cdn-cache-busting/cdn-cache-busting.test.ts @@ -103,6 +103,31 @@ describe('segment cache (CDN cache busting)', () => { } ) + it('ignores invalid RSC header values when serving a document request', async () => { + const url = new URL(`http://localhost:${port}/target-page`) + url.searchParams.set('test', 'invalid-rsc-header') + + const invalidHeaderRes = await fetch(url, { + headers: { + rsc: '0', + }, + }) + + expect(invalidHeaderRes.status).toBe(200) + expect(invalidHeaderRes.headers.get('content-type')).toContain('text/html') + expect(await invalidHeaderRes.text()).toContain( + '
Target page
' + ) + + const htmlRes = await fetch(url) + + expect(htmlRes.status).toBe(200) + expect(htmlRes.headers.get('content-type')).toContain('text/html') + expect(await htmlRes.text()).toContain( + '
Target page
' + ) + }) + it( 'perform fully prefetched navigation when a third-party proxy ' + 'performs a redirect', diff --git a/test/e2e/app-dir/use-cache/app/(partially-static)/blocked-by-unstable-cache/page.tsx b/test/e2e/app-dir/use-cache/app/(partially-static)/blocked-by-unstable-cache/page.tsx new file mode 100644 index 000000000000..32d58a52e518 --- /dev/null +++ b/test/e2e/app-dir/use-cache/app/(partially-static)/blocked-by-unstable-cache/page.tsx @@ -0,0 +1,50 @@ +import { revalidateTag, unstable_cache } from 'next/cache' +import { setTimeout } from 'node:timers/promises' + +const getUnstableTime = unstable_cache( + async () => { + // Small artificial delay so the foreground-await is actually in flight when + // the prospective prerender decides whether `cacheSignal` is ready. + await setTimeout(100) + return new Date().toISOString() + }, + ['blocked-by-unstable-cache'], + { tags: ['blocked-by-unstable-cache-tag'], revalidate: false } +) + +async function Cached() { + 'use cache' + return ( +

+ Cached: {new Date().toISOString()} +

+ ) +} + +// Awaiting an `unstable_cache` BEFORE rendering a `'use cache'` component. +// After the tag is revalidated, the next render runs a background revalidation +// prerender. In its prospective phase, the `unstable_cache` lookup hits a stale +// entry and foreground-awaits the recompute. The downstream `` must +// still be reached during prospective so its RDC entry is populated for the +// final phase — otherwise the final phase throws "Unexpected cache miss after +// cache warming phase during prerendering". +export default async function Page() { + const time = await getUnstableTime() + + return ( +
+

+ Unstable: {time} +

+ +
{ + 'use server' + revalidateTag('blocked-by-unstable-cache-tag', 'max') + }} + > + +
+
+ ) +} diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index 4b87f3c8522a..1d6327199b6e 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -363,6 +363,35 @@ describe('use-cache', () => { expect(finalValueB).toBe(finalValueB) }) + it('should reach a "use cache" rendered after a stale unstable_cache', async () => { + // Regression test: when an `unstable_cache` lookup hits a stale entry and + // foreground-awaits its recompute, a downstream `'use cache'` invocation + // rendered after it must still be reached during the prospective prerender + // phase so its RDC entry is populated. Otherwise the final phase throws + // "Unexpected cache miss after cache warming phase during prerendering" and + // the response cache fails to write a fresh APP_PAGE entry. + const browser = await next.browser('/blocked-by-unstable-cache') + const initialUnstable = await browser.elementByCss('#unstable-time').text() + const initialCached = await browser.elementByCss('#cached-time').text() + expect(initialUnstable).toBeDateString() + expect(initialCached).toBeDateString() + + // Revalidate the unstable_cache entry so the next render foreground-awaits + // the recompute. + await browser.elementByCss('#revalidate').click() + + // After revalidation, the next render must succeed and produce a fresh + // unstable-time. If the prospective prerender's `cacheSignal` resolves + // `cacheReady` before `` is reached, the final phase throws and + // the background revalidation never writes a new APP_PAGE entry, so the + // unstable-time stays at its initial value forever. + await retry(async () => { + await browser.refresh() + const after = await browser.elementByCss('#unstable-time').text() + expect(after).not.toBe(initialUnstable) + }) + }) + it('should revalidate caches nested in unstable_cache', async () => { const browser = await next.browser('/nested-in-unstable-cache') const initial = await browser.elementByCss('p').text() @@ -495,6 +524,7 @@ describe('use-cache', () => { expect.stringMatching(/\/api\/\d/), // [id] route, second entry in generateStaticParams expect.stringMatching(/\/b\d/), + '/blocked-by-unstable-cache', '/cache-fetch', '/cache-fetch-no-store', '/cache-life', diff --git a/test/e2e/middleware-general/test/index.test.ts b/test/e2e/middleware-general/test/index.test.ts index 860531374dd9..4d75e245bf6f 100644 --- a/test/e2e/middleware-general/test/index.test.ts +++ b/test/e2e/middleware-general/test/index.test.ts @@ -14,10 +14,6 @@ describe('Middleware Runtime', () => { const isNodeMiddleware = Boolean(process.env.TEST_NODE_MIDDLEWARE) - if (isNodeMiddleware && (global as any).isNextDeploy) { - return it('should skip deploy for node middleware for now', () => {}) - } - const setup = ({ i18n }: { i18n: boolean }) => { afterAll(async () => { await next.destroy() @@ -765,6 +761,23 @@ describe('Middleware Runtime', () => { `/_next/data/${next.buildId}${i18n ? '/en' : ''}/send-url.json` ) expect(res.headers.get('req-url-path')).toEqual('/send-url') + + if (i18n) { + expect(res.headers.get('req-url-pathname')).toEqual('/send-url') + expect(res.headers.get('req-url-locale')).toEqual('en') + + const defaultLocaleRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/send-url.json` + ) + expect(defaultLocaleRes.headers.get('req-url-path')).toEqual( + '/send-url' + ) + expect(defaultLocaleRes.headers.get('req-url-pathname')).toEqual( + '/send-url' + ) + expect(defaultLocaleRes.headers.get('req-url-locale')).toEqual('en') + } }) it('should keep non data requests in their original shape', async () => { @@ -791,6 +804,18 @@ describe('Middleware Runtime', () => { expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual( `${i18n ? '/en' : ''}/ssr-page-2` ) + + if (i18n) { + const defaultLocaleDataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/ssr-page.json` + ) + const defaultLocaleJson = await defaultLocaleDataRes.json() + expect(defaultLocaleJson.pageProps.message).toEqual('Bye Cruel World') + expect(defaultLocaleDataRes.headers.get('x-nextjs-matched-path')).toBe( + '/en/ssr-page-2' + ) + } }) it(`hard-navigates when the data request failed`, async () => { diff --git a/test/e2e/middleware-matcher/index.test.ts b/test/e2e/middleware-matcher/index.test.ts index 92aede717250..deeda042e67c 100644 --- a/test/e2e/middleware-matcher/index.test.ts +++ b/test/e2e/middleware-matcher/index.test.ts @@ -319,6 +319,112 @@ describe('using a single matcher', () => { }) }) +describe.each([ + { title: '' }, + { title: ' and trailingSlash', trailingSlash: true }, +])( + 'using a single matcher with i18n for a non-root route$title', + ({ trailingSlash }) => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

catchall page

+

{message}

+
+ } + + export const getServerSideProps = ({ params, locale }) => ({ + props: { + message: \`(\${locale}) Hello from /\${params.route.join("/")}\` + } + }) + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { + matcher: '/middleware/works' + }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + 'next.config.js': ` + module.exports = { + ${trailingSlash ? 'trailingSlash: true,' : ''} + i18n: { + localeDetection: false, + locales: ['es', 'en'], + defaultLocale: 'en', + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('adds the header for matched paths', async () => { + const res1 = await fetchViaHTTP(next.url, '/middleware/works') + expect(await res1.text()).toContain(`(en) Hello from /middleware/works`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + + const res2 = await fetchViaHTTP(next.url, '/es/middleware/works') + expect(await res2.text()).toContain(`(es) Hello from /middleware/works`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for matched data paths, including the default locale without a prefix', async () => { + const res1 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en/middleware/works.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res1.json()).toMatchObject({ + pageProps: { + message: '(en) Hello from /middleware/works', + }, + }) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + + const res2 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/middleware/works.json` + ) + expect(await res2.json()).toMatchObject({ + pageProps: { + message: '(es) Hello from /middleware/works', + }, + }) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + + const res3 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware/works.json` + ) + expect(await res3.json()).toMatchObject({ + pageProps: { + message: '(en) Hello from /middleware/works', + }, + }) + expect(res3.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does not add the header for an unmatched path', async () => { + const response = await fetchViaHTTP(next.url, '/about/me') + expect(await response.text()).toContain('(en) Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) + } +) + describe('using root matcher', () => { let next: NextInstance beforeAll(async () => { diff --git a/test/e2e/rewrite-request-smuggling/next.config.js b/test/e2e/rewrite-request-smuggling/next.config.js new file mode 100644 index 000000000000..d0471a4691ec --- /dev/null +++ b/test/e2e/rewrite-request-smuggling/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + async rewrites() { + return [ + { + source: '/rewrites/:path*', + destination: `http://127.0.0.1:${process.env.TEST_INTERMEDIARY_PORT}/rewrites/:path*`, + }, + ] + }, +} + +module.exports = nextConfig diff --git a/test/e2e/rewrite-request-smuggling/pages/index.tsx b/test/e2e/rewrite-request-smuggling/pages/index.tsx new file mode 100644 index 000000000000..ff7159d9149f --- /dev/null +++ b/test/e2e/rewrite-request-smuggling/pages/index.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/rewrite-request-smuggling/rewrite-request-smuggling.test.ts b/test/e2e/rewrite-request-smuggling/rewrite-request-smuggling.test.ts new file mode 100644 index 000000000000..d53fba11e479 --- /dev/null +++ b/test/e2e/rewrite-request-smuggling/rewrite-request-smuggling.test.ts @@ -0,0 +1,234 @@ +import net from 'net' +import http from 'http' +import { createNext, NextInstance } from 'e2e-utils' +import { findPort, retry } from 'next-test-utils' + +describe('rewrite-request-smuggling', () => { + if ((global as any).isNextDeploy) { + it('should skip deploy', () => {}) + return + } + + let backend: http.Server + let backendPort: number + let intermediary: http.Server + let intermediaryPort: number + let next: NextInstance + const backendRequests: string[] = [] + + async function sendSmugglingPayload({ + nextPort, + connectionHeader, + method = 'DELETE', + rewritePath = '/rewrites/poc', + }: { + nextPort: number + connectionHeader: string + method?: 'DELETE' | 'OPTIONS' + rewritePath?: string + }) { + const smuggledRequest = Buffer.from( + `GET /secret HTTP/1.1\r\nHost: 127.0.0.1:${nextPort}\r\n\r\n`, + 'latin1' + ) + const chunkSize = Buffer.from( + `${smuggledRequest.length.toString(16).toUpperCase()}\r\n`, + 'latin1' + ) + + const payload = Buffer.concat([ + Buffer.from( + `${method} ${rewritePath} HTTP/1.1\r\nHost: 127.0.0.1:${nextPort}\r\nTransfer-Encoding: chunked\r\nConnection: ${connectionHeader}\r\n\r\n`, + 'latin1' + ), + chunkSize, + smuggledRequest, + Buffer.from('\r\n0\r\n\r\n', 'latin1'), + ]) + + await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: '127.0.0.1', + port: nextPort, + }) + + socket.once('connect', () => { + socket.write(payload) + }) + socket.once('error', reject) + socket.setTimeout(5000, () => socket.destroy()) + socket.once('close', () => resolve()) + }) + } + + beforeAll(async () => { + backendPort = await findPort() + intermediaryPort = await findPort() + + backend = http.createServer((req, res) => { + backendRequests.push(`${req.method} ${req.url}`) + + if (req.url?.startsWith('/rewrites/')) { + res.statusCode = 200 + res.end('rewrite-ok') + return + } + + if (req.url === '/secret') { + res.statusCode = 200 + res.end('secret') + return + } + + res.statusCode = 404 + res.end('not-found') + }) + + intermediary = http.createServer((req, res) => { + const connectionHeader = Array.isArray(req.headers['connection']) + ? req.headers['connection'].join(',') + : req.headers['connection'] || '' + const hopByHopHeaders = connectionHeader + .split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) + const stripTransferEncodingUnconditionally = + req.url?.startsWith('/rewrites/non-rfc-strip') || false + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (key === 'connection') continue + if (stripTransferEncodingUnconditionally && key === 'transfer-encoding') + continue + if (hopByHopHeaders.includes(key)) continue + if (value !== undefined) { + forwardHeaders[key] = value + } + } + forwardHeaders.connection = stripTransferEncodingUnconditionally + ? connectionHeader.toLowerCase().includes('close') + ? 'close' + : 'keep-alive' + : 'keep-alive' + + const proxyReq = http.request( + { + hostname: '127.0.0.1', + port: backendPort, + method: req.method, + path: req.url, + headers: forwardHeaders, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) + proxyRes.pipe(res) + } + ) + + proxyReq.on('error', () => { + res.statusCode = 502 + res.end('Bad Gateway') + }) + + req.pipe(proxyReq) + }) + + await new Promise((resolve, reject) => { + backend.listen(backendPort, '127.0.0.1', resolve) + backend.once('error', reject) + }) + + await new Promise((resolve, reject) => { + intermediary.listen(intermediaryPort, '127.0.0.1', resolve) + intermediary.once('error', reject) + }) + + next = await createNext({ + files: __dirname, + env: { + TEST_INTERMEDIARY_PORT: String(intermediaryPort), + }, + }) + }) + + afterAll(async () => { + await next?.destroy() + await new Promise((resolve) => intermediary.close(() => resolve())) + await new Promise((resolve) => backend.close(() => resolve())) + }) + + it('does not smuggle a second request when using keep-alive only', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ nextPort, connectionHeader: 'keep-alive' }) + + await retry(async () => { + expect(backendRequests).toContain('DELETE /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request with keep-alive, upgrade', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + connectionHeader: 'keep-alive, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('DELETE /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request with Transfer-Encoding, upgrade', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + connectionHeader: 'Transfer-Encoding, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('DELETE /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request for OPTIONS with Transfer-Encoding, upgrade', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + method: 'OPTIONS', + connectionHeader: 'Transfer-Encoding, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('OPTIONS /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request when an intermediary strips transfer-encoding unconditionally', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + method: 'OPTIONS', + rewritePath: '/rewrites/non-rfc-strip', + connectionHeader: 'keep-alive, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('OPTIONS /rewrites/non-rfc-strip') + }) + expect(backendRequests).not.toContain('GET /secret') + }) +}) diff --git a/test/production/app-dir/max-postponed-state-size/max-postponed-state-size.test.ts b/test/production/app-dir/max-postponed-state-size/max-postponed-state-size.test.ts index 0c4fa0494027..7cd40d29e446 100644 --- a/test/production/app-dir/max-postponed-state-size/max-postponed-state-size.test.ts +++ b/test/production/app-dir/max-postponed-state-size/max-postponed-state-size.test.ts @@ -3,6 +3,9 @@ import { nextTestSetup } from 'e2e-utils' describe('app-dir - max postponed state size', () => { const { next } = nextTestSetup({ files: __dirname, + env: { + NEXT_PRIVATE_TEST_HEADERS: '1', + }, }) it('should return 413 when next-resume request exceeds max postponed state size', async () => { diff --git a/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts b/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts index 815489cb3987..c06cf13a5ad5 100644 --- a/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts +++ b/test/production/app-dir/subresource-integrity/subresource-integrity.test.ts @@ -42,6 +42,7 @@ describe('Subresource Integrity', () => { const policies = [ `script-src 'nonce-'`, // invalid nonce 'style-src "nonce-cmFuZG9tCg=="', // no script or default src + `script-src 'nonce-" onerror="alert(1)'`, // malformed nonce '', // empty string ] @@ -232,15 +233,16 @@ describe('Subresource Integrity', () => { expect(scriptsWithIntegrity).toBeGreaterThanOrEqual(2) }) - it('throws when escape characters are included in nonce', async () => { - const res = await fetchWithPolicy( - `script-src 'nonce-">"'` - ) + it('ignores malformed nonce values without failing the request', async () => { + const policies = [ + `script-src 'nonce-">"'`, + `script-src 'nonce-" onerror="alert(1)'`, + ] - if (runtime === 'node' && process.env.__NEXT_CACHE_COMPONENTS) { - expect(res.status).toBe(200) - } else { - expect(res.status).toBe(500) + for (const policy of policies) { + const $ = await renderWithPolicy(policy) + + expect($('script[nonce]').length).toBe(0) } }) } diff --git a/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts b/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts index e53984ab1f54..3f6478e71b0b 100644 --- a/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts +++ b/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts @@ -4,6 +4,9 @@ import { createNext, NextInstance } from 'e2e-utils' import { findPort, retry } from 'next-test-utils' describe('rewrite-request-smuggling', () => { + const ssrfProbePath = '/secret-upgrade' + const ssrfProbeBody = + 'SSRF_CONFIRMED: You reached the internal service at 127.0.0.1' let backend: http.Server let backendPort: number let intermediary: http.Server @@ -51,11 +54,53 @@ describe('rewrite-request-smuggling', () => { socket.write(payload) }) socket.once('error', reject) - socket.setTimeout(1000, () => socket.destroy()) + socket.setTimeout(5000, () => socket.destroy()) socket.once('close', () => resolve()) }) } + async function sendAbsoluteUrlUpgradePayload({ + nextPort, + targetPort, + }: { + nextPort: number + targetPort: number + }) { + const payload = Buffer.from( + `GET http://127.0.0.1:${targetPort}${ssrfProbePath} HTTP/1.1\r\nHost: 127.0.0.1:${nextPort}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`, + 'latin1' + ) + + return await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: '127.0.0.1', + port: nextPort, + }) + const chunks: Buffer[] = [] + let settled = false + + const finish = () => { + if (settled) return + settled = true + resolve(Buffer.concat(chunks).toString('latin1')) + } + + socket.once('connect', () => { + socket.write(payload) + }) + socket.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) + socket.once('error', (err) => { + if (settled) return + settled = true + reject(err) + }) + socket.setTimeout(1000, () => socket.destroy()) + socket.once('close', finish) + }) + } + beforeAll(async () => { backendPort = await findPort() intermediaryPort = await findPort() @@ -79,6 +124,14 @@ describe('rewrite-request-smuggling', () => { res.end('not-found') }) + backend.on('upgrade', (req, socket) => { + backendRequests.push(`${req.method} ${req.url}`) + socket.write( + `HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(ssrfProbeBody)}\r\n\r\n${ssrfProbeBody}` + ) + socket.end() + }) + intermediary = http.createServer((req, res) => { const connectionHeader = Array.isArray(req.headers['connection']) ? req.headers['connection'].join(',') @@ -226,4 +279,19 @@ describe('rewrite-request-smuggling', () => { }) expect(backendRequests).not.toContain('GET /secret') }) + + it('does not proxy upgrade requests with absolute URLs without an external rewrite', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + const response = await sendAbsoluteUrlUpgradePayload({ + nextPort, + targetPort: backendPort, + }) + + expect(response).not.toContain(ssrfProbeBody) + expect( + backendRequests.some((request) => request.includes(ssrfProbePath)) + ).toBe(false) + }) }) diff --git a/test/production/standalone-mode/required-server-files/middleware-node.js b/test/production/standalone-mode/required-server-files/middleware-node.js index 449309db288e..5c56c89f53da 100644 --- a/test/production/standalone-mode/required-server-files/middleware-node.js +++ b/test/production/standalone-mode/required-server-files/middleware-node.js @@ -20,5 +20,8 @@ export async function middleware(req) { height: 600, }) } + if (req.nextUrl.pathname === '/dynamic/secret') { + return new NextResponse('Unauthorized', { status: 401 }) + } return NextResponse.next() } diff --git a/test/production/standalone-mode/required-server-files/middleware.js b/test/production/standalone-mode/required-server-files/middleware.js index 11afc796bf87..ee1512813491 100644 --- a/test/production/standalone-mode/required-server-files/middleware.js +++ b/test/production/standalone-mode/required-server-files/middleware.js @@ -9,5 +9,8 @@ export async function middleware(req) { height: 600, }) } + if (req.nextUrl.pathname === '/dynamic/secret') { + return new NextResponse('Unauthorized', { status: 401 }) + } return NextResponse.next() } diff --git a/test/production/standalone-mode/required-server-files/required-server-files.test.ts b/test/production/standalone-mode/required-server-files/required-server-files.test.ts index 7bb6f64f7f0b..9c3bae1cf2aa 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files.test.ts @@ -1560,6 +1560,31 @@ describe('required server files', () => { minimalMode = false }) + it('should ignore external nxtP params when middleware only checks the pathname', async () => { + const blockedRes = await fetchViaHTTP( + appPort, + '/dynamic/secret', + undefined, + withInvocationId() + ) + + expect(blockedRes.status).toBe(401) + expect(await blockedRes.text()).toBe('Unauthorized') + + const bypassRes = await fetchViaHTTP( + appPort, + '/dynamic/public?nxtPslug=secret', + undefined, + withInvocationId() + ) + + expect(bypassRes.status).toBe(200) + + const $ = cheerio.load(await bypassRes.text()) + expect($('#dynamic').text()).toBe('dynamic page') + expect($('#slug').text()).toBe('public') + }) + it('should run middleware correctly', async () => { const standaloneDir = join(next.testDir, 'standalone') const res = await fetchViaHTTP( diff --git a/test/rspack-build-tests-manifest.json b/test/rspack-build-tests-manifest.json index 97c24a66e389..1e18cef56120 100644 --- a/test/rspack-build-tests-manifest.json +++ b/test/rspack-build-tests-manifest.json @@ -8159,17 +8159,17 @@ "flakey": [], "runtimeError": false }, - "test/e2e/app-dir/unstable-io/unstable-io.test.ts": { - "passed": [ - "unstable_io with cache components should make content after unstable_io() dynamic during prerender", - "unstable_io with cache components should resolve immediately inside a \"use cache\" scope", - "unstable_io with cache components should work in pages router with React.use() (CC)", - "unstable_io with cache components should work in pages router with getServerSideProps (CC)", - "unstable_io with cache components should work in pages router with getStaticProps (CC)", - "unstable_io without cache components should be a no-op during prerender without cache components", - "unstable_io without cache components should work in pages router with React.use()", - "unstable_io without cache components should work in pages router with getServerSideProps", - "unstable_io without cache components should work in pages router with getStaticProps" + "test/e2e/app-dir/io/io.test.ts": { + "passed": [ + "io with cache components should make content after io() dynamic during prerender", + "io with cache components should resolve immediately inside a \"use cache\" scope", + "io with cache components should work in pages router with React.use() (CC)", + "io with cache components should work in pages router with getServerSideProps (CC)", + "io with cache components should work in pages router with getStaticProps (CC)", + "io without cache components should be a no-op during prerender without cache components", + "io without cache components should work in pages router with React.use()", + "io without cache components should work in pages router with getServerSideProps", + "io without cache components should work in pages router with getStaticProps" ], "failed": [], "pending": [], diff --git a/test/rspack-dev-tests-manifest.json b/test/rspack-dev-tests-manifest.json index 5dbc3489cdd5..0efb7b39a331 100644 --- a/test/rspack-dev-tests-manifest.json +++ b/test/rspack-dev-tests-manifest.json @@ -10533,17 +10533,17 @@ "flakey": [], "runtimeError": false }, - "test/e2e/app-dir/unstable-io/unstable-io.test.ts": { - "passed": [ - "unstable_io with cache components should make content after unstable_io() dynamic during prerender", - "unstable_io with cache components should resolve immediately inside a \"use cache\" scope", - "unstable_io with cache components should work in pages router with React.use() (CC)", - "unstable_io with cache components should work in pages router with getServerSideProps (CC)", - "unstable_io with cache components should work in pages router with getStaticProps (CC)", - "unstable_io without cache components should be a no-op during prerender without cache components", - "unstable_io without cache components should work in pages router with React.use()", - "unstable_io without cache components should work in pages router with getServerSideProps", - "unstable_io without cache components should work in pages router with getStaticProps" + "test/e2e/app-dir/io/io.test.ts": { + "passed": [ + "io with cache components should make content after io() dynamic during prerender", + "io with cache components should resolve immediately inside a \"use cache\" scope", + "io with cache components should work in pages router with React.use() (CC)", + "io with cache components should work in pages router with getServerSideProps (CC)", + "io with cache components should work in pages router with getStaticProps (CC)", + "io without cache components should be a no-op during prerender without cache components", + "io without cache components should work in pages router with React.use()", + "io without cache components should work in pages router with getServerSideProps", + "io without cache components should work in pages router with getStaticProps" ], "failed": [], "pending": [], diff --git a/test/unit/htmlescape.test.ts b/test/unit/htmlescape.test.ts index 97f7d42103c8..3ab7a87e3bcb 100644 --- a/test/unit/htmlescape.test.ts +++ b/test/unit/htmlescape.test.ts @@ -1,7 +1,7 @@ /* eslint-env jest */ // These tests are based on https://github.com/zertosh/htmlescape/blob/3e6cf0614dd0f778fd0131e69070b77282150c15/test/htmlescape-test.js // License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE -import { htmlEscapeJsonString } from 'next/dist/server/htmlescape' +import { htmlEscapeJsonString } from 'next/dist/shared/lib/htmlescape' import vm from 'vm' describe('htmlescape', () => { diff --git a/turbopack/crates/turbo-tasks-fs/src/lib.rs b/turbopack/crates/turbo-tasks-fs/src/lib.rs index 5f9291994d1f..5dd4f221c5b1 100644 --- a/turbopack/crates/turbo-tasks-fs/src/lib.rs +++ b/turbopack/crates/turbo-tasks-fs/src/lib.rs @@ -948,14 +948,13 @@ impl FileSystem for DiskFileSystem { impl Effect for WriteEffect { type Error = AnyhowWrapper; - type Value = u128; fn key(&self) -> Box<[u8]> { self.full_path.as_os_str().as_encoded_bytes().into() } - fn value(&self) -> &u128 { - &self.content_hash + fn value_hash(&self) -> u128 { + self.content_hash } fn state_storage(&self) -> &EffectStateStorage { @@ -1124,14 +1123,13 @@ impl FileSystem for DiskFileSystem { impl Effect for WriteLinkEffect { type Error = AnyhowWrapper; - type Value = u128; fn key(&self) -> Box<[u8]> { self.full_path.as_os_str().as_encoded_bytes().into() } - fn value(&self) -> &u128 { - &self.content_hash + fn value_hash(&self) -> u128 { + self.content_hash } fn state_storage(&self) -> &EffectStateStorage { diff --git a/turbopack/crates/turbo-tasks/src/effect.rs b/turbopack/crates/turbo-tasks/src/effect.rs index 66c4950a3a93..660917e7d0b1 100644 --- a/turbopack/crates/turbo-tasks/src/effect.rs +++ b/turbopack/crates/turbo-tasks/src/effect.rs @@ -1,5 +1,5 @@ use std::{ - any::Any, + collections::hash_map, error::Error as StdError, future::Future, mem::{forget, replace}, @@ -11,9 +11,7 @@ use anyhow::Result; use futures::{StreamExt, TryStreamExt}; use parking_lot::{Mutex, MutexGuard}; use rustc_hash::{FxHashMap, FxHashSet}; -use smallvec::SmallVec; use tracing::Instrument; -use turbo_dyn_eq_hash::DynPartialEq; use crate::{ self as turbo_tasks, CollectiblesSource, NonLocalValue, ReadRef, ResolvedVc, TryJoinIterExt, @@ -42,14 +40,11 @@ pub trait Effect: TraceRawVcs + NonLocalValue + Send + Sync + 'static { /// [`SharedError`]: crate::util::SharedError type Error: EffectError; - /// The type of this effect's value for storage and comparison. - type Value: Clone + DynPartialEq + Eq + Send + Sync + 'static; - /// Unique key identifying this effect's target (e.g., absolute path bytes). fn key(&self) -> Box<[u8]>; - /// Extract the value part of this effect for storage and comparison. - fn value(&self) -> &Self::Value; + /// Extract the hash of the value part of this effect for comparison. + fn value_hash(&self) -> u128; /// Returns a reference to the state storage. fn state_storage(&self) -> &EffectStateStorage; @@ -80,7 +75,7 @@ enum EffectLastApplied { write_event: Event, }, Applied { - value: Box, + value_hash: u128, result: Result<(), Arc>, }, } @@ -98,9 +93,7 @@ pub struct EffectStateStorage { // that the dynosaur crate uses: https://github.com/spastorino/dynosaur trait DynEffect: TraceRawVcs + NonLocalValue + Send + Sync + 'static { fn key(&self) -> Box<[u8]>; - /// Compare `self`'s value against a stored `Box`, using [`DynPartialEq`]. - fn eq_value_dyn(&self, other: &dyn Any) -> bool; - fn value_dyn(&self) -> Box; + fn value_hash(&self) -> u128; fn state_storage(&self) -> &EffectStateStorage; fn dyn_apply<'a>(&'a self) -> DynEffectApplyFuture<'a>; } @@ -113,12 +106,8 @@ where Effect::key(self) } - fn eq_value_dyn(&self, other: &dyn Any) -> bool { - DynPartialEq::dyn_partial_eq(Effect::value(self), other) - } - - fn value_dyn(&self) -> Box { - Box::new(Effect::value(self).clone()) + fn value_hash(&self) -> u128 { + Effect::value_hash(self) } fn state_storage(&self) -> &EffectStateStorage { @@ -318,27 +307,27 @@ impl Effects { let unique_indices = self .unique_indices .get_or_init(|| { - let mut by_key: FxHashMap, SmallVec<[usize; 1]>> = - FxHashMap::default(); - for (i, effect) in self.effects.iter().enumerate() { - let key = effect.inner.key(); - by_key.entry(key).or_default().push(i); - } - - let mut indices = Vec::with_capacity(by_key.len()); - for (key, group) in by_key { - if group.len() > 1 { - let first_value = self.effects[group[0]].inner.value_dyn(); - for &idx in &group[1..] { - if !self.effects[idx].inner.eq_value_dyn(&*first_value) { + let mut by_key: FxHashMap, usize> = FxHashMap::default(); + for (idx, effect) in self.effects.iter().enumerate() { + match by_key.entry(effect.inner.key()) { + hash_map::Entry::Vacant(entry) => { + entry.insert(idx); + } + hash_map::Entry::Occupied(entry) => { + if self.effects[*entry.get()].inner.value_hash() + != effect.inner.value_hash() + { return Err(Arc::new(ConflictingEffectError { - key_len: key.len(), + key_len: entry.key().len(), })); } } } - let idx = group[0]; - let state_storage = self.effects[idx].inner.state_storage(); + } + + let mut indices = Vec::with_capacity(by_key.len()); + for (key, effect_idx) in by_key { + let state_storage = self.effects[effect_idx].inner.state_storage(); // Look up or create the per-key state entry and cache the Arc directly. let entry = state_storage .effect_state @@ -346,7 +335,7 @@ impl Effects { .entry(key) .or_insert_with(|| Arc::new(Mutex::new(EffectLastApplied::Unapplied))) .clone(); - indices.push((idx, entry)); + indices.push((effect_idx, entry)); } Ok(indices) }) @@ -396,9 +385,9 @@ impl Effects { EffectLastApplied::Unapplied => { break begin_in_progress(last_applied_guard); } - EffectLastApplied::Applied { value, result } => { + EffectLastApplied::Applied { value_hash, result } => { // Fast path: check if the stored value already matches - if effect.eq_value_dyn(&**value) { + if effect.value_hash() == *value_hash { return result.clone(); } else { break begin_in_progress(last_applied_guard); @@ -425,7 +414,7 @@ impl Effects { let prev_state = replace( &mut *entry.lock(), EffectLastApplied::Applied { - value: effect.value_dyn(), + value_hash: effect.value_hash(), result: effect_result.clone(), }, );