Denial of Service (server crash due to invalid HTTP header) / HTTP header validation issue

HIGH
vercel/next.js
Commit: 9e1830338642
Affected: 16.2.0 - 16.2.2 (inclusive)
2026-05-07 23:37 UTC

Description

Summary of the vulnerability fix: - The commit addresses a denial-of-service-like crash caused by non-ASCII characters being written into the internal x-next-cache-tags HTTP header used by ISR caching. Node's header validation rejects non-printable ASCII outside the allowed range, so writing a tag containing non-ASCII data (e.g., emoji, CJK, Hebrew) could cause ERR_INVALID_CHAR and crash responses, disabling ISR cache refresh. - A canonical ASCII-safe form is now enforced at public boundaries by introducing an encodeCacheTag helper and applying it to all boundary inputs: tag construction (getImplicitTags, validateTags), invalidation inputs (revalidatePath, revalidateTag, updateTag), and fetch-related tag handling. The encoder encodes runs of non-ASCII characters using encodeURIComponent while remaining idempotent for already-encoded sequences. This ensures storage, comparison, and the wire all see the same ASCII-safe form and prevents invalid header values from being emitted. - The change propagates encoding through derived tags, user-supplied tags, and path-derived tags so the entire lifecycle (storage, invalidation, and wire) stays in sync. - Tests and end-to-end tests were added to validate non-ASCII handling (including surrogate-pair emojis) and to ensure headers remain within valid ASCII bounds. - This is a real security fix (not merely a dependency bump or cleanup) that mitigates a potential DoS/crash via invalid HTTP header values in ISR timing and cache invalidation paths.

Proof of Concept

Proof-of-concept (PoC) to illustrate the exploit path prior to the fix and how the fix mitigates it: - Vulnerability class: Denial of Service / HTTP header validation crash due to non-ASCII cache tag values being written to x-next-cache-tags. - Trigger path (pre-fix behavior): 1) Create an ISR page that uses non-ASCII cache tags, e.g., emoji in cacheTag() or unstable_cache({ tags: [...] }). 2) Access the ISR page to cause a revalidation/write of the x-next-cache-tags header that includes non-ASCII data. 3) Node's HTTP header validation (validateHeaderValue) rejects code units outside the allowed set, leading to an ERR_INVALID_CHAR and a server crash or 500 response, breaking ISR refresh for affected routes. - Reproduction concept (local, with Next.js 16.2.2 before the fix): - Example page that uses a non-ASCII tag: // app/[slug]/page.tsx (conceptual; simplified) import { cacheTag } from 'next/cache' export async function generateStaticParams(){ return [{ slug: '🎉' }] } export default async function Page({ params }: { params: { slug: string } }) { 'use server' cacheTag('🎂') // non-ASCII tag // ... ISR content } - Trigger a request to this page to cause the server to write x-next-cache-tags with a non-ASCII value (e.g., the cake emoji in the tag). - Before the fix, the request could crash with ERR_INVALID_CHAR as the header is emitted with non-ASCII data. - After applying the patch, the header is encoded so it remains ASCII-safe. The encoding is applied via encodeCacheTag at all entry points, e.g., encodeCacheTag('🎂') -> '%F0%9F%8E%82'. - Reproduction concept (post-fix behavior): - The server writes an ASCII-safe header, for example: x-next-cache-tags: _N_T_/%F0%9F%8E%82 - A client can verify the header is ASCII-safe and no ERR_INVALID_CHAR is raised by Node on header handling. - Minimal code snippet to demonstrate encoding behavior (post-fix): // encode-cache-tag.ts (from the fix) const OUT_OF_CLASS_CHAR = /[^\t\x20-\x7e]/ const OUT_OF_CLASS_RUN = /[^\t\x20-\x7e]+/g export function encodeCacheTag(tag: string): string { return OUT_OF_CLASS_CHAR.test(tag) ? tag.replace(OUT_OF_CLASS_RUN, (run) => encodeURIComponent(run)) : tag } // Usage example: console.log(encodeCacheTag('🎂')) // -> "%F0%9F%8E%82" - How to verify locally: 1) Build/run a local Next.js 16.2.2 project with ISR pages that apply non-ASCII cache tags (e.g., via cacheTag('🎂')). 2) Trigger a request to cause ISR revalidation so Next.js writes x-next-cache-tags with the tag value. 3) Observe that the emitted header contains only ASCII characters (e.g., _N_T_/%F0%9F%8E%82) and that no ERR_INVALID_CHAR occurs. Notes: - The PoC illustrates both the vulnerability path and the mitigation: without encoding, non-ASCII characters could hit Node's header validation; with encoding, the header stays ASCII-safe and parsing errors are avoided.

Commit Details

Author: Hendrik Liebau

Date: 2026-05-07 23:09 UTC

Message:

Encode non-ASCII characters in cache tags at construction (#93601) When a cache tag contains a non-ASCII character (Hebrew, CJK, emoji, …) it gets written into the internal `x-next-cache-tags` HTTP header on ISR responses. Node's `validateHeaderValue` rejects any byte outside `\t\x20-\x7e`, so the response crashes with `ERR_INVALID_CHAR`. On Vercel deploys stale-if-error masks the 500 from clients, but revalidation itself keeps failing and the cache stops refreshing for affected routes. This change introduces a single `encodeCacheTag` helper and applies it at every public boundary — `validateTags` (which `cacheTag()`, `unstable_cache()`, and `fetch` tags all funnel through), `getImplicitTags` for path-derived tags, and `revalidatePath` / `revalidateTag` / `updateTag` for invalidation inputs. The encoder matches runs of out-of-class code units so surrogate pairs reach `encodeURIComponent` intact, and it is idempotent on already-encoded `%xx` sequences, so callers can pass either the raw or the encoded form interchangeably. PR #93139 already encodes path-derived tags at construction, but it misses every user-supplied tag entry point and uses a `decodeURIComponent` round-trip that silently mangles literal `%xx` characters in tag values. PR #93167 encodes only at the `setHeader` sites, which leaves storage and invalidation diverging and requires every new write site to remember the encoding step. The canonical-form-at-the-boundary approach taken here covers all entry points and keeps storage, comparison, and the wire in sync. fixes #93142 closes #93139 closes #93167 Co-authored-by: Swarnava Sengupta <swarnava.sengupta@vercel.com> Co-authored-by: Or Nakash <ornakash@gmail.com>

Triage Assessment

Vulnerability Type: Denial of Service (server crash due to invalid HTTP header) or Input validation issue

Confidence: HIGH

Reasoning:

The commit adds an encoder for non-ASCII characters in cache tags to ensure values are valid for HTTP headers. This prevents Node's header validation from crashing with ERR_INVALID_CHAR when non-ASCII data is written to x-next-cache-tags, addressing a potential denial-of-service/crash vulnerability stemming from invalid header values. The changes propagate encoding at all public boundaries to keep storage, comparison, and the wire in sync.

Verification Assessment

Vulnerability Type: Denial of Service (server crash due to invalid HTTP header) / HTTP header validation issue

Confidence: HIGH

Affected Versions: 16.2.0 - 16.2.2 (inclusive)

Code Diff

diff --git a/packages/next/src/server/lib/encode-cache-tag.ts b/packages/next/src/server/lib/encode-cache-tag.ts new file mode 100644 index 000000000000..1938c9c7e425 --- /dev/null +++ b/packages/next/src/server/lib/encode-cache-tag.ts @@ -0,0 +1,37 @@ +/** + * Percent-encode every character outside printable ASCII so a tag value can be + * safely serialized as part of the `x-next-cache-tags` HTTP header. + * + * Node's `validateHeaderValue` rejects any code unit outside `\t\x20-\x7e`, so + * a matched route path or user-supplied tag containing a non-ASCII character + * (Hebrew, Arabic, Chinese, emoji, …) would otherwise throw `ERR_INVALID_CHAR` + * and crash ISR on every affected request. + * + * This is applied at the public boundaries — tag construction + * (`getImplicitTags`, `validateTags`) and invalidation input (`revalidatePath`, + * `revalidateTag`, `updateTag`) — so storage, comparison, and the wire all see + * the same canonical ASCII-safe form. + * + * The character class `[\t\x20-\x7e]` mirrors Node's `validHdrChars` table — + * `\t` plus printable ASCII through `~`. Anything outside that is rejected + * by `validateHeaderValue`, so we encode runs of those characters and leave + * everything else (`,`, `/`, `%`, `[`, `]`, `_`, `-`, `\t`, …) byte-for-byte + * unchanged. This preserves the comma-separated header format and the + * dynamic-segment markers in derived tags (`_N_T_/[slug]/page`). + * + * Properties: + * - Fast-path: input that already fits the validation class is returned + * unchanged. This makes the encoder idempotent on already-encoded `%xx` + * sequences. + * - Matches *runs* of out-of-class code units so surrogate pairs (e.g. an + * emoji) are handed to `encodeURIComponent` as a complete code point — a + * per-code-unit regex would split the pair and throw `URIError`. + */ +const OUT_OF_CLASS_CHAR = /[^\t\x20-\x7e]/ +const OUT_OF_CLASS_RUN = /[^\t\x20-\x7e]+/g + +export function encodeCacheTag(tag: string): string { + return OUT_OF_CLASS_CHAR.test(tag) + ? tag.replace(OUT_OF_CLASS_RUN, (run) => encodeURIComponent(run)) + : tag +} diff --git a/packages/next/src/server/lib/implicit-tags.test.ts b/packages/next/src/server/lib/implicit-tags.test.ts index 31e6ef04d2fa..6c51cd2983c4 100644 --- a/packages/next/src/server/lib/implicit-tags.test.ts +++ b/packages/next/src/server/lib/implicit-tags.test.ts @@ -73,6 +73,34 @@ describe('getImplicitTags()', () => { '_N_T_/foo/bar/baz', ], }, + { + // Non-ASCII pathname must be percent-encoded so it can be safely + // serialized into the `x-next-cache-tags` HTTP header. Surrogate-pair + // emoji exercises run-based replacement (a per-code-unit regex would + // throw `URIError`). + page: '/[slug]/page', + pathname: '/🎉', + fallbackRouteParams: null, + expectedTags: [ + '_N_T_/layout', + '_N_T_/[slug]/layout', + '_N_T_/[slug]/page', + '_N_T_/%F0%9F%8E%89', + ], + }, + { + // Already-encoded pathname must not be double-encoded. The encoder + // is idempotent on ASCII input including `%xx` sequences. + page: '/[slug]/page', + pathname: '/%F0%9F%8E%89', + fallbackRouteParams: null, + expectedTags: [ + '_N_T_/layout', + '_N_T_/[slug]/layout', + '_N_T_/[slug]/page', + '_N_T_/%F0%9F%8E%89', + ], + }, ])( 'for page $page with pathname $pathname', async ({ page, pathname, fallbackRouteParams, expectedTags }) => { diff --git a/packages/next/src/server/lib/implicit-tags.ts b/packages/next/src/server/lib/implicit-tags.ts index 3e808e808f26..c983fbcaf33a 100644 --- a/packages/next/src/server/lib/implicit-tags.ts +++ b/packages/next/src/server/lib/implicit-tags.ts @@ -1,6 +1,7 @@ import { NEXT_CACHE_IMPLICIT_TAG_ID } from '../../lib/constants' import type { OpaqueFallbackRouteParams } from '../request/fallback-params' import { getCacheHandlerEntries } from '../use-cache/handlers' +import { encodeCacheTag } from './encode-cache-tag' import { createLazyResult, type LazyResult } from './lazy-result' export interface ImplicitTags { @@ -78,17 +79,19 @@ export async function getImplicitTags( ): Promise<ImplicitTags> { const tags = new Set<string>() - // Add the derived tags from the page. + // Add the derived tags from the page. Encode each tag so a non-ASCII + // pathname doesn't trip header validation when written to + // `x-next-cache-tags`. Idempotent on already-ASCII input. const derivedTags = getDerivedTags(page) for (let tag of derivedTags) { - tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}` + tag = encodeCacheTag(`${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`) tags.add(tag) } // Add the tags from the pathname. If the route has unknown params, we don't // want to add the pathname as a tag, as it will be invalid. if (pathname && (!fallbackRouteParams || fallbackRouteParams.size === 0)) { - const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}` + const tag = encodeCacheTag(`${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}`) tags.add(tag) } diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 39ed601e153a..60bbb7185298 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -30,6 +30,7 @@ import { import { cloneResponse } from './clone-response' import type { IncrementalCache } from './incremental-cache' import { RenderStage } from '../app-render/staged-rendering' +import { encodeCacheTag } from './encode-cache-tag' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -95,7 +96,10 @@ export function validateTags(tags: any[], description: string) { reason: `exceeded max length of ${NEXT_CACHE_TAG_MAX_LENGTH}`, }) } else { - validTags.push(tag) + // Encode so a non-ASCII tag can be safely serialized into the + // `x-next-cache-tags` HTTP header without tripping Node's header + // validation. Length is checked on the raw input above. + validTags.push(encodeCacheTag(tag)) } if (validTags.length > NEXT_CACHE_TAG_MAX_ITEMS) { diff --git a/packages/next/src/server/web/spec-extension/revalidate.ts b/packages/next/src/server/web/spec-extension/revalidate.ts index 8737f0ee2b4a..0955c36fb146 100644 --- a/packages/next/src/server/web/spec-extension/revalidate.ts +++ b/packages/next/src/server/web/spec-extension/revalidate.ts @@ -16,6 +16,7 @@ import { ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate, } from '../../../shared/lib/action-revalidation-kind' import { removeTrailingSlash } from '../../../shared/lib/router/utils/remove-trailing-slash' +import { encodeCacheTag } from '../../lib/encode-cache-tag' type CacheLifeConfig = { expire?: number @@ -36,7 +37,7 @@ export function revalidateTag(tag: string, profile: string | CacheLifeConfig) { '"revalidateTag" without the second argument is now deprecated, add second argument of "max" or use "updateTag". See more info here: https://nextjs.org/docs/messages/revalidate-tag-single-arg' ) } - return revalidate([tag], `revalidateTag ${tag}`, profile) + return revalidate([encodeCacheTag(tag)], `revalidateTag ${tag}`, profile) } /** @@ -58,7 +59,7 @@ export function updateTag(tag: string) { ) } // updateTag uses immediate expiration (no profile) without deprecation warning - return revalidate([tag], `updateTag ${tag}`, undefined) + return revalidate([encodeCacheTag(tag)], `updateTag ${tag}`, undefined) } /** @@ -101,7 +102,7 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') { return } - let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${removeTrailingSlash(originalPath)}` + let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${encodeCacheTag(removeTrailingSlash(originalPath))}` if (type) { normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}` diff --git a/test/e2e/app-dir/non-ascii-cache-tags/app/[slug]/page.tsx b/test/e2e/app-dir/non-ascii-cache-tags/app/[slug]/page.tsx new file mode 100644 index 000000000000..71ce2dfd3dc4 --- /dev/null +++ b/test/e2e/app-dir/non-ascii-cache-tags/app/[slug]/page.tsx @@ -0,0 +1,76 @@ +import { cacheTag, unstable_cache, updateTag } from 'next/cache' +import { connection } from 'next/server' +import { Suspense } from 'react' + +export function generateStaticParams() { + return [{ slug: '🎉' }] +} + +async function Cached({ params }: { params: Promise<{ slug: string }> }) { + 'use cache' + const { slug } = await params + cacheTag('🎂') + return ( + <> + <p id="slug">{slug}</p> + <p> + Cached: <span id="cached-time">{new Date().toISOString()}</span> + </p> + </> + ) +} + +async function Dynamic() { + await connection() + + return ( + <p> + Dynamic: <span id="dynamic-time">{new Date().toISOString()}</span> + </p> + ) +} + +const getUnstableCached = unstable_cache( + async () => new Date().toISOString(), + ['unstable-cache-time'], + { tags: ['🌶'], revalidate: false } +) + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const fetched = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { next: { tags: ['🌮'], revalidate: false } } + ).then((r) => r.text()) + + const unstableCached = await getUnstableCached() + + return ( + <main> + <Cached params={params} /> + <Suspense fallback={<p>Loading...</p>}> + <Dynamic /> + </Suspense> + <p> + Fetched: <span id="fetched">{fetched}</span> + </p> + <p> + Unstable cached: <span id="unstable-cached-time">{unstableCached}</span> + </p> + <form> + <button + id="update-tag" + formAction={async () => { + 'use server' + updateTag('🎂') + }} + > + updateTag + </button> + </form> + </main> + ) +} diff --git a/test/e2e/app-dir/non-ascii-cache-tags/app/api/revalidate/route.ts b/test/e2e/app-dir/non-ascii-cache-tags/app/api/revalidate/route.ts new file mode 100644 index 000000000000..e007c2137033 --- /dev/null +++ b/test/e2e/app-dir/non-ascii-cache-tags/app/api/revalidate/route.ts @@ -0,0 +1,16 @@ +import { revalidatePath, revalidateTag } from 'next/cache' + +export async function POST(request: Request) { + const { searchParams } = new URL(request.url) + const path = searchParams.get('path') + const tag = searchParams.get('tag') + + if (path) { + revalidatePath(path) + } + if (tag) { + revalidateTag(tag, 'max') + } + + return Response.json({ ok: true, path, tag }) +} diff --git a/test/e2e/app-dir/non-ascii-cache-tags/app/layout.tsx b/test/e2e/app-dir/non-ascii-cache-tags/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/non-ascii-cache-tags/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + <html> + <body>{children}</body> + </html> + ) +} diff --git a/test/e2e/app-dir/non-ascii-cache-tags/next.config.js b/test/e2e/app-dir/non-ascii-cache-tags/next.config.js new file mode 100644 index 000000000000..e64bae22d658 --- /dev/null +++ b/test/e2e/app-dir/non-ascii-cache-tags/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/non-ascii-cache-tags/non-ascii-cache-tags.test.ts b/test/e2e/app-dir/non-ascii-cache-tags/non-ascii-cache-tags.test.ts new file mode 100644 index 000000000000..d93bd1ce182b --- /dev/null +++ b/test/e2e/app-dir/non-ascii-cache-tags/non-ascii-cache-tags.test.ts @@ -0,0 +1,116 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +// Regression test for https://github.com/vercel/next.js/issues/93142 +// +// Any non-ASCII character (Hebrew, Arabic, CJK, emoji, …) in a cache tag — +// whether it's a path-derived implicit tag or a user-supplied tag from +// `cacheTag()`, `unstable_cache({tags})`, or `fetch({next:{tags}})` — gets +// written into the internal `x-next-cache-tags` HTTP header on ISR responses. +// Node's `validateHeaderValue` rejects any byte outside `\t\x20-\x7e`, so the +// response crashes with `ERR_INVALID_CHAR`. +// +// On Vercel deploy stale-if-error masks the 500 from clients, but revalidation +// itself keeps failing and the cache stops refreshing for affected routes. The +// revalidate / updateTag cases here cover that round-trip: a cached entry keyed +// under a non-ASCII path / tag must actually be invalidated by +// `revalidatePath`, `revalidateTag`, or `updateTag` against each cache backend. +describe('non-ASCII cache tags', () => { + const { next, isNextDeploy } = nextTestSetup({ + files: __dirname, + }) + + const SLUG = '🎉' + const TAG = '🎂' + const FETCH_TAG = '🌮' + const UNSTABLE_TAG = '🌶' + const PATH = `/${encodeURIComponent(SLUG)}` + + it('serves a non-ASCII slug ISR page without ERR_INVALID_CHAR', async () => { + const res = await next.fetch(PATH) + expect(res.status).toBe(200) + + if (isNextDeploy) { + const tags = res.headers.get('x-next-cache-tags') + if (tags !== null) { + // Anything outside the validation character class would have + // crashed `setHeader` on the way out, so reaching the client at + // all is itself a signal — but assert explicitly to guard format. + expect(tags).toMatch(/^[\t\x20-\x7e]+$/) + } + } + }) + + it('invalidates a cached entry via revalidatePath with a non-ASCII path', async () => { + const initial = (await next.render$(PATH))('#cached-time').text() + + const res = await next.fetch( + `/api/revalidate?path=${encodeURIComponent(`/${SLUG}`)}`, + { method: 'POST' } + ) + expect(res.status).toBe(200) + + // Revalidation may take a moment to propagate. + await retry(async () => { + const after = (await next.render$(PATH))('#cached-time').text() + expect(after).not.toBe(initial) + }) + }) + + it('invalidates a cached entry via revalidateTag with a non-ASCII tag', async () => { + const initial = (await next.render$(PATH))('#cached-time').text() + + const res = await next.fetch( + `/api/revalidate?tag=${encodeURIComponent(TAG)}`, + { method: 'POST' } + ) + expect(res.status).toBe(200) + + await retry(async () => { + const after = (await next.render$(PATH))('#cached-time').text() + expect(after).not.toBe(initial) + }) + }) + + it('invalidates a fetch entry tagged with a non-ASCII tag via revalidateTag', async () => { + const initial = (await next.render$(PATH))('#fetched').text() + + const res = await next.fetch( + `/api/revalidate?tag=${encodeURIComponent(FETCH_TAG)}`, + ... [truncated]
← Back to Alerts View on GitHub →