URL Scanner / one URL, every check
Scan a URL
One URL in, and every check that can run: the security header grade, cookie flags,
information disclosure, mixed content, CSP posture, third-party script risk, missing
SRI, exposed source maps, .git and .env, CORS origin
reflection and Host header reflection — plus DNS records, DNSSEC, mail
authentication (SPF, DKIM, DMARC), WHOIS, hosting and archives, none of which need
any setup. Pick the areas you want or run everything. Every finding carries impact
and remediation, and the whole run exports as Markdown.
Target
step 1http:// or https:// only. Redirects are followed and the final document is what gets assessed.
What to check
Backend
Saved locally so every tool that needs a backend can use it. It is your endpoint URL, not your scan data — nothing about your targets is stored.
What each option costs you. A browser cannot read another origin's response — that is the same-origin policy, not a limitation here — so something server-side has to make the request.
The public API works immediately but sees every URL you submit, returns headers only, and refuses private addresses. Your own Worker unlocks content analysis, path discovery and active request tests, and nothing about your targets reaches a third party. Neither can reach an internal host — for those, paste output into the Security Header Analyzer, which makes no request at all.
Deploy the Worker — five minutes, free tier, full source
Cloudflare's free plan covers 100,000 requests a day.
Open workers.cloudflare.com,
create a Worker, replace the default code with this, deploy, and paste the
*.workers.dev URL above.
Two details are deliberate. redirect: 'manual' keeps
the redirect chain under this page's control so every hop is shown. The
private-address refusal is what stops the endpoint becoming an SSRF proxy
aimed at your own network — do not remove it.
/* ==========================================================================
Pentest Toolkit — scanner backend (Cloudflare Worker)
--------------------------------------------------------------------------
Deploy this once and the toolkit's URL modes work fully, with nothing about
your targets reaching a third party.
1. https://workers.cloudflare.com -> Create Worker
2. Replace the default code with this file
3. Deploy, copy the *.workers.dev URL
4. Paste it into any tool's "your own endpoint" field
Free tier covers 100,000 requests a day.
API
GET /?url=<target> raw HTTP response (headers only)
GET /?url=<target>&body=1 JSON: status, headers, body
GET /?url=<target>&origin=<origin> JSON, with that Origin sent
GET /?url=<target>&method=OPTIONS JSON, using that method
GET /?urls=<a,b,c>&body=1 JSON: { results: [ ... ] }
GET /?url=<target>&host=<host> JSON, with that Host header sent
Safety
Only http and https. Private, loopback, link-local and cloud-metadata
addresses are refused, so this cannot be turned into an SSRF proxy
against your own infrastructure. Redirects are never followed here —
the caller follows the chain so it can show every hop. Bodies are capped.
========================================================================== */
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Max-Age': '86400'
};
const MAX_BODY = 512 * 1024; // 512 KB is plenty for HTML analysis
const MAX_BATCH = 12;
const TIMEOUT_MS = 12000;
const ALLOWED_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'POST', 'PUT', 'DELETE'];
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: { ...CORS, 'Content-Type': 'application/json; charset=utf-8' }
});
}
function text(body, status = 200) {
return new Response(body, {
status,
headers: { ...CORS, 'Content-Type': 'text/plain; charset=utf-8' }
});
}
/* Refuse anything that could reach infrastructure the caller should not be
pivoting into through a public endpoint. */
function isBlockedHost(hostname) {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.internal') ||
h.endsWith('.local') || h === '::1' || h === '0.0.0.0') return true;
if (/^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h) ||
/^169\.254\./.test(h) || /^0\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true; // CGNAT
if (/^f[cd][0-9a-f]{2}:/.test(h) || /^fe80:/.test(h)) return true; // IPv6 ULA / link-local
if (h === 'metadata.google.internal' || h === '169.254.169.254') return true;
return false;
}
function parseTarget(raw) {
let u;
try { u = new URL(raw); } catch { throw new Error('invalid url'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('only http and https');
if (u.username || u.password) throw new Error('embedded credentials refused');
if (!u.hostname) throw new Error('no hostname');
if (isBlockedHost(u.hostname)) throw new Error('private or link-local address refused');
return u;
}
async function probe(target, opts = {}) {
const u = parseTarget(target);
const headers = {
/* A recognisable agent, so the operator of a scanned host can identify
and contact whoever ran this rather than seeing anonymous traffic. */
'User-Agent': opts.userAgent ||
'Mozilla/5.0 (compatible; PentestToolkit/1.0; +https://prashantique.github.io/pentest-toolkit/)',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9'
};
if (opts.origin) headers['Origin'] = opts.origin;
if (opts.host) headers['Host'] = opts.host;
if (opts.cookie) headers['Cookie'] = opts.cookie;
if (opts.referer) headers['Referer'] = opts.referer;
const method = ALLOWED_METHODS.includes((opts.method || 'GET').toUpperCase())
? opts.method.toUpperCase() : 'GET';
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res;
try {
res = await fetch(u.href, {
method,
headers,
redirect: 'manual', /* the caller walks the chain itself */
signal: controller.signal
});
} catch (e) {
clearTimeout(timer);
return { url: u.href, error: e.name === 'AbortError'
? 'timed out after ' + TIMEOUT_MS + 'ms' : (e.message || 'fetch failed') };
}
clearTimeout(timer);
const out = {
url: u.href,
method,
status: res.status,
statusText: res.statusText || '',
headers: {},
setCookie: [],
timing: Date.now() - started
};
for (const [k, v] of res.headers) {
if (k.toLowerCase() === 'set-cookie') out.setCookie.push(v);
else out.headers[k] = v;
}
/* Workers collapse repeated Set-Cookie; getSetCookie gives them back. */
if (typeof res.headers.getSetCookie === 'function') {
const all = res.headers.getSetCookie();
if (all && all.length) out.setCookie = all;
}
if (opts.body) {
try {
const reader = res.body ? res.body.getReader() : null;
if (reader) {
const chunks = []; let total = 0;
while (total < MAX_BODY) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); total += value.length;
}
try { reader.cancel(); } catch {}
const buf = new Uint8Array(total);
let off = 0;
for (const c of chunks) { buf.set(c.subarray(0, Math.min(c.length, total - off)), off); off += c.length; }
out.body = new TextDecoder('utf-8', { fatal: false }).decode(buf);
out.bodyTruncated = total >= MAX_BODY;
out.bodyBytes = total;
} else {
out.body = ''; out.bodyBytes = 0;
}
} catch (e) {
out.bodyError = e.message || 'could not read body';
}
}
return out;
}
/* Emit a raw HTTP response, so a caller can reuse a paste-mode parser. */
function asRaw(r) {
if (r.error) return 'error: ' + r.error;
let out = `HTTP/1.1 ${r.status} ${r.statusText}\n`;
for (const [k, v] of Object.entries(r.headers)) out += `${k}: ${v}\n`;
for (const c of r.setCookie) out += `set-cookie: ${c}\n`;
return out;
}
export default {
async fetch(request) {
if (request.method === 'OPTIONS') return new Response(null, { headers: CORS });
if (request.method !== 'GET') return text('error: GET only', 405);
const q = new URL(request.url).searchParams;
if (q.has('health')) {
return json({ ok: true, service: 'pentest-toolkit-scanner', version: 1,
maxBody: MAX_BODY, maxBatch: MAX_BATCH, timeoutMs: TIMEOUT_MS });
}
const opts = {
body: q.get('body') === '1',
origin: q.get('origin') || null,
method: q.get('method') || 'GET',
host: q.get('host') || null,
cookie: q.get('cookie') || null,
referer: q.get('referer') || null
};
/* batch: several paths in one round trip, for well-known discovery */
if (q.has('urls')) {
const list = q.get('urls').split(',').map(s => s.trim()).filter(Boolean).slice(0, MAX_BATCH);
if (!list.length) return json({ error: 'no urls' }, 400);
const results = await Promise.all(list.map(async (t) => {
try { return await probe(t, opts); }
catch (e) { return { url: t, error: e.message }; }
}));
return json({ results });
}
const target = q.get('url');
if (!target) return text('error: missing url parameter', 400);
let r;
try { r = await probe(target, opts); }
catch (e) { return q.get('raw') === '1' ? text('error: ' + e.message, 400) : json({ error: e.message }, 400); }
/* Default to raw so this is a drop-in for the public header API. */
if (!opts.body && q.get('json') !== '1') return text(asRaw(r));
return json(r);
}
};
Enter a URL and press Scan.
Collected data
What each selected module returned. Findings derived from it are in the list below.
Findings
Reference
Can a security scan run entirely in the browser?
Only partly, and it is worth knowing which parts. Same-origin policy stops a page reading another origin's response, so headers and bodies have to be fetched by something server-side.
Two checks are genuine exceptions and run in this page with no backend at all: framing behaviour, tested by actually embedding the target in an iframe and observing whether it renders, and window.opener retention, tested by opening it — that one has its own COOP & Tabnabbing tester. Both are behavioural results rather than header inferences, which makes them harder to dispute.
How do I test for CORS origin reflection?
Send a crafted Origin and check whether the server echoes it into
Access-Control-Allow-Origin. This scanner tries four variants, because
allowlists are usually broken in one of four ways:
| Origin sent | What it proves if reflected |
|---|---|
https://attacker.example | The allowlist is not an allowlist — anything is accepted |
null | Sandboxed iframes and redirects can read responses |
https://target.com.attacker.example | Suffix matching — the check uses endsWith or similar |
https://attackertarget.com | Prefix matching — the check uses startsWith or a loose regex |
Reflection with Access-Control-Allow-Credentials: true is
account takeover: any page the victim visits can read authenticated
responses using the victim's own cookies. Fix it by comparing against a fixed list and
emitting only the matched literal value — never the submitted one.
What does an exposed .git directory actually give away?
The whole repository. If /.git/HEAD returns a
ref: line, the objects are usually fetchable too, and the source can be
reconstructed — code, configuration, and every credential ever committed.
The part people miss: removing a secret in a later commit does not remove
it from history. So the correct response is not just blocking the path. Rotate
every credential that has ever appeared in that repository, then block
.git at the server and deploy build artefacts rather than a working copy.
Is target="_blank" without rel="noopener" still a finding?
Not in a current browser — Chrome 88, Firefox 79 and Safari 12.1
apply noopener to anchors implicitly. This scanner reports it as low
severity for that reason, rather than inflating it.
It stays a real finding in three cases: legacy engines and in-app WebViews,
Electron shells and document viewers that never adopted the default; anchors carrying
rel="opener", which explicitly switches the old behaviour back on; and
window.open() or form submissions, which never received the implicit
protection at all.
Why does missing Subresource Integrity matter?
Because without an integrity attribute, whatever that host serves runs
unconditionally in your origin, with full access to the DOM and any token reachable
from it.
Three ways that goes wrong, all of which have happened: a CDN is compromised, a
package is hijacked, or a domain expires and someone else registers it. Polyfill.io
and British Airways were both this shape. Add
integrity="sha384-…" with crossorigin="anonymous", pinned to
an exact version — and prefer self-hosting where you can.
Can this scan an internal or staging site?
No, and neither can anything else that runs in a browser. The public API refuses private and link-local addresses, and a Cloudflare Worker has no route into your network either.
For internal hosts, run the tooling yourself and paste the output: the
Security Header Analyzer takes
curl -I, Burp or DevTools output, and the
SSL/TLS Analyzer takes sslscan, testssl.sh, nmap or
sslyze. Both make no network request at all, so they work from inside any segment you
can already reach.