diff --git a/tools/zeithawk/README.md b/tools/zeithawk/README.md index 96f5e09..5160c0c 100644 --- a/tools/zeithawk/README.md +++ b/tools/zeithawk/README.md @@ -10,6 +10,23 @@ 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. +## Running it + +Host `index.html` as a static file (e.g. via [GitHub +Pages](https://github.com/BruceLittle/SecurityReview/settings/pages) — +Source: "Deploy from a branch", branch `main`, folder `/(root)` — it'll be +served at `https://.github.io//tools/zeithawk/`) or open the +file directly in a browser. + +**Don't rely on a Claude Artifact preview of this file for real use.** +Claude's artifact sandbox blocks outbound `fetch()` to arbitrary +third-party domains via its own CSP — every request in this app +(Repeater, Intruder, Recon, Vuln Scan, and any CORS proxy) will fail with +a generic `Failed to fetch` there regardless of target, proxy +configuration, or anything else in this code. That's a property of the +artifact wrapper, not a bug here — confirmed by the exact same requests +working correctly once run from a real hosted page instead. + ## Tools - **Repeater** — build one HTTP request (method/URL/headers/body), send it, @@ -52,26 +69,26 @@ zero-backend tool. For anything that needs true request interception or raw socket control, use a real intercepting proxy (Burp Suite, OWASP ZAP, mitmproxy). -### CORS proxy toggle (on by default) +### CORS proxy toggle (on by default, configurable endpoint) A switch in the header routes Repeater, Intruder, Recon's DNS/RDAP -lookups, and Vuln Scan through the public proxy -[corsproxy.io](https://corsproxy.io), which fetches the target +lookups, and Vuln Scan through a proxy, which fetches the target server-side and returns the response with permissive CORS headers — letting ZeitHawk reach targets that don't set their own. It's **on by default** since that's the point of the toggle, but it comes with real tradeoffs: -- **It's a third party.** The target URL, and anything you send it - (headers, body, Intruder payloads), passes through corsproxy.io's - servers, which you don't control and can't audit. Turn it off for - anything sensitive, internal, or already CORS-permissive. +- **Whatever endpoint is set is a third party you don't fully control** + (unless you deployed it yourself — see below). The target URL, and + anything you send it (headers, body, Intruder payloads), passes through + it. Turn the toggle off for anything sensitive, internal, or already + CORS-permissive. - **It doesn't change Vuln Scan's honesty guarantee.** Header checks still only ever report "Present" when actually read, never a false "Missing" — - because whether corsproxy.io itself exposes the target's original - headers via `Access-Control-Expose-Headers` isn't something ZeitHawk can - verify, on or off. -- **Method/header/body forwarding depends on corsproxy.io's own behavior**, + because whether the configured proxy itself exposes the target's + original headers via `Access-Control-Expose-Headers` isn't something + ZeitHawk can verify, on or off. +- **Method/header/body forwarding depends on the proxy's own behavior**, not ZeitHawk's — if a request behaves differently proxied vs. direct, toggle it off and compare. - Recon's port-reachability check is deliberately **not** proxied: it uses @@ -79,8 +96,19 @@ tradeoffs: through a proxy would only change the network vantage point, not fix anything. -State persists per-browser via `localStorage`; nothing about the toggle -itself is sent anywhere. +**Default endpoint** is the public [corsproxy.io](https://corsproxy.io). +A **Proxy endpoint** field beneath the toggle lets you point it at a +private proxy instead — see `tools/zeithawk/cors-proxy-worker/` for a +ready-to-deploy Cloudflare Worker that does the same job under your own +account rather than a public third party. (A Google Apps Script web app +*can't* fill this role: it has no way to set +`Access-Control-Allow-Origin` on its own response at all, so a plain +`fetch()` could never read what it returned — see +`tools/apps-script-backend/Code.gs` for where that limitation is +documented, from when that backend was built for a different purpose.) + +Both the on/off state and the endpoint persist per-browser via +`localStorage`; nothing about the toggle itself is sent anywhere. **Authorized testing only.** Only point this at systems you own or have explicit permission to test. diff --git a/tools/zeithawk/cors-proxy-worker/README.md b/tools/zeithawk/cors-proxy-worker/README.md new file mode 100644 index 0000000..824d592 --- /dev/null +++ b/tools/zeithawk/cors-proxy-worker/README.md @@ -0,0 +1,57 @@ +# ZeitHawk CORS proxy (Cloudflare Worker) + +A private alternative to routing ZeitHawk's requests through the public +`corsproxy.io`. Same idea (fetch the target server-side, hand back the +response with permissive CORS headers), but it's your own deployment +instead of a third party you don't control — and unlike a Google Apps +Script web app, a Cloudflare Worker can actually set +`Access-Control-Allow-Origin` on its response, which Apps Script cannot +do at all (see `tools/apps-script-backend/Code.gs` for why that backend +uses JSONP instead of a proxy). + +## Deploy it (no CLI needed, ~2 minutes) + +1. Sign up for a free Cloudflare account at + [dash.cloudflare.com/sign-up](https://dash.cloudflare.com/sign-up) if + you don't have one — the free tier (100,000 requests/day) is plenty + for this. +2. In the dashboard, go to **Workers & Pages** → **Create** → **Create + Worker**. +3. Give it a name (e.g. `zeithawk-proxy`) → **Deploy** (this creates a + placeholder — you'll replace its code next). +4. Click **Edit code** (or "Quick edit") to open the online editor. +5. Delete the placeholder code and paste in the contents of + [`worker.js`](./worker.js) from this folder. +6. **Save and deploy.** +7. Copy the Worker's URL — it looks like + `https://zeithawk-proxy..workers.dev`. + +## Point ZeitHawk at it + +In ZeitHawk's header, the CORS proxy section has a **Proxy endpoint** +field (below the on/off toggle), pre-filled with +`https://corsproxy.io/?url=`. Replace it with your Worker's URL plus +`/?url=`, e.g.: + +``` +https://zeithawk-proxy..workers.dev/?url= +``` + +That's stored in your browser's `localStorage`, so it persists across +visits until you change it again. + +## Caveats + +- **No allowlist, no auth.** Anyone who has this Worker's URL can use it + as an open relay to fetch anything from anywhere. That's the point (you + need it to reach arbitrary authorized-test targets), but don't reuse + this Worker for anything you wouldn't want acting as an open proxy. +- **It forwards headers and body as-is** — including anything you type + into Repeater/Intruder. Nothing about what you send through it is + logged by this code, but Cloudflare's own platform-level request logs + (if you have logging enabled on your account) would still see it, same + as any Worker you deploy. +- **Still bound by what the target itself allows.** If a target blocks + Cloudflare's IP ranges, rate-limits aggressively, or requires a + specific `User-Agent`, this proxy won't get around that — it's not a + general-purpose anti-blocking tool, just a CORS workaround. diff --git a/tools/zeithawk/cors-proxy-worker/worker.js b/tools/zeithawk/cors-proxy-worker/worker.js new file mode 100644 index 0000000..611235d --- /dev/null +++ b/tools/zeithawk/cors-proxy-worker/worker.js @@ -0,0 +1,101 @@ +/** + * A minimal CORS-bypassing reverse proxy for ZeitHawk, deployed as a + * Cloudflare Worker. Unlike a Google Apps Script web app (which can't set + * response headers at all, so a plain fetch() can never read its body + * cross-origin), a Worker can freely set Access-Control-Allow-Origin and + * Access-Control-Expose-Headers — which is the whole point here. + * + * Usage: GET /?url= + * Forwards method, headers, and body to the target, then returns the + * target's real status/headers/body with permissive CORS headers added. + * + * This has no allowlist or auth by design — it's meant to be pointed at + * whatever target you're authorized to test, the same as ZeitHawk itself. + * Anyone with the deployed URL can use it as an open proxy, so don't put + * anything sensitive in front of it you wouldn't want relayed. + */ + +const HOP_BY_HOP_REQUEST_HEADERS = [ + 'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', + 'te', 'trailer', 'proxy-authorization', 'proxy-connection', +]; + +export default { + async fetch(request) { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + + const reqUrl = new URL(request.url); + const target = reqUrl.searchParams.get('url'); + if (!target) { + return jsonError('Missing ?url= query parameter', 400); + } + + let targetUrl; + try { + targetUrl = new URL(target); + } catch (e) { + return jsonError('Invalid target URL: ' + target, 400); + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return jsonError('Only http/https targets are supported', 400); + } + + const forwardHeaders = new Headers(); + for (const [key, value] of request.headers) { + const k = key.toLowerCase(); + if (HOP_BY_HOP_REQUEST_HEADERS.includes(k)) continue; + if (k.startsWith('cf-') || k === 'x-forwarded-for' || k === 'x-real-ip') continue; + forwardHeaders.set(key, value); + } + + const init = { + method: request.method, + headers: forwardHeaders, + redirect: 'follow', + }; + if (!['GET', 'HEAD'].includes(request.method)) { + init.body = await request.arrayBuffer(); + } + + let targetRes; + try { + targetRes = await fetch(targetUrl.toString(), init); + } catch (e) { + return jsonError('Upstream request failed: ' + ((e && e.message) || String(e)), 502); + } + + const respHeaders = new Headers(targetRes.headers); + const exposeList = Array.from(respHeaders.keys()).join(', '); + respHeaders.set('Access-Control-Allow-Origin', '*'); + respHeaders.set('Access-Control-Expose-Headers', exposeList || '*'); + // Hop-by-hop, and content-encoding since fetch() already decoded the body. + respHeaders.delete('content-encoding'); + respHeaders.delete('content-length'); + respHeaders.delete('transfer-encoding'); + respHeaders.delete('connection'); + + return new Response(targetRes.body, { + status: targetRes.status, + statusText: targetRes.statusText, + headers: respHeaders, + }); + }, +}; + +function corsHeaders() { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Max-Age': '86400', + }; +} + +function jsonError(message, status) { + return new Response(JSON.stringify({ error: message }), { + status, + headers: Object.assign({ 'content-type': 'application/json' }, corsHeaders()), + }); +} diff --git a/tools/zeithawk/index.html b/tools/zeithawk/index.html index 4619ced..07868cb 100644 --- a/tools/zeithawk/index.html +++ b/tools/zeithawk/index.html @@ -112,6 +112,7 @@ padding:12px 14px;font-size:13px;line-height:1.55;color:var(--ink);margin-top:10px; } .proxy-toggle code{background:var(--paper-raised);} + .proxy-base-row input{font-family:'IBM Plex Mono',monospace;font-size:12px;padding:7px 9px;} .switch{position:relative;display:inline-block;width:36px;height:20px;flex:0 0 auto;margin-top:1px;} .switch input{opacity:0;width:0;height:0;} .switch-track{position:absolute;inset:0;background:var(--line);border-radius:999px;cursor:pointer;transition:background 0.15s;} @@ -311,7 +312,12 @@

ZeitHawk

- CORS proxy: on — routes Repeater, Intruder, Recon's DNS/RDAP lookups, and Vuln Scan through the public proxy corsproxy.io so requests reach targets that would otherwise block cross-origin access. This sends the target URL — and anything you send it (headers, body, payloads) — through that third-party service, which you don't control. Turn this off for anything sensitive, or when testing a target that already sets its own permissive CORS headers. + CORS proxy: on — routes Repeater, Intruder, Recon's DNS/RDAP lookups, and Vuln Scan through a proxy so requests reach targets that would otherwise block cross-origin access. This sends the target URL — and anything you send it (headers, body, payloads) — through whatever endpoint is set below, which you don't control unless you deployed it yourself. Turn this off for anything sensitive, or when testing a target that already sets its own permissive CORS headers. +
+ + +

Default is the public corsproxy.io. See tools/zeithawk/cors-proxy-worker/ in the repo to deploy your own private proxy and paste its URL here instead.

+
@@ -677,33 +683,61 @@

Vuln Scan

} /* ------------------------------------------------------------------ * - * Public CORS proxy (opt-out) — routes requests through corsproxy.io so - * targets that don't set their own CORS headers can still be reached. - * A third-party service sees the target URL and whatever you send it, + * CORS proxy (opt-out, configurable endpoint) — routes requests through + * a proxy so targets that don't set their own CORS headers can still be + * reached. Defaults to the public corsproxy.io, but the endpoint can be + * pointed at a private proxy instead (see tools/zeithawk/cors-proxy-worker/). + * Whichever endpoint is set sees the target URL and whatever you send it, * so this is off-able for sensitive targets. Does NOT change the header- * visibility caveat in Vuln Scan: we only ever claim a header is present * when we actually read it, never infer "missing" from silence, since we - * can't verify what corsproxy.io itself exposes via CORS. + * can't verify what the configured proxy itself exposes via CORS. * ------------------------------------------------------------------ */ -const CORS_PROXY_BASE = 'https://corsproxy.io/?url='; +const DEFAULT_PROXY_BASE = 'https://corsproxy.io/?url='; function isProxyEnabled(){ try{ return localStorage.getItem('zeithawk_proxy_enabled') !== '0'; }catch(e){ return true; } } function setProxyEnabled(v){ try{ localStorage.setItem('zeithawk_proxy_enabled', v ? '1' : '0'); }catch(e){} } +function getProxyBase(){ + try{ return localStorage.getItem('zeithawk_proxy_base') || DEFAULT_PROXY_BASE; }catch(e){ return DEFAULT_PROXY_BASE; } +} +function setProxyBase(v){ + try{ localStorage.setItem('zeithawk_proxy_base', (v||'').trim() || DEFAULT_PROXY_BASE); }catch(e){} +} function viaProxy(url){ - return isProxyEnabled() ? (CORS_PROXY_BASE + encodeURIComponent(url)) : url; + return isProxyEnabled() ? (getProxyBase() + encodeURIComponent(url)) : url; +} +// Tries a direct fetch first and only falls back to the configured proxy +// if that outright fails. Targets that already send their own permissive +// CORS headers (dns.google, for one) keep working with no dependency on +// a third party at all; targets that need the proxy still get it. This +// also means a proxy outage/misbehavior never breaks something that +// worked fine unproxied before the proxy toggle existed. +async function fetchDirectOrProxied(url, opts){ + if(!isProxyEnabled()) return fetch(url, opts); + try{ + return await fetch(url, opts); + }catch(directErr){ + return fetch(viaProxy(url), opts); + } } const proxyInput = document.getElementById('proxy-toggle-input'); const proxyStatus = document.getElementById('proxy-toggle-status'); +const proxyBaseInput = document.getElementById('proxy-base-input'); proxyInput.checked = isProxyEnabled(); proxyStatus.textContent = proxyInput.checked ? 'on' : 'off'; +proxyBaseInput.value = getProxyBase(); proxyInput.addEventListener('change', ()=>{ setProxyEnabled(proxyInput.checked); proxyStatus.textContent = proxyInput.checked ? 'on' : 'off'; }); +proxyBaseInput.addEventListener('change', ()=>{ + setProxyBase(proxyBaseInput.value); + proxyBaseInput.value = getProxyBase(); +}); // Fires a request from the browser and normalizes the outcome. Subject to // the same CORS/forbidden-header rules as any page — see the banner above. @@ -719,7 +753,7 @@

Vuln Scan

if(body && !['GET','HEAD'].includes(method)) opts.body = body; const t0 = performance.now(); try{ - const res = await fetch(viaProxy(url), opts); + const res = await fetchDirectOrProxied(url, opts); const text = await res.text(); const ms = Math.round(performance.now() - t0); const outHeaders = []; @@ -1225,7 +1259,7 @@

Vuln Scan

async function dohQuery(name, type){ const url = 'https://dns.google/resolve?name=' + encodeURIComponent(name) + '&type=' + encodeURIComponent(type); try{ - const res = await fetch(viaProxy(url), { signal: AbortSignal.timeout(6000) }); + const res = await fetchDirectOrProxied(url, { signal: AbortSignal.timeout(6000) }); if(!res.ok) return { ok:false, error: 'HTTP '+res.status }; const data = await res.json(); return { ok:true, data }; @@ -1237,7 +1271,7 @@

Vuln Scan

async function rdapLookup(target, isIp){ const url = 'https://rdap.org/' + (isIp ? 'ip/' : 'domain/') + encodeURIComponent(target); try{ - const res = await fetch(viaProxy(url), { headers:{accept:'application/rdap+json'}, signal: AbortSignal.timeout(8000) }); + const res = await fetchDirectOrProxied(url, { headers:{accept:'application/rdap+json'}, signal: AbortSignal.timeout(8000) }); if(!res.ok) return { ok:false, error:'HTTP '+res.status }; const data = await res.json(); return { ok:true, data }; @@ -1357,11 +1391,16 @@

Vuln Scan

const resolvedIps = []; results.forEach((r, i)=>{ const t = types[i]; - if(!r.ok || !r.data.Answer) return; - r.data.Answer.forEach(ans=>{ - rows.push([t, ans.data, ans.TTL]); - if(t === 'A' || t === 'AAAA') resolvedIps.push(ans.data); - }); + // A malformed/unexpected response for one record type must never + // stop the rest of forEach's iterations (types after it in the + // array — NS, CNAME, SOA — would otherwise silently disappear). + try{ + if(!r.ok || !Array.isArray(r.data.Answer)) return; + r.data.Answer.forEach(ans=>{ + rows.push([t, ans.data, ans.TTL]); + if(t === 'A' || t === 'AAAA') resolvedIps.push(ans.data); + }); + }catch(e){ /* skip this record type, keep processing the rest */ } }); document.getElementById('recon-dns-tbody').innerHTML = rows.length ? rows.map(r=>''+escapeHtml(r[0])+''+escapeHtml(r[1])+''+escapeHtml(r[2])+'').join('') @@ -1483,7 +1522,7 @@

Vuln Scan

let mainRes = null, mainErr = null, bodyText = ''; try{ - mainRes = await fetch(viaProxy(url), { mode:'cors', signal: AbortSignal.timeout(8000) }); + mainRes = await fetchDirectOrProxied(url, { mode:'cors', signal: AbortSignal.timeout(8000) }); bodyText = await mainRes.text(); }catch(e){ mainErr = (e && e.message) || String(e); } @@ -1535,7 +1574,7 @@

Vuln Scan

const pathResults = await Promise.all(PATH_CHECKS.map(async pc=>{ try{ - const res = await fetch(viaProxy(origin + pc.path), { mode:'cors', signal: AbortSignal.timeout(5000) }); + const res = await fetchDirectOrProxied(origin + pc.path, { mode:'cors', signal: AbortSignal.timeout(5000) }); return Object.assign({}, pc, { status: res.status }); }catch(e){ return Object.assign({}, pc, { status: null }); } }));