Skip to content

fix(app): keep client trace metadata out of shared HTML cache entries - #2755

Open
NathanDrake2406 wants to merge 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-app-trace-meta-cache
Open

fix(app): keep client trace metadata out of shared HTML cache entries#2755
NathanDrake2406 wants to merge 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-app-trace-meta-cache

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

experimental.clientTraceMetadata renders request-scoped OpenTelemetry propagation entries (traceparent, baggage, …) as <meta> tags in the App Router SSR head. Runtime cacheable App Router responses are teed and stored as full HTML by finalizeAppPageHtmlCacheResponse, and reading the OTel propagation context is not recorded as dynamic request usage — so the cache-write skip never fires.

Result: on a cache miss, the generating request's trace context is baked into the stored HTML and replayed to every later cache HIT, until the entry expires or is revalidated. Where operators put tenant/user correlation data in baggage, that is cross-user leakage of one request's identifiers into unrelated users' pages; it is also a cache-poisoning vector, since the first requester can influence the injected traceparent/baggage via incoming headers.

Pages Router already gates emission on route staticness (shouldEmitPagesClientTraceMetadata returns false for getStaticProps). App Router had no equivalent gate.

Triage evidence

  • client-trace-metadata.ts suppresses emission only under VINEXT_PRERENDER=1; there is no runtime cache-generation suppression.
  • app-ssr-entry.ts appends getClientTraceMetadataHTML() into the head injection.
  • app-page-render.ts routes cacheable runtime HTML into finalizeAppPageHtmlCacheResponse, which reads the tee'd stream and stores it after only dynamic-usage and cache-policy checks.
  • app-page-cache-render.ts (ISR revalidation / PPR fallback shell) also passed the allow-list into a render whose entire purpose is producing a shared cache artifact.
  • Confirmed by an added regression test that fails on main: the stored entry contained content="tenant=alice,user=alice-123".

Upstream comparison: Next.js v16.2.6 emits client trace metadata for dynamically server-rendered App and Pages routes, while its production static routes omit it. Vinext adds a separate runtime HTML-cache boundary: a live request response can later become a shared artifact. The vinext global-registry path also synthesizes a span when none is active, so cacheable HTML can contain a traceparent in setups where Next.js would emit nothing.

Fix

Enforce the invariant HTML stored in a shared cache contains no request-scoped trace metadata at every App Router storage boundary:

  1. For origin-managed ISR, finalizeAppPageHtmlCacheResponse removes only the per-render, privately marked vinext trace-metadata block from the cache copy. Application-authored <meta> tags with the same names are preserved. The live response the generating request receives is unchanged.
  2. renderAppPageCacheArtifacts (ISR/PPR cache-fill render) no longer receives the allow-list at all — that render exists solely to produce a shared artifact.
  3. Edge-managed adapters such as Cloudflare cache the outgoing response stream rather than a separate origin-store copy. Any outgoing response that may enter a shared cache — including cacheable development responses, force-static Edge Runtime routes, and responses whose cache headers middleware can promote — suppresses client trace metadata before rendering. Only responses with a framework no-store policy and no middleware cache-header ownership retain it. The gate covers generic, Cloudflare, and Vercel CDN cache-control headers.

markClientTraceMetadataBlock and stripClientTraceMetadataBlock live next to the renderer. For origin-managed ISR, a random marker carries injection provenance across the SSR stream/cache tee, so cleanup cannot confuse framework-injected tags with application metadata. The common unset path still avoids loading the feature module.

Why strip rather than suppress emission

Origin-managed ISR has a separate cache tee, so stripping preserves trace metadata on the generating response while keeping stored HTML clean. Edge-managed adapters and cacheable development responses expose the outgoing bytes directly, so potentially shared responses suppress metadata before rendering. Known no-store responses keep the feature only when middleware cannot override or add shared-cache directives. This conservative split is necessary because final cacheability can resolve only after the stream drains.

Verification

  • vp test run tests/client-trace-metadata.test.ts tests/app-page-cache.test.ts tests/app-page-cache-render.test.ts — 60 passed.
  • vp test run tests/app-page-render.test.ts tests/app-page-dispatch.test.ts tests/app-page-stream.test.ts tests/app-page-response.test.ts tests/otel-tracer-extension.test.ts — 200 passed. (One unhandled client disconnect rejection appears here; verified pre-existing on clean upstream/main.)
  • vp test run tests/cloudflare-cdn-cache.test.ts — 16 passed.
  • The finalizer regression verifies that request trace metadata is absent from the stored entry while an application-authored <meta name="baggage"> remains intact.
  • Full vp check plus the staged unit/integration suites and knip ran via the pre-commit hook and passed.

`experimental.clientTraceMetadata` renders request-scoped OpenTelemetry
propagation entries (traceparent, baggage) as `<meta>` tags in the App
Router SSR head. Runtime cacheable responses are teed into the App Page
HTML cache verbatim, and reading the OTel context is not recorded as
dynamic usage, so the generating request's trace context was stored and
replayed to every later cache HIT — leaking one user's trace/baggage
correlation data (tenant, user id) into another user's HTML.

Pages Router already gates emission on route staticness
(`shouldEmitPagesClientTraceMetadata`); App Router had no equivalent.

Strip the emitted tags at the single point where runtime HTML becomes a
shared cache entry, and stop the ISR/PPR cache-artifact render from
emitting them at all. The live response still carries the generating
request's trace context; the cache entry carries none, which is correct —
a cache HIT has no request whose trace it could correlate.
@pkg-pr-new

pkg-pr-new Bot commented Jul 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2755
npm i https://pkg.pr.new/create-vinext-app@2755
npm i https://pkg.pr.new/@vinext/types@2755
npm i https://pkg.pr.new/vinext@2755

commit: b3f5565

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1d786bf33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/app-page-cache-finalizer.ts Outdated
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b3f5565 against base dc05459 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.5 KB 134.5 KB ⚫ -0.0%
Client entry size (gzip) vinext 122.1 KB 122.1 KB ⚫ +0.0%
Dev server cold start vinext 3.04 s 2.94 s 🟢 -3.3%
Production build time vinext 3.25 s 3.28 s ⚫ +0.9%
RSC entry closure size (gzip) vinext 112.3 KB 112.5 KB ⚫ +0.2%
Server bundle size (gzip) vinext 189.7 KB 190.1 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

Statically importing client-trace-metadata pulled the feature-specific
module into the shared App Router RSC server graph even when
experimental.clientTraceMetadata is unset. Gate on the allow-list and
import the stripping helper only for configured cache writes.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 4a91b3432a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review July 30, 2026 08:18
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: client trace metadata cache isolation

The security invariant — HTML stored in a shared cache must contain no request-scoped trace metadata — is correctly enforced at all three storage boundaries:

  • Origin-managed ISR: emit-with-marker on the live response + strip-by-marker on the tee'd cache copy. The random per-render UUID marker makes it unguessable, so framework-injected tags are removed without touching application-authored <meta name="baggage"> tags. Verified the marked block is spliced into the head as one contiguous raw string (createTickBufferedTransform enqueues injectHTML() unescaped) and the finalizer strips against the fully-buffered readStreamAsText, so indexOf reliably finds both markers.
  • Edge-managed adapters + dev: suppress emission before render for anything that can enter a shared cache. renderAppPageCacheArtifacts (ISR/PPR fill) no longer receives the allow-list at all — correct, since its output is a shared artifact and, on SWR, the served-from-cache HTML.
  • Known no-store retention is conservatively gated by !middlewareMayControlSharedCaching, so middleware that can promote to a shared-cache policy forces suppression.

The lazy import("./client-trace-metadata.js") behind the marker check keeps the feature module out of the RSC graph for the common unconfigured case (addressing Codex's P1). Tests cover the emit/suppress/mark matrix well, including the middleware-promotion and force-static-edge edge cases, and the regression asserts the stored entry is clean while the app-authored tag survives.

No blocking issues. A few minor, non-blocking observations below.

canSanitizeSeparateOriginCacheCopy &&
clientTraceMetadata &&
clientTraceMetadata.length > 0
? crypto.randomUUID()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: crypto.randomUUID() is used here against the global crypto, whereas shims/headers.ts:1086 guards with if (crypto && typeof crypto.randomUUID === "function") before calling it. Global crypto is present in all currently-supported runtimes (Node 18+, Workers), so this is not a correctness problem — just an inconsistency worth noting. node:crypto's randomUUID (already imported in dev-server.ts / app-rsc-entry.ts) would match the repo's prevailing pattern.

if (options.clientTraceMetadataMarker) {
const { stripClientTraceMetadataBlock } = await import("./client-trace-metadata.js");
cachedHtml = stripClientTraceMetadataBlock(cachedHtml, options.clientTraceMetadataMarker);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the dynamic import + strip runs before the dynamic-usage skip check (lines 158-164) and the cache-policy check (171-174). When the write is ultimately skipped, this does the module load and string scan for nothing. The marker is only set in the origin-adapter production path where a write is usually expected, so the impact is small — but moving the strip below the skip checks would avoid the work on the skip paths. Not blocking.

const middlewareMayControlSharedCaching =
options.middlewareContext.headers?.has("cache-control") === true ||
options.middlewareContext.headers?.has("cdn-cache-control") === true ||
options.middlewareContext.headers?.has("cloudflare-cdn-cache-control") === true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The middlewareMayControlSharedCaching gate checks cache-control, cdn-cache-control, and cloudflare-cdn-cache-control. Standard CDN cache-control also includes vercel-cdn-cache-control (Next.js honors it in its cache-control resolution). Since vinext targets Cloudflare this is a low-priority gap, but if middleware sets only Vercel-CDN-Cache-Control to promote shared caching, this gate would miss it and trace metadata could be retained. Worth a follow-up or a short comment noting the intentional scope.

@ask-bonk

ask-bonk Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my review of PR #2755:

Verdict: No blocking issues — the fix is correct.

The PR enforces the invariant that shared-cache HTML must contain no request-scoped OpenTelemetry trace metadata, closing a cross-user leakage / cache-poisoning vector. I verified the logic at all three storage boundaries:

  1. Origin-managed ISR — emit-with-marker on the live response, strip-by-marker on the tee'd cache copy. Confirmed the marked block is spliced into the head as one contiguous, unescaped raw string, and the finalizer strips against fully-buffered text, so the indexOf-based strip reliably finds both markers. The random per-render UUID marker prevents confusion with app-authored <meta> tags.
  2. Edge/dev adapters — suppress emission before render for anything that can enter a shared cache; renderAppPageCacheArtifacts no longer receives the allow-list at all.
  3. No-store retention — conservatively gated so middleware that could promote to shared caching forces suppression.

The lazy import addresses Codex's earlier P1 about keeping the feature module out of the RSC graph. Tests cover the matrix thoroughly.

Three minor, non-blocking notes posted inline:

  • crypto.randomUUID() used unguarded vs. the guarded/node:crypto pattern used elsewhere.
  • Finalizer runs the strip + dynamic import before the cache-skip checks (small wasted work on skip paths).
  • middlewareMayControlSharedCaching doesn't check Vercel-CDN-Cache-Control (low priority given Cloudflare focus).

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: keep client trace metadata out of shared HTML cache

I traced the fix through all three storage boundaries and the security invariant — HTML stored in a shared cache must contain no request-scoped trace metadata — holds:

  • Origin-managed ISR (marker path): For every path where the default DefaultCdnCacheAdapter (ownsBackgroundRevalidation = true) can reach finalizeAppPageHtmlCacheResponse with shouldWriteToCache/speculative write, canSanitizeSeparateOriginCacheCopy is true and a marker is set, so the tee'd cache copy is stripped. I checked the revalidateSeconds > 0, revalidateSeconds === Infinity, and non-edge isForceStatic policy branches (app-page-response.ts:267-306) — all get a marker. The peekDynamicUsage() timing gap is safe: a render that turns dynamic during streaming still had a marker set at gate time (pre-render peekDynamicUsage() was false), so the finalizer strips; a render dynamic at gate time yields shouldWriteToCache: false and blocks the speculative write via dynamicUsedDuringRender, so the finalizer never runs.
  • Edge/dev adapters + retention: With a non-origin adapter (ownsBackgroundRevalidation = false), canSanitizeSeparateOriginCacheCopy is false, so anything not provably no-store suppresses emission before render. renderAppPageCacheArtifacts no longer receives the allow-list at all — correct, since its output is a shared artifact (and the SWR served-from-cache HTML).
  • RSC cache copy: trace metadata is HTML-head-only, never in the RSC payload, so the capturedRscDataPromise write needs no stripping. Confirmed.
  • Pages Router parity: already gated via shouldEmitPagesClientTraceMetadata (getStaticProps → false), so its cache stays clean. No parity gap introduced.

On the earlier ask-bonk note that the strip runs before the skip checks: that's already handled — the dynamic import + strip at app-page-cache-finalizer.ts:172 sits after both the dynamic-usage skip (151-157) and the cache-policy skip (164-167), and the comment says so. No change needed there.

No blocking issues. One minor consistency nit inline.

if (!hasKnownNoStoreHtmlPolicy && !canSanitizeSeparateOriginCacheCopy) {
clientTraceMetadata = undefined;
} else if (!hasKnownNoStoreHtmlPolicy && canSanitizeSeparateOriginCacheCopy) {
clientTraceMetadataMarker = crypto.randomUUID();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (non-blocking) consistency nit: every other server module uses import { randomUUID } from "node:crypto" (see dev-server.ts:3, entries/app-rsc-entry.ts:10, config/next-config.ts:11), and shims/headers.ts:1086 guards the global before calling crypto.randomUUID(). This is the only place calling the unguarded global crypto.randomUUID(). Not a correctness problem (global crypto is present in Node 18+ and Workers), just an inconsistency with the repo's prevailing pattern.

Suggested change
clientTraceMetadataMarker = crypto.randomUUID();
clientTraceMetadataMarker = randomUUID();

(with import { randomUUID } from "node:crypto"; added to the imports).

@ask-bonk

ask-bonk Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2755.

Summary

Verdict: No blocking issues — the fix is correct.

I traced the security fix through all three storage boundaries and verified the invariant (shared-cache HTML must contain no request-scoped OTel trace metadata) holds:

  1. Origin-managed ISR (marker path): Every path where the default adapter (ownsBackgroundRevalidation = true) reaches the finalizer with a cache write sets a marker → the tee'd cache copy is stripped while the live response keeps its trace context. Checked the revalidate > 0, revalidate === Infinity, and non-edge force-static policy branches.

  2. Edge/dev adapters: Non-origin adapters can't sanitize a separate copy, so anything not provably no-store suppresses emission before render. renderAppPageCacheArtifacts correctly no longer receives the allow-list.

  3. No-store retention: Conservatively gated by !middlewareMayControlSharedCaching.

I specifically verified the peekDynamicUsage() timing gap is safe (dynamic-during-streaming still gets a marker; dynamic-at-gate-time blocks the write entirely), that the RSC cache copy needs no stripping (trace metadata is HTML-head-only), and that Pages Router parity is preserved via shouldEmitPagesClientTraceMetadata.

I also confirmed the earlier ask-bonk note about the strip running before the skip checks was already addressed — the strip at app-page-cache-finalizer.ts:172 sits after both skip checks.

One minor, non-blocking inline nit: app-page-render.ts:1033 uses the unguarded global crypto.randomUUID() where every other server module imports randomUUID from node:crypto (posted a suggestion).

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants