← zurück zur Toolbox
security_headers_web_v1.py · Quelltext
49146 Bytes · SHA-256: 87fdaedac7fe14af385f492e4bb51b517ffcf1fff4fa702cb6f376dfaf0e7d7f
#!/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> → {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> → <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()