Skip to content

Commit c6b8270

Browse files
committed
refactor: refactored transform.go and 3 others
- Updated transform.go - Updated transform_test.go - Updated static-policy.test.js
1 parent df7f525 commit c6b8270

4 files changed

Lines changed: 174 additions & 32 deletions

File tree

internal/htmltx/transform.go

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,28 @@ func TransformTo(w io.Writer, r io.Reader, opt Options) error {
147147
return out.Flush()
148148
}
149149

150+
type bootConfig struct {
151+
TabID string `json:"tabId"`
152+
EntryID string `json:"entryId"`
153+
TargetURL string `json:"targetUrl"`
154+
DocumentCookie string `json:"documentCookie"`
155+
RuntimeToken string `json:"runtimeToken"`
156+
}
157+
150158
func runtimePrelude(opt Options) string {
151-
boot := map[string]string{
152-
"tabId": opt.TabID, "entryId": opt.EntryID,
153-
"targetUrl": opt.TargetURL.String(), "documentCookie": opt.DocumentCookie,
154-
"runtimeToken": opt.RuntimeToken,
155-
}
156-
b, _ := json.Marshal(boot)
157-
return `<script nonce="zp" src="/__zp/zp-core.js"></script><script nonce="zp">Object.defineProperty(window,"__ZP_BOOT",{value:` + string(b) + `,configurable:true});try{document.currentScript.remove()}catch{}</script><script nonce="zp" src="/__zp/runtime-prelude.js"></script>`
159+
bootJSON, _ := json.Marshal(bootConfig{
160+
TabID: opt.TabID,
161+
EntryID: opt.EntryID,
162+
TargetURL: opt.TargetURL.String(),
163+
DocumentCookie: opt.DocumentCookie,
164+
RuntimeToken: opt.RuntimeToken,
165+
})
166+
var b strings.Builder
167+
b.Grow(len(bootJSON) + 170)
168+
b.WriteString(`<script nonce=zp src=/__zp/zp-core.js></script><script nonce=zp id=__zp-boot type=application/json>`)
169+
b.Write(bootJSON)
170+
b.WriteString(`</script><script nonce=zp src=/__zp/runtime-prelude.js></script>`)
171+
return b.String()
158172
}
159173

160174
func rewriteToken(tok xhtml.Token, opt Options) xhtml.Token {
@@ -163,6 +177,9 @@ func rewriteToken(tok xhtml.Token, opt Options) xhtml.Token {
163177
var dataTarget string
164178
for _, a := range tok.Attr {
165179
key := strings.ToLower(a.Key)
180+
if key == "data-zp-target-url" || key == "data-zp-blocked-url" {
181+
continue
182+
}
166183
if tag == "a" && key == "ping" {
167184
continue
168185
}
@@ -173,8 +190,7 @@ func rewriteToken(tok xhtml.Token, opt Options) xhtml.Token {
173190
}
174191
if shouldRewriteAttr(tag, key) {
175192
trimmed := strings.TrimSpace(a.Val)
176-
lower := strings.ToLower(trimmed)
177-
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(lower, "javascript:") {
193+
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
178194
attrs = append(attrs, a)
179195
continue
180196
}
@@ -214,14 +230,43 @@ func isDocumentNavigationAttr(tag, key string) bool {
214230
return (tag == "a" || tag == "area" || tag == "form" || tag == "input" || tag == "button" || tag == "iframe" || tag == "frame") && shouldRewriteAttr(tag, key)
215231
}
216232

233+
func hasExecutableURLScheme(s string) bool {
234+
scheme, ok := urlScheme(s)
235+
return ok && (strings.EqualFold(scheme, "javascript") || strings.EqualFold(scheme, "data") || strings.EqualFold(scheme, "vbscript"))
236+
}
237+
238+
func urlScheme(s string) (string, bool) {
239+
if s == "" || !isASCIILetter(s[0]) {
240+
return "", false
241+
}
242+
for i := 1; i < len(s); i++ {
243+
c := s[i]
244+
if c == ':' {
245+
return s[:i], true
246+
}
247+
if isASCIILetter(c) || isASCIIDigit(c) || c == '+' || c == '-' || c == '.' {
248+
continue
249+
}
250+
return "", false
251+
}
252+
return "", false
253+
}
254+
255+
func isASCIILetter(c byte) bool {
256+
return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z'
257+
}
258+
259+
func isASCIIDigit(c byte) bool {
260+
return '0' <= c && c <= '9'
261+
}
262+
217263
func wrapAttrURL(raw string, opt Options, nav bool) (wrapped, target string, ok bool) {
218264
s := strings.TrimSpace(raw)
219265
if s == "" || strings.HasPrefix(s, "#") {
220266
return raw, "", false
221267
}
222-
lower := strings.ToLower(s)
223-
if strings.HasPrefix(lower, "javascript:") {
224-
return raw, "", false
268+
if hasExecutableURLScheme(s) {
269+
return "#", "", false
225270
}
226271
u, err := url.Parse(s)
227272
if err != nil {
@@ -254,8 +299,13 @@ func baseSyncScript(raw string, opt Options) string {
254299
if abs.Scheme != "http" && abs.Scheme != "https" {
255300
return ""
256301
}
257-
b, _ := json.Marshal(abs.String())
258-
return `<script nonce="zp">window.__ZP_SET_BASE&&window.__ZP_SET_BASE(` + string(b) + `);</script>`
302+
baseJSON, _ := json.Marshal(abs.String())
303+
var b strings.Builder
304+
b.Grow(len(baseJSON) + 70)
305+
b.WriteString(`<script nonce=zp>window.__ZP_SET_BASE&&window.__ZP_SET_BASE(`)
306+
b.Write(baseJSON)
307+
b.WriteString(`);</script>`)
308+
return b.String()
259309
}
260310

261311
func shouldDropToken(tok xhtml.Token) bool {

internal/htmltx/transform_test.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package htmltx
22

33
import (
4+
"encoding/json"
45
"net/url"
56
"strings"
67
"testing"
@@ -25,15 +26,68 @@ func TestTransformInjectsAndLaundersDocumentNavigation(t *testing.T) {
2526
}
2627
}
2728

28-
func TestTransformLeavesJavascriptAndFragmentsUnwrapped(t *testing.T) {
29+
func TestTransformPreservesFragmentsAndBlocksExecutableNavigationSchemes(t *testing.T) {
2930
target, _ := url.Parse("https://example.com/")
30-
out, err := Transform(strings.NewReader(`<body><a href="#x">hash</a><a href="javascript:alert(1)">js</a></body>`), Options{TabID: "t", EntryID: "e", TargetURL: target})
31+
out, err := Transform(strings.NewReader(`<body><a href="#x">hash</a><a href="javascript:alert(1)" data-zp-target-url="https://attacker.test/">js</a><a href="DATA:text/html,hello">data</a><form action="vbscript:msgbox(1)"></form><iframe src="data:text/html,frame"></iframe></body>`), Options{TabID: "t", EntryID: "e", TargetURL: target})
3132
if err != nil {
3233
t.Fatal(err)
3334
}
3435
s := string(out)
35-
if !strings.Contains(s, `href="#x"`) || !strings.Contains(s, `href="javascript:alert(1)"`) {
36-
t.Fatalf("expected fragment/javascript to remain inert: %s", s)
36+
if !strings.Contains(s, `href="#x"`) {
37+
t.Fatalf("expected fragment link to remain local: %s", s)
38+
}
39+
for _, forbidden := range []string{`href="javascript:`, `href="DATA:`, `action="vbscript:`, `src="data:`} {
40+
if strings.Contains(s, forbidden) {
41+
t.Fatalf("executable navigation scheme remained in active attribute %q: %s", forbidden, s)
42+
}
43+
}
44+
if strings.Contains(s, `https://attacker.test/`) {
45+
t.Fatalf("target-supplied ZeroProxy control attribute remained: %s", s)
46+
}
47+
if got := strings.Count(s, `data-zp-blocked-url=`); got != 4 {
48+
t.Fatalf("blocked URL marker count = %d, want 4 in %s", got, s)
49+
}
50+
}
51+
52+
func TestRuntimePreludeEmbedsBootAsInertJSON(t *testing.T) {
53+
target, _ := url.Parse(`https://example.com/path?q="</script><script>evil()</script>&x=1`)
54+
tabID := `tab"</script><script>evil()</script>`
55+
out, err := Transform(strings.NewReader(`<body></body>`), Options{
56+
TabID: tabID,
57+
EntryID: "entry",
58+
TargetURL: target,
59+
DocumentCookie: `a="</script>`,
60+
RuntimeToken: `tok<&>`,
61+
})
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
s := string(out)
66+
if strings.Contains(s, `Object.defineProperty(window,"__ZP_BOOT"`) {
67+
t.Fatalf("boot config was embedded in executable JavaScript: %s", s)
68+
}
69+
const open = `<script nonce=zp id=__zp-boot type=application/json>`
70+
start := strings.Index(s, open)
71+
if start < 0 {
72+
t.Fatalf("missing inert boot JSON script in %s", s)
73+
}
74+
start += len(open)
75+
end := strings.Index(s[start:], `</script>`)
76+
if end < 0 {
77+
t.Fatalf("unterminated boot JSON script in %s", s)
78+
}
79+
bootRaw := s[start : start+end]
80+
for _, unsafe := range []string{"<", ">", "&"} {
81+
if strings.Contains(bootRaw, unsafe) {
82+
t.Fatalf("boot JSON contains raw %q in %s", unsafe, bootRaw)
83+
}
84+
}
85+
var boot map[string]string
86+
if err := json.Unmarshal([]byte(bootRaw), &boot); err != nil {
87+
t.Fatalf("boot JSON did not decode: %v in %s", err, bootRaw)
88+
}
89+
if boot["tabId"] != tabID || boot["targetUrl"] != target.String() || boot["runtimeToken"] != `tok<&>` {
90+
t.Fatalf("boot JSON mismatch: %#v", boot)
3791
}
3892
}
3993

test/js/static-policy.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ test('runtime avoids stale escape gaps and forbidden harness markers', () => {
1717
assert.ok(rt.includes('Function.prototype.toString'));
1818
});
1919

20+
test('runtime reads boot config from inert JSON script', () => {
21+
const rt = fs.readFileSync('web/runtime-prelude.js', 'utf8');
22+
assert.ok(rt.includes("getElementById('__zp-boot')"));
23+
assert.ok(rt.includes('JSON.parse(el.textContent'));
24+
assert.ok(rt.includes('type="application/json"'));
25+
assert.equal(rt.includes('Object.defineProperty(window,"__ZP_BOOT"'), false);
26+
});
27+
2028
test('runtime installs required escape-vector hooks', () => {
2129
const rt = fs.readFileSync('web/runtime-prelude.js', 'utf8');
2230
for (const needle of [

web/runtime-prelude.js

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
if (root[marker]) return;
66
Object.defineProperty(root, marker, { value: true, enumerable: false, configurable: false });
77

8-
const boot = Object.assign({ tabId: '', entryId: '', targetUrl: location.href, documentCookie: '' }, root.__ZP_BOOT || {});
8+
const boot = Object.assign({ tabId: '', entryId: '', targetUrl: location.href, documentCookie: '' }, readBootConfig());
99
const runtimeToken = String(boot.runtimeToken || '');
1010
const TARGET_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36';
1111
const TARGET_APP_VERSION = TARGET_USER_AGENT.replace(/^Mozilla\//, '');
1212
const TARGET_PLATFORM = 'Win32';
13-
try { delete root.__ZP_BOOT; } catch { try { Object.defineProperty(root, '__ZP_BOOT', { value: undefined, enumerable: false }); } catch {} }
13+
clearBootConfig();
1414
const Native = captureNative(root);
1515
const toStringMap = new WeakMap();
1616
const toStringMaskedPrototypes = new WeakSet();
@@ -31,6 +31,27 @@
3131
const canvasHookedWindows = new WeakSet();
3232
const audioHookedWindows = new WeakSet();
3333

34+
35+
function readBootConfig() {
36+
const d = root.document;
37+
const el = d && d.getElementById && d.getElementById('__zp-boot');
38+
if (el) {
39+
try {
40+
const parsed = JSON.parse(el.textContent || '{}');
41+
if (parsed && typeof parsed === 'object') return parsed;
42+
} catch {}
43+
}
44+
return root.__ZP_BOOT || {};
45+
}
46+
47+
function clearBootConfig() {
48+
const d = root.document;
49+
const el = d && d.getElementById && d.getElementById('__zp-boot');
50+
if (el) {
51+
try { el.remove(); } catch {}
52+
}
53+
try { delete root.__ZP_BOOT; } catch { try { Object.defineProperty(root, '__ZP_BOOT', { value: undefined, enumerable: false }); } catch {} }
54+
}
3455
function captureNative(w) {
3556
const d = w.document;
3657
return {
@@ -117,6 +138,9 @@
117138
define(proto, 'dispatchEvent', function(event) { const list = this[listenersKey] && this[listenersKey].get(event.type) || []; try { if (!event.target) Object.defineProperty(event, 'target', { value: this, configurable: true }); } catch {} const handler = this['on' + event.type]; if (typeof handler === 'function') handler.call(this, event); for (const fn of list.slice()) fn.call(this, event); return !event.defaultPrevented; });
118139
}
119140
function isHTTPURL(raw) { try { const u = new URL(String(raw), baseURL); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } }
141+
function hasExecutableURLScheme(raw) { return /^(?:javascript|data|vbscript):/i.test(String(raw).trim()); }
142+
function blockedURLValue(el, key) { const tag = el && el.localName; return key === 'src' && (tag === 'iframe' || tag === 'frame') ? 'about:blank' : '#'; }
143+
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); }
120144
function targetURL(raw, base = baseURL) { return ZP.canonicalTargetURL(String(raw), base).href; }
121145
function targetWSURL(raw, base = baseURL) { return ZP.canonicalWebSocketURL(String(raw), base.replace(/^http/, 'ws')).href; }
122146
function shareNavURL(raw, base = baseURL) { return ZP.makeShareURL(targetURL(raw, base), proxyOrigin); }
@@ -206,7 +230,7 @@
206230
function installBeacon() { if (!navigator.sendBeacon || !Native.navigatorSendBeacon) return; define(navigator, 'sendBeacon', (url, data) => Native.navigatorSendBeacon(targetURL(url), data)); }
207231

208232
function installNavigationTraps() {
209-
document.addEventListener('click', ev => { const nav = clickNavigationTarget(ev); if (!nav) return; ev.preventDefault(); ev.stopImmediatePropagation(); navigateToTarget(nav.href); }, true);
233+
document.addEventListener('click', ev => { const nav = clickNavigationTarget(ev); if (!nav) return; ev.preventDefault(); ev.stopImmediatePropagation(); if (nav.href) navigateToTarget(nav.href); }, true);
210234
document.addEventListener('submit', ev => { const f = ev.target; if (!f) return; ev.preventDefault(); submitForm(f, ev.submitter); }, true);
211235
if (Native.formSubmit) define(HTMLFormElement.prototype, 'submit', function() { submitForm(this); });
212236
if (Native.formRequestSubmit) define(HTMLFormElement.prototype, 'requestSubmit', function(submitter) { submitForm(this, submitter); });
@@ -249,7 +273,8 @@
249273
if (target && target !== '_self') return null;
250274
}
251275
const raw = isAnchor ? el.getAttribute('data-zp-target-url') || el.getAttribute('href') : typeof el.href === 'string' ? el.href : '';
252-
if (!raw || raw[0] === '#' || /^javascript:/i.test(raw)) continue;
276+
if (!raw || raw[0] === '#') continue;
277+
if (hasExecutableURLScheme(raw)) return { href: '', element: el };
253278
if (isHTTPURL(raw)) return { href: raw, element: el };
254279
}
255280
return null;
@@ -376,16 +401,19 @@
376401
updateVirtualBase(v);
377402
return Native.setAttribute.call(this, k, v);
378403
}
379-
if (isURLBearing(this, key) && isHTTPURL(v)) {
380-
const t = targetURL(v);
381-
urlMeta.set(this, t);
382-
Native.setAttribute.call(this, 'data-zp-target-url', t);
383-
if ((this.localName === 'iframe' || this.localName === 'frame') && key === 'src') {
384-
Native.setAttribute.call(this, k, 'about:blank');
385-
shareNavURL(t).then(u => Native.setAttribute.call(this, k, u)).catch(()=>{});
386-
return;
404+
if (isURLBearing(this, key)) {
405+
if (hasExecutableURLScheme(v)) return blockExecutableURL(this, key, v);
406+
if (isHTTPURL(v)) {
407+
const t = targetURL(v);
408+
urlMeta.set(this, t);
409+
Native.setAttribute.call(this, 'data-zp-target-url', t);
410+
if ((this.localName === 'iframe' || this.localName === 'frame') && key === 'src') {
411+
Native.setAttribute.call(this, k, 'about:blank');
412+
shareNavURL(t).then(u => Native.setAttribute.call(this, k, u)).catch(()=>{});
413+
return;
414+
}
415+
return Native.setAttribute.call(this, k, t);
387416
}
388-
return Native.setAttribute.call(this, k, t);
389417
}
390418
if ((this.localName === 'iframe' || this.localName === 'frame') && key === 'srcdoc') return Native.setAttribute.call(this, k, injectSrcdoc(String(v)));
391419
return Native.setAttribute.call(this, k, v);
@@ -398,7 +426,8 @@
398426
}
399427
function isURLBearing(el, key) { const tag = el.localName; return key === 'href' && (tag === 'a' || tag === 'area') || key === 'action' && tag === 'form' || key === 'formaction' && (tag === 'input' || tag === 'button') || key === 'src' && (tag === 'iframe' || tag === 'frame'); }
400428
function transformHTML(s) { return s.replace(/<base\b[^>]*\shref=(["'])([\s\S]*?)\1[^>]*>/ig, (_, q, href) => baseSyncScript(href)).replace(/(<iframe\b[^>]*\ssrcdoc=["'])([\s\S]*?)(["'])/ig, (_, p, h, q) => p + injectSrcdoc(h).replace(/"/g,'&quot;') + q); }
401-
function injectSrcdoc(s) { return '<script src="/__zp/zp-core.js"><\/script><script>Object.defineProperty(window,"__ZP_BOOT",{value:' + JSON.stringify(boot).replace(/</g,'\\u003c') + ',configurable:true});try{document.currentScript.remove()}catch{}<\/script><script src="/__zp/runtime-prelude.js"><\/script>' + s; }
429+
function injectSrcdoc(s) { return '<script src="/__zp/zp-core.js"><\/script><script id="__zp-boot" type="application/json">' + bootJSON() + '<\/script><script src="/__zp/runtime-prelude.js"><\/script>' + s; }
430+
function bootJSON() { return JSON.stringify(boot).replace(/[<>&]/g, c => c === '<' ? '\\u003c' : c === '>' ? '\\u003e' : '\\u0026'); }
402431
function baseSyncScript(raw) { return '<script>window.__ZP_SET_BASE&&window.__ZP_SET_BASE(' + JSON.stringify(String(raw)).replace(/</g,'\\u003c') + ');<\/script>'; }
403432
function syncBaseElement(node) {
404433
if (!node) return;
@@ -430,6 +459,7 @@
430459
}
431460
if (!isURLBearing(el, key)) return;
432461
const raw = Native.getAttribute.call(el, key);
462+
if (hasExecutableURLScheme(raw)) { blockExecutableURL(el, key, raw); return; }
433463
if (!raw || !isHTTPURL(raw) || String(raw).startsWith(proxyOrigin)) return;
434464
let target;
435465
try { target = targetURL(raw); } catch { return; }

0 commit comments

Comments
 (0)