Skip to content

Commit 5d0667c

Browse files
committed
feat: enhance URL handling in HTML transformation and add tests for passive subresources
1 parent 3e51422 commit 5d0667c

6 files changed

Lines changed: 109 additions & 14 deletions

File tree

internal/htmltx/transform.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,12 @@ func rewriteToken(tok xhtml.Token, opt Options) xhtml.Token {
222222
attrs = append(attrs, a)
223223
continue
224224
}
225-
if tag == "link" && key == "href" {
225+
if shouldRewritePassiveAttr(tag, key) {
226226
trimmed := strings.TrimSpace(a.Val)
227227
if target, ok := resolveTargetURL(a.Val, opt); ok {
228228
a.Val = target
229229
dataTarget = target
230-
} else if trimmed != "" && hasExecutableURLScheme(trimmed) {
230+
} else if trimmed != "" && hasDangerousURLScheme(trimmed) {
231231
a.Val = shareurl.ControlPrefix + "error/POLICY_BLOCKED"
232232
attrs = append(attrs, xhtml.Attribute{Key: "data-zp-blocked-url", Val: trimmed})
233233
}
@@ -295,6 +295,23 @@ func shouldRewriteAttr(tag, key string) bool {
295295
return false
296296
}
297297

298+
func shouldRewritePassiveAttr(tag, key string) bool {
299+
switch key {
300+
case "href":
301+
return tag == "link" || tag == "image" || tag == "use"
302+
case "src":
303+
return tag == "img" || tag == "source" || tag == "audio" || tag == "video" || tag == "track" || tag == "input"
304+
case "poster":
305+
return tag == "video"
306+
}
307+
return false
308+
}
309+
310+
func hasDangerousURLScheme(s string) bool {
311+
scheme, ok := urlScheme(s)
312+
return ok && (strings.EqualFold(scheme, "javascript") || strings.EqualFold(scheme, "vbscript"))
313+
}
314+
298315
func isDocumentNavigationAttr(tag, key string) bool {
299316
return (tag == "a" || tag == "area" || tag == "form" || tag == "input" || tag == "button" || tag == "iframe" || tag == "frame") && shouldRewriteAttr(tag, key)
300317
}

internal/htmltx/transform_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,22 @@ func TestTransformStripsIntegrityButBacksUpForRuntimeMasking(t *testing.T) {
144144
}
145145
}
146146
}
147+
148+
func TestTransformAbsolutizesPassiveSubresources(t *testing.T) {
149+
target, _ := url.Parse("https://example.com/app/page.html")
150+
out, err := Transform(strings.NewReader(`<body><img src="/logo.png"><video poster="poster.jpg"><source src="../media.webm"></video><img src="data:image/png;base64,AAAA"></body>`), Options{TabID: "tab", EntryID: "entry", TargetURL: target})
151+
if err != nil {
152+
t.Fatal(err)
153+
}
154+
s := string(out)
155+
for _, want := range []string{`src="https://example.com/logo.png"`, `poster="https://example.com/app/poster.jpg"`, `src="https://example.com/media.webm"`, `src="data:image/png;base64,AAAA"`} {
156+
if !strings.Contains(s, want) {
157+
t.Fatalf("missing %q in %s", want, s)
158+
}
159+
}
160+
for _, forbidden := range []string{`src="/logo.png"`, `poster="poster.jpg"`, `src="../media.webm"`} {
161+
if strings.Contains(s, forbidden) {
162+
t.Fatalf("unresolved passive subresource %q remained in %s", forbidden, s)
163+
}
164+
}
165+
}

rewriter-rs/src/lib.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,7 @@ impl<'a> Rewriter<'a> {
945945
fn is_global_name(&self, name: &str) -> bool { GLOBALS.iter().any(|global| *global == name) && !self.declared(name) }
946946

947947
fn member_needs_helper_static(&self, expr: &StaticMemberExpression<'a>) -> bool {
948-
MEMBER_HELPER_PROPS.iter().any(|prop| *prop == expr.property.name.as_str())
948+
!matches!(&expr.object, Expression::Super(_)) && MEMBER_HELPER_PROPS.iter().any(|prop| *prop == expr.property.name.as_str())
949949
}
950950

951951
fn member_needs_helper_computed(&self, expr: &ComputedMemberExpression<'a>) -> bool {
@@ -997,6 +997,9 @@ impl<'a> Rewriter<'a> {
997997
fn call_target(&self, callee: &Expression<'a>) -> Option<(String, String)> {
998998
match callee {
999999
Expression::StaticMemberExpression(expr) => {
1000+
if matches!(&expr.object, Expression::Super(_)) {
1001+
return None;
1002+
}
10001003
let prop = expr.property.name.as_str();
10011004
if CALL_HELPER_PROPS.iter().any(|name| *name == prop) || self.member_needs_helper_static(expr) {
10021005
Some((self.render_expression(&expr.object), format!("{:?}", prop)))
@@ -1185,6 +1188,19 @@ mod tests {
11851188
assert!(code.contains("__zp_call(Object,\"getOwnPropertyDescriptor\",[__zp_get(globalThis,\"window\"),'location'])"));
11861189
}
11871190

1191+
#[test]
1192+
fn preserves_super_member_syntax() {
1193+
let code = rewrite_ok(
1194+
"class Child extends Parent { method() { super.get(); return super.constructor; } }",
1195+
"module",
1196+
"https://example.com/app.js",
1197+
);
1198+
assert!(code.contains("super.get();"));
1199+
assert!(code.contains("return super.constructor;"));
1200+
assert!(!code.contains("__zp_get(super"));
1201+
assert!(!code.contains("__zp_call(super"));
1202+
}
1203+
11881204
#[test]
11891205
fn parse_failures_return_error() {
11901206
let out = rewrite_script("if (", "classic", "https://example.com/app.js", "/zp/");

test/e2e/proxy.test.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ function createTargetServer(requests) {
120120
if (url.pathname === '/') {
121121
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
122122
res.end(`<!doctype html><html><head><title>E2E Home</title><link rel="stylesheet" href="/site.css"></head><body>
123-
<main id="style-probe" class="root-stylesheet-probe"><h1>E2E Home</h1><a id="next" href="/next">Next page</a></main>
123+
<main id="style-probe" class="root-stylesheet-probe"><h1>E2E Home</h1><img id="image-probe" src="/image-probe.png" alt=""><a id="next" href="/next">Next page</a></main>
124124
<script>
125125
window.__ua = navigator.userAgent;
126126
window.__platform = navigator.platform;
@@ -184,6 +184,11 @@ function createTargetServer(requests) {
184184
res.end(`.root-stylesheet-probe{border-top:7px solid rgb(12, 34, 56); padding-left:13px}`);
185185
return;
186186
}
187+
if (url.pathname === '/image-probe.png') {
188+
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
189+
res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64'));
190+
return;
191+
}
187192
if (url.pathname === '/gtm.js') {
188193
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
189194
res.end(`window.__gtmFixture = {
@@ -213,6 +218,8 @@ function createTargetServer(requests) {
213218
});
214219
root.find('.trigger').trigger('click');
215220
root.append($.parseHTML('<div class="parsed"><span>parsed</span></div>'));
221+
const emptyHtml = $('<div id="empty-html-probe"></div>').appendTo(root);
222+
emptyHtml.html('<span>filled</span>');
216223
const deferred = $.Deferred();
217224
const ajax = $.ajax({ url: '/jquery-ajax.json', dataType: 'json' });
218225
const script = $.getScript('/jquery-plugin.js');
@@ -230,6 +237,8 @@ function createTargetServer(requests) {
230237
attrClicked: root.find('.trigger').attr('data-clicked'),
231238
parsedText: root.find('.parsed span').text(),
232239
param: $.param({ a: 1, b: ['x', 'y'] }),
240+
htmlProbeText: emptyHtml.find('span').text(),
241+
htmlProbeChildren: emptyHtml.children().length,
233242
ajaxData,
234243
plugin: window.__jqueryPlugin || null,
235244
globalEvalHref: window.__jqueryGlobalEvalHref || null,
@@ -580,6 +589,10 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
580589
const cs = el && getComputedStyle(el);
581590
return cs && { borderTopWidth: cs.borderTopWidth, borderTopColor: cs.borderTopColor, paddingLeft: cs.paddingLeft };
582591
})(),
592+
imageProbe: (() => {
593+
const el = document.getElementById('image-probe');
594+
return el && { complete: el.complete, naturalWidth: el.naturalWidth, src: el.getAttribute('src') };
595+
})(),
583596
}));
584597
assert.equal(home.title, 'E2E Home');
585598
assert.match(home.hash, /^#k=/);
@@ -603,6 +616,10 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
603616
assert.equal(home.phase2EvalLocation, `http://${targetHost}:${targetPort}/`);
604617
assert.deepEqual(home.styleProbe, { borderTopWidth: '7px', borderTopColor: 'rgb(12, 34, 56)', paddingLeft: '13px' });
605618
assert.ok(requests.some(r => r.url === '/site.css' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`);
619+
assert.equal(home.imageProbe.complete, true);
620+
assert.equal(home.imageProbe.naturalWidth, 1);
621+
assert.equal(home.imageProbe.src, `http://${targetHost}:${targetPort}/image-probe.png`);
622+
assert.ok(requests.some(r => r.url === '/image-probe.png' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`);
606623
assert.ok(requests.some(r => r.url === '/' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`);
607624
await page.waitForFunction(() => window.__rewriteAdvanced && window.__rewriteAdvanced.wsMessage === 'echo:rewrite-script', { timeout: 30000 });
608625
const rewriteAdvanced = await page.evaluate(() => window.__rewriteAdvanced);
@@ -670,6 +687,8 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
670687
assert.equal(jquery.dataClicked, true);
671688
assert.equal(jquery.attrClicked, 'yes');
672689
assert.equal(jquery.parsedText, 'parsed');
690+
assert.equal(jquery.htmlProbeText, 'filled');
691+
assert.equal(jquery.htmlProbeChildren, 1);
673692
assert.equal(jquery.param, 'a=1&b%5B%5D=x&b%5B%5D=y');
674693
assert.deepEqual(jquery.ajaxData, { ok: true, path: '/jquery-ajax.json' });
675694
assert.equal(jquery.plugin && jquery.plugin.loaded, true);

test/js/static-policy.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,13 @@ test('runtime installs required escape-vector hooks', () => {
5151
"'contentWindow'",
5252
"'contentDocument'",
5353
'new WeakSet',
54-
"attributeFilter: ['href', 'xlink:href', 'src', 'srcdoc', 'action', 'formaction', 'integrity', 'type', 'rel', 'target']",
54+
"attributeFilter: ['href', 'xlink:href', 'src', 'srcdoc', 'action', 'formaction', 'poster', 'integrity', 'type', 'rel', 'target']",
5555
'enforceObservedAttribute',
5656
'data-zp-integrity',
5757
'installIntegrityProp',
5858
'installScriptProp',
5959
'installLinkProp',
60+
'shouldBlockURLAttribute',
6061
'installToStringMasking',
6162
'toStringMap',
6263
'installCanvasAntiFingerprinting',

web/runtime-prelude.js

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@
213213
function proxyHistoryURL() { return activeProxyPath + activeProxyFragment; }
214214
function isHTTPURL(raw) { try { const u = new URL(String(raw), baseURL); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } }
215215
function hasExecutableURLScheme(raw) { return /^(?:javascript|data|vbscript):/i.test(String(raw).trim()); }
216+
function hasDangerousURLScheme(raw) { return /^(?:javascript|vbscript):/i.test(String(raw).trim()); }
217+
function shouldBlockURLAttribute(el, key, raw) {
218+
const tag = el && el.localName;
219+
const localKey = attrLocalName(key);
220+
const strict = localKey === 'src' && tag === 'script' || localKey === 'src' && (tag === 'iframe' || tag === 'frame') || usesRawURLAttribute(el, key);
221+
return strict ? hasExecutableURLScheme(raw) : hasDangerousURLScheme(raw);
222+
}
216223
function blockedURLValue(el, key) { const tag = el && el.localName; return key === 'src' && (tag === 'iframe' || tag === 'frame') ? 'about:blank' : key === 'src' && tag === 'script' ? ZP.errorPath('POLICY_BLOCKED') : '#'; }
217224
function blockExecutableURL(el, key, raw) { urlMeta.delete(el); Native.setAttribute.call(el, 'data-zp-target-url', ''); Native.setAttribute.call(el, 'data-zp-blocked-url', String(raw).trim()); Native.setAttribute.call(el, key, blockedURLValue(el, key)); if (key === 'src' && (el.localName === 'iframe' || el.localName === 'frame')) instrumentIframe(el); }
218225
function isIntegrityBearing(el) { const tag = el && el.localName; return tag === 'script' || tag === 'link'; }
@@ -1347,7 +1354,18 @@
13471354
return;
13481355
}
13491356
if (rel && Native.removeAttribute) Native.removeAttribute.call(el, 'data-zp-blocked-rel');
1350-
if (hasSuppressedBlockedLinkRel(el)) blockLinkURL(el, Native.getAttribute.call(el, 'href') || '');
1357+
if (hasSuppressedBlockedLinkRel(el)) {
1358+
blockLinkURL(el, Native.getAttribute.call(el, 'href') || '');
1359+
return;
1360+
}
1361+
const href = Native.getAttribute.call(el, 'href') || '';
1362+
if (href && isHTTPURL(href) && !String(href).startsWith(proxyOrigin)) {
1363+
const target = targetURL(href);
1364+
const alreadyMapped = urlMeta.get(el) === target && Native.getAttribute.call(el, 'data-zp-target-url') === target && Native.getAttribute.call(el, 'href') === target;
1365+
urlMeta.set(el, target);
1366+
if (Native.getAttribute.call(el, 'data-zp-target-url') !== target) Native.setAttribute.call(el, 'data-zp-target-url', target);
1367+
if (!alreadyMapped && Native.getAttribute.call(el, 'href') !== target) Native.setAttribute.call(el, 'href', target);
1368+
}
13511369
}
13521370
function sanitizeSerializedHTML(html) {
13531371
const parserDoc = Native.createHTMLDocument ? Native.createHTMLDocument('') : document.implementation.createHTMLDocument('');
@@ -1414,7 +1432,10 @@
14141432
return null;
14151433
};
14161434
if (prop === Symbol.iterator) return function*(){ for (let i = 0; i < length(); i++) yield nth(i); };
1417-
if (/^(?:0|[1-9]\\d*)$/.test(String(prop))) return nth(Number(prop));
1435+
if (/^(?:0|[1-9]\d*)$/.test(String(prop))) {
1436+
const index = Number(prop);
1437+
return index < length() ? nth(index) : undefined;
1438+
}
14181439
const value = raw && raw[prop];
14191440
return typeof value === 'function' ? value.bind(raw) : value;
14201441
},
@@ -1509,7 +1530,7 @@
15091530
}
15101531
if (this.localName === 'script' && (localKey === 'src' || localKey === 'href')) return setScriptSource(this, v);
15111532
if (isURLBearing(this, key)) {
1512-
if (hasExecutableURLScheme(v)) return blockExecutableURL(this, localKey, v);
1533+
if (shouldBlockURLAttribute(this, localKey, v)) return blockExecutableURL(this, localKey, v);
15131534
if (isHTTPURL(v)) {
15141535
const t = targetURL(v);
15151536
urlMeta.set(this, t);
@@ -1531,7 +1552,7 @@
15311552
if (key === 'integrity' && isIntegrityBearing(this)) return setBackedIntegrity(this, v);
15321553
if (this.localName === 'script' && (localKey === 'src' || localKey === 'href')) return setScriptSource(this, v);
15331554
if (isURLBearing(this, key)) {
1534-
if (hasExecutableURLScheme(v)) return blockExecutableURL(this, localKey, v);
1555+
if (shouldBlockURLAttribute(this, localKey, v)) return blockExecutableURL(this, localKey, v);
15351556
if (isHTTPURL(v)) {
15361557
const t = targetURL(v);
15371558
urlMeta.set(this, t);
@@ -1665,11 +1686,12 @@
16651686
set(v) {
16661687
if (isBlockedLink(this) || hasSuppressedBlockedLinkRel(this)) return blockLinkURL(this, v);
16671688
const value = String(v);
1668-
if (hasExecutableURLScheme(value)) return blockExecutableURL(this, 'href', value);
1689+
if (shouldBlockURLAttribute(this, 'href', value)) return blockExecutableURL(this, 'href', value);
16691690
if (isHTTPURL(value)) {
16701691
const t = targetURL(value);
16711692
urlMeta.set(this, t);
16721693
Native.setAttribute.call(this, 'data-zp-target-url', t);
1694+
return hrefDescriptor.set ? hrefDescriptor.set.call(this, t) : Native.setAttribute.call(this, 'href', t);
16731695
}
16741696
return hrefDescriptor.set ? hrefDescriptor.set.call(this, value) : Native.setAttribute.call(this, 'href', value);
16751697
},
@@ -1729,7 +1751,7 @@
17291751
}
17301752
function instrumentScriptElement(el) { prepareScriptElement(el); }
17311753
function isSVGURLBearing(el, key) { return el && el.namespaceURI === 'http://www.w3.org/2000/svg' && attrLocalName(key) === 'href' && /^(a|image|use|script)$/.test(el.localName || ''); }
1732-
function isURLBearing(el, key) { const tag = el.localName; const localKey = attrLocalName(key); return localKey === 'href' && (tag === 'a' || tag === 'area' || isSVGURLBearing(el, key)) || localKey === 'action' && tag === 'form' || localKey === 'formaction' && (tag === 'input' || tag === 'button') || localKey === 'src' && (tag === 'iframe' || tag === 'frame' || tag === 'script'); }
1754+
function isURLBearing(el, key) { const tag = el.localName; const localKey = attrLocalName(key); return localKey === 'href' && (tag === 'a' || tag === 'area' || tag === 'link' || isSVGURLBearing(el, key)) || localKey === 'action' && tag === 'form' || localKey === 'formaction' && (tag === 'input' || tag === 'button') || localKey === 'src' && (tag === 'iframe' || tag === 'frame' || tag === 'script' || tag === 'img' || tag === 'source' || tag === 'audio' || tag === 'video' || tag === 'track' || tag === 'input') || localKey === 'poster' && tag === 'video'; }
17331755
function executableScriptDataType(el) {
17341756
const kind = executableScriptKindForElement(el);
17351757
if (kind) return kind;
@@ -1815,6 +1837,7 @@
18151837
Native.setAttribute.call(node, 'data-zp-blocked-' + lowerAttr, val);
18161838
Native.setAttribute.call(node, attrName, rewriteEventAttribute(val));
18171839
}
1840+
if (isURLBearing(node, lowerAttr)) enforceObservedAttribute(node, lowerAttr);
18181841
}
18191842
}
18201843
}
@@ -1838,7 +1861,7 @@
18381861
if (r.type === 'attributes') enforceObservedAttribute(r.target, String(r.attributeName || '').toLowerCase());
18391862
else for (const n of r.addedNodes || []) { syncBaseElement(n); enforceSubtreePolicies(n); instrumentDescendantIframes(n); }
18401863
}
1841-
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['href', 'xlink:href', 'src', 'srcdoc', 'action', 'formaction', 'integrity', 'type', 'rel', 'target'] });
1864+
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['href', 'xlink:href', 'src', 'srcdoc', 'action', 'formaction', 'poster', 'integrity', 'type', 'rel', 'target'] });
18421865
} catch {}
18431866
}
18441867
function enforceObservedAttribute(el, key) {
@@ -1868,7 +1891,7 @@
18681891
}
18691892
if (!isURLBearing(el, key)) return;
18701893
const raw = Native.getAttribute.call(el, key);
1871-
if (hasExecutableURLScheme(raw)) { blockExecutableURL(el, localKey, raw); return; }
1894+
if (shouldBlockURLAttribute(el, localKey, raw)) { blockExecutableURL(el, localKey, raw); return; }
18721895
if (!raw || !isHTTPURL(raw) || String(raw).startsWith(proxyOrigin)) return;
18731896
let target;
18741897
try { target = targetURL(raw); } catch { return; }
@@ -1888,7 +1911,7 @@
18881911
if (!node || typeof node !== 'object') return;
18891912
if (node.nodeType === 1) {
18901913
enforceElementPolicy(node);
1891-
if (node.querySelectorAll) node.querySelectorAll('script,link,iframe,frame,a,area,form,input,button,svg a,svg image,svg use').forEach(enforceElementPolicy);
1914+
if (node.querySelectorAll) node.querySelectorAll('script,link,iframe,frame,a,area,form,input,button,img,source,audio,video,track,svg a,svg image,svg use').forEach(enforceElementPolicy);
18921915
}
18931916
}
18941917
function enforceElementPolicy(el) {

0 commit comments

Comments
 (0)