Skip to content

Commit 69129df

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 69129df

File tree

2 files changed

+57
-0
lines changed

2 files changed

+57
-0
lines changed

packages/goto/src/adblock.js

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

33
const { PuppeteerBlocker } = require('@ghostery/adblocker-puppeteer')
4+
const { randomUUID } = require('crypto')
45
const fs = require('fs/promises')
56
const path = require('path')
67

@@ -87,9 +88,11 @@ const sendMessage = (page, message) =>
8788
const setupAutoConsent = async page => {
8889
if (page._autoconsentSetup) return
8990
const autoconsentPlaywrightScript = await getAutoconsentPlaywrightScript()
91+
const nonce = randomUUID()
9092

9193
await page.exposeFunction('autoconsentSendMessage', async message => {
9294
if (!message || typeof message !== 'object') return
95+
if (message.__nonce !== nonce) return
9396

9497
if (message.type === 'init') {
9598
return sendMessage(page, { type: 'initResp', config: autoconsentConfig })
@@ -104,6 +107,15 @@ const setupAutoConsent = async page => {
104107
}
105108
})
106109

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

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

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

118+
test('eval triggered from child frame is rejected', async t => {
119+
const browserless = await getBrowserContext(t)
120+
121+
const url = await runServer(t, ({ req, res }) => {
122+
res.setHeader('content-type', 'text/html')
123+
if (req.url === '/frame') {
124+
return res.end('<html><body></body></html>')
125+
}
126+
res.end('<html><body><iframe src="/frame"></iframe></body></html>')
127+
})
128+
129+
const run = browserless.withPage((page, goto) => async () => {
130+
await goto(page, { url, waitUntil: 'load' })
131+
132+
const iframeEl = await page.waitForSelector('iframe')
133+
const iframe = await iframeEl.contentFrame()
134+
135+
await page.evaluate(() => {
136+
window.__iframeEval = false
137+
})
138+
139+
const hasBinding = await iframe
140+
.evaluate(() => typeof window.autoconsentSendMessage === 'function')
141+
.catch(() => false)
142+
143+
if (!hasBinding) return false
144+
145+
await iframe.evaluate(() => {
146+
window
147+
.autoconsentSendMessage({
148+
type: 'eval',
149+
id: 'frame-eval',
150+
code: 'window.__iframeEval = true'
151+
})
152+
.catch(() => {})
153+
})
154+
155+
await new Promise(resolve => setTimeout(resolve, 1000))
156+
return page.evaluate(() => window.__iframeEval)
157+
})
158+
159+
const result = await run()
160+
t.is(result, false, 'eval from child frame must not execute in main frame')
161+
})
162+
118163
test('`disableAdblock` removes blocker listeners and keeps request interception enabled', async t => {
119164
const browserless = await getBrowserContext(t)
120165
const url = await getUrl(t)

0 commit comments

Comments
 (0)