Skip to content

Commit 0c1ac30

Browse files
anpeteclaude
andcommitted
fix(page-context): harden page-type-signal collection per review
Address PR #2770 review feedback: - Use `??` (not `||`) for maxPageTypeSignals/maxJsonLdBlockLength so an explicit 0 is honored (@moon0326). - Import captured JSONparse/Set/hasOwnProperty so a hostile page can't subvert parsing or dedup. - Guard @type/@graph reads with hasOwnProperty.call to avoid Object.prototype pollution surfacing spurious types. - Bound JSON-LD recursion (MAX_JSON_LD_DEPTH=32) so adversarial nesting can't fail the whole collection. - Clamp config-driven limits (Math.min) to safe ceilings. - Case-insensitive ld+json script discovery (APPLICATION/LD+JSON). - Add unit specs (depth cap, prototype pollution, casing, explicit 0) and a windows-project integration test (page + config) asserting end-to-end pageTypeSignals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3a3341d commit 0c1ac30

6 files changed

Lines changed: 150 additions & 13 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ResultsCollector } from './page-objects/results-collector.js';
3+
4+
const HTML = '/page-context/pages/page-type-signals.html';
5+
const CONFIG = './integration-test/test-pages/page-context/config/page-type-signals.json';
6+
7+
/**
8+
* Return the page-type signals from the first collectionResult message that carries them.
9+
* @param {ResultsCollector} collector
10+
*/
11+
async function collectedSignals(collector) {
12+
const messages = await collector.waitForMessage('collectionResult', 1);
13+
for (const message of messages) {
14+
const params = /** @type {{ serializedPageData?: string }} */ (message.payload.params);
15+
if (!params?.serializedPageData) continue;
16+
const data = JSON.parse(params.serializedPageData);
17+
if (data.pageTypeSignals) return data.pageTypeSignals;
18+
}
19+
return null;
20+
}
21+
22+
test('page-context collects page-type signals end-to-end', async ({ page }, testInfo) => {
23+
const collector = ResultsCollector.create(page, testInfo.project.use);
24+
await collector.load(HTML, CONFIG);
25+
26+
const signals = await collectedSignals(collector);
27+
28+
// jsonLdType is collected in priority order (Recipe before the embedded VideoObject); the
29+
// second block uses non-canonical casing to exercise case-insensitive discovery.
30+
expect(signals).toStrictEqual({
31+
jsonLdType: ['Recipe', 'NewsArticle', 'VideoObject'],
32+
ogType: 'article',
33+
lang: 'en',
34+
});
35+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"readme": "Enables page-context collection with includePageTypeSignals to verify end-to-end signal extraction.",
3+
"version": 1,
4+
"features": {
5+
"pageContext": {
6+
"state": "enabled",
7+
"exceptions": [],
8+
"settings": {
9+
"collectOnInit": "enabled",
10+
"subscribeToLoad": "enabled",
11+
"subscribeToCollect": "enabled",
12+
"observeMutations": "enabled",
13+
"bodyFallback": "enabled",
14+
"trimBlankLinks": "enabled",
15+
"includeIframes": "enabled",
16+
"includePageTypeSignals": "enabled",
17+
"maxContentLength": 9500,
18+
"maxTitleLength": 100,
19+
"maxDepth": 5000,
20+
"cacheExpiration": 30000,
21+
"recheckLimit": 5
22+
}
23+
}
24+
},
25+
"unprotectedTemporary": []
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width">
6+
<title>Page Type Signals Test</title>
7+
<meta name="description" content="Test page exercising page-type-signal collection">
8+
<meta property="og:type" content="article">
9+
<!-- Multi-@type block: priority order matters to consumers (Recipe before the embedded video). -->
10+
<script type="application/ld+json">
11+
{ "@context": "https://schema.org", "@type": ["Recipe", "NewsArticle"] }
12+
</script>
13+
<!-- Non-canonical casing must still be discovered (case-insensitive selector). -->
14+
<script type="APPLICATION/LD+JSON">
15+
{ "@graph": [{ "@type": "VideoObject" }] }
16+
</script>
17+
<link rel="stylesheet" href="../../shared/style.css">
18+
</head>
19+
<body>
20+
<p><a href="../index.html">[Page Context]</a></p>
21+
22+
<article>
23+
<h1>Page Type Signals Test</h1>
24+
<p>This page exposes JSON-LD <code>@type</code>, <code>og:type</code>, and a declared language so
25+
the page-context feature can collect normalized page-type signals.</p>
26+
</article>
27+
</body>
28+
</html>

injected/playwright.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineConfig({
2121
'integration-test/web-detection.spec.js',
2222
'integration-test/web-events.spec.js',
2323
'integration-test/web-interference-detection-events.spec.js',
24+
'integration-test/page-type-signals.spec.js',
2425
],
2526
use: { injectName: 'windows', platform: 'windows' },
2627
},

injected/src/features/page-context.js

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
import ContentFeature from '../content-feature.js';
66
import { getFaviconList } from './favicon.js';
77
import { isDuckAi, isBeingFramed, getTabUrl } from '../utils.js';
8+
// eslint-disable-next-line no-redeclare
9+
import { JSONparse, Set, hasOwnProperty } from '../captured-globals.js';
810
const MSG_PAGE_CONTEXT_RESPONSE = 'collectionResult';
911

12+
// Bail past this nesting depth when walking JSON-LD so adversarially deep blocks
13+
// can't blow the stack and fail the whole content collection.
14+
const MAX_JSON_LD_DEPTH = 32;
15+
1016
export function checkNodeIsVisible(node) {
1117
try {
1218
const style = window.getComputedStyle(node);
@@ -250,14 +256,16 @@ function extractJsonLdTypes(document, maxTypes, maxBlockLength) {
250256
types.push(type);
251257
};
252258

253-
const blocks = document.querySelectorAll('script[type="application/ld+json"]');
259+
// Case-insensitive attribute match ("i") so non-canonical casing (e.g. APPLICATION/LD+JSON)
260+
// is still discovered.
261+
const blocks = document.querySelectorAll('script[type="application/ld+json" i]');
254262
for (const block of blocks) {
255263
if (types.length >= maxTypes) break;
256264
const text = block.textContent || '';
257265
if (!text || text.length > maxBlockLength) continue;
258266
let data;
259267
try {
260-
data = JSON.parse(text);
268+
data = JSONparse(text);
261269
} catch (e) {
262270
continue;
263271
}
@@ -269,24 +277,30 @@ function extractJsonLdTypes(document, maxTypes, maxBlockLength) {
269277

270278
/**
271279
* Walk a parsed JSON-LD value (object, array, or `@graph` container) and report each `@type`.
280+
* Own-property checks keep a polluted `Object.prototype` from surfacing spurious `@type` /
281+
* `@graph` keys, and recursion is bounded by {@link MAX_JSON_LD_DEPTH}.
272282
* @param {unknown} node
273283
* @param {(value: unknown) => void} add
284+
* @param {number} [depth]
274285
*/
275-
function collectJsonLdTypes(node, add) {
286+
function collectJsonLdTypes(node, add, depth = 0) {
287+
if (depth > MAX_JSON_LD_DEPTH) return;
276288
if (Array.isArray(node)) {
277-
for (const item of node) collectJsonLdTypes(item, add);
289+
for (const item of node) collectJsonLdTypes(item, add, depth + 1);
278290
return;
279291
}
280292
if (!node || typeof node !== 'object') return;
281293
const obj = /** @type {Record<string, unknown>} */ (node);
282-
const type = obj['@type'];
283-
if (Array.isArray(type)) {
284-
for (const value of type) add(value);
285-
} else if (type != null) {
286-
add(type);
294+
if (hasOwnProperty.call(obj, '@type')) {
295+
const type = obj['@type'];
296+
if (Array.isArray(type)) {
297+
for (const value of type) add(value);
298+
} else if (type != null) {
299+
add(type);
300+
}
287301
}
288-
if (obj['@graph']) {
289-
collectJsonLdTypes(obj['@graph'], add);
302+
if (hasOwnProperty.call(obj, '@graph')) {
303+
collectJsonLdTypes(obj['@graph'], add, depth + 1);
290304
}
291305
}
292306

@@ -722,9 +736,11 @@ export default class PageContext extends ContentFeature {
722736
}
723737

724738
getPageTypeSignals() {
739+
// `??` so an explicit 0 isn't silently replaced by the default; Math.min clamps remote
740+
// config to a safe ceiling to bound parse work.
725741
return extractPageTypeSignals(document, {
726-
maxTypes: this.getFeatureSetting('maxPageTypeSignals') || 10,
727-
maxBlockLength: this.getFeatureSetting('maxJsonLdBlockLength') || 100000,
742+
maxTypes: Math.min(this.getFeatureSetting('maxPageTypeSignals') ?? 10, 50),
743+
maxBlockLength: Math.min(this.getFeatureSetting('maxJsonLdBlockLength') ?? 100000, 1000000),
728744
});
729745
}
730746

injected/unit-test/page-context-signals.spec.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,37 @@ describe('page-context.js - extractPageTypeSignals', () => {
7878
expect(signals.jsonLdType).toEqual([]);
7979
});
8080

81+
it('collects nothing when maxTypes is explicitly 0', () => {
82+
const dom = new JSDOM(pageWithHead(scriptLd({ '@type': 'Recipe' })));
83+
expect(extractPageTypeSignals(dom.window.document, { maxTypes: 0 }).jsonLdType).toEqual([]);
84+
});
85+
86+
it('discovers JSON-LD with non-canonical script type casing', () => {
87+
const head = '<script type="APPLICATION/LD+JSON">' + JSON.stringify({ '@type': 'Recipe' }) + '</script>';
88+
expect(signalsFor(pageWithHead(head)).jsonLdType).toEqual(['Recipe']);
89+
});
90+
91+
it('bails out of deeply nested JSON-LD without collecting past the cap (a thrown RangeError fails this test)', () => {
92+
let nested = /** @type {any} */ ({ '@type': 'TooDeep' });
93+
for (let i = 0; i < 60; i++) nested = { '@graph': [nested] };
94+
const head = scriptLd({ '@type': 'Shallow', '@graph': [nested] });
95+
const signals = signalsFor(pageWithHead(head));
96+
expect(signals.jsonLdType).toContain('Shallow');
97+
expect(signals.jsonLdType).not.toContain('TooDeep');
98+
});
99+
100+
it('ignores @type / @graph inherited from a polluted Object.prototype', () => {
101+
// Aliased reference so the deliberate pollution doesn't trip the no-extend-native lint rule.
102+
const proto = /** @type {Record<string, unknown>} */ (Object.prototype);
103+
proto['@type'] = 'Polluted';
104+
try {
105+
const signals = signalsFor(pageWithHead(scriptLd({ name: 'no own @type' })));
106+
expect(signals.jsonLdType).toEqual([]);
107+
} finally {
108+
delete proto['@type'];
109+
}
110+
});
111+
81112
it('collects all three signals from a realistic page (recipe with embedded video)', () => {
82113
const head =
83114
'<meta property="og:type" content="article">' +

0 commit comments

Comments
 (0)