Skip to content

Commit c021d10

Browse files
unstubbableswarnavaornakash
authored
[backport] Encode non-ASCII characters in cache tags at construction (#93918)
Backports: - #93617 - #93601 --------- Co-authored-by: Swarnava Sengupta <swarnava.sengupta@vercel.com> Co-authored-by: Or Nakash <ornakash@gmail.com>
1 parent 9184ddb commit c021d10

13 files changed

Lines changed: 390 additions & 9 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Percent-encode every character outside printable ASCII so a tag value can be
3+
* safely serialized as part of the `x-next-cache-tags` HTTP header.
4+
*
5+
* Node's `validateHeaderValue` rejects any code unit outside `\t\x20-\x7e`, so
6+
* a matched route path or user-supplied tag containing a non-ASCII character
7+
* (Hebrew, Arabic, Chinese, emoji, …) would otherwise throw `ERR_INVALID_CHAR`
8+
* and crash ISR on every affected request.
9+
*
10+
* This is applied at the public boundaries — tag construction
11+
* (`getImplicitTags`, `validateTags`) and invalidation input (`revalidatePath`,
12+
* `revalidateTag`, `updateTag`) — so storage, comparison, and the wire all see
13+
* the same canonical ASCII-safe form.
14+
*
15+
* The character class `[\t\x20-\x7e]` mirrors Node's `validHdrChars` table —
16+
* `\t` plus printable ASCII through `~`. Anything outside that is rejected
17+
* by `validateHeaderValue`, so we encode runs of those characters and leave
18+
* everything else (`,`, `/`, `%`, `[`, `]`, `_`, `-`, `\t`, …) byte-for-byte
19+
* unchanged. This preserves the comma-separated header format and the
20+
* dynamic-segment markers in derived tags (`_N_T_/[slug]/page`).
21+
*
22+
* Properties:
23+
* - Fast-path: input that already fits the validation class is returned
24+
* unchanged. This makes the encoder idempotent on already-encoded `%xx`
25+
* sequences.
26+
* - Matches *runs* of out-of-class code units so surrogate pairs (e.g. an
27+
* emoji) are handed to `encodeURIComponent` as a complete code point — a
28+
* per-code-unit regex would split the pair and throw `URIError`.
29+
*/
30+
const OUT_OF_CLASS_CHAR = /[^\t\x20-\x7e]/
31+
const OUT_OF_CLASS_RUN = /[^\t\x20-\x7e]+/g
32+
33+
export function encodeCacheTag(tag: string): string {
34+
return OUT_OF_CLASS_CHAR.test(tag)
35+
? tag.replace(OUT_OF_CLASS_RUN, (run) => encodeURIComponent(run))
36+
: tag
37+
}

packages/next/src/server/lib/implicit-tags.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@ describe('getImplicitTags()', () => {
7373
'_N_T_/foo/bar/baz',
7474
],
7575
},
76+
{
77+
// Non-ASCII pathname must be percent-encoded so it can be safely
78+
// serialized into the `x-next-cache-tags` HTTP header. Surrogate-pair
79+
// emoji exercises run-based replacement (a per-code-unit regex would
80+
// throw `URIError`).
81+
page: '/[slug]/page',
82+
pathname: '/🎉',
83+
fallbackRouteParams: null,
84+
expectedTags: [
85+
'_N_T_/layout',
86+
'_N_T_/[slug]/layout',
87+
'_N_T_/[slug]/page',
88+
'_N_T_/%F0%9F%8E%89',
89+
],
90+
},
91+
{
92+
// Already-encoded pathname must not be double-encoded. The encoder
93+
// is idempotent on ASCII input including `%xx` sequences.
94+
page: '/[slug]/page',
95+
pathname: '/%F0%9F%8E%89',
96+
fallbackRouteParams: null,
97+
expectedTags: [
98+
'_N_T_/layout',
99+
'_N_T_/[slug]/layout',
100+
'_N_T_/[slug]/page',
101+
'_N_T_/%F0%9F%8E%89',
102+
],
103+
},
76104
])(
77105
'for page $page with pathname $pathname',
78106
async ({ page, pathname, fallbackRouteParams, expectedTags }) => {

packages/next/src/server/lib/implicit-tags.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NEXT_CACHE_IMPLICIT_TAG_ID } from '../../lib/constants'
22
import type { OpaqueFallbackRouteParams } from '../request/fallback-params'
33
import { getCacheHandlerEntries } from '../use-cache/handlers'
4+
import { encodeCacheTag } from './encode-cache-tag'
45
import { createLazyResult, type LazyResult } from './lazy-result'
56

67
export interface ImplicitTags {
@@ -78,17 +79,19 @@ export async function getImplicitTags(
7879
): Promise<ImplicitTags> {
7980
const tags = new Set<string>()
8081

81-
// Add the derived tags from the page.
82+
// Add the derived tags from the page. Encode each tag so a non-ASCII
83+
// pathname doesn't trip header validation when written to
84+
// `x-next-cache-tags`. Idempotent on already-ASCII input.
8285
const derivedTags = getDerivedTags(page)
8386
for (let tag of derivedTags) {
84-
tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`
87+
tag = encodeCacheTag(`${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`)
8588
tags.add(tag)
8689
}
8790

8891
// Add the tags from the pathname. If the route has unknown params, we don't
8992
// want to add the pathname as a tag, as it will be invalid.
9093
if (pathname && (!fallbackRouteParams || fallbackRouteParams.size === 0)) {
91-
const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}`
94+
const tag = encodeCacheTag(`${NEXT_CACHE_IMPLICIT_TAG_ID}${pathname}`)
9295
tags.add(tag)
9396
}
9497

packages/next/src/server/lib/patch-fetch.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { cloneResponse } from './clone-response'
3131
import type { IncrementalCache } from './incremental-cache'
3232
import { RenderStage } from '../app-render/staged-rendering'
33+
import { encodeCacheTag } from './encode-cache-tag'
3334

3435
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
3536

@@ -95,7 +96,10 @@ export function validateTags(tags: any[], description: string) {
9596
reason: `exceeded max length of ${NEXT_CACHE_TAG_MAX_LENGTH}`,
9697
})
9798
} else {
98-
validTags.push(tag)
99+
// Encode so a non-ASCII tag can be safely serialized into the
100+
// `x-next-cache-tags` HTTP header without tripping Node's header
101+
// validation. Length is checked on the raw input above.
102+
validTags.push(encodeCacheTag(tag))
99103
}
100104

101105
if (validTags.length > NEXT_CACHE_TAG_MAX_ITEMS) {

packages/next/src/server/web/spec-extension/revalidate.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate,
1717
} from '../../../shared/lib/action-revalidation-kind'
1818
import { removeTrailingSlash } from '../../../shared/lib/router/utils/remove-trailing-slash'
19+
import { encodeCacheTag } from '../../lib/encode-cache-tag'
1920

2021
type CacheLifeConfig = {
2122
expire?: number
@@ -32,7 +33,7 @@ export function revalidateTag(tag: string, profile: string | CacheLifeConfig) {
3233
'"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'
3334
)
3435
}
35-
return revalidate([tag], `revalidateTag ${tag}`, profile)
36+
return revalidate([encodeCacheTag(tag)], `revalidateTag ${tag}`, profile)
3637
}
3738

3839
/**
@@ -54,7 +55,7 @@ export function updateTag(tag: string) {
5455
)
5556
}
5657
// updateTag uses immediate expiration (no profile) without deprecation warning
57-
return revalidate([tag], `updateTag ${tag}`, undefined)
58+
return revalidate([encodeCacheTag(tag)], `updateTag ${tag}`, undefined)
5859
}
5960

6061
/**
@@ -97,7 +98,7 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') {
9798
return
9899
}
99100

100-
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${removeTrailingSlash(originalPath)}`
101+
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${encodeCacheTag(removeTrailingSlash(originalPath))}`
101102

102103
if (type) {
103104
normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}`

packages/next/src/server/web/spec-extension/unstable-cache.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,12 @@ export function unstable_cache<T extends Callback>(
288288
// Check if we need to do foreground revalidation
289289
if (workStore.isStaticGeneration) {
290290
// When the page is revalidating and the cache entry is stale,
291-
// we need to wait for fresh data (blocking revalidate)
292-
return workStore.pendingRevalidates[invocationKey]
291+
// we need to wait for fresh data (blocking revalidate). The
292+
// `await` here keeps `cacheSignal.endRead` (in the outer
293+
// `finally`) suspended until the recompute + cacheNewResult
294+
// actually complete, so the prospective prerender's
295+
// `cacheSignal` doesn't resolve `cacheReady` prematurely.
296+
return await workStore.pendingRevalidates[invocationKey]
293297
}
294298
// Otherwise, we're doing background revalidation - return stale immediately
295299
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { cacheTag, unstable_cache, updateTag } from 'next/cache'
2+
import { connection } from 'next/server'
3+
import { Suspense } from 'react'
4+
5+
export function generateStaticParams() {
6+
return [{ slug: '🎉' }]
7+
}
8+
9+
async function Cached({ params }: { params: Promise<{ slug: string }> }) {
10+
'use cache'
11+
const { slug } = await params
12+
cacheTag('🎂')
13+
return (
14+
<>
15+
<p id="slug">{slug}</p>
16+
<p>
17+
Cached: <span id="cached-time">{new Date().toISOString()}</span>
18+
</p>
19+
</>
20+
)
21+
}
22+
23+
async function Dynamic() {
24+
await connection()
25+
26+
return (
27+
<p>
28+
Dynamic: <span id="dynamic-time">{new Date().toISOString()}</span>
29+
</p>
30+
)
31+
}
32+
33+
const getUnstableCached = unstable_cache(
34+
async () => new Date().toISOString(),
35+
['unstable-cache-time'],
36+
{ tags: ['🌶'], revalidate: false }
37+
)
38+
39+
export default async function Page({
40+
params,
41+
}: {
42+
params: Promise<{ slug: string }>
43+
}) {
44+
const fetched = await fetch(
45+
'https://next-data-api-endpoint.vercel.app/api/random',
46+
{ next: { tags: ['🌮'], revalidate: false } }
47+
).then((r) => r.text())
48+
49+
const unstableCached = await getUnstableCached()
50+
51+
return (
52+
<main>
53+
<Cached params={params} />
54+
<Suspense fallback={<p>Loading...</p>}>
55+
<Dynamic />
56+
</Suspense>
57+
<p>
58+
Fetched: <span id="fetched">{fetched}</span>
59+
</p>
60+
<p>
61+
Unstable cached: <span id="unstable-cached-time">{unstableCached}</span>
62+
</p>
63+
<form>
64+
<button
65+
id="update-tag"
66+
formAction={async () => {
67+
'use server'
68+
updateTag('🎂')
69+
}}
70+
>
71+
updateTag
72+
</button>
73+
</form>
74+
</main>
75+
)
76+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { revalidatePath, revalidateTag } from 'next/cache'
2+
3+
export async function POST(request: Request) {
4+
const { searchParams } = new URL(request.url)
5+
const path = searchParams.get('path')
6+
const tag = searchParams.get('tag')
7+
8+
if (path) {
9+
revalidatePath(path)
10+
}
11+
if (tag) {
12+
revalidateTag(tag, 'max')
13+
}
14+
15+
return Response.json({ ok: true, path, tag })
16+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
cacheComponents: true,
6+
}
7+
8+
module.exports = nextConfig

0 commit comments

Comments
 (0)