Skip to content

Commit f46d5ed

Browse files
committed
feat: implement submission handling in service worker
- Added support for pending submissions with a TTL to manage document submissions. - Introduced new message types for submission preparation and routing. - Enhanced error handling for expired submissions and policy violations. - Updated error constants to include SUBMISSION_EXPIRED.
1 parent 28c5477 commit f46d5ed

7 files changed

Lines changed: 455 additions & 76 deletions

File tree

test/e2e/proxy.test.js

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ function listen(server, host = '127.0.0.1') {
3434
}
3535

3636
function closeServer(server) {
37-
return new Promise(resolve => server.close(() => resolve()));
37+
return new Promise(resolve => {
38+
let settled = false;
39+
const done = () => { if (!settled) { settled = true; resolve(); } };
40+
server.close(done);
41+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
42+
setTimeout(done, 1000);
43+
});
3844
}
3945

4046
async function waitForHTTP(url, timeoutMs = 15000) {
@@ -96,7 +102,7 @@ class SocketReader {
96102

97103
function createTargetServer(requests) {
98104
const server = http.createServer((req, res) => {
99-
requests.push({ url: req.url, method: req.method, host: req.headers.host || '', userAgent: req.headers['user-agent'] || '', cookie: req.headers.cookie || '' });
105+
requests.push({ url: req.url, method: req.method, host: req.headers.host || '', userAgent: req.headers['user-agent'] || '', cookie: req.headers.cookie || '', contentType: req.headers['content-type'] || '' });
100106
const url = new URL(req.url, 'http://target.local');
101107
if (url.pathname === '/') {
102108
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -224,6 +230,23 @@ function createTargetServer(requests) {
224230
res.end('data: sse-ok\n\n');
225231
return;
226232
}
233+
if (url.pathname === '/form-echo') {
234+
const chunks = [];
235+
req.on('data', chunk => chunks.push(chunk));
236+
req.on('end', () => {
237+
const body = Buffer.concat(chunks).toString('utf8');
238+
const kind = url.searchParams.get('kind') || '';
239+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
240+
res.end(`<!doctype html><html><head><title>Form Echo ${kind}</title></head><body>
241+
<main id="form-result" data-method="${req.method}" data-kind="${kind}" data-content-type="${req.headers['content-type'] || ''}">
242+
<pre id="form-body">${body.replace(/[&<>]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[ch]))}</pre>
243+
<a id="next" href="/next">Next page</a>
244+
</main>
245+
<script>window.__formEcho=${JSON.stringify({ kind, method: req.method, contentType: req.headers['content-type'] || '', body })};</script>
246+
</body></html>`);
247+
});
248+
return;
249+
}
227250
if (url.pathname === '/post-echo') {
228251
const chunks = [];
229252
req.on('data', chunk => chunks.push(chunk));
@@ -434,7 +457,7 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
434457
phase2EvalLocation: window.__phase2EvalLocation,
435458
}));
436459
assert.equal(home.title, 'E2E Home');
437-
assert.equal(home.hash, '');
460+
assert.match(home.hash, /^#k=/);
438461
assert.equal(home.shellVisible, false);
439462
assert.equal(home.userAgent, TARGET_UA);
440463
assert.equal(home.appVersion, TARGET_UA.replace(/^Mozilla\//, ''));
@@ -801,23 +824,84 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
801824
assert.equal(requests.filter(r => r.userAgent && r.userAgent !== TARGET_UA).length, 0, `target requests: ${JSON.stringify(requests)}`);
802825
assert.ok(requests.some(r => r.url.startsWith('/direct-fetch') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`);
803826

804-
const forgedMessage = await page.evaluate(() => new Promise(resolve => {
805-
const channel = new MessageChannel();
806-
const timer = setTimeout(() => resolve({ timeout: true }), 3000);
807-
channel.port1.onmessage = ev => {
808-
clearTimeout(timer);
809-
resolve(ev.data || null);
827+
const serviceWorkerPolicy = await page.evaluate(async () => {
828+
const out = {
829+
exposed: 'serviceWorker' in navigator,
830+
controller: navigator.serviceWorker && navigator.serviceWorker.controller,
831+
registrationCount: null,
832+
registerError: '',
810833
};
811-
navigator.serviceWorker.controller.postMessage({ type: 'ZP_RESOLVE_ENTRY', path: location.pathname }, [channel.port2]);
812-
}));
813-
assert.deepEqual(forgedMessage, { ok: false, error: 'POLICY_BLOCKED' });
834+
if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) out.registrationCount = (await navigator.serviceWorker.getRegistrations()).length;
835+
try { await navigator.serviceWorker.register('/target-sw.js'); }
836+
catch (err) { out.registerError = err && err.name || String(err); }
837+
return out;
838+
});
839+
assert.equal(serviceWorkerPolicy.exposed, true);
840+
assert.equal(serviceWorkerPolicy.controller, null);
841+
assert.equal(serviceWorkerPolicy.registrationCount, 0);
842+
assert.equal(serviceWorkerPolicy.registerError, 'NotSupportedError');
814843
const bootLeak = await page.evaluate(() => ({
815844
bootType: typeof window.__ZP_BOOT,
816845
scriptContainsRuntimeToken: Array.from(document.scripts).some(s => s.textContent.includes('runtimeToken')),
817846
}));
818847
assert.equal(bootLeak.bootType, 'undefined');
819848
assert.equal(bootLeak.scriptContainsRuntimeToken, false);
820849

850+
851+
async function submitFormFixture(kind) {
852+
await page.evaluate(kind => {
853+
const f = document.createElement('form');
854+
f.method = 'POST';
855+
f.enctype = kind === 'multipart' ? 'multipart/form-data' : kind === 'plain' ? 'text/plain' : 'application/x-www-form-urlencoded';
856+
f.action = '/form-echo?kind=wrong';
857+
const input = document.createElement('input');
858+
input.name = 'alpha';
859+
input.value = 'one';
860+
f.appendChild(input);
861+
if (kind === 'multipart') {
862+
const file = document.createElement('input');
863+
file.type = 'file';
864+
file.name = 'upload';
865+
const dt = new DataTransfer();
866+
dt.items.add(new File(['file-body'], 'hello.txt', { type: 'text/plain' }));
867+
file.files = dt.files;
868+
f.appendChild(file);
869+
}
870+
const button = document.createElement('button');
871+
button.type = 'submit';
872+
button.name = 'submitter';
873+
button.value = kind;
874+
button.setAttribute('formaction', '/form-echo?kind=' + kind);
875+
f.appendChild(button);
876+
document.body.appendChild(f);
877+
f.requestSubmit(button);
878+
}, kind);
879+
await page.waitForFunction(k => window.__formEcho && window.__formEcho.kind === k, { timeout: 30000 }, kind);
880+
return page.evaluate(() => { const loc = __zp_get(globalThis, 'location'); return { echo: window.__formEcho, virtualHref: loc.href, virtualHash: loc.hash, documentURL: __zp_get(document, 'URL'), baseURI: __zp_get(document, 'baseURI') }; });
881+
}
882+
const urlencodedForm = await submitFormFixture('urlencoded');
883+
assert.equal(urlencodedForm.echo.method, 'POST');
884+
assert.match(urlencodedForm.echo.contentType, /^application\/x-www-form-urlencoded/);
885+
assert.equal(urlencodedForm.echo.body, 'alpha=one&submitter=urlencoded');
886+
const plainForm = await submitFormFixture('plain');
887+
assert.match(plainForm.echo.contentType, /^text\/plain/);
888+
assert.match(plainForm.echo.body, /alpha=one/);
889+
assert.match(plainForm.echo.body, /submitter=plain/);
890+
const multipartForm = await submitFormFixture('multipart');
891+
assert.match(multipartForm.echo.contentType, /^multipart\/form-data; boundary=/);
892+
assert.match(multipartForm.echo.body, /name="upload"; filename="hello.txt"/);
893+
assert.match(multipartForm.echo.body, /file-body/);
894+
const rawAfterSubmit = page.url();
895+
const rawKey = new URL(rawAfterSubmit).hash ? new URLSearchParams(new URL(rawAfterSubmit).hash.slice(1)).get('k') : '';
896+
assert.match(rawAfterSubmit, /#k=/);
897+
assert.match(rawAfterSubmit, /\?zp_submit=/);
898+
for (const surface of [multipartForm.virtualHref, multipartForm.virtualHash, multipartForm.documentURL, multipartForm.baseURI]) {
899+
assert.equal(surface.includes('zp_submit='), false, surface);
900+
if (rawKey) assert.equal(surface.includes(rawKey), false, surface);
901+
}
902+
assert.ok(requests.some(r => r.url.startsWith('/form-echo?kind=urlencoded') && r.contentType.startsWith('application/x-www-form-urlencoded')), `target requests: ${JSON.stringify(requests)}`);
903+
assert.ok(requests.some(r => r.url.startsWith('/form-echo?kind=plain') && r.contentType.startsWith('text/plain')), `target requests: ${JSON.stringify(requests)}`);
904+
assert.ok(requests.some(r => r.url.startsWith('/form-echo?kind=multipart') && r.contentType.startsWith('multipart/form-data')), `target requests: ${JSON.stringify(requests)}`);
821905
await page.click('#next');
822906
await page.waitForFunction(() => document.title === 'E2E Next', { timeout: 30000 });
823907
const next = await page.evaluate(() => ({
@@ -828,7 +912,7 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ
828912
userAgent: navigator.userAgent,
829913
}));
830914
assert.equal(next.title, 'E2E Next');
831-
assert.equal(next.hash, '');
915+
assert.match(next.hash, /^#k=/);
832916
assert.equal(next.shellVisible, false);
833917
assert.equal(next.userAgent, TARGET_UA);
834918
assert.match(next.href, new RegExp(`^http://proxy\\.localhost:${proxyPort}/p/`));

test/js/compat-pipeline.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ test('window fetch, XHR, and EventSource route through runtime transport shims',
1212
assert.ok(rt.includes('ZPXMLHttpRequest'));
1313
assert.ok(rt.includes('ZPEventSource'));
1414
assert.ok(rt.includes('/__zp/api/fetch'));
15-
assert.match(rt, /fetchThroughRuntime\(target\.href/);
15+
assert.match(rt, /Native\.fetch\('\/__zp\/api\/fetch'/);
1616
});
1717

1818
test('runtime navigation uses bound Location methods and catches expando href clicks', () => {

test/js/static-policy.test.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ test('runtime installs required escape-vector hooks', () => {
5151
"'contentWindow'",
5252
"'contentDocument'",
5353
'new WeakSet',
54-
"attributeFilter: ['href', 'src', 'srcdoc', 'action', 'formaction', 'integrity', 'type']",
54+
"attributeFilter: ['href', 'xlink:href', 'src', 'srcdoc', 'action', 'formaction', 'integrity', 'type', 'rel', 'target']",
5555
'enforceObservedAttribute',
5656
'data-zp-integrity',
5757
'installIntegrityProp',
5858
'installScriptProp',
59+
'installLinkProp',
5960
'installToStringMasking',
6061
'toStringMap',
6162
'installCanvasAntiFingerprinting',
@@ -89,6 +90,10 @@ test('runtime installs required escape-vector hooks', () => {
8990
'createContextualFragment',
9091
'parseFromString',
9192
'rewriteEventAttribute',
93+
'enforceSubtreePolicies',
94+
'installTargetServiceWorkerBlocker',
95+
'serializeFormSubmission',
96+
'shareFragmentForKey',
9297
'postMessageWrapperFor',
9398
]) assert.ok(rt.includes(needle), `missing ${needle}`);
9499
});
@@ -147,6 +152,9 @@ test('phase 2 script rewriting pipeline is fail-closed', () => {
147152
assert.equal(core.includes('navigate-to'), false);
148153
assert.equal(server.includes('navigate-to'), false);
149154
assert.ok(sw.includes('MAX_REQUEST_BODY_BYTES'));
155+
assert.ok(sw.includes('pendingSubmissions'));
156+
assert.ok(sw.includes('ZP_SUBMIT_PREPARE'));
157+
assert.ok(sw.includes('zp_submit'));
150158
assert.ok(sw.includes('REQUEST_BODY_TOO_LARGE'));
151159
assert.ok(fs.readFileSync('internal/swhttp/bridge_js.go', 'utf8').includes('GetBody'));
152160
assert.ok(fs.readFileSync('internal/shareurl/shareurl.go', 'utf8').includes('unsupported target URL'));
@@ -155,7 +163,7 @@ test('phase 2 script rewriting pipeline is fail-closed', () => {
155163

156164
test('service worker names every required safe error class', () => {
157165
const core = fs.readFileSync('web/zp-core.js', 'utf8');
158-
for (const code of ['BAD_HMAC','INVALID_SHARE_LINK','MALFORMED_ROUTE','SW_NOT_READY','TARGET_PROTOCOL_BLOCKED','TLS_CERTIFICATE_INVALID','TLS_HANDSHAKE_FAILED','TARGET_CONNECT_FAILED','MALFORMED_HTML','REALM_INJECTION_FAILURE','REQUEST_BODY_TOO_LARGE','POLICY_BLOCKED']) {
166+
for (const code of ['BAD_HMAC','INVALID_SHARE_LINK','MALFORMED_ROUTE','SW_NOT_READY','TARGET_PROTOCOL_BLOCKED','TLS_CERTIFICATE_INVALID','TLS_HANDSHAKE_FAILED','TARGET_CONNECT_FAILED','MALFORMED_HTML','REALM_INJECTION_FAILURE','REQUEST_BODY_TOO_LARGE','SUBMISSION_EXPIRED','POLICY_BLOCKED']) {
159167
assert.ok(core.includes(code), `missing ${code}`);
160168
}
161169
});

web/index.html

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,26 @@ <h1>ZeroProxy</h1>
4747
return u.origin;
4848
}
4949
async function openTarget(targetUrl) {
50-
const share = await ZP.makeShareURL(ZP.canonicalTargetURL(targetUrl).href, proxyOrigin());
51-
location.assign(share);
50+
const target = ZP.canonicalTargetURL(targetUrl).href;
51+
const share = await ZP.encryptShareURL(target);
52+
try { await swRequest({ type: 'ZP_OPEN_SHARE', routeKey: share.encrypted, targetUrl: share.targetUrl }); } catch {}
53+
location.assign(proxyOrigin() + '/p/' + share.encrypted + '#k=' + encodeURIComponent(share.key));
54+
}
55+
function shareFragmentPolicy(params) {
56+
return params.get('zp_frag') === 'erase' || params.get('zp_erase_fragment') === '1' ? 'erase' : 'compat';
5257
}
5358
async function handleShare() {
5459
if (!location.pathname.startsWith('/p/')) return false;
5560
const encrypted = location.pathname.slice(3);
56-
const key = new URLSearchParams(location.hash.startsWith('#') ? location.hash.slice(1) : location.hash).get('k');
61+
const params = new URLSearchParams(location.hash.startsWith('#') ? location.hash.slice(1) : location.hash);
62+
const key = params.get('k');
63+
const fragmentPolicy = shareFragmentPolicy(params);
5764
if (!key) { location.replace('/__zp/error/INVALID_SHARE_LINK'); return true; }
5865
try {
5966
const targetUrl = await ZP.decryptShareURL(encrypted, key);
60-
history.replaceState(null, '', location.pathname);
61-
const result = await swRequest({ type: 'ZP_OPEN_SHARE', routeKey: encrypted, targetUrl });
62-
location.replace(result.path);
67+
if (fragmentPolicy === 'erase') history.replaceState(null, '', location.pathname);
68+
const result = await swRequest({ type: 'ZP_OPEN_SHARE', routeKey: encrypted, targetUrl, fragmentPolicy });
69+
location.replace(result.path + (fragmentPolicy === 'compat' ? '#k=' + encodeURIComponent(key) : ''));
6370
} catch (e) {
6471
const code = e && e.code || e && e.message || 'INVALID_SHARE_LINK';
6572
location.replace('/__zp/error/' + encodeURIComponent(code));

0 commit comments

Comments
 (0)