Skip to content

Commit ebc83be

Browse files
authored
fix(metadata): keep streamed icon keys opaque (#3518)
* fix(metadata): keep streamed icon keys opaque * fix(metadata): reuse wider hash for streamed icon ownership
1 parent 42721a7 commit ebc83be

4 files changed

Lines changed: 93 additions & 7 deletions

File tree

‎packages/vinext/src/server/app-page-route-wiring.tsx‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from "vinext/shims/metadata";
3434
import { BfcacheSegmentBoundary, Children, ParallelSlot, Slot } from "vinext/shims/slot";
3535
import { StreamedIconsInsertion } from "vinext/shims/streamed-icons";
36+
import { fnv1a64 } from "vinext/internal/utils/hash";
3637
import { createInlineScriptTag, escapeHtmlAttr } from "./html.js";
3738
import type { AppPageParams } from "./app-page-boundary.js";
3839
import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js";
@@ -719,12 +720,8 @@ function hasStreamedIcons(metadata: Metadata): boolean {
719720
}
720721

721722
function createStreamedIconKey(pathname: string, metadataHtml: string): string {
722-
let hash = 2166136261;
723-
for (let index = 0; index < metadataHtml.length; index++) {
724-
hash ^= metadataHtml.charCodeAt(index);
725-
hash = Math.imul(hash, 16777619);
726-
}
727-
return `${pathname}:${(hash >>> 0).toString(36)}`;
723+
// Keep route identity opaque so crawlers cannot mistake the key for a URL.
724+
return `vi${fnv1a64(`${pathname}\0${metadataHtml}`)}`;
728725
}
729726

730727
const STREAMED_ICON_KEY_PLACEHOLDER = "vinext-pending-streamed-icon-key";

‎tests/e2e/app-router/metadata-icons.spec.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,59 @@ const starIcons: OwnedIcon[] = [
200200
];
201201

202202
test.describe("Next.js compat: streamed metadata icons", () => {
203+
for (const pathname of [
204+
"/metadata-icons-stream/heart",
205+
"/nextjs-compat/metadata-generate/my-post:pk5ufy:0",
206+
]) {
207+
test(`keeps streamed icon keys opaque for crawlers at ${pathname}`, async ({ request }) => {
208+
const headers = { "User-Agent": "Googlebot" };
209+
const response = await request.get(pathname, { headers });
210+
expect(response.status()).toBe(200);
211+
const html = await response.text();
212+
const markers = [...html.matchAll(/<link data-vinext-streamed-icon="([^"]+)"/g)].map(
213+
([, marker]) => marker,
214+
);
215+
expect(markers.length).toBeGreaterThan(0);
216+
for (const marker of markers) {
217+
expect(marker).toMatch(/^vi[a-z0-9]+:\d+$/);
218+
}
219+
220+
const flightResponse = await request.get(`${pathname}?_rsc=icons`, {
221+
headers: { ...headers, RSC: "1" },
222+
});
223+
expect(flightResponse.headers()["content-type"]).toContain("text/x-component");
224+
const flight = await flightResponse.text();
225+
const keys = [...flight.matchAll(/"metadataKey":"([^"]+)"/g)].map(([, key]) => key);
226+
expect(keys.length).toBeGreaterThan(0);
227+
for (const key of keys) {
228+
expect(key).toMatch(/^vi[a-z0-9]+$/);
229+
expect(markers.every((marker) => marker.startsWith(`${key}:`))).toBe(true);
230+
}
231+
});
232+
}
233+
234+
test("keeps distinct icon ownership across paths that collide under a 32-bit hash", async ({
235+
page,
236+
consoleErrors,
237+
}) => {
238+
// Both full pathnames have the same 32-bit FNV state before the metadata suffix.
239+
await page.goto("/metadata-icons-stream/ae0qv10f");
240+
await expect.poll(() => ownedIcons(page)).toEqual(heartIcons);
241+
const firstMarker = await page
242+
.locator("head link[data-vinext-streamed-icon]")
243+
.first()
244+
.getAttribute("data-vinext-streamed-icon");
245+
246+
await page.locator("#metadata-icons-collision").click();
247+
await expect(page).toHaveURL(/\/metadata-icons-stream\/n8eg563h$/);
248+
await expect(page.locator("head link[data-vinext-streamed-icon]").first()).not.toHaveAttribute(
249+
"data-vinext-streamed-icon",
250+
firstMarker!,
251+
);
252+
await expect.poll(() => ownedIcons(page)).toEqual(heartIcons);
253+
expect(consoleErrors).toEqual([]);
254+
});
255+
203256
test("relocates every metadata icon relation with the request CSP nonce", async ({
204257
page,
205258
consoleErrors,

‎tests/features.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3457,6 +3457,25 @@ describe("createAppPageRouteBodyMetadata (body-placement canonical)", () => {
34573457
expect(html).toMatch(/<script>document\.querySelectorAll[\s\S]*<\/script><\/div>$/);
34583458
});
34593459

3460+
it("body placement: uses opaque icon keys that vary with pathname and metadata", () => {
3461+
const marker = (pathname: string, icon: string) => {
3462+
const node = createAppPageRouteBodyMetadata({ icons: { icon } }, pathname, "body");
3463+
const html = renderToStaticMarkup(node as React.ReactElement);
3464+
const key = html.match(/data-vinext-streamed-icon="([^"]+)"/)?.[1];
3465+
expect(key).toMatch(/^vi[a-z0-9]+:0$/);
3466+
return key;
3467+
};
3468+
3469+
const key = marker("/blog/my-post:pk5ufy:0", "/favicon.ico");
3470+
expect(marker("/blog/my-post:pk5ufy:0", "/favicon.ico")).toBe(key);
3471+
expect(marker("/blog/another-post", "/favicon.ico")).not.toBe(key);
3472+
expect(marker("/blog/my-post:pk5ufy:0", "/other.ico")).not.toBe(key);
3473+
// These paths collide under the previous single-round 32-bit FNV hash.
3474+
expect(marker("/products/vb9e3r", "/favicon.ico")).not.toBe(
3475+
marker("/products/obz81c", "/favicon.ico"),
3476+
);
3477+
});
3478+
34603479
it("body placement: serializes icon-bearing metadata once", () => {
34613480
let titleReads = 0;
34623481
const title = 'data-vinext-streamed-icon="vinext-pending-streamed-icon-key:0';
@@ -3479,7 +3498,7 @@ describe("createAppPageRouteBodyMetadata (body-placement canonical)", () => {
34793498
expect(titleReads).toBe(2);
34803499
expect(html).toContain(`<title>${title}</title>`);
34813500
expect(html).not.toContain(' data-injected="true"');
3482-
expect(html).toContain("/icons&quot; data-injected=&quot;true:");
3501+
expect(html).not.toContain("/icons");
34833502
});
34843503

34853504
it("body placement: includes icon cleanup reconciliation without streamed icons", () => {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Link from "next/link";
2+
3+
export { generateMetadata } from "../heart/page";
4+
5+
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
6+
const { slug } = await params;
7+
const nextSlug = slug === "ae0qv10f" ? "n8eg563h" : "ae0qv10f";
8+
return (
9+
<Link
10+
id="metadata-icons-collision"
11+
href={`/metadata-icons-stream/${nextSlug}`}
12+
prefetch={false}
13+
>
14+
Change route with identical icons
15+
</Link>
16+
);
17+
}

0 commit comments

Comments
 (0)