Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions tools/zeithawk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<owner>.github.io/<repo>/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,
Expand Down Expand Up @@ -52,35 +69,46 @@ 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
`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.
**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.
Expand Down
57 changes: 57 additions & 0 deletions tools/zeithawk/cors-proxy-worker/README.md
Original file line number Diff line number Diff line change
@@ -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.<your-subdomain>.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.<your-subdomain>.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.
101 changes: 101 additions & 0 deletions tools/zeithawk/cors-proxy-worker/worker.js
Original file line number Diff line number Diff line change
@@ -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 <this-worker-url>/?url=<url-encoded target>
* 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()),
});
}
Loading
Loading