Skip to content

Commit 8f3c488

Browse files
committed
test: enhance iframe isolation and network containment tests
1 parent 753aa11 commit 8f3c488

3 files changed

Lines changed: 210 additions & 15 deletions

File tree

test/e2e/proxy.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,52 @@ test('browser traffic uses test SOCKS5, proxied /p navigation, and Chrome UA', {
231231
assert.match(home.href, new RegExp(`^http://proxy\\.localhost:${proxyPort}/p/`));
232232
assert.ok(requests.some(r => r.url === '/' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`);
233233

234+
const iframeIsolation = await page.evaluate(async target => {
235+
const blockedByPolicy = fn => {
236+
try { fn(); return ''; }
237+
catch (err) { return err && err.message || String(err); }
238+
};
239+
240+
const sync = document.createElement('iframe');
241+
document.body.appendChild(sync);
242+
const syncRTC = blockedByPolicy(() => new sync.contentWindow.RTCPeerConnection());
243+
const docRTC = blockedByPolicy(() => new sync.contentDocument.defaultView.RTCPeerConnection());
244+
245+
const modern = document.createElement('iframe');
246+
document.body.append(modern);
247+
const modernRTC = blockedByPolicy(() => new modern.contentWindow.RTCPeerConnection());
248+
const websocketShared = modern.contentWindow.WebSocket === window.WebSocket;
249+
const ws = new modern.contentWindow.WebSocket('ws://evil.example/socket');
250+
const websocketURL = ws.url;
251+
try { ws.close(); } catch {}
252+
253+
const observed = document.createElement('iframe');
254+
document.body.appendChild(observed);
255+
const attr = document.createAttribute('src');
256+
attr.value = target;
257+
observed.attributes.setNamedItem(attr);
258+
const rewrittenSrc = await new Promise((resolve, reject) => {
259+
const deadline = Date.now() + 5000;
260+
(function poll() {
261+
const current = observed.attributes.getNamedItem('src')?.value || '';
262+
if (current.startsWith(location.origin + '/p/')) { resolve(current); return; }
263+
if (Date.now() > deadline) { reject(new Error(`src not rewritten: ${current}`)); return; }
264+
setTimeout(poll, 25);
265+
})();
266+
});
267+
268+
sync.remove();
269+
modern.remove();
270+
observed.remove();
271+
return { syncRTC, docRTC, modernRTC, websocketShared, websocketURL, rewrittenSrc };
272+
}, `http://e2e.test:${targetPort}/next`);
273+
assert.equal(iframeIsolation.syncRTC, 'Blocked by ZeroProxy policy');
274+
assert.equal(iframeIsolation.docRTC, 'Blocked by ZeroProxy policy');
275+
assert.equal(iframeIsolation.modernRTC, 'Blocked by ZeroProxy policy');
276+
assert.equal(iframeIsolation.websocketShared, true);
277+
assert.equal(iframeIsolation.websocketURL, 'ws://evil.example/socket');
278+
assert.match(iframeIsolation.rewrittenSrc, new RegExp(`^http://proxy\\.localhost:${proxyPort}/p/`));
279+
234280
await page.click('#next');
235281
await page.waitForFunction(() => document.title === 'E2E Next', { timeout: 30000 });
236282
const next = await page.evaluate(() => ({

test/js/static-policy.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ test('runtime avoids forbidden global deception hooks', () => {
1313
assert.equal(rt.includes('Function.prototype.toString'), false);
1414
assert.equal(rt.includes('Object.getOwnPropertyDescriptor ='), false);
1515
assert.equal(rt.includes('window.__zp'), false);
16+
assert.equal(rt.includes('queueMicrotask'), false);
1617
});
1718

1819
test('runtime installs required escape-vector hooks', () => {
@@ -29,9 +30,19 @@ test('runtime installs required escape-vector hooks', () => {
2930
"'appendChild'",
3031
"'insertBefore'",
3132
"'replaceChild'",
33+
"'append'",
34+
"'prepend'",
35+
"'before'",
36+
"'after'",
37+
"'replaceWith'",
3238
"'insertAdjacentHTML'",
3339
"'getAttribute'",
3440
'installNetworkContainment',
41+
"'contentWindow'",
42+
"'contentDocument'",
43+
'new WeakSet',
44+
"attributeFilter: ['href', 'src', 'srcdoc', 'action', 'formaction']",
45+
'enforceObservedAttribute',
3546
'installStorageFacades',
3647
'localStorage',
3748
'indexedDB',

web/runtime-prelude.js

Lines changed: 153 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
const documentCookieRecords = [];
2121
initDocumentCookieRecords(documentCookie);
2222
const urlMeta = new WeakMap();
23-
const iframeMeta = new WeakSet();
23+
const networkContainmentMarker = Symbol.for('zeroproxy.network.contained');
24+
const iframeHooksMarker = Symbol.for('zeroproxy.iframe.hooks');
2425
const listenersKey = Symbol('zp.listeners');
2526
const workerBlobURLs = new Set();
2627

@@ -365,12 +366,39 @@
365366
try {
366367
new MO(records => {
367368
for (const r of records) {
368-
if (r.type === 'attributes') syncBaseElement(r.target);
369-
else for (const n of r.addedNodes || []) syncBaseElement(n);
369+
if (r.type === 'attributes') enforceObservedAttribute(r.target, String(r.attributeName || '').toLowerCase());
370+
else for (const n of r.addedNodes || []) { syncBaseElement(n); instrumentDescendantIframes(n); }
370371
}
371-
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
372+
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['href', 'src', 'srcdoc', 'action', 'formaction'] });
372373
} catch {}
373374
}
375+
function enforceObservedAttribute(el, key) {
376+
if (!el || !key) return;
377+
const tag = el.localName;
378+
if (tag === 'base' && key === 'href') { syncBaseElement(el); return; }
379+
if ((tag === 'iframe' || tag === 'frame') && key === 'srcdoc') {
380+
const raw = Native.getAttribute.call(el, 'srcdoc');
381+
if (raw && !raw.startsWith(injectSrcdoc(''))) Native.setAttribute.call(el, 'srcdoc', injectSrcdoc(String(raw)));
382+
instrumentIframe(el);
383+
return;
384+
}
385+
if (!isURLBearing(el, key)) return;
386+
const raw = Native.getAttribute.call(el, key);
387+
if (!raw || !isHTTPURL(raw) || String(raw).startsWith(proxyOrigin)) return;
388+
let target;
389+
try { target = targetURL(raw); } catch { return; }
390+
const alreadyMapped = urlMeta.get(el) === target && Native.getAttribute.call(el, 'data-zp-target-url') === target;
391+
urlMeta.set(el, target);
392+
Native.setAttribute.call(el, 'data-zp-target-url', target);
393+
if ((tag === 'iframe' || tag === 'frame') && key === 'src') {
394+
Native.setAttribute.call(el, key, 'about:blank');
395+
shareNavURL(target).then(u => Native.setAttribute.call(el, key, u)).catch(()=>{});
396+
instrumentIframe(el);
397+
return;
398+
}
399+
if (alreadyMapped) return;
400+
Native.setAttribute.call(el, key, target);
401+
}
374402

375403
function installWorkerHooks() {
376404
if (Native.Worker) define(root, 'Worker', function(url, opts) { return new Native.Worker(workerBootstrapURL(url), opts); });
@@ -402,26 +430,136 @@
402430
}
403431

404432
function installIframeHooks(w) {
405-
define(w.document, 'createElement', function(name, opts) { const el = Native.createElement(String(name), opts); if (/^i?frame$/i.test(String(name))) queueMicrotask(() => instrumentIframe(el)); return el; });
406-
for (const [proto, name, nativeFn] of [[w.Node.prototype,'appendChild',Native.appendChild],[w.Node.prototype,'insertBefore',Native.insertBefore],[w.Node.prototype,'replaceChild',Native.replaceChild]]) {
407-
define(proto, name, function(...args) { const ret = nativeFn.apply(this, args); for (const a of args) instrumentDescendantIframes(a); return ret; });
408-
}
433+
if (!w || !w.document || !w.Node || !w.Element) return;
434+
try {
435+
if (w[iframeHooksMarker]) return;
436+
Object.defineProperty(w, iframeHooksMarker, { value: true, enumerable: false, configurable: false });
437+
} catch {}
438+
const instrumentedWindows = new WeakSet();
439+
const nativeCreateElement = w === root ? Native.createElement : w.document.createElement.bind(w.document);
440+
441+
installFrameAccessors(w.HTMLIFrameElement && w.HTMLIFrameElement.prototype);
442+
installFrameAccessors(w.HTMLFrameElement && w.HTMLFrameElement.prototype);
443+
444+
define(w.document, 'createElement', function(name, opts) {
445+
const el = nativeCreateElement(String(name), opts);
446+
if (/^i?frame$/i.test(String(name))) instrumentDescendantIframes(el);
447+
return el;
448+
});
449+
450+
patchInsertion(w.Node.prototype, 'appendChild', w.Node.prototype.appendChild);
451+
patchInsertion(w.Node.prototype, 'insertBefore', w.Node.prototype.insertBefore);
452+
patchInsertion(w.Node.prototype, 'replaceChild', w.Node.prototype.replaceChild);
453+
for (const method of ['append', 'prepend', 'before', 'after', 'replaceWith']) patchInsertion(w.Element.prototype, method, w.Element.prototype[method]);
454+
409455
if (w.HTMLIFrameElement) { installFrameProp(w.HTMLIFrameElement.prototype, 'src'); installFrameProp(w.HTMLIFrameElement.prototype, 'srcdoc'); }
410-
function installFrameProp(proto, prop) { const d = Object.getOwnPropertyDescriptor(proto, prop); if (!d || !d.set) return; try { Object.defineProperty(proto, prop, { get: d.get, set(v) { if (prop === 'srcdoc') d.set.call(this, injectSrcdoc(String(v))); else if (isHTTPURL(v)) { d.set.call(this, 'about:blank'); shareNavURL(v).then(u => d.set.call(this, u)).catch(()=>{}); } else d.set.call(this, v); instrumentIframe(this); }, configurable: false }); } catch {} }
456+
if (w.HTMLFrameElement) installFrameProp(w.HTMLFrameElement.prototype, 'src');
457+
458+
function patchInsertion(proto, name, nativeFn) {
459+
if (!proto || typeof nativeFn !== 'function') return;
460+
define(proto, name, function(...args) {
461+
const frames = collectIframesFromArgs(args);
462+
const ret = nativeFn.apply(this, args);
463+
instrumentFrameList(frames);
464+
return ret;
465+
});
466+
}
467+
function installFrameAccessors(proto) {
468+
if (!proto) return;
469+
const win = frameDescriptor(proto, 'contentWindow');
470+
if (win && win.get) {
471+
try { Object.defineProperty(proto, 'contentWindow', { get() { return containFrameWindow(win.get.call(this), this); }, configurable: false, enumerable: true }); } catch {}
472+
}
473+
const doc = frameDescriptor(proto, 'contentDocument');
474+
if (doc && doc.get) {
475+
try { Object.defineProperty(proto, 'contentDocument', { get() { const childDoc = doc.get.call(this); if (childDoc && childDoc.defaultView) containFrameWindow(childDoc.defaultView, this); return childDoc; }, configurable: false, enumerable: true }); } catch {}
476+
}
477+
}
478+
function frameDescriptor(proto, prop) {
479+
for (let p = proto; p; p = Object.getPrototypeOf(p)) {
480+
const d = Object.getOwnPropertyDescriptor(p, prop);
481+
if (d) return d;
482+
}
483+
return null;
484+
}
485+
function containFrameWindow(childWin, frame) {
486+
if (!childWin) return childWin;
487+
try { if (childWin[networkContainmentMarker]) return childWin; } catch { if (instrumentedWindows.has(childWin)) return childWin; }
488+
instrumentedWindows.add(childWin);
489+
try { installNetworkContainment(childWin); }
490+
catch (e) {
491+
instrumentedWindows.delete(childWin);
492+
try { frame && frame.remove && frame.remove(); } catch {}
493+
throw e;
494+
}
495+
return childWin;
496+
}
497+
function installFrameProp(proto, prop) {
498+
const d = Object.getOwnPropertyDescriptor(proto, prop);
499+
if (!d || !d.set) return;
500+
try {
501+
Object.defineProperty(proto, prop, {
502+
get: d.get,
503+
set(v) {
504+
if (prop === 'srcdoc') d.set.call(this, injectSrcdoc(String(v)));
505+
else if (isHTTPURL(v) && !String(v).startsWith(proxyOrigin)) {
506+
d.set.call(this, 'about:blank');
507+
shareNavURL(v).then(u => d.set.call(this, u)).catch(()=>{});
508+
} else d.set.call(this, v);
509+
instrumentIframe(this);
510+
},
511+
configurable: false
512+
});
513+
} catch {}
514+
}
515+
}
516+
function collectIframesFromArgs(args) {
517+
let frames = null;
518+
for (const node of args) frames = collectIframes(node, frames);
519+
return frames;
520+
}
521+
function collectIframes(node, frames) {
522+
if (!node || typeof node !== 'object') return frames;
523+
if (/^(IFRAME|FRAME)$/.test(node.nodeName || '')) {
524+
if (!frames) frames = [];
525+
frames.push(node);
526+
}
527+
if (node.querySelectorAll) {
528+
const descendants = node.querySelectorAll('iframe,frame');
529+
for (let i = 0; i < descendants.length; i++) {
530+
if (!frames) frames = [];
531+
frames.push(descendants[i]);
532+
}
533+
}
534+
return frames;
535+
}
536+
function instrumentFrameList(frames) { if (frames) for (const frame of frames) instrumentIframe(frame); }
537+
function instrumentDescendantIframes(node) { instrumentFrameList(collectIframes(node, null)); }
538+
function instrumentIframe(frame) {
539+
if (!frame || !/^(IFRAME|FRAME)$/.test(frame.nodeName || '')) return;
540+
try {
541+
const src = Native.getAttribute.call(frame, 'src');
542+
if ((!src || /^about:blank$/i.test(src)) && frame.contentWindow) installNetworkContainment(frame.contentWindow);
543+
} catch { try { frame.remove(); } catch {} }
411544
}
412-
function instrumentDescendantIframes(node) { if (!node || !node.querySelectorAll) { if (node && /^(IFRAME|FRAME)$/.test(node.nodeName)) instrumentIframe(node); return; } node.querySelectorAll('iframe,frame').forEach(instrumentIframe); }
413-
function instrumentIframe(frame) { if (!frame || iframeMeta.has(frame)) return; iframeMeta.add(frame); try { if (!frame.getAttribute('src') && frame.contentWindow) installNetworkContainment(frame.contentWindow); } catch { try { frame.remove(); } catch {} } }
414545
function installNetworkContainment(w) {
546+
if (!w) return;
547+
try { if (w[networkContainmentMarker]) return; } catch {}
415548
// Native fetch is intentionally left intact; the Service Worker owns request capture.
416549
installNavigatorIdentity(w);
417-
if (root.WebSocket) define(w, 'WebSocket', root.WebSocket);
550+
if (root.WebSocket && !define(w, 'WebSocket', root.WebSocket)) throw normalizedError('SecurityError');
418551
if (w.navigator && navigator.sendBeacon) define(w.navigator, 'sendBeacon', navigator.sendBeacon.bind(navigator));
419-
installBlockers(w);
552+
installIframeHooks(w);
553+
installBlockers(w, true);
554+
try { Object.defineProperty(w, networkContainmentMarker, { value: true, enumerable: false, configurable: false }); } catch {}
420555
}
421556

422-
function installBlockers(w) {
557+
function installBlockers(w, strict = false) {
423558
const blockCtor = function(){ throw normalizedError('NotSupportedError'); };
424-
for (const name of ['RTCPeerConnection','webkitRTCPeerConnection','RTCDataChannel','WebTransport','WebSocketStream']) define(w, name, blockCtor);
559+
for (const name of ['RTCPeerConnection','webkitRTCPeerConnection','RTCDataChannel','WebTransport','WebSocketStream']) {
560+
const ok = define(w, name, blockCtor);
561+
if (strict && name in w && !ok) throw normalizedError('SecurityError');
562+
}
425563
const nav = w.navigator;
426564
if (nav) {
427565
for (const name of ['serial','hid','usb','bluetooth','requestMIDIAccess','credentials','geolocation','clipboard','wakeLock']) { try { Object.defineProperty(nav, name, { get(){ throw normalizedError('NotSupportedError'); }, configurable: false }); } catch {} }

0 commit comments

Comments
 (0)