From f798153f774b4f9b2aedc7150e0d7c9402801605 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:19:15 +0000 Subject: [PATCH 1/8] Add ZeitHawk, a standalone client-side HTTP security testing toolkit A self-contained HTML page with Repeater, Intruder, Decoder, and Comparer tools in the spirit of Burp Suite, for manual testing of authorized targets. No build step or backend; runs entirely in the browser. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SkupxCaLk9Nnq24YhigdAn --- tools/zeithawk/README.md | 44 ++ tools/zeithawk/index.html | 1047 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1091 insertions(+) create mode 100644 tools/zeithawk/README.md create mode 100644 tools/zeithawk/index.html diff --git a/tools/zeithawk/README.md b/tools/zeithawk/README.md new file mode 100644 index 0000000..30c646c --- /dev/null +++ b/tools/zeithawk/README.md @@ -0,0 +1,44 @@ +# ZeitHawk + +A standalone, browser-only HTTP security testing toolkit built in the spirit +of Burp Suite's core tools — Repeater, Intruder, Decoder, and Comparer. It's +a single self-contained HTML file (`index.html`) with no build step, no +server, and no dependencies beyond a Google Fonts stylesheet; open it +directly in a browser or host it as a static file. + +It is not part of the SecurityReview Rails application and has no runtime +dependency on it — it lives here as a companion tool for people doing manual +review/testing work against systems they're authorized to test. + +## Tools + +- **Repeater** — build one HTTP request (method/URL/headers/body), send it, + inspect the status/headers/body, tweak, resend. +- **Intruder** — mark an injection point with `§inject§` anywhere in the + URL, a header, or the body; runs a payload list (built-in presets or a + custom list) against it sequentially and reports status/length/timing per + payload. +- **Decoder** — Base64/URL/HTML-entity/Hex/Unicode transforms, JWT decode + (unverified), and SHA-1/256/384/512 hashing, chainable output → input. +- **Comparer** — line-level diff between two blocks of text. +- **History** — every request sent from Repeater or Intruder, stored in + `localStorage`, click a row to reload it into Repeater. + +## Constraints (by design — it runs entirely client-side) + +Because it's a plain web page with no proxy or backend, every request is +subject to the same rules any page's `fetch()` is subject to: + +- **CORS** — the target must allow cross-origin requests, or the response + can't be read (status/headers/body all come back opaque/blocked). +- **Forbidden headers** — a browser will never let a page set `Host`, + `Cookie`, `Origin`, `Referer`, `Content-Length`, and a handful of others; + ZeitHawk flags and skips these when building a request. + +These aren't bugs to fix later — they're the tradeoff of a zero-install, +zero-backend tool. For anything that needs true request interception or +raw socket control, use a real intercepting proxy (Burp Suite, OWASP ZAP, +mitmproxy). + +**Authorized testing only.** Only point this at systems you own or have +explicit permission to test. diff --git a/tools/zeithawk/index.html b/tools/zeithawk/index.html new file mode 100644 index 0000000..8da3f4b --- /dev/null +++ b/tools/zeithawk/index.html @@ -0,0 +1,1047 @@ +ZeitHawk + + +
+
+ Zeitview · Security Tooling +

ZeitHawk

+

Craft, replay, and fuzz HTTP requests, then decode, hash, and diff what comes back — a lightweight, browser-only toolkit built in the spirit of Burp Suite's core tools: Repeater, Intruder, Decoder, and Comparer.

+
+ + Authorized testing only — only point this at systems you own or are explicitly authorized to test. Every request runs from your browser, so it's bound by the same CORS rules as any web page: the target must allow cross-origin access for you to see its status, headers, or body here, and browsers never let a page override Host, Cookie, Origin, or a handful of other headers. Nothing you enter leaves your browser except the requests you explicitly send. +
+
+ + + +
+ + +
+
+

Repeater

+

Build one HTTP request, send it, inspect the response, tweak, resend. Every send is logged to History.

+
+
+
+
+ + +
+
+ Headers +
+ +
+
+ Body + +
+
+ + + +
+

Tip: click any row in History to reload it here for another pass.

+
+
+
+
+ ↻ +

Send a request to see its status, headers, and body here.

+
+
+
+
+
+ + + + + + + + + + + + + +
+ + +
+ +', '">', "'>", ''].join('\n'), + traversal: ['../../../../etc/passwd', '..%2f..%2f..%2fetc%2fpasswd', '....//....//etc/passwd', '/etc/passwd'].join('\n'), +}; +document.getElementById('int-preset').value = 'numeric'; +document.getElementById('int-payloads').value = PRESETS.numeric; +document.getElementById('int-preset').addEventListener('change', (e)=>{ + const v = e.target.value; + if(PRESETS[v]) document.getElementById('int-payloads').value = PRESETS[v]; +}); + +let intruderResults = []; +let intruderStopFlag = false; +let intruderSort = { key:'idx', dir:1 }; + +function renderIntruderTable(){ + const tbody = document.getElementById('int-tbody'); + const sorted = intruderResults.slice().sort((a,b)=>{ + const k = intruderSort.key; + let av = a[k], bv = b[k]; + if(av == null) av = -1; if(bv == null) bv = -1; + if(typeof av === 'string') return av.localeCompare(bv) * intruderSort.dir; + return (av - bv) * intruderSort.dir; + }); + tbody.innerHTML = ''; + sorted.forEach(r=>{ + const tr = document.createElement('tr'); + tr.innerHTML = + ''+r.idx+''+ + ''+escapeHtml(r.payload.length>40 ? r.payload.slice(0,40)+'…' : r.payload)+''+ + ''+(r.status ?? 'ERR')+''+ + ''+r.length+''+ + ''+r.ms+''; + tr.addEventListener('click', ()=>showIntruderDetail(r)); + tbody.appendChild(tr); + }); +} +function showIntruderDetail(r){ + const el = document.getElementById('int-detail'); + el.innerHTML = ''; + const label = document.createElement('span'); label.className='field-label'; label.textContent='Response body — payload: '+r.payload; + const pre = document.createElement('pre'); pre.className='readout'; + let bodyText = r.body || ''; + try{ bodyText = JSON.stringify(JSON.parse(r.body), null, 2); }catch(e){} + pre.textContent = r.error ? ('Error: '+r.error) : (bodyText || '(empty body)'); + el.appendChild(label); el.appendChild(pre); +} +document.querySelectorAll('#int-table th[data-sort]').forEach(th=>{ + th.addEventListener('click', ()=>{ + const key = th.dataset.sort; + intruderSort.dir = (intruderSort.key === key) ? -intruderSort.dir : 1; + intruderSort.key = key; + renderIntruderTable(); + }); +}); + +document.getElementById('int-start').addEventListener('click', async ()=>{ + const method = document.getElementById('int-method').value; + const urlTpl = document.getElementById('int-url').value.trim(); + const bodyTpl = document.getElementById('int-body').value; + const headerTpl = intHeaders.getRows(); + const delay = Math.max(0, parseInt(document.getElementById('int-delay').value, 10) || 0); + let payloads = document.getElementById('int-payloads').value.split('\n').map(s=>s.trim()).filter(Boolean); + if(!urlTpl){ return; } + if(payloads.length === 0){ return; } + if(payloads.length > 200){ payloads = payloads.slice(0,200); } + + intruderResults = []; + renderIntruderTable(); + document.getElementById('int-detail').innerHTML = ''; + intruderStopFlag = false; + document.getElementById('int-start').disabled = true; + document.getElementById('int-stop').disabled = false; + + for(let i=0; i({k:h.k, v: (h.v||'').split('§inject§').join(payload)})); + document.getElementById('int-progress').textContent = 'Sent '+i+' / '+payloads.length; + const result = await fireRequest(method, url, headers, body); + intruderResults.push({ + idx: i+1, payload, + status: result.status, length: result.size, ms: result.ms, + body: result.body, error: result.error || null, + }); + renderIntruderTable(); + if(delay > 0 && i < payloads.length-1) await new Promise(r=>setTimeout(r, delay)); + } + document.getElementById('int-progress').textContent = 'Done — '+intruderResults.length+' / '+payloads.length+' sent'; + document.getElementById('int-start').disabled = false; + document.getElementById('int-stop').disabled = true; +}); +document.getElementById('int-stop').addEventListener('click', ()=>{ intruderStopFlag = true; }); + +/* ------------------------------------------------------------------ * + * DECODER + * ------------------------------------------------------------------ */ + +function b64Encode(str){ return btoa(unescape(encodeURIComponent(str))); } +function b64Decode(str){ return decodeURIComponent(escape(atob(str.trim()))); } +function b64urlDecodeRaw(s){ + s = s.replace(/-/g,'+').replace(/_/g,'/'); + while(s.length % 4) s += '='; + return b64Decode(s); +} +const HTML_NAMED_ENC = {'&':'amp','<':'lt','>':'gt','"':'quot',"'":'#39'}; +function htmlEncode(str){ return str.replace(/[&<>"']/g, ch=>'&'+HTML_NAMED_ENC[ch]+';'); } +function htmlDecode(str){ + const named = {amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:' '}; + return str.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (m, ent)=>{ + if(ent[0]==='#'){ + const cp = (ent[1]==='x'||ent[1]==='X') ? parseInt(ent.slice(2),16) : parseInt(ent.slice(1),10); + return isNaN(cp) ? m : String.fromCodePoint(cp); + } + return Object.prototype.hasOwnProperty.call(named, ent) ? named[ent] : m; + }); +} +function hexEncode(str){ return Array.from(new TextEncoder().encode(str)).map(b=>b.toString(16).padStart(2,'0')).join(' '); } +function hexDecode(str){ + const clean = str.replace(/[^0-9a-fA-F]/g,''); + if(clean.length % 2) throw new Error('Odd number of hex digits'); + const bytes = new Uint8Array(clean.length/2); + for(let i=0;i{ + const cp = ch.codePointAt(0); + return (cp>126||cp<32) ? '\\u'+cp.toString(16).padStart(4,'0') : ch; + }).join(''); +} +function unicodeUnescape(str){ return str.replace(/\\u([0-9a-fA-F]{4})/g, (m,h)=>String.fromCharCode(parseInt(h,16))); } +function decodeJwt(token){ + const parts = token.trim().split('.'); + if(parts.length < 2) throw new Error('Not a JWT — expected header.payload.signature'); + const header = JSON.parse(b64urlDecodeRaw(parts[0])); + const payload = JSON.parse(b64urlDecodeRaw(parts[1])); + let out = 'HEADER:\n'+JSON.stringify(header,null,2)+'\n\nPAYLOAD:\n'+JSON.stringify(payload,null,2); + if(parts[2]) out += '\n\n(signature not verified — this is a decode-only view)'; + return out; +} +async function shaHash(algo, str){ + const buf = await crypto.subtle.digest(algo, new TextEncoder().encode(str)); + return Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join(''); +} + +const decInput = document.getElementById('dec-input'); +const decOutput = document.getElementById('dec-output'); +async function runOp(op){ + const input = decInput.value; + try{ + let out; + switch(op){ + case 'b64enc': out = b64Encode(input); break; + case 'b64dec': out = b64Decode(input); break; + case 'urlenc': out = encodeURIComponent(input); break; + case 'urldec': out = decodeURIComponent(input); break; + case 'htmlenc': out = htmlEncode(input); break; + case 'htmldec': out = htmlDecode(input); break; + case 'hexenc': out = hexEncode(input); break; + case 'hexdec': out = hexDecode(input); break; + case 'unienc': out = unicodeEscape(input); break; + case 'unidec': out = unicodeUnescape(input); break; + case 'jwtdec': out = decodeJwt(input); break; + case 'sha1': out = await shaHash('SHA-1', input); break; + case 'sha256': out = await shaHash('SHA-256', input); break; + case 'sha384': out = await shaHash('SHA-384', input); break; + case 'sha512': out = await shaHash('SHA-512', input); break; + default: out = ''; + } + decOutput.value = out; + }catch(err){ + decOutput.value = 'Error: ' + (err && err.message ? err.message : String(err)); + } +} +document.querySelectorAll('[data-op]').forEach(btn=>{ + btn.addEventListener('click', ()=>runOp(btn.dataset.op)); +}); +document.getElementById('dec-chain').addEventListener('click', ()=>{ + decInput.value = decOutput.value; +}); +// Resting-state demo, computed live so it's always correct. +decInput.value = 'Authorized testing only.'; +runOp('b64enc'); + +/* ------------------------------------------------------------------ * + * COMPARER + * ------------------------------------------------------------------ */ + +function diffLines(a, b){ + const A = a.split('\n'), B = b.split('\n'); + const n = A.length, m = B.length; + if(n*m > 4000000){ + return null; // too large for an in-browser O(n*m) diff + } + const dp = Array.from({length:n+1}, ()=> new Int32Array(m+1)); + for(let i=n-1;i>=0;i--){ + for(let j=m-1;j>=0;j--){ + dp[i][j] = (A[i]===B[j]) ? dp[i+1][j+1]+1 : Math.max(dp[i+1][j], dp[i][j+1]); + } + } + const ops = []; let i=0, j=0; + while(i= dp[i][j+1]){ ops.push({t:'del', line:A[i]}); i++; } + else { ops.push({t:'add', line:B[j]}); j++; } + } + while(i{ + const row = document.createElement('div'); + row.className = 'diff-line ' + (op.t==='add'?'add':op.t==='del'?'del':''); + const gutter = op.t==='add' ? '+' : op.t==='del' ? '−' : ' '; + row.innerHTML = ''+gutter+''+escapeHtml(op.line || ' ')+''; + out.appendChild(row); + if(op.t==='add') added++; else if(op.t==='del') removed++; else same++; + }); + summary.innerHTML = + '+'+added+' added'+ + '−'+removed+' removed'+ + ''+same+' unchanged'; +} +document.getElementById('cmp-run').addEventListener('click', runCompare); +document.getElementById('cmp-a').value = '{\n "user": "j.doe",\n "role": "viewer",\n "mfa_enabled": false,\n "last_login": "2026-09-01T10:03:00Z"\n}'; +document.getElementById('cmp-b').value = '{\n "user": "j.doe",\n "role": "admin",\n "mfa_enabled": false,\n "last_login": "2026-09-16T08:47:00Z"\n}'; +runCompare(); + +/* ------------------------------------------------------------------ * + * HISTORY + * ------------------------------------------------------------------ */ + +function renderHistory(){ + const list = loadHistory(); + const el = document.getElementById('hist-list'); + el.innerHTML = ''; + if(list.length === 0){ + el.innerHTML = '
◷

No requests sent yet. Anything you fire from Repeater or Intruder shows up here.

'; + return; + } + list.forEach(entry=>{ + const row = document.createElement('div'); + row.className = 'history-row'; + const date = new Date(entry.ts); + const statusHtml = entry.error + ? 'ERR' + : ''+entry.status+''; + row.innerHTML = + '
'+statusHtml+ + ''+entry.method+''+ + ''+escapeHtml(entry.url)+'
'+ + ''+date.toLocaleString()+''; + row.addEventListener('click', ()=>{ + document.getElementById('rep-method').value = entry.method; + document.getElementById('rep-url').value = entry.url; + document.getElementById('rep-body').value = entry.body || ''; + repHeaders.setRows(entry.headers || []); + selectTool('repeater'); + }); + el.appendChild(row); + }); +} +document.getElementById('hist-clear').addEventListener('click', ()=>{ + clearHistory(); + renderHistory(); +}); + +})(); + From 21092a20457377911c8eb567ac50b3513b7a2b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:25:42 +0000 Subject: [PATCH 2/8] Add a Security-authorization escalation link to ZeitHawk Mirrors the AI Tool Finder artifact's escalation pattern: a mailto link prefilled with the Repeater target, for requesting written sign-off before testing something not yet authorized. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SkupxCaLk9Nnq24YhigdAn --- tools/zeithawk/README.md | 6 +++++- tools/zeithawk/index.html | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tools/zeithawk/README.md b/tools/zeithawk/README.md index 30c646c..4ca6be4 100644 --- a/tools/zeithawk/README.md +++ b/tools/zeithawk/README.md @@ -41,4 +41,8 @@ raw socket control, use a real intercepting proxy (Burp Suite, OWASP ZAP, mitmproxy). **Authorized testing only.** Only point this at systems you own or have -explicit permission to test. +explicit permission to test. If you don't have written sign-off for a +target yet, the "Request Security authorization" link in the banner opens +a prefilled email to Security (same escalation pattern as the AI Tool +Finder artifact) — update `SECURITY_APPROVAL_EMAIL` in `index.html` if +that alias ever changes. diff --git a/tools/zeithawk/index.html b/tools/zeithawk/index.html index 8da3f4b..110f413 100644 --- a/tools/zeithawk/index.html +++ b/tools/zeithawk/index.html @@ -264,6 +264,14 @@ [hidden]{display:none!important;} .footer-note{margin-top:36px;padding-top:18px;border-top:1px solid var(--line);color:var(--unknown);font-size:12px;font-family:'IBM Plex Mono',monospace;text-align:center;line-height:1.6;} + + .auth-link{ + font-family:'IBM Plex Mono',monospace;font-size:12.5px;font-weight:600;color:var(--restricted); + background:none;border:none;padding:0;text-decoration:none;border-bottom:1px solid var(--restricted); + padding-bottom:1px;cursor:pointer; + } + .auth-link:hover{color:var(--accent);border-color:var(--accent);} + .disclaimer .auth-row{display:block;margin-top:8px;}
@@ -273,7 +281,10 @@

ZeitHawk

Craft, replay, and fuzz HTTP requests, then decode, hash, and diff what comes back — a lightweight, browser-only toolkit built in the spirit of Burp Suite's core tools: Repeater, Intruder, Decoder, and Comparer.

- Authorized testing only — only point this at systems you own or are explicitly authorized to test. Every request runs from your browser, so it's bound by the same CORS rules as any web page: the target must allow cross-origin access for you to see its status, headers, or body here, and browsers never let a page override Host, Cookie, Origin, or a handful of other headers. Nothing you enter leaves your browser except the requests you explicitly send. + + Authorized testing only — only point this at systems you own or are explicitly authorized to test. Every request runs from your browser, so it's bound by the same CORS rules as any web page: the target must allow cross-origin access for you to see its status, headers, or body here, and browsers never let a page override Host, Cookie, Origin, or a handful of other headers. Nothing you enter leaves your browser except the requests you explicitly send. + Don't have written sign-off for the target in Repeater yet? +
@@ -525,6 +536,26 @@

History

function escapeHtml(str){ const d=document.createElement('div'); d.textContent = String(str); return d.innerHTML; } +// Zeitview policy: same escalation path as the AI Tool Finder — point this at your +// real Security intake (an email alias, or swap buildAuthorizationRequestHref() +// below for a link to a ticket queue) before relying on it. +// TODO(Bruce): confirm this is the right alias for testing-authorization requests. +const SECURITY_APPROVAL_EMAIL = 'security@zeitview.com'; +function buildAuthorizationRequestHref(){ + const target = (document.getElementById('rep-url').value || '').trim() || '(not specified)'; + const subject = 'Security testing authorization request: ZeitHawk'; + const bodyLines = [ + `Target: ${target}`, + 'Tool: ZeitHawk (Repeater / Intruder)', + '', + 'Requesting written sign-off to run manual HTTP testing against the target above before proceeding.', + ]; + return `mailto:${SECURITY_APPROVAL_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyLines.join('\n'))}`; +} +document.getElementById('request-auth-btn').addEventListener('click', ()=>{ + window.location.href = buildAuthorizationRequestHref(); +}); + function statusClass(status){ if(status == null) return 'status-err'; if(status >= 200 && status < 300) return 'status-2xx'; From b7f95a3339f035a91a05f14012ad6542d99bdefb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:40:23 +0000 Subject: [PATCH 3/8] Add an optional Apps Script + Firestore backend for ZeitHawk The "Request Security authorization" mailto still opens with zero setup; this adds a best-effort, silently-no-op-until-configured POST alongside it that logs the same request to Firestore via a small Apps Script web app, so there's a durable, searchable record beyond one inbox. Includes a "View recent authorization requests" JSONP read-back panel in the banner. tools/apps-script-backend/ has the deployable Code.gs + appsscript.json and a README walking through GCP project setup, Firestore (Native mode), and web app deployment, plus the CORS/no-auth tradeoffs of an anonymous Apps Script endpoint. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SkupxCaLk9Nnq24YhigdAn --- tools/apps-script-backend/Code.gs | 155 ++++++++++++++++++++++ tools/apps-script-backend/README.md | 100 ++++++++++++++ tools/apps-script-backend/appsscript.json | 14 ++ tools/zeithawk/README.md | 14 ++ tools/zeithawk/index.html | 77 ++++++++++- 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 tools/apps-script-backend/Code.gs create mode 100644 tools/apps-script-backend/README.md create mode 100644 tools/apps-script-backend/appsscript.json diff --git a/tools/apps-script-backend/Code.gs b/tools/apps-script-backend/Code.gs new file mode 100644 index 0000000..de67df6 --- /dev/null +++ b/tools/apps-script-backend/Code.gs @@ -0,0 +1,155 @@ +/** + * Shared logging backend for ZeitHawk and AI Tool Finder. + * + * Both artifacts open a mailto: link for "Request Security authorization" / + * "Request Security Approval" / "Request IT review" — that's the real + * request path and works with zero setup. This web app additionally logs + * a durable copy of each request to Firestore, so there's a searchable + * record beyond whatever lands in one inbox. + * + * Deployment — see README.md in this folder for the full walkthrough: + * 1. Associate this Apps Script project with a standard GCP project + * (Project Settings -> Google Cloud Platform (GCP) Project) and + * enable the Firestore API on it, in Native mode. + * 2. Set PROJECT_ID below to that GCP project's ID. + * 3. Deploy -> New deployment -> Web app, execute as "Me", + * access "Anyone" (the artifacts call this anonymously). + * 4. Paste the resulting /exec URL into APPS_SCRIPT_URL in both + * tools/zeithawk/index.html and the AI Tool Finder artifact. + * + * This endpoint has no auth — "Anyone" access means anyone with the URL + * can write to it. That's an accepted tradeoff for logging low-sensitivity + * *requests* (never put secrets in the fields below), not a security + * boundary. See README.md for the caveats in more detail. + */ + +const PROJECT_ID = 'REPLACE_WITH_YOUR_GCP_PROJECT_ID'; +const DATABASE_ID = '(default)'; +const ALLOWED_COLLECTIONS = ['security_authorization_requests', 'app_review_requests']; +const MAX_LIST_LIMIT = 50; + +function doPost(e) { + try { + const payload = JSON.parse(e.postData.contents); + const collection = payload.collection; + if (ALLOWED_COLLECTIONS.indexOf(collection) === -1) { + return jsonOutput_({ ok: false, error: 'Unknown collection: ' + collection }); + } + const doc = { + tool: String(payload.tool || '').slice(0, 200), + target: String(payload.target || '').slice(0, 500), + requester: String(payload.requester || '').slice(0, 200), + note: String(payload.note || '').slice(0, 2000), + createdAt: new Date().toISOString(), + }; + const created = firestoreCreateDocument_(collection, doc); + return jsonOutput_({ ok: true, id: created.name.split('/').pop() }); + } catch (err) { + return jsonOutput_({ ok: false, error: String(err) }); + } +} + +function doGet(e) { + const params = (e && e.parameter) || {}; + const collection = params.collection; + const callback = params.callback; + let result; + + if (ALLOWED_COLLECTIONS.indexOf(collection) === -1) { + result = { ok: false, error: 'Unknown collection: ' + collection }; + } else { + const limit = Math.min(parseInt(params.limit, 10) || 20, MAX_LIST_LIMIT); + try { + result = { ok: true, items: firestoreListDocuments_(collection, limit) }; + } catch (err) { + result = { ok: false, error: String(err) }; + } + } + + // Apps Script web app responses don't carry CORS headers, so a plain + // cross-origin fetch() can't read the body. Callers that need to read + // the result (rather than fire-and-forget a POST) pass ?callback=name + // and get JSONP back via a in the XSS preset breaking the whole page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Intruder XSS payload preset included the literal string ''. Browsers close a ', '">', "'>", ''].join('\n'), + xss: ['