diff --git a/tools/zeithawk/README.md b/tools/zeithawk/README.md index 9bb4338..96f5e09 100644 --- a/tools/zeithawk/README.md +++ b/tools/zeithawk/README.md @@ -38,7 +38,7 @@ review/testing work against systems they're authorized to test. ## Constraints (by design — it runs entirely client-side) -Because it's a plain web page with no proxy or backend, every request is +Because it's a plain web page with no backend of its own, 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 @@ -52,6 +52,36 @@ 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) + +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 +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. +- **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**, + 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 + `mode:'no-cors'`, which already isn't blocked by CORS, so routing it + 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. + **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 index b28529d..4619ced 100644 --- a/tools/zeithawk/index.html +++ b/tools/zeithawk/index.html @@ -106,6 +106,19 @@ .disclaimer strong{color:var(--restricted);} .disclaimer code{background:var(--paper-raised);} + .proxy-toggle{ + display:flex;gap:10px;align-items:flex-start; + background:var(--slate-soft);border:1px solid var(--line);border-radius:10px; + padding:12px 14px;font-size:13px;line-height:1.55;color:var(--ink);margin-top:10px; + } + .proxy-toggle code{background:var(--paper-raised);} + .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;} + .switch-track::before{content:'';position:absolute;width:16px;height:16px;left:2px;top:2px;background:var(--paper-raised);border-radius:50%;transition:transform 0.15s;box-shadow:0 1px 2px rgba(0,0,0,0.25);} + .switch input:checked + .switch-track{background:var(--accent);} + .switch input:checked + .switch-track::before{transform:translateX(16px);} + /* tab bar (reuses the wizard "console" instrument as a tool switcher) */ .console{ display:flex;align-items:stretch;gap:0; @@ -292,6 +305,15 @@

ZeitHawk

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. +
+ + + 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. + +
@@ -654,6 +676,35 @@

Vuln Scan

return 'dot-restricted'; } +/* ------------------------------------------------------------------ * + * 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, + * 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. + * ------------------------------------------------------------------ */ + +const CORS_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 viaProxy(url){ + return isProxyEnabled() ? (CORS_PROXY_BASE + encodeURIComponent(url)) : url; +} +const proxyInput = document.getElementById('proxy-toggle-input'); +const proxyStatus = document.getElementById('proxy-toggle-status'); +proxyInput.checked = isProxyEnabled(); +proxyStatus.textContent = proxyInput.checked ? 'on' : 'off'; +proxyInput.addEventListener('change', ()=>{ + setProxyEnabled(proxyInput.checked); + proxyStatus.textContent = proxyInput.checked ? 'on' : 'off'; +}); + // 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. async function fireRequest(method, url, headerRows, body){ @@ -668,7 +719,7 @@

Vuln Scan

if(body && !['GET','HEAD'].includes(method)) opts.body = body; const t0 = performance.now(); try{ - const res = await fetch(url, opts); + const res = await fetch(viaProxy(url), opts); const text = await res.text(); const ms = Math.round(performance.now() - t0); const outHeaders = []; @@ -1174,7 +1225,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(url, { signal: AbortSignal.timeout(6000) }); + const res = await fetch(viaProxy(url), { signal: AbortSignal.timeout(6000) }); if(!res.ok) return { ok:false, error: 'HTTP '+res.status }; const data = await res.json(); return { ok:true, data }; @@ -1186,7 +1237,7 @@

Vuln Scan

async function rdapLookup(target, isIp){ const url = 'https://rdap.org/' + (isIp ? 'ip/' : 'domain/') + encodeURIComponent(target); try{ - const res = await fetch(url, { headers:{accept:'application/rdap+json'}, signal: AbortSignal.timeout(8000) }); + const res = await fetch(viaProxy(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 }; @@ -1199,6 +1250,9 @@

Vuln Scan

{port:8000, scheme:'http'}, {port:3000, scheme:'http'}, ]; +// Deliberately not routed through the CORS proxy: mode:'no-cors' already +// isn't blocked by CORS (that's why it's used here), so proxying would +// only change the network vantage point, not fix anything. async function checkPort(host, port, scheme){ const url = scheme+'://'+host+':'+port+'/'; const t0 = performance.now(); @@ -1429,7 +1483,7 @@

Vuln Scan

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

Vuln Scan

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