Skip to content

Commit 65fe04a

Browse files
committed
feat: implement event attribute facade to mask internal ZP backing attributes in NamedNodeMap
1 parent 5cc0f9c commit 65fe04a

4 files changed

Lines changed: 110 additions & 6 deletions

File tree

test/e2e/proxy.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3170,6 +3170,7 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
31703170
const button = document.createElement('button');
31713171
button.setAttribute('onclick', 'window.__eventHandlerLocation = location.href');
31723172
document.body.appendChild(button);
3173+
out.eventHandlerExpectedLocation = __zp_get(globalThis, 'location').href;
31733174
button.click();
31743175
out.eventHandlerLocation = window.__eventHandlerLocation || '';
31753176
button.remove();
@@ -3209,7 +3210,7 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
32093210
assert.notEqual(escapeMatrix.dataWorker, 'ran');
32103211
assert.ok(
32113212
escapeMatrix.eventHandlerLocation === '' ||
3212-
escapeMatrix.eventHandlerLocation === `http://${targetHost}:${targetPort}/#compound-tail`,
3213+
escapeMatrix.eventHandlerLocation === escapeMatrix.eventHandlerExpectedLocation,
32133214
`event handler location: ${escapeMatrix.eventHandlerLocation}`,
32143215
);
32153216
assert.equal(

test/js/membrane-invariants.test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,70 @@ test('membrane: hiddenGlobalKey predicate classifies ZP globals vs app globals',
348348
);
349349
});
350350

351+
function loadFilteredNamedNodeMap() {
352+
const src = readRuntime();
353+
const code = [
354+
'function attrLocalName(key) { const s = String(key || "").toLowerCase(); const i = s.indexOf(":"); return i >= 0 ? s.slice(i + 1) : s; }',
355+
extractFunction(src, 'eventAttrName'),
356+
extractFunction(src, 'eventDataAttrName'),
357+
extractFunction(src, 'eventAttributeFacade'),
358+
extractFunction(src, 'filteredNamedNodeMap'),
359+
extractFunction(src, 'filteredCollection'),
360+
'function isZPAttrName(name) { return String(name || "").toLowerCase().startsWith("data-zp-"); }',
361+
'function setEventAttribute(owner, name, value) { owner.lastEventSet = { name, value }; }',
362+
'function setNamedAttributeNode() { return null; }',
363+
'function removeNamedAttributeNode() { return null; }',
364+
'module.exports = { filteredNamedNodeMap };',
365+
].join('\n\n');
366+
const sandbox = { module: { exports: {} }, Object, Proxy, String, Number, Symbol };
367+
vm.createContext(sandbox);
368+
vm.runInContext(code, sandbox);
369+
return sandbox.module.exports.filteredNamedNodeMap;
370+
}
371+
372+
test('membrane: NamedNodeMap named lookup exposes masked event attrs without ZP backing attrs', () => {
373+
const filteredNamedNodeMap = loadFilteredNamedNodeMap();
374+
const owner = {};
375+
const backing = { name: 'data-zp-event-onsubmit', value: 'rewritten-internal-code' };
376+
const href = { name: 'href', value: '/home' };
377+
const raw = [backing, href];
378+
raw.length = 2;
379+
raw.getNamedItem = (name) =>
380+
raw.find((attr) => String(attr.name).toLowerCase() === String(name).toLowerCase()) || null;
381+
382+
const filtered = filteredNamedNodeMap(raw, owner);
383+
const eventAttr = filtered.onsubmit;
384+
385+
assert.equal(filtered.length, 1, 'hidden ZP backing attr must not count as visible');
386+
assert.equal(filtered.href, href, 'ordinary named attributes must remain reachable');
387+
assert.equal(
388+
filtered['data-zp-event-onsubmit'],
389+
undefined,
390+
'ZP event backing attr must stay hidden',
391+
);
392+
assert.equal(
393+
filtered.getNamedItem('onsubmit').name,
394+
'onsubmit',
395+
'property and getNamedItem agree',
396+
);
397+
assert.equal(eventAttr.name, 'onsubmit');
398+
assert.equal(
399+
eventAttr.value,
400+
'',
401+
'rewritten event handler source must not leak through Attr.value',
402+
);
403+
assert.equal(eventAttr.ownerElement, owner);
404+
assert.equal(
405+
'onsubmit' in filtered,
406+
true,
407+
'named event attr must participate in property existence',
408+
);
409+
410+
eventAttr.value = 'next-source';
411+
assert.equal(owner.lastEventSet.name, 'onsubmit');
412+
assert.equal(owner.lastEventSet.value, 'next-source');
413+
});
414+
351415
// DOM-enumeration filter: selectorTargetsZP is the pure gate that makes
352416
// querySelector / querySelectorAll / matches / closest refuse ZP-artifact
353417
// selectors. Extract and exercise it directly.

test/js/static-policy.test.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ test('runtime keeps JavaScript rewriting fail-closed and canonicalizes module UR
287287
const start = rt.indexOf('function scriptProxyPath(target, kind)');
288288
const end = rt.indexOf('function setScriptSource', start);
289289
const body = rt.slice(start, end);
290-
assert.ok(body.includes("if (kind !== 'module')"), 'module proxy URLs must keep referrer data out');
290+
assert.ok(
291+
body.includes("if (kind !== 'module')"),
292+
'module proxy URLs must keep referrer data out',
293+
);
291294
assert.ok(
292295
body.indexOf("params.set('tab'") < body.indexOf("if (kind !== 'module')"),
293296
'runtime tab token must be part of module graph identity',
@@ -304,12 +307,16 @@ test('runtime keeps JavaScript rewriting fail-closed and canonicalizes module UR
304307

305308
test('filtered DOM collections expose numeric indexes to native slice', () => {
306309
const rt = readRuntimeSource();
307-
assert.ok(
308-
rt.includes("has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\\d*)$/.test(String(prop)) && Number(prop) < length()); }"),
310+
assert.match(
311+
rt,
312+
/has\(_target, prop\) \{[\s\S]*Number\(prop\) < length\(\)[\s\S]*\}/,
309313
'filtered collection HasProperty must recognize all numeric indexes',
310314
);
315+
assert.ok(rt.includes("prop === 'length'"));
311316
assert.equal(
312-
rt.includes("has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\\\\d*)$/.test(String(prop)) && Number(prop) < length()); }"),
317+
rt.includes(
318+
"has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\\\\d*)$/.test(String(prop)) && Number(prop) < length()); }",
319+
),
313320
false,
314321
'filtered collection HasProperty must not match a literal backslash-d',
315322
);

web/runtime-prelude.mjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,27 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
20662066
if (owner) enforceAttributeNodeOwner(owner, attr);
20672067
return ret;
20682068
}
2069+
function eventAttributeFacade(owner, name, backing) {
2070+
return new Proxy(backing || {}, {
2071+
get(target, prop) {
2072+
if (prop === 'name' || prop === 'nodeName' || prop === 'localName') return name;
2073+
if (prop === 'value' || prop === 'nodeValue' || prop === 'textContent') return '';
2074+
if (prop === 'ownerElement') return owner || null;
2075+
if (prop === 'namespaceURI' || prop === 'prefix') return null;
2076+
if (prop === 'specified') return true;
2077+
const value = target && target[prop];
2078+
return typeof value === 'function' ? value.bind(target) : value;
2079+
},
2080+
set(target, prop, value) {
2081+
if (prop === 'value' || prop === 'nodeValue' || prop === 'textContent') {
2082+
if (owner) setEventAttribute(owner, name, value);
2083+
return true;
2084+
}
2085+
target[prop] = value;
2086+
return true;
2087+
}
2088+
});
2089+
}
20692090
function removeNamedAttributeNode(owner, raw, name, ns) {
20702091
const local = String(name || '').toLowerCase();
20712092
if (isZPAttrName(local)) return null;
@@ -2112,6 +2133,10 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
21122133
const lower = String(name || '').toLowerCase();
21132134
if (isZPAttrName(lower)) return null;
21142135
for (let i = 0; raw && i < raw.length; i++) if (raw[i] && String(raw[i].name).toLowerCase() === lower && visible(raw[i])) return raw[i];
2136+
if (eventAttrName(lower)) {
2137+
const backed = raw && raw.getNamedItem ? raw.getNamedItem(eventDataAttrName(lower)) : null;
2138+
if (backed) return eventAttributeFacade(owner, lower, backed);
2139+
}
21152140
return null;
21162141
};
21172142
const findNamedNS = (ns, name) => {
@@ -2164,10 +2189,17 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
21642189
const index = Number(prop);
21652190
return index < length() ? nth(index) : undefined;
21662191
}
2192+
if (typeof prop === 'string' && methods && typeof methods.getNamedItem === 'function') {
2193+
const named = methods.getNamedItem(prop);
2194+
if (named) return named;
2195+
}
21672196
const value = raw && raw[prop];
21682197
return typeof value === 'function' ? value.bind(raw) : value;
21692198
},
2170-
has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\d*)$/.test(String(prop)) && Number(prop) < length()); }
2199+
has(_target, prop) {
2200+
if (prop === 'length' || (/^(?:0|[1-9]\d*)$/.test(String(prop)) && Number(prop) < length())) return true;
2201+
return typeof prop === 'string' && methods && typeof methods.getNamedItem === 'function' && !!methods.getNamedItem(prop);
2202+
}
21712203
});
21722204
}
21732205
function sanitizeSerializedHTML(html) {

0 commit comments

Comments
 (0)