Skip to content

Commit 8e63e7f

Browse files
committed
feat: strip Permissions-Policy and Feature-Policy headers and prevent sync XHR when disallowed by policy
1 parent a86b02f commit 8e63e7f

10 files changed

Lines changed: 63 additions & 11 deletions

File tree

internal/headers/policy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
var hidden = map[string]struct{}{
99
"set-cookie": {}, "set-cookie2": {},
1010
"content-security-policy": {}, "content-security-policy-report-only": {},
11+
"permissions-policy": {}, "feature-policy": {},
1112
"report-to": {}, "reporting-endpoints": {}, "nel": {},
1213
"service-worker-allowed": {},
1314
"sourcemap": {}, "x-sourcemap": {},

internal/headers/policy_freeze_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ func TestConstructorPolicyStripsFullHiddenSet(t *testing.T) {
1818
strip := []string{
1919
"Set-Cookie", "Set-Cookie2",
2020
"Content-Security-Policy", "Content-Security-Policy-Report-Only",
21+
"Permissions-Policy", "Feature-Policy",
2122
"Report-To", "Reporting-Endpoints", "NEL",
2223
"Service-Worker-Allowed",
2324
"SourceMap", "X-SourceMap",
@@ -140,7 +141,8 @@ func TestConstructorPolicyStripsLocationAndHopByHop(t *testing.T) {
140141
func TestHiddenHeaderTableOracle(t *testing.T) {
141142
hiddenTrue := []string{
142143
"set-cookie", "SET-COOKIE2", "content-security-policy",
143-
"content-security-policy-report-only", "report-to", "reporting-endpoints",
144+
"content-security-policy-report-only", "permissions-policy", "feature-policy",
145+
"report-to", "reporting-endpoints",
144146
"nel", "service-worker-allowed", "sourcemap", "x-sourcemap",
145147
"alt-svc", "link", "refresh", "clear-site-data",
146148
// hop-by-hop

internal/headers/policy_oracle_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ func policyOracleCorpus() []policyOracleCase {
8686
// stripped:
8787
"Set-Cookie": {"sid=1; Path=/"},
8888
"Content-Security-Policy": {"default-src 'self'"},
89+
"Permissions-Policy": {"sync-xhr=()"},
90+
"Feature-Policy": {"sync-xhr 'none'"},
8991
"Location": {"https://target.example/next"},
9092
"Alt-Svc": {"h3=\":443\""},
9193
"Connection": {"keep-alive"},

internal/headers/policy_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66
)
77

88
func TestConstructorPolicyStripsForbiddenHeaders(t *testing.T) {
9-
h := http.Header{"Set-Cookie": {"a=b"}, "Content-Security-Policy": {"default-src *"}, "Location": {"https://target/"}, "Alt-Svc": {"h3=\":443\""}, "Content-Type": {"text/html"}, "Content-Length": {"10"}}
9+
h := http.Header{"Set-Cookie": {"a=b"}, "Content-Security-Policy": {"default-src *"}, "Permissions-Policy": {"sync-xhr=()"}, "Location": {"https://target/"}, "Alt-Svc": {"h3=\":443\""}, "Content-Type": {"text/html"}, "Content-Length": {"10"}}
1010
out := ConstructorPolicy(h, true, false)
11-
for _, name := range []string{"Set-Cookie", "Content-Security-Policy", "Location", "Alt-Svc", "Content-Length"} {
11+
for _, name := range []string{"Set-Cookie", "Content-Security-Policy", "Permissions-Policy", "Location", "Alt-Svc", "Content-Length"} {
1212
if out.Get(name) != "" {
1313
t.Fatalf("%s leaked: %#v", name, out)
1414
}

scripts/build.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ async function makeRustRewriterClassic() {
242242
`let initError = null;`,
243243
`let initPromise = null;`,
244244
`function wasmSource() { return WASM_URL; }`,
245-
`function loadWasmBytesSync() { if (typeof XMLHttpRequest !== 'function') return null; const xhr = new XMLHttpRequest(); xhr.open('GET', WASM_URL, false); if (xhr.overrideMimeType) xhr.overrideMimeType('text/plain; charset=x-user-defined'); xhr.send(null); if (!((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0)) throw new Error('RUST_REWRITER_WASM_HTTP_' + xhr.status); const text = String(xhr.responseText || ''); const bytes = new Uint8Array(text.length); for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 255; return bytes; }`,
245+
`function syncXHRAllowed() { const doc = globalThis.document; const policy = doc && (doc.permissionsPolicy || doc.featurePolicy); if (!policy || typeof policy.allowsFeature !== 'function') return true; try { return policy.allowsFeature('sync-xhr'); } catch { return true; } }`,
246+
`function loadWasmBytesSync() { if (typeof XMLHttpRequest !== 'function' || !syncXHRAllowed()) return null; const xhr = new XMLHttpRequest(); xhr.open('GET', WASM_URL, false); if (xhr.overrideMimeType) xhr.overrideMimeType('text/plain; charset=x-user-defined'); xhr.send(null); if (!((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0)) throw new Error('RUST_REWRITER_WASM_HTTP_' + xhr.status); const text = String(xhr.responseText || ''); const bytes = new Uint8Array(text.length); for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 255; return bytes; }`,
246247
`function clearWasmTiming() { try { if (globalThis.performance && typeof globalThis.performance.clearResourceTimings === 'function') globalThis.performance.clearResourceTimings(); } catch {} }`,
247248
`function init() { if (initialized) return Promise.resolve(true); if (!initPromise) initPromise = wasm_bindgen({ module_or_path: wasmSource() }).then(() => { initialized = true; clearWasmTiming(); return true; }).catch(err => { initError = err; initPromise = null; throw err; }); return initPromise; }`,
248249
`function initSync(bytes) { const source = bytes || loadWasmBytesSync(); if (!source) return false; if (!initialized) { wasm_bindgen.initSync({ module: source }); initialized = true; clearWasmTiming(); } return true; }`,

test/e2e/expected-deltas.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
"policyHeaders.reportOnly": {
88
"proxy": "",
99
"native": "default-src 'none'; connect-src 'none'"
10+
},
11+
"policyHeaders.permissionsPolicy": {
12+
"proxy": "",
13+
"native": "sync-xhr=()"
14+
},
15+
"policyHeaders.featurePolicy": {
16+
"proxy": "",
17+
"native": "sync-xhr 'none'"
1018
}
1119
},
1220
"nativeVsZeroProxyRawSetDifferentialAllowlist": [
@@ -20,6 +28,16 @@
2028
"pattern": "^policyHeaders\\.reportOnly$",
2129
"reason": "ZeroProxy strips upstream report-only policy before constructing proxy responses."
2230
},
31+
{
32+
"id": "membrane-permissions-policy-header",
33+
"pattern": "^policyHeaders\\.permissionsPolicy$",
34+
"reason": "ZeroProxy strips upstream permissions policy before constructing proxy responses."
35+
},
36+
{
37+
"id": "membrane-feature-policy-header",
38+
"pattern": "^policyHeaders\\.featurePolicy$",
39+
"reason": "ZeroProxy strips upstream legacy feature policy before constructing proxy responses."
40+
},
2341
{
2442
"id": "canvas-randomization",
2543
"pattern": "^surface\\.fingerprint\\.canvas\\.(length|stableRead)$",

test/e2e/proxy.test.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,9 @@ function createTargetServer(requests) {
753753
return {
754754
text: await policyHeaders.text(),
755755
csp: policyHeaders.headers.get('Content-Security-Policy') || '',
756-
reportOnly: policyHeaders.headers.get('Content-Security-Policy-Report-Only') || ''
756+
reportOnly: policyHeaders.headers.get('Content-Security-Policy-Report-Only') || '',
757+
permissionsPolicy: policyHeaders.headers.get('Permissions-Policy') || '',
758+
featurePolicy: policyHeaders.headers.get('Feature-Policy') || ''
757759
};
758760
})(), 'policy-headers');
759761
const redirected = await fetch('/redirect302?diff=1', { cache: 'no-store' });
@@ -1109,6 +1111,8 @@ function createTargetServer(requests) {
11091111
'Cache-Control': 'no-store',
11101112
'Content-Security-Policy': "default-src 'none'; script-src 'none'",
11111113
'Content-Security-Policy-Report-Only': "default-src 'none'; connect-src 'none'",
1114+
'Permissions-Policy': 'sync-xhr=()',
1115+
'Feature-Policy': "sync-xhr 'none'",
11121116
});
11131117
res.end('policy-header-ok');
11141118
return;

test/js/static-policy.test.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,11 +350,25 @@ test('runtime HTTP facade resolves relative requests without site-specific host
350350

351351
test('Rust rewriter bootstrap falls back to async WASM load if sync bytes fail', () => {
352352
const build = fs.readFileSync('scripts/build.mjs', 'utf8');
353+
assert.ok(build.includes("policy.allowsFeature('sync-xhr')"));
354+
assert.ok(build.includes("typeof XMLHttpRequest !== 'function' || !syncXHRAllowed()"));
353355
assert.ok(build.includes('function bootstrapInit()'));
354356
assert.ok(build.includes('try { if (initSync()) return; } catch {} init().catch(() => {})'));
355357
assert.ok(build.includes('bootstrapInit();'));
356358
});
357359

360+
test('response wrappers strip target permissions policy headers', () => {
361+
const swResponses = fs.readFileSync('web/sw/responses.js', 'utf8');
362+
assert.ok(swResponses.includes("h.delete('Permissions-Policy')"));
363+
assert.ok(swResponses.includes("h.delete('Feature-Policy')"));
364+
});
365+
366+
test('runtime sync XHR avoids native sync requests when policy disables them', () => {
367+
const rt = readRuntimeSource();
368+
assert.ok(rt.includes("policy.allowsFeature('sync-xhr')"));
369+
assert.ok(rt.includes('if (!syncXHRAllowed()) return failSyncXHR(xhr);'));
370+
});
371+
358372
test('HTML document transform is a thin Go wrapper over Rust lol_html policy', () => {
359373
const htmltx = fs.readFileSync('internal/htmltx/transform.go', 'utf8');
360374
const kernel = fs.readFileSync('cmd/wasm-kernel/main.go', 'utf8');

web/runtime-prelude.mjs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,11 +1448,24 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
14481448
if (xhr.readyState !== UNSENT && xhr.readyState !== OPENED || xhr._sent) throw normalizedError('InvalidStateError');
14491449
xhr._withCredentials = !!value;
14501450
}
1451+
function syncXHRAllowed() {
1452+
const doc = root.document;
1453+
const policy = doc && (doc.permissionsPolicy || doc.featurePolicy);
1454+
if (!policy || typeof policy.allowsFeature !== 'function') return true;
1455+
try { return policy.allowsFeature('sync-xhr'); } catch { return true; }
1456+
}
1457+
function failSyncXHR(xhr) {
1458+
xhr.status = 0;
1459+
xhr.statusText = '';
1460+
xhr._sent = false;
1461+
xhrDone(xhr, 'error');
1462+
}
14511463
function sendSyncXHR(xhr, body) {
14521464
if (xhr._timeout) throw normalizedError('InvalidAccessError');
14531465
if (xhr._responseType && xhr._responseType !== 'text') throw normalizedError('InvalidAccessError');
14541466
xhr._sent = true;
14551467
fireEvent(xhr, 'loadstart');
1468+
if (!syncXHRAllowed()) return failSyncXHR(xhr);
14561469
const nativeXHR = new Native.XMLHttpRequest();
14571470
const internal = isZeroProxyAssetURL(xhr._url);
14581471
nativeXHR.open(xhr._method, internal ? xhr._url : `${ZP.apiPath('fetch')}?url=${encodeURIComponent(xhr._url)}`, false);
@@ -1484,12 +1497,7 @@ import { createWorkerFacades } from './runtime/workers/facades.mjs';
14841497
if (xhr._responseType === 'document') xhr.response = xhr.responseXML;
14851498
xhr._sent = false;
14861499
xhrDone(xhr, 'load');
1487-
} catch {
1488-
xhr.status = 0;
1489-
xhr.statusText = '';
1490-
xhr._sent = false;
1491-
xhrDone(xhr, 'error');
1492-
}
1500+
} catch { failSyncXHR(xhr); }
14931501
}
14941502
Object.defineProperties(ZPXMLHttpRequest.prototype, {
14951503
[Symbol.toStringTag]: { value: 'XMLHttpRequest', configurable: true },

web/sw/responses.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@
8585
const h = new Headers(resp.headers);
8686
const allowDynamicCompile = h.get('X-ZP-Dynamic-Compile') === '1';
8787
h.delete('X-ZP-Dynamic-Compile');
88+
h.delete('Permissions-Policy');
89+
h.delete('Feature-Policy');
8890
h.set('Content-Security-Policy', ZP.fixedCSP(servers || [], { allowDynamicCompile }));
8991
h.set('X-Content-Type-Options', 'nosniff');
9092
h.set('Cache-Control', h.get('Cache-Control') || 'no-store');

0 commit comments

Comments
 (0)