← zurück zur Toolbox

security_headers_web_v1.py · Quelltext

49146 Bytes · SHA-256: 87fdaedac7fe14af385f492e4bb51b517ffcf1fff4fa702cb6f376dfaf0e7d7f

⤓ Download
#!/usr/home/jozapf/public_html/toolenv/bin/python
# -*- coding: utf-8 -*-
# TOOLBOX-TILE: {"title": "Security Headers", "desc": "HTTP-Security-Header einer Seite prüfen und A+…F benoten — HSTS, CSP, X-Frame-Options, Cookies, Info-Leaks.", "icon": "🛡️", "type": "web", "example": "?url=example.com", "order": 10}
"""
Security-Headers-Web — HTTP security header analyzer & grader
Version: 1.1.0
Date: 2026-07-11

A small, self-hosted web tool that fetches a URL server-side, inspects its HTTP
response security headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy, cookies, information disclosure, ...) and
assigns an A+..F grade — comparable to securityheaders.com and the Mozilla
Observatory.

Runs as a plain Python CGI script (no framework). Query string is read via
urllib.parse.parse_qs (the stdlib `cgi` module was removed in Python 3.13).
Dark-mode UI matched to the sibling toolbox tool Meta-Debug-Web.

Why server-side: a browser cannot read a third-party site's response headers
(cross-origin fetch yields an opaque response). The fetch therefore happens on
the server, which is not subject to that restriction.

1.1.0: CSP scoring tightened to weigh script-execution safety (Observatory-
style): 'unsafe-inline'/'unsafe-eval' in script-src (or scripts left
unconstrained) weigh far more and cap the grade at B.
"""

import html
import os
import re
import sys
import traceback
from urllib.parse import parse_qs

import requests

# Shared, hardened outbound fetch (SSRF-safe: host-validated manual redirects,
# DNS pinning, capped bodies). Sits next to this tool on the server. Fail-safe:
# if it cannot be imported we fall back to the tool's original request so an
# import problem never takes the tool offline.
try:
    from toolbox_fetch import safe_get
except Exception:  # pragma: no cover
    safe_get = None

# Bot/abuse guard (toolbox_guard.py sits next to this tool on the server).
# Fail open on import trouble: the guard is best-effort protection, never a
# reason to take a working tool offline.
try:
    from toolbox_guard import guard as _guard
except Exception:  # pragma: no cover
    def _guard(_tool):
        return True

USER_AGENT = "SecurityHeadersWeb/1.0 (+https://jozapf.de)"

# HSTS max-age thresholds (seconds).
_HSTS_MIN = 15_768_000          # 6 months — minimum for a "pass"
_HSTS_GOOD = 31_536_000         # 12 months — recommended

# Grade bands aligned to the Mozilla/MDN HTTP Observatory: A+ 100+, A 90-99,
# A- 85-89, B+ 80-84, B 70-79, ... Bonuses (only if base >= 90) can push above 100.
_GRADE_BANDS = [
    (100, "A+", "#059669"),
    (90,  "A",  "#10b981"),
    (85,  "A-", "#34d399"),
    (80,  "B+", "#65a30d"),
    (70,  "B",  "#84cc16"),
    (65,  "B-", "#a3e635"),
    (60,  "C+", "#ca8a04"),
    (50,  "C",  "#eab308"),
    (45,  "C-", "#facc15"),
    (40,  "D+", "#ea580c"),
    (30,  "D",  "#f97316"),
    (25,  "D-", "#fb923c"),
    (0,   "F",  "#ef4444"),
]

_STATUS_LABEL = {
    "pass": "Present",
    "warn": "Weak",
    "fail": "Missing",
    "info": "Info",
}


# --------------------------------------------------------------------------- #
# Adapter: present a toolbox_fetch.FetchResult with the same surface analyze()
# already consumes from a requests.Response. NOTHING in the scoring code changes
# — only where the raw HTTP data comes from.
# --------------------------------------------------------------------------- #

class _Hop(object):
    """A redirect hop with the .status_code/.url attributes analyze() reads."""
    __slots__ = ("status_code", "url")

    def __init__(self, status_code, url):
        self.status_code = status_code
        self.url = url


class _RawHeaders(object):
    """Minimal stand-in for urllib3's HTTPHeaderDict so _cookie_lines() can call
    .getlist('Set-Cookie') and get every cookie line separately (multiple
    Set-Cookie headers are folded in requests' CaseInsensitiveDict, which would
    corrupt cookie scoring)."""
    __slots__ = ("_setcookie",)

    def __init__(self, setcookie_lines):
        self._setcookie = list(setcookie_lines or [])

    def getlist(self, name):
        if name.lower() == "set-cookie":
            return list(self._setcookie)
        return []


class _RawShim(object):
    __slots__ = ("headers",)

    def __init__(self, setcookie_lines):
        self.headers = _RawHeaders(setcookie_lines)


class _SafeResp(object):
    """Requests-Response-shaped view over a FetchResult, exposing exactly the
    fields analyze() and _cookie_lines() use: headers, url, status_code, text,
    history[] (as hop objects) and raw.headers.getlist('Set-Cookie')."""
    __slots__ = ("url", "status_code", "headers", "text", "history", "raw")

    def __init__(self, fr, setcookie_lines):
        self.url = fr.url
        self.status_code = fr.status_code
        self.headers = fr.headers
        self.text = fr.text
        self.history = [_Hop(h.get("status"), h.get("url")) for h in fr.history]
        self.raw = _RawShim(setcookie_lines)


def fetch_url(url: str, timeout: int = 12):
    """Fetch the URL following redirects (like securityheaders' followRedirects).

    Uses the shared SSRF-safe fetch when available; a refusal (internal/off-host
    target, transport error, redirect budget) surfaces as a RequestException so
    main() renders a clean error page instead of contacting an internal host.
    Falls back to the original request only if the shared module is unavailable.
    """
    headers = {
        "User-Agent": USER_AGENT,
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    }
    if safe_get is not None:
        # A response hook grabs the raw Set-Cookie lines (headers are available
        # even with stream=True) on every hop; the final response wins, matching
        # the original code that read only the final response's cookies.
        sess = requests.Session()
        captured = {"setcookie": []}

        def _grab_cookies(resp, **_kwargs):
            try:
                captured["setcookie"] = list(resp.raw.headers.getlist("Set-Cookie"))
            except Exception:
                pass

        sess.hooks["response"].append(_grab_cookies)
        fr = safe_get(url, timeout=timeout, headers=headers, session=sess)
        if fr is None:
            raise requests.exceptions.RequestException(
                "Target host refused by the SSRF guard or unreachable.")
        return _SafeResp(fr, captured["setcookie"])
    return requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)


def _hsts_preloaded(host: str, timeout: int = 6) -> bool:
    """True only if the host is actually on the public HSTS preload list.
    Mozilla's v2 Observatory scores 'hsts-preloaded' from the real Chromium
    preload list, NOT from the `preload` directive — so a directive alone
    (not yet on the list) earns no bonus. Fails safe to False if unreachable."""
    try:
        r = requests.get("https://hstspreload.org/api/v2/status",
                         params={"domain": host}, timeout=timeout)
        return r.json().get("status") == "preloaded"
    except Exception:
        return False


# Multi-label public suffixes so foo.co.uk resolves to a 2nd-level domain correctly.
_MULTI_TLDS = {"co.uk", "org.uk", "gov.uk", "ac.uk", "me.uk", "com.au", "net.au",
               "org.au", "co.nz", "co.jp", "com.br", "co.za", "com.tr"}


def _registrable(host):
    """Best-effort registrable ('second level') domain, à la Mozilla's tldts use."""
    host = (host or "").lower().strip(".")
    parts = host.split(".")
    if len(parts) <= 2:
        return host
    last2 = ".".join(parts[-2:])
    if last2 in _MULTI_TLDS and len(parts) >= 3:
        return ".".join(parts[-3:])
    return last2


def _sri_result(html, page_host, page_https, is_html=True):
    """Reproduce the Mozilla v2 subresource-integrity classification exactly.
    Returns (status, modifier, detail)."""
    if not is_html:
        return ("info", 0, "Response is not HTML — no scripts to protect (not scored).")
    order = ["all_secure", "ext_secure", "impl_not_secure",
             "notimpl_ext_secure", "notimpl_not_secure"]
    mod = {"all_secure": 5, "ext_secure": 5, "impl_not_secure": -20,
           "notimpl_ext_secure": -5, "notimpl_not_secure": -50}
    result = [None]

    def worse(new):
        if result[0] is None or order.index(new) > order.index(result[0]):
            result[0] = new

    site_dom = _registrable(page_host)
    foreign = False
    n_src = 0
    for tag in re.findall(r'<script\b[^>]*>', html or "", re.I):
        m = re.search(r'\bsrc\s*=\s*["\']([^"\']+)["\']', tag, re.I)
        if not m:
            continue
        n_src += 1
        src = m.group(1).strip()
        integrity = bool(re.search(r'\bintegrity\s*=', tag, re.I))
        rel_proto = src.startswith("//")
        full_url = bool(re.match(r'^https?://', src, re.I))
        rel_origin = not rel_proto and not full_url
        if rel_proto:
            same_2ld = True
        elif full_url:
            hm = re.match(r'^https?://([^/:]+)', src, re.I)
            same_2ld = bool(hm) and _registrable(hm.group(1)) == site_dom
        else:
            same_2ld = True
        secure_origin = rel_origin or (same_2ld and not rel_proto)
        scheme = None
        if not rel_proto and not rel_origin:
            sm = re.match(r'^([a-zA-Z]+):', src)
            scheme = (sm.group(1).lower() + ":") if sm else None
        secure_scheme = (scheme == "https:") or (rel_origin and page_https)
        if not secure_origin:
            foreign = True
            if integrity and not secure_scheme:
                worse("impl_not_secure")
            elif (not integrity) and secure_scheme:
                worse("notimpl_ext_secure")
            elif (not integrity) and (not secure_scheme):
                worse("notimpl_not_secure")
            # integrity AND secure_scheme on a foreign origin -> handled by fallback
        else:
            if integrity and secure_scheme and result[0] is None:
                result[0] = "all_secure"
    if n_src == 0:
        return ("info", 0, "No external scripts loaded (SRI not applicable).")
    if result[0] is None:
        if foreign:
            result[0] = "ext_secure"
        else:
            return ("pass", 0,
                    "All scripts load from the page's own origin (SRI not required).")
    detail = {
        "all_secure": "All scripts use Subresource Integrity over HTTPS.",
        "ext_secure": "All external scripts use Subresource Integrity over HTTPS.",
        "impl_not_secure": "SRI is set, but an external script is not loaded over HTTPS.",
        "notimpl_ext_secure": "External script(s) are loaded without Subresource Integrity.",
        "notimpl_not_secure": "External script(s) loaded without SRI and not over HTTPS.",
    }[result[0]]
    r = result[0]
    status = "pass" if r in ("all_secure", "ext_secure") else "warn"
    return (status, mod[r], detail)


def _cookies_result(cookie_lines, hsts_pass):
    """Reproduce the Mozilla v2 cookies classification exactly.
    Returns (status, modifier, detail)."""
    order = ["nosecure_hsts", "nosecure", "sess_nosecure_hsts", "samesite_invalid",
             "anticsrf_nosamesite", "sess_nohttponly", "sess_nosecure"]
    mod = {"nosecure_hsts": -5, "nosecure": -20, "sess_nosecure_hsts": -10,
           "samesite_invalid": -20, "anticsrf_nosamesite": -20,
           "sess_nohttponly": -30, "sess_nosecure": -40}
    result = [None]

    def worse(new):
        if result[0] is None or order.index(new) > order.index(result[0]):
            result[0] = new

    parsed, count = [], 0
    for line in cookie_lines or []:
        first = line.split(";")[0]
        key = first.split("=", 1)[0].strip()
        if not key or key.lower() == "heroku-session-affinity":
            continue
        count += 1
        attrs = [a.strip().lower() for a in line.split(";")[1:]]
        secure = "secure" in attrs
        httponly = "httponly" in attrs
        samesite = None
        samesite_present = False
        for a in attrs:
            if a == "samesite" or a.startswith("samesite="):
                samesite_present = True
                samesite = a.split("=", 1)[1].strip() if "=" in a else ""
        invalid_ss = samesite_present and (samesite == "" or
                                           samesite not in ("lax", "strict", "none"))
        parsed.append((key.lower(), secure, httponly, samesite, invalid_ss))

    if count == 0:
        return ("pass", 0, "No cookies are set.")

    has_missing_samesite = False
    for key_l, secure, httponly, samesite, invalid_ss in parsed:
        session_id = ("login" in key_l) or ("sess" in key_l)
        anticsrf = "csrf" in key_l
        if invalid_ss:
            worse("samesite_invalid")
        if (not secure) and samesite == "none":
            worse("samesite_invalid")
        if (not secure) and hsts_pass:
            worse("nosecure_hsts")
        elif not secure:
            worse("nosecure")
        if anticsrf and not samesite:
            worse("anticsrf_nosamesite")
        if session_id and (not secure) and hsts_pass:
            worse("sess_nosecure_hsts")
        elif session_id and (not secure):
            worse("sess_nosecure")
        if session_id and (not httponly):
            worse("sess_nohttponly")
        if not samesite:
            has_missing_samesite = True

    if result[0] is None:
        n = len(parsed)
        if has_missing_samesite:
            return ("pass", 0, "%d cookie(s): Secure + HttpOnly, but SameSite is missing." % n)
        return ("pass", 5, "%d cookie(s): Secure, HttpOnly and SameSite all set." % n)
    detail = {
        "nosecure_hsts": "Cookie without the Secure flag (mitigated by HSTS).",
        "nosecure": "Cookie set without the Secure flag.",
        "sess_nosecure_hsts": "Session cookie without Secure (mitigated by HSTS).",
        "samesite_invalid": "Cookie has an invalid or unsafe SameSite value.",
        "anticsrf_nosamesite": "Anti-CSRF cookie without a SameSite attribute.",
        "sess_nohttponly": "Session cookie without the HttpOnly flag.",
        "sess_nosecure": "Session cookie set without the Secure flag.",
    }[result[0]]
    return ("warn", mod[result[0]], detail)


def _redirection_result(host, timeout=10):
    """Reproduce the Mozilla v2 redirection test: fetch http://<host> and follow
    the redirect chain. Returns (status, modifier, detail)."""
    start = "http://" + host
    rd_headers = {"User-Agent": USER_AGENT}
    if safe_get is not None:
        # Only the redirect chain is scored — no body needed. A None result
        # (off-host/non-public hop, transport error) collapses onto the existing
        # "no HTTP listener to redirect" case, exactly as an exception would.
        fr = safe_get(start, timeout=timeout, headers=rd_headers, read_body=False)
        if fr is None:
            return ("info", 0, "No HTTP listener to redirect (not scored).")
        chain = [h.get("url") for h in fr.history] + [fr.url]
    else:
        try:
            r = requests.get(start, timeout=timeout, allow_redirects=True,
                             headers=rd_headers)
        except Exception:
            return ("info", 0, "No HTTP listener to redirect (not scored).")
        chain = [h.url for h in r.history] + [r.url]

    def sch(u):
        m = re.match(r'^([a-zA-Z]+):', u or "")
        return m.group(1).lower() if m else ""

    def hst(u):
        m = re.match(r'^[a-zA-Z]+://([^/:]+)', u or "")
        return m.group(1).lower() if m else ""

    if len(chain) == 1:                       # stayed on http, no redirect
        return ("warn", -20, "http:// is not redirected to https://.")
    if sch(chain[-1]) != "https":             # final hop not https
        return ("warn", -20, "The redirect target is not HTTPS.")
    if sch(chain[1]) == "http":               # http -> http first
        return ("warn", -10,
                "http first redirects to another http URL instead of straight to https.")
    if sch(chain[0]) == "http" and sch(chain[1]) == "https" \
            and hst(chain[0]) != hst(chain[1]):
        return ("warn", -5,
                "http redirects off-host (e.g. apex→www) before reaching https.")
    return ("pass", 0, "http redirects straight to https.")


def _cors_result(url, timeout=10):
    """Reproduce the Mozilla v2 CORS test: request with an Origin header and
    inspect Access-Control-Allow-Origin / -Credentials. Only the reflect-origin-
    plus-credentials case scores (-50); '*' and restricted access are 0."""
    test_origin = "https://http-observatory.security.mozilla.org"
    cors_headers = {"User-Agent": USER_AGENT, "Origin": test_origin}
    if safe_get is not None:
        # Only the response headers are inspected — no body needed.
        fr = safe_get(url, timeout=timeout, headers=cors_headers, read_body=False)
        if fr is None:
            return ("info", 0, "CORS could not be tested (not scored).")
        resp_headers = fr.headers
    else:
        try:
            r = requests.get(url, timeout=timeout, allow_redirects=True,
                             headers=cors_headers)
        except Exception:
            return ("info", 0, "CORS could not be tested (not scored).")
        resp_headers = r.headers
    acao = (resp_headers.get("Access-Control-Allow-Origin") or "").strip()
    acac = (resp_headers.get("Access-Control-Allow-Credentials") or "").strip().lower()
    if not acao:
        return ("pass", 0, "No CORS headers — resources are same-origin by default.")
    if acao == "*":
        return ("info", 0, "Access-Control-Allow-Origin: * (public read access; not penalized).")
    if acao == test_origin and acac == "true":
        return ("warn", -50,
                "Reflects the request Origin AND allows credentials — any site can read "
                "authenticated responses.")
    return ("pass", 0, "CORS is restricted to specific origins.")


def grade_for(score: int):
    """Map a score to (grade, color). Bonuses may push the score above 100 (A+)."""
    score = max(0, score)
    for threshold, grade, color in _GRADE_BANDS:
        if score >= threshold:
            return grade, color
    return "F", "#ef4444"


def _band_color(letter):
    """Return the hex color for a grade letter from _GRADE_BANDS."""
    for _threshold, grade, color in _GRADE_BANDS:
        if grade == letter:
            return color
    return "#ef4444"


def _cookie_lines(resp):
    """Return the raw Set-Cookie header lines from the final response."""
    try:
        lines = resp.raw.headers.getlist("Set-Cookie")
        if lines:
            return list(lines)
    except Exception:
        pass
    single = resp.headers.get("Set-Cookie")
    return [single] if single else []


def analyze(resp):
    """
    Inspect the final response and return a dict:
      score, grade, color, checks[], extras[], raw_headers[], history[],
      final_url, status_code, is_https
    Each check: {name, status, value, detail, advice}.
    """
    headers = resp.headers
    final_url = resp.url or ""
    is_https = final_url.lower().startswith("https://")
    checks = []
    score = 100
    bonus = 0  # Mozilla rule: bonuses are only added if the base score is >= 90
    hsts_pass = False  # HSTS present with a sufficient max-age (used by the cookie test)

    def add(name, status, value, detail, advice=""):
        checks.append({
            "name": name, "status": status, "value": value,
            "detail": detail, "advice": advice,
        })

    # --- HTTPS ---------------------------------------------------------------
    if not is_https:
        score -= 30
        add("HTTPS", "fail", final_url,
            "The final URL is not served over HTTPS.",
            "Serve the site over HTTPS and redirect all HTTP traffic to it.")
    else:
        add("HTTPS", "pass", final_url, "Served over HTTPS.")

    # --- Redirection (Mozilla v2: http:// must redirect straight to https) ---
    _orig = (resp.history[0].url if resp.history else final_url)
    _orig_host = re.sub(r"^https?://", "", _orig, flags=re.I).split("/")[0].split(":")[0]
    rd_status, rd_mod, rd_detail = _redirection_result(_orig_host)
    if rd_mod < 0:
        score += rd_mod
    add("Redirection", rd_status, None, rd_detail,
        "Redirect http:// straight to the https:// version of the same host."
        if rd_mod < 0 else "")

    # --- Strict-Transport-Security (HSTS) ------------------------------------
    hsts = headers.get("Strict-Transport-Security")
    if not hsts:
        score -= 20
        add("Strict-Transport-Security", "fail", None,
            "Missing — browsers are not forced to use HTTPS.",
            "Set: max-age=31536000; includeSubDomains; preload")
    else:
        m = re.search(r"max-age\s*=\s*(\d+)", hsts, re.I)
        max_age = int(m.group(1)) if m else 0
        has_sub = "includesubdomains" in hsts.lower()
        has_preload = "preload" in hsts.lower()
        if max_age < _HSTS_MIN:
            score -= 10
            add("Strict-Transport-Security", "warn", hsts,
                f"Present, but max-age={max_age}s is below 6 months.",
                "Raise max-age to at least 15768000 (6 months); 31536000+ recommended.")
        else:
            hsts_pass = True
            notes = []
            if not has_sub:
                notes.append("includeSubDomains recommended")
            _host = re.sub(r"^https?://", "", final_url, flags=re.I).split("/")[0].split(":")[0]
            if _hsts_preloaded(_host):
                bonus += 5          # Mozilla v2: hsts-preloaded (from the real preload list) +5
            elif has_preload:
                notes.append("preload directive set, but the domain is not on the preload list yet")
            else:
                notes.append("preload eligible")
            add("Strict-Transport-Security", "pass", hsts,
                "Present with a sufficient max-age."
                + (f" ({'; '.join(notes)})" if notes else ""))

    # --- Content-Security-Policy ---------------------------------------------
    csp = headers.get("Content-Security-Policy")
    csp_ro = headers.get("Content-Security-Policy-Report-Only")
    csp_script_unsafe = False  # True when the policy fails to protect scripts -> caps grade at B
    if not csp:
        if csp_ro:
            score -= 20
            add("Content-Security-Policy", "warn", csp_ro,
                "Only a Report-Only policy is set — it is not enforced.",
                "Move the policy to Content-Security-Policy once validated.")
        else:
            score -= 25
            add("Content-Security-Policy", "fail", None,
                "Missing — no resource-level protection against XSS/injection.",
                "Define a restrictive policy, e.g. default-src 'self'.")
    else:
        # Parse the policy into directive -> value. Only the directive that
        # governs *script execution* matters for unsafe-inline/unsafe-eval:
        # script-src, or default-src when script-src is absent. unsafe-inline
        # in style-src is common and low-risk, so it is noted but not penalized
        # — this mirrors how the Mozilla Observatory scores CSP.
        directives = {}
        for part in csp.split(";"):
            tokens = part.split()
            if tokens:
                directives[tokens[0].lower()] = " ".join(tokens[1:]).lower()

        has_script_constraint = "script-src" in directives or "default-src" in directives
        script_val = directives.get("script-src", directives.get("default-src", ""))
        style_val = directives.get("style-src", "")

        script_inline = "'unsafe-inline'" in script_val
        script_eval = "'unsafe-eval'" in script_val
        # A policy that permits inline/eval scripts — or leaves scripts
        # unconstrained — offers essentially no XSS protection, its main
        # purpose. It is weighted heavily and caps the overall grade at B.
        csp_script_unsafe = script_inline or script_eval or not has_script_constraint

        # Mozilla v2 assigns ONE expectation per test (the single worst), not a sum:
        # a policy with both unsafe-inline and unsafe-eval scores -20, not -30.
        weak = []
        penalty = 0
        if not has_script_constraint:
            weak.append("no default-src/script-src to constrain scripts")
            penalty = 20
        elif script_inline:
            weak.append("'unsafe-inline' in script-src")
            penalty = 20
            if script_eval:
                weak.append("also 'unsafe-eval' (not added; v2 counts the single worst)")
        elif script_eval:
            weak.append("'unsafe-eval' in script-src")
            penalty = 10

        style_note = ""
        if "'unsafe-inline'" in style_val:
            style_note = " ('unsafe-inline' in style-src is low-risk and not penalized)"

        if weak:
            score -= penalty
            add("Content-Security-Policy", "warn", csp,
                "Present but effectively unsafe for script execution: "
                + ", ".join(weak)
                + ". Allowing inline/eval scripts gives little real XSS protection,"
                + " so this caps the grade at B.",
                "Remove unsafe-inline/unsafe-eval from script-src; use nonces or hashes.")
        else:
            # Mozilla v2: the "no-unsafe" bonus requires NO unsafe-inline/unsafe-eval
            # ANYWHERE (incl. style-src). unsafe-inline only in style-src is neutral (0).
            csp_lower = csp.lower()
            if "'unsafe-inline'" not in csp_lower and "'unsafe-eval'" not in csp_lower:
                if directives.get("default-src", "").strip() == "'none'":
                    bonus += 10     # csp-implemented-with-no-unsafe-default-src-none +10
                else:
                    bonus += 5      # csp-implemented-with-no-unsafe +5
            add("Content-Security-Policy", "pass", csp,
                "Present and constrains script execution without unsafe-inline/unsafe-eval."
                + style_note)

    # --- X-Frame-Options / frame-ancestors -----------------------------------
    xfo = headers.get("X-Frame-Options")
    frame_ancestors = bool(csp and "frame-ancestors" in csp.lower())
    if not xfo and not frame_ancestors:
        score -= 20
        add("X-Frame-Options", "fail", None,
            "Missing — the page can be framed by other origins (clickjacking).",
            "Set X-Frame-Options: DENY or SAMEORIGIN (or CSP frame-ancestors 'none').")
    elif not xfo and frame_ancestors:
        bonus += 5              # Mozilla: x-frame-options-implemented-via-csp +5
        add("X-Frame-Options", "pass", "(covered by CSP frame-ancestors)",
            "Clickjacking is mitigated via CSP frame-ancestors.")
    else:
        val = xfo.strip().upper()
        if val in ("DENY", "SAMEORIGIN"):
            bonus += 5      # Mozilla v2: x-frame-options-sameorigin-or-deny +5
            add("X-Frame-Options", "pass", xfo, "Clickjacking protection is active.")
        else:
            score -= 5
            add("X-Frame-Options", "warn", xfo,
                "Unusual/deprecated value (ALLOW-FROM is obsolete).",
                "Use DENY or SAMEORIGIN.")

    # --- X-Content-Type-Options ----------------------------------------------
    xcto = headers.get("X-Content-Type-Options")
    if not xcto:
        score -= 5
        add("X-Content-Type-Options", "fail", None,
            "Missing — MIME-type sniffing is possible.",
            "Set X-Content-Type-Options: nosniff.")
    elif xcto.strip().lower() != "nosniff":
        score -= 5
        add("X-Content-Type-Options", "warn", xcto,
            "Present but not set to 'nosniff'.",
            "Set the value exactly to nosniff.")
    else:
        add("X-Content-Type-Options", "pass", xcto, "MIME-type sniffing is disabled.")

    # --- Referrer-Policy ------------------------------------------------------
    # Mozilla does NOT penalize a missing Referrer-Policy (modifier 0); it only
    # penalizes an unsafe value and awards a +5 bonus for a "private" value.
    _RP_PRIVATE = {"no-referrer", "same-origin", "strict-origin",
                   "strict-origin-when-cross-origin"}
    rp = headers.get("Referrer-Policy")
    if not rp:
        add("Referrer-Policy", "info", None,
            "Not set — not penalized (Mozilla treats this as neutral), but recommended.",
            "Set Referrer-Policy: strict-origin-when-cross-origin.")
    else:
        vals = [v.strip().lower() for v in rp.split(",") if v.strip()]
        if any(v == "unsafe-url" for v in vals) or not vals:
            score -= 5
            add("Referrer-Policy", "warn", rp,
                "Present but uses a weak value (unsafe-url).",
                "Use strict-origin-when-cross-origin.")
        else:
            if vals and vals[-1] in _RP_PRIVATE:
                bonus += 5      # Mozilla: referrer-policy-private +5
            add("Referrer-Policy", "pass", rp, "Referrer behaviour is controlled.")

    # --- Permissions-Policy ---------------------------------------------------
    # Mozilla's Observatory does not score Permissions-Policy, so it does not
    # affect the grade here either — kept purely as advisory information.
    pp = headers.get("Permissions-Policy")
    fp = headers.get("Feature-Policy")
    if pp:
        add("Permissions-Policy", "pass", pp, "Browser feature access is restricted.")
    elif fp:
        add("Permissions-Policy", "info", fp,
            "Only the deprecated Feature-Policy is set (not scored, advisory).",
            "Migrate to Permissions-Policy.")
    else:
        add("Permissions-Policy", "info", None,
            "Not set — not scored (advisory), but recommended.",
            "Define a policy, e.g. geolocation=(), camera=(), microphone=().")

    # --- Cross-Origin-Resource-Policy (Mozilla v2: same-origin/same-site +10) -
    corp = headers.get("Cross-Origin-Resource-Policy")
    if corp:
        cv = corp.strip().lower()
        if cv in ("same-origin", "same-site"):
            bonus += 10     # Mozilla v2: cross-origin-resource-policy same-origin/same-site +10
            add("Cross-Origin-Resource-Policy", "pass", corp,
                "Resources are protected against cross-origin loads.")
        elif cv == "cross-origin":
            add("Cross-Origin-Resource-Policy", "info", corp,
                "Set to 'cross-origin' (permissive; no bonus).")
        else:
            score -= 5      # Mozilla v2: cross-origin-resource-policy-header-invalid -5
            add("Cross-Origin-Resource-Policy", "warn", corp,
                "Invalid value.", "Use 'same-origin' or 'same-site'.")

    # --- Cookies (Mozilla v2 classification) ---------------------------------
    cookie_lines = _cookie_lines(resp)
    ck_status, ck_mod, ck_detail = _cookies_result(cookie_lines, hsts_pass)
    if ck_mod > 0:
        bonus += ck_mod
    elif ck_mod < 0:
        score += ck_mod
    add("Cookies", ck_status,
        "; ".join(l.split(";")[0] for l in cookie_lines) or None, ck_detail,
        "Set Secure, HttpOnly and SameSite on all cookies (session cookies need Secure + HttpOnly)."
        if ck_mod < 0 else "")

    # --- Subresource Integrity (Mozilla v2 classification) -------------------
    ct = (headers.get("Content-Type") or "").split(";")[0].strip().lower()
    is_html = (not ct) or (ct in ("text/html", "application/xhtml+xml"))
    try:
        body = resp.text or ""
    except Exception:
        body = ""
    sri_host = re.sub(r"^https?://", "", final_url, flags=re.I).split("/")[0].split(":")[0]
    sri_status, sri_mod, sri_detail = _sri_result(body, sri_host, is_https, is_html)
    if sri_mod > 0:
        bonus += sri_mod
    elif sri_mod < 0:
        score += sri_mod
    add("Subresource Integrity", sri_status, None, sri_detail,
        "Add integrity=\"sha384-…\" + crossorigin to external <script> tags and load them over HTTPS."
        if sri_mod < 0 else "")

    # --- Cross-Origin Resource Sharing (Mozilla v2) --------------------------
    cors_status, cors_mod, cors_detail = _cors_result(final_url)
    if cors_mod < 0:
        score += cors_mod
    add("Cross-Origin Resource Sharing", cors_status, None, cors_detail,
        "Do not reflect the request Origin together with Access-Control-Allow-Credentials: true."
        if cors_mod < 0 else "")

    # --- Information disclosure -----------------------------------------------
    leaks = []
    server = headers.get("Server")
    if server and re.search(r"\d", server):
        leaks.append(("Server", server))
    for name in ("X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version", "X-Generator"):
        val = headers.get(name)
        if val:
            leaks.append((name, val))
    if leaks:
        add("Information Disclosure", "info",
            "; ".join(f"{k}: {v}" for k, v in leaks),
            "Server/framework version info is exposed (not scored, advisory).",
            "Strip version details from Server / X-Powered-By or suppress the headers.")
    else:
        add("Information Disclosure", "pass", None,
            "No obvious server/framework version leaks.")

    # --- Additional (informational) cross-origin isolation headers -----------
    extras = []
    for name in ("Cross-Origin-Opener-Policy",
                 "Cross-Origin-Embedder-Policy"):
        val = headers.get(name)
        extras.append((name, val))

    raw_headers = list(resp.headers.items())
    history = [(r.status_code, r.url) for r in resp.history]

    # Mozilla rule: bonus points are only awarded if the base score is already
    # an A (>= 90). Total is capped at 135, floored at 0.
    if score >= 90:
        score += bonus
    score = max(0, min(135, score))
    grade, color = grade_for(score)
    # Deliberate deviation from Mozilla (explicit product decision): a CSP that
    # fails to protect scripts caps the grade at B, regardless of the numeric score.
    if csp_script_unsafe and grade in ("A+", "A", "A-", "B+"):
        grade, color = "B", _band_color("B")

    return {
        "score": score,
        "grade": grade,
        "color": color,
        "checks": checks,
        "extras": extras,
        "raw_headers": raw_headers,
        "history": history,
        "final_url": final_url,
        "status_code": resp.status_code,
        "is_https": is_https,
    }


# --------------------------------------------------------------------------- #
# HTML rendering
# --------------------------------------------------------------------------- #

def _pill(status):
    label = _STATUS_LABEL.get(status, status)
    return f'<span class="pill pill-{html.escape(status)}">{html.escape(label)}</span>'


def _check_card(check):
    name = html.escape(check["name"])
    status = check["status"]
    value = check.get("value")
    detail = html.escape(check.get("detail") or "")
    advice = check.get("advice") or ""

    value_html = ""
    if value:
        value_html = f'<div class="check-value"><code>{html.escape(str(value))}</code></div>'
    advice_html = ""
    if advice:
        advice_html = f'<p class="check-advice"><strong>Fix:</strong> {html.escape(advice)}</p>'

    return f"""
        <div class="check check-{html.escape(status)}">
            <div class="check-head">
                <span class="check-name">{name}</span>
                {_pill(status)}
            </div>
            {value_html}
            <p class="check-detail">{detail}</p>
            {advice_html}
        </div>"""


def build_html_page(url: str, resp=None, analysis=None, error_message=None):
    escaped_url = html.escape(url or "", quote=True)

    error_block = ""
    if error_message:
        error_block = f"""
        <div class="card error-card">
            <h2>Error</h2>
            <p>{html.escape(error_message)}</p>
        </div>"""

    results_block = ""
    if analysis is not None:
        grade = html.escape(analysis["grade"])
        color = analysis["color"]
        score = analysis["score"]
        checks = analysis["checks"]
        n_pass = sum(1 for c in checks if c["status"] == "pass")
        n_warn = sum(1 for c in checks if c["status"] == "warn")
        n_fail = sum(1 for c in checks if c["status"] == "fail")

        checks_html = "".join(_check_card(c) for c in checks)

        not_set = "<span class='empty-state'>not set</span>"
        extras_rows = "".join(
            f"<tr><td class='prop-cell'>{html.escape(name)}</td>"
            f"<td class='content-cell'>{html.escape(val) if val else not_set}</td></tr>"
            for name, val in analysis["extras"]
        )

        raw_rows = "".join(
            f"<tr><td class='prop-cell'>{html.escape(k)}</td>"
            f"<td class='content-cell'>{html.escape(v)}</td></tr>"
            for k, v in analysis["raw_headers"]
        )

        history_html = ""
        if analysis["history"]:
            hops = "".join(
                f"<li><code>{code}</code> &rarr; {html.escape(hop_url)}</li>"
                for code, hop_url in analysis["history"]
            )
            history_html = f"""
        <div class="card">
            <h2>Redirect Chain</h2>
            <ul class="hop-list">
                {hops}
                <li><code>{analysis['status_code']}</code> &rarr; <strong>{html.escape(analysis['final_url'])}</strong> (final)</li>
            </ul>
        </div>"""

        results_block = f"""
        <div class="card summary-card">
            <div class="grade-badge" style="--grade-color: {color};">{grade}</div>
            <div class="summary-meta">
                <div class="score-line">Score <strong>{score}</strong>/100</div>
                <div class="count-line">
                    <span class="count count-pass">{n_pass} present</span>
                    <span class="count count-warn">{n_warn} weak</span>
                    <span class="count count-fail">{n_fail} missing</span>
                </div>
                <dl class="summary-kv">
                    <dt>Final URL</dt>
                    <dd><a href="{html.escape(analysis['final_url'], quote=True)}" target="_blank" rel="noopener">{html.escape(analysis['final_url'])}</a></dd>
                    <dt>HTTP Status</dt>
                    <dd><code>{analysis['status_code']}</code></dd>
                </dl>
            </div>
        </div>

        <div class="card">
            <h2>Security Header Checks</h2>
            <div class="checks-grid">
                {checks_html}
            </div>
        </div>

        {history_html}

        <div class="card">
            <h2>Cross-Origin Isolation (informational)</h2>
            <table>
                <thead><tr><th>Header</th><th>Value</th></tr></thead>
                <tbody>{extras_rows}</tbody>
            </table>
        </div>

        <div class="card">
            <h2>All Response Headers</h2>
            <table>
                <thead><tr><th>Header</th><th>Value</th></tr></thead>
                <tbody>{raw_rows or "<tr><td colspan='2' class='empty-state'>No headers.</td></tr>"}</tbody>
            </table>
        </div>

        <p style="text-align:center;margin-top:1.75rem;font-size:0.85rem;color:#94a3b8">Diese Header professionell aufsetzen lassen? <a href="https://jozapf.de" target="_blank" rel="noopener" style="color:#3b82f6;text-decoration:none;font-weight:500">jozapf.de</a></p>"""

    page = "Content-Type: text/html; charset=utf-8\n\n"
    page += f"""<!DOCTYPE html>
<!-- jozapf.de toolbox · rev jzt-7c3f9a2e -->
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Jo Zapf Toolbox - Security-Headers-Web</title>
    <link rel="preconnect" href="https://assets.jozapf.de" crossorigin>
    <link rel="stylesheet" href="https://assets.jozapf.de/css/fonts.css">
    <style>
        :root {{
            --bg-primary: #0f172a;
            --bg-secondary: #1e293b;
            --bg-tertiary: #334155;
            --text-primary: #f1f5f9;
            --text-secondary: #94a3b8;
            --accent: #3b82f6;
            --accent-secondary: #8b5cf6;
            --border: #334155;
            --code-bg: #0f172a;
            --success: #10b981;
            --warn: #f59e0b;
            --error: #ef4444;
        }}
        * {{ box-sizing: border-box; margin: 0; padding: 0; }}
        html {{ font-size: 16px; scroll-behavior: smooth; }}
        body {{
            font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: var(--bg-primary);
            color: var(--text-primary);
            line-height: 1.6;
            min-height: 100vh;
            padding: 2rem;
        }}
        .container {{ max-width: 1100px; margin: 0 auto; }}
        h1 {{
            font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem;
            background: linear-gradient(135deg, var(--accent) 0%, var(--accent-secondary) 100%);
            -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
        }}
        .subtitle {{ color: var(--text-secondary); font-size: 1rem; margin-bottom: 2rem; }}
        h2 {{ font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }}
        .card {{
            background: var(--bg-secondary); border: 1px solid var(--border);
            border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem;
        }}
        .error-card {{ border-left: 4px solid var(--error); }}
        .error-card h2 {{ color: var(--error); }}

        /* Form */
        form {{ display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }}
        input[type="text"] {{
            flex: 1; min-width: 280px; padding: 0.75rem 1rem; border-radius: 8px;
            border: 1px solid var(--border); background: var(--bg-primary);
            color: var(--text-primary); font-size: 1rem; font-family: inherit;
        }}
        input[type="text"]::placeholder {{ color: var(--text-secondary); }}
        input[type="text"]:focus {{
            outline: none; border-color: var(--accent);
            box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
        }}
        button {{
            padding: 0.75rem 1.5rem; border-radius: 8px; border: none; cursor: pointer;
            background: var(--accent); color: #fff; font-size: 1rem; font-weight: 600;
            font-family: inherit; transition: background 0.2s, transform 0.1s;
        }}
        button:hover {{ background: #2563eb; }}
        button:active {{ transform: scale(0.98); }}
        .form-hint {{ width: 100%; font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.5rem; }}

        /* Summary + grade */
        .summary-card {{ display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; }}
        .grade-badge {{
            flex: 0 0 auto; width: 110px; height: 110px; border-radius: 16px;
            display: flex; align-items: center; justify-content: center;
            font-size: 3.5rem; font-weight: 700; color: #fff;
            background: var(--grade-color);
            box-shadow: 0 8px 24px color-mix(in srgb, var(--grade-color) 40%, transparent);
        }}
        .summary-meta {{ flex: 1; min-width: 260px; }}
        .score-line {{ font-size: 1.1rem; color: var(--text-secondary); }}
        .score-line strong {{ color: var(--text-primary); font-size: 1.4rem; }}
        .count-line {{ display: flex; gap: 0.75rem; flex-wrap: wrap; margin: 0.5rem 0 0.75rem; }}
        .count {{ font-size: 0.85rem; font-weight: 600; padding: 0.2em 0.6em; border-radius: 6px; }}
        .count-pass {{ background: rgba(16,185,129,0.15); color: var(--success); }}
        .count-warn {{ background: rgba(245,158,11,0.15); color: var(--warn); }}
        .count-fail {{ background: rgba(239,68,68,0.15); color: var(--error); }}
        .summary-kv dt {{ font-weight: 600; color: var(--text-secondary); margin-top: 0.5rem; font-size: 0.85rem; }}
        .summary-kv dd {{ margin: 0.1rem 0 0; word-break: break-all; }}
        .summary-kv a {{ color: var(--accent); text-decoration: none; }}
        .summary-kv a:hover {{ text-decoration: underline; }}

        /* Checks */
        .checks-grid {{ display: grid; gap: 1rem; }}
        .check {{
            background: var(--bg-primary); border: 1px solid var(--border);
            border-left-width: 4px; border-radius: 8px; padding: 1rem 1.25rem;
        }}
        .check-pass {{ border-left-color: var(--success); }}
        .check-warn {{ border-left-color: var(--warn); }}
        .check-fail {{ border-left-color: var(--error); }}
        .check-info {{ border-left-color: var(--accent); }}
        .check-head {{ display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.4rem; }}
        .check-name {{ font-weight: 600; font-size: 1rem; }}
        .pill {{ font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.2em 0.6em; border-radius: 999px; }}
        .pill-pass {{ background: rgba(16,185,129,0.15); color: var(--success); }}
        .pill-warn {{ background: rgba(245,158,11,0.15); color: var(--warn); }}
        .pill-fail {{ background: rgba(239,68,68,0.15); color: var(--error); }}
        .pill-info {{ background: rgba(59,130,246,0.15); color: var(--accent); }}
        .check-value {{ margin: 0.35rem 0; }}
        .check-detail {{ color: var(--text-secondary); font-size: 0.9rem; }}
        .check-advice {{ color: var(--text-primary); font-size: 0.9rem; margin-top: 0.4rem; }}
        .check-advice strong {{ color: var(--accent); }}

        /* Tables */
        table {{ width: 100%; border-collapse: collapse; font-size: 0.9rem; }}
        th, td {{ border-bottom: 1px solid var(--border); padding: 0.6rem 1rem; vertical-align: top; text-align: left; }}
        th {{ background: var(--bg-primary); font-weight: 600; }}
        td {{ color: var(--text-secondary); }}
        .prop-cell {{ width: 280px; color: var(--accent); font-weight: 500; }}
        .content-cell {{ word-break: break-word; }}

        /* Misc */
        code {{
            font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
            font-size: 0.85em; background: var(--code-bg); color: #e879f9;
            padding: 0.15em 0.4em; border-radius: 4px; word-break: break-all;
        }}
        .hop-list {{ list-style: none; font-size: 0.9rem; }}
        .hop-list li {{ padding: 0.3rem 0; border-bottom: 1px solid var(--border); word-break: break-all; }}
        .hop-list li:last-child {{ border-bottom: none; }}
        .empty-state {{ color: var(--text-secondary); font-style: italic; }}

        @media (max-width: 768px) {{
            body {{ padding: 1rem; }}
            h1 {{ font-size: 1.5rem; }}
            .card {{ padding: 1rem; }}
            .prop-cell {{ width: 140px; }}
            th, td {{ padding: 0.5rem 0.75rem; }}
            .grade-badge {{ width: 88px; height: 88px; font-size: 2.75rem; }}
        }}
    </style>
</head>
<body>
    <div class="container">
        <h1>toolbox.jozapf.de | Security-Headers-Web v1.1.0</h1>
        <p class="subtitle">Analyze HTTP security headers and grade them A+ to F</p>

        <div class="card">
            <h2>Analyze URL</h2>
            <form method="get" action="">
                <input type="text" name="url" value="{escaped_url}" placeholder="https://example.com">
                <button type="submit">Analyze</button>
            </form>
            <p class="form-hint">
                Fetches the page server-side (following redirects) and inspects HSTS, CSP,
                X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy,
                cookies and information disclosure. <strong>No files are stored.</strong>
            </p>
        </div>

        {error_block}
        {results_block}
    </div>
</body>
</html>
"""
    return page


def main():
    params = parse_qs(os.environ.get("QUERY_STRING", ""))
    url = params.get("url", [""])[0].strip()

    if not url:
        sys.stdout.write(build_html_page(url=""))
        return

    # Guard runs only on the executing path (this does outbound HTTP). On a
    # blocked request the guard has already written the full response.
    if not _guard("security-headers"):
        return

    # Default to https:// when the user omits the scheme.
    if not re.match(r"^https?://", url, re.I):
        url = "https://" + url

    try:
        resp = fetch_url(url)
    except Exception as e:
        sys.stdout.write(build_html_page(url=url, error_message=f"Error fetching URL: {e}"))
        return

    try:
        analysis = analyze(resp)
        sys.stdout.write(build_html_page(url=url, resp=resp, analysis=analysis))
    except Exception:
        sys.stdout.write("Content-Type: text/plain; charset=utf-8\n\n")
        sys.stdout.write("Error during header analysis:\n")
        traceback.print_exc(file=sys.stdout)


if __name__ == "__main__":
    main()