Skip to content

Commit 38346e5

Browse files
committed
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
1 parent 6eeda50 commit 38346e5

File tree

2 files changed

+95
-3
lines changed

2 files changed

+95
-3
lines changed

packages/goto/src/adblock.js

Lines changed: 16 additions & 3 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

@@ -84,12 +86,14 @@ const sendMessage = (page, message) =>
8486
}, message)
8587
.catch(() => {})
8688

87-
const setupAutoConsent = async page => {
89+
const setupAutoConsent = async (page, timeout) => {
8890
if (page._autoconsentSetup) return
8991
const autoconsentPlaywrightScript = await getAutoconsentPlaywrightScript()
92+
const nonce = randomUUID()
9093

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

9498
if (message.type === 'init') {
9599
return sendMessage(page, { type: 'initResp', config: autoconsentConfig })
@@ -98,12 +102,21 @@ const setupAutoConsent = async page => {
98102
if (message.type === 'eval') {
99103
let result = false
100104
try {
101-
result = await page.evaluate(message.code)
105+
result = await pTimeout(page.evaluate(message.code), timeout)
102106
} catch {}
103107
return sendMessage(page, { type: 'evalResp', id: message.id, result })
104108
}
105109
})
106110

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+
107120
await page.evaluateOnNewDocument(autoconsentPlaywrightScript)
108121
page._autoconsentSetup = true
109122
}
@@ -119,7 +132,7 @@ const enableBlockingInPage = (page, run, actionTimeout) => {
119132

120133
return [
121134
run({
122-
fn: setupAutoConsent(page),
135+
fn: setupAutoConsent(page, actionTimeout),
123136
timeout: actionTimeout,
124137
debug: 'autoconsent:setup'
125138
}),

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,85 @@ test('autoconsent eval that throws still sends evalResp with result false', asyn
115115
t.is(received.result, false)
116116
})
117117

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+
118197
test('`disableAdblock` removes blocker listeners and keeps request interception enabled', async t => {
119198
const browserless = await getBrowserContext(t)
120199
const url = await getUrl(t)

0 commit comments

Comments
 (0)