Skip to content

Commit bf4ecb1

Browse files
authored
chore(goto): better autoconsent setup (#712)
* chore(goto): better autoconsent setup * fix(goto): catch eval errors in autoconsent handler `page.evaluate(message.code)` can throw (runtime errors, navigation during evaluation, invalid code) but had no try/catch, so sendMessage was never called and autoconsent never received the evalResp — stalling the consent-detection flow. Fall back to `result: false` on failure, matching the defensive `.catch(() => {})` pattern already used by sendMessage. Made-with: Cursor * fix(goto): restrict autoconsent eval to main frame via nonce page.exposeFunction propagates bindings to every frame, including cross-origin iframes. A malicious iframe could call autoconsentSendMessage({type:'eval', code:'…'}) and page.evaluate would execute that code in the main frame's context, bypassing the Same-Origin Policy (same pattern as the DuckDuckGo Android UXSS, CVSS 8.6). Generate a per-page random nonce and wrap the binding in the top frame only (window.self === window.top) so every legitimate message carries it. Child frames keep the raw CDP binding which lacks the nonce, so their messages are silently rejected by the handler. Made-with: Cursor * fix: timeout setup
1 parent 972e3d2 commit bf4ecb1

File tree

2 files changed

+140
-6
lines changed

2 files changed

+140
-6
lines changed

packages/goto/src/adblock.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use strict'
22

33
const { PuppeteerBlocker } = require('@ghostery/adblocker-puppeteer')
4+
const { randomUUID } = require('crypto')
5+
const pTimeout = require('p-timeout')
46
const fs = require('fs/promises')
57
const path = require('path')
68

@@ -53,6 +55,10 @@ const autoconsentConfig = Object.freeze({
5355
enablePrehide: true,
5456
/* apply CSS-only rules that hide popups lacking a reject button */
5557
enableCosmeticRules: true,
58+
/* enable rules auto-generated from common CMP patterns */
59+
enableGeneratedRules: true,
60+
/* fall back to heuristic click when no specific rule matches */
61+
enableHeuristicAction: true,
5662
/* skip bundled ABP/uBO cosmetic filter list (saves bundle size) */
5763
enableFilterList: false,
5864
/* how many times to retry CMP detection (~50 ms apart) */
@@ -80,29 +86,44 @@ const sendMessage = (page, message) =>
8086
}, message)
8187
.catch(() => {})
8288

83-
const setupAutoConsent = async page => {
89+
const setupAutoConsent = async (page, timeout) => {
8490
if (page._autoconsentSetup) return
8591
const autoconsentPlaywrightScript = await getAutoconsentPlaywrightScript()
92+
const nonce = randomUUID()
8693

8794
await page.exposeFunction('autoconsentSendMessage', async message => {
8895
if (!message || typeof message !== 'object') return
96+
if (message.__nonce !== nonce) return
8997

9098
if (message.type === 'init') {
9199
return sendMessage(page, { type: 'initResp', config: autoconsentConfig })
92100
}
93101

94102
if (message.type === 'eval') {
95-
return sendMessage(page, { type: 'evalResp', id: message.id, result: false })
103+
let result = false
104+
try {
105+
result = await pTimeout(page.evaluate(message.code), timeout)
106+
} catch {}
107+
return sendMessage(page, { type: 'evalResp', id: message.id, result })
96108
}
97109
})
98110

111+
/* Wrap the binding in the top frame so every outgoing message carries the
112+
nonce. Child frames (including cross-origin iframes) keep the raw CDP
113+
binding which lacks the nonce, so their messages are silently rejected. */
114+
await page.evaluateOnNewDocument(n => {
115+
if (window.self !== window.top) return
116+
const raw = window.autoconsentSendMessage
117+
if (raw) window.autoconsentSendMessage = msg => raw({ ...msg, __nonce: n })
118+
}, nonce)
119+
99120
await page.evaluateOnNewDocument(autoconsentPlaywrightScript)
100121
page._autoconsentSetup = true
101122
}
102123

103124
const runAutoConsent = async page => page.evaluate(await getAutoconsentPlaywrightScript())
104125

105-
const enableBlockingInPage = (page, run, actionTimeout) => {
126+
const enableBlockingInPage = (page, run, timeout) => {
106127
page.disableAdblock = () =>
107128
getEngine()
108129
.then(engine => engine.disableBlockingInPage(page, { keepRequestInterception: true }))
@@ -111,13 +132,13 @@ const enableBlockingInPage = (page, run, actionTimeout) => {
111132

112133
return [
113134
run({
114-
fn: setupAutoConsent(page),
115-
timeout: actionTimeout,
135+
fn: setupAutoConsent(page, timeout),
136+
timeout,
116137
debug: 'autoconsent:setup'
117138
}),
118139
run({
119140
fn: getEngine().then(engine => engine.enableBlockingInPage(page)),
120-
timeout: actionTimeout,
141+
timeout,
121142
debug: 'adblock'
122143
})
123144
]

packages/goto/test/unit/adblock/index.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,119 @@ test('skip autoconsent setup when `adblock` is false', async t => {
8181
t.false(calls.includes('autoconsentSendMessage'))
8282
})
8383

84+
test('autoconsent eval that throws still sends evalResp with result false', async t => {
85+
const browserless = await getBrowserContext(t)
86+
const url = await getUrl(t)
87+
88+
const run = browserless.withPage((page, goto) => async () => {
89+
await goto(page, { url })
90+
91+
return page.evaluate(() => {
92+
return new Promise(resolve => {
93+
const timeout = setTimeout(() => resolve(null), 3000)
94+
window.autoconsentReceiveMessage = msg => {
95+
if (msg.type === 'evalResp' && msg.id === 'test-throw') {
96+
clearTimeout(timeout)
97+
resolve(msg)
98+
}
99+
}
100+
window
101+
.autoconsentSendMessage({
102+
type: 'eval',
103+
id: 'test-throw',
104+
code: '(() => { throw new Error("boom") })()'
105+
})
106+
.catch(() => {})
107+
})
108+
})
109+
})
110+
111+
const received = await run()
112+
t.truthy(received, 'evalResp should be received even when eval code throws')
113+
t.is(received.type, 'evalResp')
114+
t.is(received.id, 'test-throw')
115+
t.is(received.result, false)
116+
})
117+
118+
test('autoconsent eval that hangs is timed out', async t => {
119+
const browserless = await getBrowserContext(t)
120+
const url = await getUrl(t)
121+
122+
const run = browserless.withPage((page, goto) => async () => {
123+
await goto(page, { url })
124+
125+
return page.evaluate(() => {
126+
return new Promise(resolve => {
127+
const timeout = setTimeout(() => resolve(null), 10000)
128+
window.autoconsentReceiveMessage = msg => {
129+
if (msg.type === 'evalResp' && msg.id === 'test-hang') {
130+
clearTimeout(timeout)
131+
resolve(msg)
132+
}
133+
}
134+
window
135+
.autoconsentSendMessage({
136+
type: 'eval',
137+
id: 'test-hang',
138+
code: 'new Promise(() => {})'
139+
})
140+
.catch(() => {})
141+
})
142+
})
143+
})
144+
145+
const received = await run()
146+
t.truthy(received, 'evalResp should be received even when eval code hangs')
147+
t.is(received.type, 'evalResp')
148+
t.is(received.id, 'test-hang')
149+
t.is(received.result, false)
150+
})
151+
152+
test('eval triggered from child frame is rejected', async t => {
153+
const browserless = await getBrowserContext(t)
154+
155+
const url = await runServer(t, ({ req, res }) => {
156+
res.setHeader('content-type', 'text/html')
157+
if (req.url === '/frame') {
158+
return res.end('<html><body></body></html>')
159+
}
160+
res.end('<html><body><iframe src="/frame"></iframe></body></html>')
161+
})
162+
163+
const run = browserless.withPage((page, goto) => async () => {
164+
await goto(page, { url, waitUntil: 'load' })
165+
166+
const iframeEl = await page.waitForSelector('iframe')
167+
const iframe = await iframeEl.contentFrame()
168+
169+
await page.evaluate(() => {
170+
window.__iframeEval = false
171+
})
172+
173+
const hasBinding = await iframe
174+
.evaluate(() => typeof window.autoconsentSendMessage === 'function')
175+
.catch(() => false)
176+
177+
if (!hasBinding) return false
178+
179+
await iframe.evaluate(() => {
180+
window
181+
.autoconsentSendMessage({
182+
type: 'eval',
183+
id: 'frame-eval',
184+
code: 'window.__iframeEval = true'
185+
})
186+
.catch(() => {})
187+
})
188+
189+
await new Promise(resolve => setTimeout(resolve, 1000))
190+
return page.evaluate(() => window.__iframeEval)
191+
})
192+
193+
const result = await run()
194+
t.is(result, false, 'eval from child frame must not execute in main frame')
195+
})
196+
84197
test('`disableAdblock` removes blocker listeners and keeps request interception enabled', async t => {
85198
const browserless = await getBrowserContext(t)
86199
const url = await getUrl(t)

0 commit comments

Comments
 (0)