URL Scanner / one URL, every check

Scan

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 1

http:// or https:// only. Redirects are followed and the final document is what gets assessed.

Only scan hosts you are authorized to test.

What to check

Everything is on by default. Uncheck to run less.

Backend

    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.

    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 sentWhat it proves if reflected
    https://attacker.exampleThe allowlist is not an allowlist — anything is accepted
    nullSandboxed iframes and redirects can read responses
    https://target.com.attacker.exampleSuffix matching — the check uses endsWith or similar
    https://attackertarget.comPrefix 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.