Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
name: http-security-headers-checker description: Reviews HTTP response security headers from pasted curl -I/-IL/-v output, a HAR export or a headers file - HSTS, Content-Security-Policy, frame-ancestors/X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, COOP, Set-Cookie flags and __Host- prefixes, CORS origin reflection, version leaks and deprecated headers. Ranks findings and returns a corrected header set. Includes an offline stdlib Python checker with --self-test.
HTTP Security Headers Checker
You review the security headers a site actually sends, the way a careful web security reviewer would before a launch or a pen test. You work from captured responses (curl, DevTools HAR), explain what each missing or weak header lets an attacker do in plain words, and hand back exact header lines the user can paste into nginx, Apache, a CDN rule or their framework. You never claim to have fetched a URL yourself; you only judge what was captured.
Files in this skill
scripts/sec_headers.py- parses curl-I,-IL(redirect chains),-voutput and HAR files, classifies each response (html, script, style, api, asset, redirect) and reports findings per response with a fix (Python 3.8+ standard library only)references/header-rules.md- what each header does, the safe values, which response types need it, and rollout traps (HSTS includeSubDomains, CSP report-only first, CORS allowlists)references/finding-codes.md- every finding code with severity, when it is acceptable, and the usual fixtemplates/security-headers-review.md- the review to hand back (summary, fix table, header block per server, rollout)examples/shop-headers.txt- a shop's HTTP redirect, home page, JS bundle and a CORS test against its API, with mistakes on purposeexamples/shop-headers-fixed.txt- the same responses after the fixes (checks clean)examples/header-report.md- the real output of the checker on the broken example
Workflow
1. Collect the responses
Ask for the URLs that matter: the HTTP and HTTPS home page, a logged-in page, one JS/CSS asset, an API endpoint that returns user data, and the login response (it sets the session cookie). Give the user the commands:
curl -sIL http://example.com/ # redirect chain + final page
curl -sI https://example.com/static/app.js # an asset
curl -sv -o /dev/null -H "Origin: https://evil.example" https://api.example.com/me 2>&1 # CORS test
Put # https://url on the line above each block, or export a HAR from DevTools (Network tab, "Save all as HAR") - tell the user a HAR contains cookies and tokens and should be trimmed or rotated after sharing. Some servers answer HEAD differently from GET; if headers look missing, ask for curl -s -D - -o /dev/null URL.
2. Run the checker
python3 scripts/sec_headers.py examples/shop-headers.txt --origin https://evil.example
python3 scripts/sec_headers.py site.har --min-severity MEDIUM --json
python3 scripts/sec_headers.py headers.txt --url https://example.com/account --ignore COOP_MISSING
python3 scripts/sec_headers.py --self-test
Pass --origin with the untrusted Origin used in the CORS test so an echoed value is reported as reflection. Exit 0 = no HIGH, 1 = HIGH findings, 2 = bad input. If you cannot run code, walk through references/header-rules.md by hand in the same order.
3. Judge the findings
- HIGH: exploitable or broken now - no HSTS on HTML, no HTTP to HTTPS redirect, scripts allowed from any https: host, session cookies without Secure, rejected
__Host-cookies, CORS that reflects any Origin with credentials. - MEDIUM: real weakness - no or report-only CSP,
'unsafe-inline'/'unsafe-eval', no clickjacking protection orALLOW-FROM, short HSTS, missing nosniff on scripts and APIs, full-URL referrers, JS-readable auth cookies. - LOW: hardening and hygiene - Permissions-Policy, COOP, object-src/base-uri, SameSite not explicit, version banners, deprecated X-XSS-Protection/Expect-CT.
- Check context before you escalate: a public, cookie-free JSON API may use
Access-Control-Allow-Origin: *; a page that must be embedded by a partner needsframe-ancestorswith that origin, not DENY; a double-submit CSRF cookie has to stay readable from JavaScript.
4. Deliver
Fill templates/security-headers-review.md: a one-line verdict, the fix table in priority order, one header block per server type the user runs (nginx add_header ... always;, Apache Header always set, a CDN rule or framework middleware), and the rollout: CSP as Content-Security-Policy-Report-Only first, HSTS max-age raised in steps before includeSubDomains/preload, then retest with the same curl commands.
Example result
On examples/shop-headers.txt the checker reports 25 findings (HIGH 4, MEDIUM 6, LOW 15): HTTP redirects with a temporary 302, the page allows scripts from any https: host plus 'unsafe-inline', sessionid has no Secure flag, __Host-csrf carries a Domain attribute so browsers drop it, HSTS is only one day without includeSubDomains, X-Frame-Options: ALLOW-FROM gives no protection, the JS bundle lacks nosniff, and the API echoes https://evil.example with Allow-Credentials: true, so any site could read /v1/me. examples/shop-headers-fixed.txt checks clean (exit 0).
Rules
- Judge only captured headers; mark TLS setup,
<meta>CSP tags, the preload list and server-side Origin allowlists as "to verify". - Never suggest
includeSubDomainsorpreloadwithout asking whether every subdomain (mail, legacy, staging) serves HTTPS; preload is hard to undo. - Never hand over an enforcing CSP that was not tested: give the report-only version and the list of hosts it needs.
- Do not recommend removing a header that another system relies on (a partner iframe, a JS-read CSRF cookie) without saying what breaks.
- Treat pasted cookies, tokens and HAR files as secrets: do not repeat their values in the report beyond the cookie names.
- Write fixes as exact header lines for the user's server, and say where they go (every response, HTML only, API only). FILE:references/header-rules.md
Header rules
Order of review: transport (HTTPS, HSTS) -> script control (CSP) -> framing -> content sniffing -> leaks (Referrer, Permissions) -> cookies -> CORS -> disclosure.
Which responses need what
| Header | HTML pages | Scripts / CSS | JSON/XML APIs | Images, fonts | Redirects |
|---|---|---|---|---|---|
| Strict-Transport-Security | yes (HIGH if missing) | yes | yes | yes | yes, on HTTPS redirects |
| Content-Security-Policy | yes | no | optional (default-src 'none'; frame-ancestors 'none') |
no | no |
| frame-ancestors / X-Frame-Options | yes | no | no | no | no |
| X-Content-Type-Options: nosniff | yes | yes (MEDIUM) | yes (MEDIUM) | yes | no |
| Referrer-Policy, Permissions-Policy, COOP | yes | no | no | no | no |
Strict-Transport-Security (RFC 6797)
- Only valid over HTTPS; browsers ignore it on http:// responses. The HTTP site must answer with 301/308 to https.
max-agein seconds; 31536000 (1 year) is the normal target, at least 15552000 (180 days).max-age=0deletes the policy.includeSubDomainscovers every subdomain - check mail, legacy and intranet hosts first. Raise max-age in steps (300 -> 1 week -> 1 month -> 1 year).preload(hstspreload.org) needs max-age >= 31536000, includeSubDomains, and the header on the apex HTTPS response. Removal from the list takes months.
Content-Security-Policy (CSP Level 3)
- Script control comes from
script-src, falling back todefault-src. A policy with neither does not restrict scripts. - Dangerous sources in script-src:
*,https:,http:,data:,blob:- any of them lets an attacker load their own script. With'strict-dynamic'and a nonce, host lists andhttps:are ignored by modern browsers (they are fallbacks for old ones). 'unsafe-inline'is ignored when a nonce or hash is present; otherwise inline XSS is not blocked.'unsafe-eval'allowseval/new Function.- Always add
object-src 'none'andbase-uri 'self'(or 'none'); addform-action 'self'against form hijacking. - Several CSP headers are all enforced (a source must pass every policy).
Content-Security-Policy-Report-Onlyblocks nothing; use it withreport-toto collect violations for 1-2 weeks, then switch. - CSP in
<meta http-equiv>cannot setframe-ancestors,report-urior sandbox; the checker does not read HTML.
Framing (clickjacking)
Content-Security-Policy: frame-ancestors 'self'is the modern control;X-Frame-Options: SAMEORIGINorDENYfor old browsers. When both exist, browsers use frame-ancestors.X-Frame-Options: ALLOW-FROMis unsupported in current browsers (no protection). Two XFO values (or a comma list) can make browsers ignore the header.
X-Content-Type-Options
- Only valid value:
nosniff. Stops browsers running a response as script or style when its Content-Type says otherwise. Matters most for uploads, JSONP-like APIs and scripts.
Referrer-Policy
- Browser default today is
strict-origin-when-cross-origin; set it explicitly for older browsers.unsafe-urlandno-referrer-when-downgradeleak full paths and query strings (reset tokens, search terms) to other sites. In a comma list the last value the browser understands wins.
Permissions-Policy
- Syntax:
camera=(), microphone=(), geolocation=(self "https://maps.example"). The oldFeature-Policy: camera 'none'syntax is ignored by Permissions-Policy parsers.=*allows every origin.
Cross-Origin-Opener-Policy
same-originisolates the window from pages it opens or that open it (XS-leaks, tabnabbing). Usesame-origin-allow-popupsif login or payment pop-ups must talk back.
Cookies (RFC 6265bis)
Secureon every cookie of an HTTPS site;HttpOnlyon session/auth cookies; explicitSameSite=Lax(Strict for admin, None only with Secure for real cross-site needs).__Host-prefix: Secure,Path=/, no Domain, otherwise the browser rejects the cookie.__Secure-: Secure required.Domain=example.comshares the cookie with every subdomain (a compromised or user-content subdomain can read or overwrite it). Host-only (no Domain) is safer.- The checker treats names containing sess, sid, auth, token, jwt, login, remember, identity, account, csrf or xsrf as session-like; CSRF tokens are exempt from the HttpOnly rule.
CORS (Fetch standard)
Access-Control-Allow-Origin: *cannot be combined with credentials (browsers block it) - fine only for public, cookie-free data.- Reflecting the request Origin with
Access-Control-Allow-Credentials: truelets any website read logged-in responses. Test by sendingOrigin: https://evil.example; a correct server omits ACAO or returns its own allowlisted origin. - Never allowlist
null(sandboxed iframes, file://) or plain-HTTP origins for credentialed requests. Dynamic ACAO needsVary: Originso caches keep answers apart.
Disclosure and deprecated headers
Server: nginx/1.18.0,X-Powered-By,X-AspNet-Versionhelp attackers match CVEs (LOW, but free to fix:server_tokens off;,ServerTokens Prod,app.disable('x-powered-by')).- Remove
X-XSS-Protection: 1(or send0),Expect-CTandPublic-Key-Pins.
Where to set headers
- nginx:
add_header Name "value" always;- a location block with its own add_header drops all inherited ones; repeat them or use an include file. - Apache:
Header always set Name "value". Caddy:header { Name "value" }. CDN: response header transform rule. Express: helmet; Django: SecurityMiddleware settings; Rails:config.action_dispatch.default_headers. FILE:references/finding-codes.md
Finding codes
| Code | Sev | Problem | Acceptable when | Usual fix |
|---|---|---|---|---|
| HTTP_NO_HTTPS_REDIRECT | HIGH | http:// URL served without redirect to https | never for a public site | 301/308 to https |
| HSTS_MISSING | HIGH (HTML, redirects) / MEDIUM | no Strict-Transport-Security on HTTPS | local dev only | max-age=31536000; includeSubDomains |
| HSTS_INVALID | HIGH | no numeric max-age, header ignored | never | fix the syntax |
| CSP_WILDCARD_SCRIPT | HIGH | script-src allows *, https:, data: ... |
with nonce + 'strict-dynamic' (not flagged) | exact hosts or nonces |
| CSP_NO_SCRIPT_RESTRICTION | HIGH | CSP has no script-src/default-src | never | add default-src |
| COOKIE_NO_SECURE | HIGH (session) / MEDIUM | cookie sent over HTTP too | never on HTTPS sites | Secure |
| COOKIE_PREFIX_VIOLATION | HIGH | __Host-/__Secure- rules broken, cookie rejected |
never | Secure, Path=/, no Domain |
| COOKIE_SAMESITE_NONE_NO_SECURE | HIGH | SameSite=None without Secure, cookie rejected | never | add Secure |
| CORS_ORIGIN_REFLECTED | HIGH (credentials) / MEDIUM | server echoed the untrusted test Origin | never with credentials | exact allowlist |
| CORS_NULL_ORIGIN | HIGH (credentials) / MEDIUM | null origin allowed |
never | remove |
| CSP_MISSING | MEDIUM | HTML without CSP | static brochure page with no input (still LOW-risk) | report-only policy, then enforce |
| CSP_REPORT_ONLY | MEDIUM | only report-only policy | during a 1-2 week trial | enforce the same policy |
| CSP_UNSAFE_INLINE / CSP_UNSAFE_EVAL | MEDIUM | inline/eval scripts allowed | legacy app while migrating (track it) | nonces, hashes, no eval |
| CLICKJACKING_UNPROTECTED | MEDIUM | page can be framed by anyone | embeddable widgets (then list partners) | frame-ancestors 'self' |
| FRAME_ANCESTORS_WILDCARD | MEDIUM | frame-ancestors * |
public embed widget without actions | list partner origins |
| XFO_ALLOW_FROM / XFO_INVALID | MEDIUM | X-Frame-Options value ignored | never | frame-ancestors |
| HSTS_SHORT_MAX_AGE / HSTS_DISABLED | MEDIUM | max-age below 180 days / 0 | ramp-up or planned rollback | step up to 1 year |
| XCTO_MISSING | MEDIUM (scripts, styles, APIs) / LOW | no nosniff | never | nosniff |
| REFERRER_POLICY_UNSAFE | MEDIUM | full URL sent cross-site | never with tokens in URLs | strict-origin-when-cross-origin |
| COOKIE_NO_HTTPONLY | MEDIUM | session-like cookie readable by JS | the front end must read it (not CSRF, already exempt) | HttpOnly |
| CORS_WILDCARD_WITH_CREDENTIALS | MEDIUM | invalid combination | never | allowlist or no credentials |
| CORS_HTTP_ORIGIN_TRUSTED | MEDIUM | credentials shared with http:// origin | never | https origins only |
| HPKP_DEPRECATED | MEDIUM | Public-Key-Pins sent | never | remove |
| HSTS_NO_SUBDOMAINS / HSTS_PRELOAD_INCOMPLETE / HSTS_ON_HTTP | LOW | weaker or ignored HSTS | subdomains without HTTPS yet | fix subdomains, then add |
| HTTP_REDIRECT_TEMPORARY | LOW | 302/307 instead of 301/308 | short migrations | 301/308 |
| CSP_OBJECT_SRC / CSP_NO_BASE_URI | LOW | missing hardening directives | - | object-src 'none'; base-uri 'self' |
| REFERRER_POLICY_MISSING | LOW | browser default used | modern-browser audience | set explicitly |
| PERMISSIONS_POLICY_* / FEATURE_POLICY_ONLY | LOW | features not restricted or old syntax | - | camera=(), microphone=()... |
| COOP_MISSING | LOW | no opener isolation | pages that rely on window.opener | same-origin(-allow-popups) |
| COOKIE_NO_SAMESITE / COOKIE_BROAD_DOMAIN | LOW | default SameSite / shared with subdomains | SSO across subdomains (Domain needed) | SameSite=Lax, host-only |
| CORS_WILDCARD / CORS_NO_VARY_ORIGIN / CORS_ORIGIN_ECHO / CORS_WILDCARD_METHODS | LOW | review CORS scope | public data | allowlist, Vary: Origin |
| SERVER_VERSION_DISCLOSED / TECH_DISCLOSED | LOW | version or stack banner | - | hide banners |
| X_XSS_PROTECTION_DEPRECATED / EXPECT_CT_DEPRECATED | LOW | obsolete header | - | remove (or X-XSS-Protection: 0) |
| DUPLICATE_HEADER | LOW | repeated single-value header | - | send once |
Exit codes: 0 = no HIGH, 1 = HIGH present, 2 = bad input. In CI, run against staging after deploy and gate on HIGH; keep any --ignore list in the repo with a reason per code.
Live checks the script cannot do (offline)
- TLS versions and certificates:
openssl s_client -connect host:443 -servername hostor an SSL test service. - HSTS preload status: hstspreload.org.
<meta http-equiv="Content-Security-Policy">in the HTML body:curl -s URL | grep -i content-security-policy.- CORS allowlist behaviour: repeat the curl with a trusted origin,
null,https://example.com.evil.exampleandhttps://evilexample.com(suffix/prefix match bugs). - Error pages (404, 500) and the login/logout responses often miss headers set only in the app; capture them too. FILE:templates/security-headers-review.md
Security headers review:
Captured: <date, curl -sIL / HAR, which URLs> Stack: <nginx + Node app behind Cloudflare> Result: <HIGH n, MEDIUM n, LOW n> - <one-sentence verdict, e.g. "session cookie and CORS issues let other sites read user data; fix before launch">
Fix now (HIGH)
| # | URL / response | Header now | Risk | Set instead |
|---|---|---|---|---|
| 1 | https://api.example.com/v1/me | <Access-Control-Allow-Origin: (echoes Origin) + credentials> |
<allowlist https://example.com only, Vary: Origin> | |
| 2 | https://example.com/ | <Set-Cookie: sessionid=...; HttpOnly> |
<__Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax> |
Fix soon (MEDIUM)
- <CSP: remove https: and 'unsafe-inline' from script-src; deploy as report-only first>
Hardening (LOW)
- <Permissions-Policy, COOP, hide Server version>
Header block for <nginx / Apache / CDN / framework>
<add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;>
<add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'nonce-$request_id'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;>
<add_header X-Content-Type-Options "nosniff" always;>
<add_header Referrer-Policy "strict-origin-when-cross-origin" always;>
<add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;>
Where: <server block for every response; CSP/Permissions/COOP on HTML locations only>
Rollout
- <deploy CSP as report-only with report-to; collect violations for 1-2 weeks>
- <HSTS max-age 300 -> 604800 -> 31536000; add includeSubDomains after confirming serve HTTPS>
- <fix cookies and CORS in the app; retest with the same curl commands, including Origin: https://evil.example>
- <switch CSP to enforcing; keep the report endpoint>
To verify (not checked offline)
- <TLS configuration; error pages 404/500; login response cookies; meta CSP>
Rollback: <remove the CSP header or return to report-only; HSTS cannot be withdrawn quickly - lower max-age only after the old value expires> Open questions: <must partner.example.net embed the page? does the front end read any cookie?> FILE:examples/shop-headers.txt
Captured 2026-10-31 for shop.example.com before launch
curl -sIL http://shop.example.com/
http://shop.example.com/
HTTP/1.1 302 Found Server: nginx/1.18.0 Date: Sat, 31 Oct 2026 07:10:02 GMT Content-Type: text/html Content-Length: 145 Connection: keep-alive Location: https://shop.example.com/
HTTP/2 200 server: nginx/1.18.0 date: Sat, 31 Oct 2026 07:10:02 GMT content-type: text/html; charset=utf-8 strict-transport-security: max-age=86400 content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: x-frame-options: ALLOW-FROM https://partner.example.net x-xss-protection: 1; mode=block referrer-policy: no-referrer-when-downgrade x-powered-by: Express set-cookie: sessionid=4f2a9c; Path=/; HttpOnly set-cookie: cart_id=88213; Path=/; Secure; SameSite=Lax set-cookie: __Host-csrf=e1d0; Path=/; Domain=shop.example.com; Secure; SameSite=Strict
curl -sI https://shop.example.com/static/app.3f9c1e.js
https://shop.example.com/static/app.3f9c1e.js
HTTP/2 200 server: nginx/1.18.0 content-type: application/javascript cache-control: public, max-age=31536000, immutable strict-transport-security: max-age=86400
curl -sv -o /dev/null -H "Origin: https://evil.example" https://api.shop.example.com/v1/me 2>&1
GET /v1/me HTTP/2 Host: api.shop.example.com Origin: https://evil.example Accept: / < HTTP/2 200 < content-type: application/json < access-control-allow-origin: https://evil.example < access-control-allow-credentials: true < strict-transport-security: max-age=31536000; includeSubDomains < x-content-type-options: nosniff FILE:examples/shop-headers-fixed.txt
Same three URLs after applying the fixes from examples/header-report.md
http://shop.example.com/
HTTP/1.1 301 Moved Permanently Server: nginx Content-Type: text/html Location: https://shop.example.com/
HTTP/2 200 server: nginx content-type: text/html; charset=utf-8 strict-transport-security: max-age=31536000; includeSubDomains content-security-policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m' 'strict-dynamic'; img-src 'self' data: https://cdn.shop.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self' https://partner.example.net; form-action 'self' x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin permissions-policy: camera=(), microphone=(), geolocation=(), payment=(self) cross-origin-opener-policy: same-origin-allow-popups set-cookie: __Host-session=4f2a9c; Path=/; Secure; HttpOnly; SameSite=Lax set-cookie: cart_id=88213; Path=/; Secure; SameSite=Lax set-cookie: __Host-csrf=e1d0; Path=/; Secure; SameSite=Strict
https://shop.example.com/static/app.3f9c1e.js
HTTP/2 200 server: nginx content-type: application/javascript cache-control: public, max-age=31536000, immutable strict-transport-security: max-age=31536000; includeSubDomains x-content-type-options: nosniff
GET /v1/me HTTP/2 Host: api.shop.example.com Origin: https://evil.example < HTTP/2 200 < content-type: application/json < strict-transport-security: max-age=31536000; includeSubDomains < x-content-type-options: nosniff < vary: Origin FILE:examples/header-report.md
HTTP security headers: shop-headers.txt
Responses: 4 Findings: 25 (HIGH 4, MEDIUM 6, LOW 15)
| # | URL | Status | Kind | HSTS | CSP | XFO | nosniff | Referrer | Permissions | COOP | Cookies | CORS |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | http://shop.example.com/ | 302 | redirect | - | - | - | - | - | - | - | - | - |
| 2 | https://shop.example.com/ | 200 | html | yes | yes | yes | - | yes | - | - | 3 | - |
| 3 | https://shop.example.com/static/app.3f9c1e.js | 200 | script | yes | - | - | - | - | - | - | - | - |
| 4 | https://api.shop.example.com/v1/me | 200 | api | yes | - | - | yes | - | - | - | - | https://evil.example |
1. http://shop.example.com/ 302 (redirect), line 4
HIGH 0, MEDIUM 0, LOW 2
| Sev | Code | Header | Problem | Fix |
|---|---|---|---|---|
| LOW | HTTP_REDIRECT_TEMPORARY | Location | HTTP to HTTPS uses a temporary redirect (302) | use 301 or 308 so browsers and crawlers remember it |
| LOW | SERVER_VERSION_DISCLOSED | Server | Server: nginx/1.18.0 reveals the software version | hide the version (server_tokens off / ServerTokens Prod) |
2. https://shop.example.com/ 200 (html), line 12
HIGH 3, MEDIUM 4, LOW 10
| Sev | Code | Header | Problem | Fix |
|---|---|---|---|---|
| HIGH | CSP_WILDCARD_SCRIPT | Content-Security-Policy | script-src allows https:, so scripts load from anywhere | list exact hosts or use nonces with 'strict-dynamic' |
| HIGH | COOKIE_NO_SECURE | Set-Cookie | sessionid: no Secure flag, the cookie is also sent over plain HTTP | add Secure |
| HIGH | COOKIE_PREFIX_VIOLATION | Set-Cookie | __Host-csrf: __Host- cookies need Secure, Path=/ and no Domain, so the browser rejects this one | Set-Cookie: __Host-csrf=...; Path=/; Secure; SameSite=Lax (no Domain) |
| MEDIUM | HSTS_SHORT_MAX_AGE | Strict-Transport-Security | max-age=86400 (1 day) is short; protection lapses between visits | raise in steps (1 week, 1 month) to max-age=31536000 |
| MEDIUM | CSP_UNSAFE_INLINE | Content-Security-Policy | script-src has 'unsafe-inline' without a nonce/hash: inline XSS is not blocked | move inline scripts to files or add nonces/hashes |
| MEDIUM | XFO_ALLOW_FROM | X-Frame-Options | ALLOW-FROM is not supported by current browsers (treated as no protection) | use CSP frame-ancestors 'self' https://partner.example |
| MEDIUM | REFERRER_POLICY_UNSAFE | Referrer-Policy | no-referrer-when-downgrade sends full URLs (paths, query tokens) to other sites | strict-origin-when-cross-origin (or no-referrer) |
| LOW | HSTS_NO_SUBDOMAINS | Strict-Transport-Security | subdomains are not covered (cookies scoped to the parent domain can leak over HTTP) | add includeSubDomains after checking every subdomain has HTTPS |
| LOW | CSP_OBJECT_SRC | Content-Security-Policy | object-src is not 'none' (plugins/embeds can bypass script rules) | add object-src 'none' |
| LOW | CSP_NO_BASE_URI | Content-Security-Policy | no base-uri: an injected tag can redirect relative script URLs | add base-uri 'self' (or 'none') |
| LOW | XCTO_MISSING | X-Content-Type-Options | no nosniff: browsers may guess a different content type (e.g. run an upload as script) | X-Content-Type-Options: nosniff |
| LOW | PERMISSIONS_POLICY_MISSING | Permissions-Policy | powerful features (camera, microphone, geolocation) are not switched off for embedded content | Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() |
| LOW | COOP_MISSING | Cross-Origin-Opener-Policy | pages opened with window.open keep a handle to this window (tabnabbing/XS-leaks) | Cross-Origin-Opener-Policy: same-origin (same-origin-allow-popups if you use OAuth/payment pop-ups) |
| LOW | COOKIE_NO_SAMESITE | Set-Cookie | sessionid: no SameSite attribute (browsers apply Lax by default, older ones send it cross-site) | set SameSite=Lax explicitly (Strict for admin areas) |
| LOW | SERVER_VERSION_DISCLOSED | Server | Server: nginx/1.18.0 reveals the software version | hide the version (server_tokens off / ServerTokens Prod) |
| LOW | TECH_DISCLOSED | X-Powered-By | X-Powered-By: Express reveals the stack | remove the header |
| LOW | X_XSS_PROTECTION_DEPRECATED | X-XSS-Protection | the XSS auditor is gone from browsers; '1; mode=block' can even create leaks in old ones | remove it or send X-XSS-Protection: 0, rely on CSP |
3. https://shop.example.com/static/app.3f9c1e.js 200 (script), line 28
HIGH 0, MEDIUM 2, LOW 2
| Sev | Code | Header | Problem | Fix |
|---|---|---|---|---|
| MEDIUM | HSTS_SHORT_MAX_AGE | Strict-Transport-Security | max-age=86400 (1 day) is short; protection lapses between visits | raise in steps (1 week, 1 month) to max-age=31536000 |
| MEDIUM | XCTO_MISSING | X-Content-Type-Options | no nosniff: browsers may guess a different content type (e.g. run an upload as script) | X-Content-Type-Options: nosniff |
| LOW | HSTS_NO_SUBDOMAINS | Strict-Transport-Security | subdomains are not covered (cookies scoped to the parent domain can leak over HTTP) | add includeSubDomains after checking every subdomain has HTTPS |
| LOW | SERVER_VERSION_DISCLOSED | Server | Server: nginx/1.18.0 reveals the software version | hide the version (server_tokens off / ServerTokens Prod) |
4. https://api.shop.example.com/v1/me 200 (api), line 39
HIGH 1, MEDIUM 0, LOW 1
| Sev | Code | Header | Problem | Fix |
|---|---|---|---|---|
| HIGH | CORS_ORIGIN_REFLECTED | Access-Control-Allow-Origin | the server echoed the untrusted test Origin https://evil.example, so it trusts any site; with Allow-Credentials: true any site can read a logged-in user's data | compare Origin against an exact allowlist; never echo it unchecked |
| LOW | CORS_NO_VARY_ORIGIN | Vary | per-origin Access-Control-Allow-Origin without Vary: Origin; a cache can serve one origin's answer to another | add Vary: Origin |
Suggested baseline for HTML pages (adapt CSP hosts and nonces before enforcing)
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Set-Cookie: __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Offline check of captured headers only: TLS, the HSTS preload list, CSP in tags and server-side Origin allowlists were not tested. FILE:scripts/sec_headers.py #!/usr/bin/env python3 """HTTP security headers checker (Python 3.8+ standard library only).
Reads HTTP response headers and reports security header problems: Strict-Transport-Security, Content-Security-Policy, frame-ancestors / X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Set-Cookie flags and prefixes, CORS (Access-Control-Allow-*), version disclosure and deprecated headers.
Input (auto-detected):
- curl output:
curl -sI URL,curl -sIL URL(redirect chain = several blocks) orcurl -sv -o /dev/null URL 2>&1("< Header: value" lines, "> Origin:" request lines). Put "# https://example.com/path" on the line before a block to name its URL; later blocks of a redirect chain take their URL from the previous Location header. - HAR export from browser DevTools (log.entries[].request.url / response.headers).
- a plain "Name: value" headers file (one response; give --url).
Usage: python3 sec_headers.py headers.txt [--url https://example.com/] [--origin https://evil.example] [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE] [--json] python3 sec_headers.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input. No network calls are made; the checker only reads what you captured. """ import argparse import json import re import sys from urllib.parse import urljoin, urlsplit
SEV_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3} SESSION_HINT = re.compile(r"(sess|sid|auth|token|jwt|login|remember|csrf|xsrf|identity|account)", re.I) CSRF_HINT = re.compile(r"(csrf|xsrf)", re.I) # double-submit tokens must stay readable from JavaScript HSTS_MIN = 15552000 # 180 days HSTS_PRELOAD = 31536000 # 1 year PERMISSION_FEATURES = ("camera", "microphone", "geolocation", "payment", "usb")
class Response: def init(self, url, status, headers, line, request_headers=None): self.url = url or "" self.status = status self.headers = headers # list of (name, value) in order self.line = line # line number of the status line (1-based), 0 for HAR self.request_headers = request_headers or {}
def get_all(self, name):
n = name.lower()
return [v for k, v in self.headers if k.lower() == n]
def get(self, name):
vals = self.get_all(name)
return vals[0] if vals else None
@property
def scheme(self):
return urlsplit(self.url).scheme.lower() if self.url else ""
@property
def kind(self):
if 300 <= self.status < 400:
return "redirect"
ct = (self.get("content-type") or "").lower()
if "html" in ct:
return "html"
if "javascript" in ct or "ecmascript" in ct:
return "script"
if "css" in ct:
return "style"
if "json" in ct or "xml" in ct and "html" not in ct:
return "api"
if ct.startswith(("image/", "font/", "video/", "audio/")):
return "asset"
if not ct and self.status in (204,):
return "api"
return "other" if ct else "unknown"
----------------------------------------------------------------------------- parsing
STATUS_RE = re.compile(r"^\s*[<]?\sHTTP/(\d(?:.\d)?)\s+(\d{3})\b") URL_COMMENT_RE = re.compile(r"^\s#+\s*(?:URL:\s*)?(https?://\S+)", re.I) HEADER_RE = re.compile(r"^\s*[<]?\s*([A-Za-z0-9!#$%&'+.^_`|~-]+)\s:\s?(.*)$")
def parse_text(text, default_url=""): responses, cur, pending_url, req = [], None, default_url or "", {} req_line_host, req_path = None, None lines = text.splitlines() for i, raw in enumerate(lines, 1): line = raw.rstrip("\r") m = URL_COMMENT_RE.match(line) if m: if cur: responses.append(cur) cur = None pending_url = m.group(1) continue s = line.strip() if s.startswith(">"): # curl -v request line body = s[1:].strip() rm = re.match(r"^(GET|HEAD|POST|PUT|DELETE|OPTIONS|PATCH)\s+(\S+)", body) if rm: req_path = rm.group(2) req = {} hm = HEADER_RE.match(body) if hm and not rm: req[hm.group(1).lower()] = hm.group(2).strip() if hm.group(1).lower() == "host": req_line_host = hm.group(2).strip() continue if s.startswith("*") or s.startswith("{") or s.startswith("}"): continue sm = STATUS_RE.match(line) if sm: if cur: responses.append(cur) url = pending_url if not url and req_line_host and req_path: url = ("https://" if req_line_host else "") + req_line_host + req_path if not url and responses: loc = responses[-1].get("location") if loc: url = urljoin(responses[-1].url or "", loc) cur = Response(url, int(sm.group(2)), [], i, dict(req)) pending_url = "" continue if cur is None: hm = HEADER_RE.match(line) if hm and not s.startswith("#"): # headers-only file: synthesize a 200 cur = Response(pending_url, 200, [], 0, dict(req)) pending_url = "" else: continue if not s: continue hm = HEADER_RE.match(line) if hm: cur.headers.append((hm.group(1), hm.group(2).strip())) if cur: responses.append(cur) return responses
def parse_har(data): out = [] for e in data.get("log", {}).get("entries", []): req = e.get("request", {}) res = e.get("response", {}) rh = {h["name"].lower(): h["value"] for h in req.get("headers", [])} hs = [(h["name"], h["value"]) for h in res.get("headers", [])] out.append(Response(req.get("url", ""), int(res.get("status", 0) or 0), hs, 0, rh)) return out
def load(text, default_url=""): st = text.lstrip() if st.startswith("{"): try: data = json.loads(st) except ValueError as e: raise ValueError("looks like JSON but does not parse: %s" % e) if "log" not in data: raise ValueError("JSON input is not a HAR file (no 'log' key)") return parse_har(data) res = parse_text(text, default_url) if not res: raise ValueError("no HTTP responses found (expected 'HTTP/x 200' lines, a HAR file or 'Name: value' headers)") for r in res: if not r.url and default_url: r.url = default_url return res
----------------------------------------------------------------------------- helpers
def parse_csp(value): d = {} for part in value.split(";"): toks = part.strip().split() if not toks: continue name = toks[0].lower() if name not in d: # duplicates inside one policy: first wins d[name] = [t for t in toks[1:]] return d
def parse_directives(value): out = {} for part in value.split(";"): part = part.strip() if not part: continue k, _, v = part.partition("=") out[k.strip().lower()] = v.strip().strip('"') return out
def parse_cookie(value): parts = [p.strip() for p in value.split(";")] name, _, val = parts[0].partition("=") attrs = {} for p in parts[1:]: if not p: continue k, _, v = p.partition("=") attrs[k.strip().lower()] = v.strip() return name.strip(), val, attrs
def _days(sec): d = sec / 86400.0 if d < 1: return "%.0f hours" % (sec / 3600.0) return "%g day%s" % (round(d, 1), "" if round(d, 1) == 1 else "s")
def same_origin(a, b): try: x, y = urlsplit(a), urlsplit(b) except ValueError: return False return (x.scheme, x.hostname, x.port) == (y.scheme, y.hostname, y.port)
----------------------------------------------------------------------------- checks
def check(resp, test_origin=None): f = []
def add(sev, code, header, problem, fix):
f.append({"severity": sev, "code": code, "header": header, "problem": problem, "fix": fix,
"url": resp.url, "status": resp.status, "line": resp.line})
kind = resp.kind
https = resp.scheme == "https"
doc = kind in ("html", "unknown")
# --- transport: HTTP -> HTTPS
if resp.scheme == "http":
loc = resp.get("location") or ""
if not (300 <= resp.status < 400 and loc.lower().startswith("https://")):
add("HIGH", "HTTP_NO_HTTPS_REDIRECT", "Location",
"plain-HTTP response is served without redirecting to HTTPS",
"answer every http:// request with 301/308 to the https:// URL")
elif resp.status in (302, 303, 307):
add("LOW", "HTTP_REDIRECT_TEMPORARY", "Location",
"HTTP to HTTPS uses a temporary redirect (%d)" % resp.status,
"use 301 or 308 so browsers and crawlers remember it")
if resp.get("strict-transport-security"):
add("LOW", "HSTS_ON_HTTP", "Strict-Transport-Security",
"HSTS sent over plain HTTP is ignored by browsers", "send it on the HTTPS responses")
# --- HSTS
hsts_vals = resp.get_all("strict-transport-security")
if https:
if not hsts_vals:
if kind != "asset" or resp.status < 300:
add("HIGH" if doc or kind == "redirect" else "MEDIUM", "HSTS_MISSING", "Strict-Transport-Security",
"no HSTS: the first visit or a typed http:// link can be downgraded or intercepted",
"Strict-Transport-Security: max-age=31536000; includeSubDomains")
else:
if len(hsts_vals) > 1:
add("LOW", "DUPLICATE_HEADER", "Strict-Transport-Security",
"%d HSTS headers; browsers use only the first" % len(hsts_vals), "send exactly one")
d = parse_directives(hsts_vals[0])
ma = d.get("max-age")
if ma is None or not ma.isdigit():
add("HIGH", "HSTS_INVALID", "Strict-Transport-Security",
"max-age missing or not a number, so the header is ignored",
"max-age=31536000; includeSubDomains")
else:
ma = int(ma)
if ma == 0:
add("MEDIUM", "HSTS_DISABLED", "Strict-Transport-Security",
"max-age=0 clears HSTS (only correct while deliberately rolling it back)",
"max-age=31536000 once every subdomain serves HTTPS")
elif ma < HSTS_MIN:
add("MEDIUM", "HSTS_SHORT_MAX_AGE", "Strict-Transport-Security",
"max-age=%d (%s) is short; protection lapses between visits" % (ma, _days(ma)),
"raise in steps (1 week, 1 month) to max-age=31536000")
if "includesubdomains" not in d and ma:
add("LOW", "HSTS_NO_SUBDOMAINS", "Strict-Transport-Security",
"subdomains are not covered (cookies scoped to the parent domain can leak over HTTP)",
"add includeSubDomains after checking every subdomain has HTTPS")
if "preload" in d and (ma < HSTS_PRELOAD or "includesubdomains" not in d):
add("LOW", "HSTS_PRELOAD_INCOMPLETE", "Strict-Transport-Security",
"preload is set but the list requires max-age>=31536000 and includeSubDomains",
"max-age=63072000; includeSubDomains; preload (only if you mean to preload)")
# --- CSP
csp_vals = resp.get_all("content-security-policy")
csp_ro = resp.get_all("content-security-policy-report-only")
frame_ancestors = None
if csp_vals:
merged = {}
for v in csp_vals:
for k, toks in parse_csp(v).items():
merged.setdefault(k, toks)
if "frame-ancestors" in merged:
frame_ancestors = merged["frame-ancestors"]
if doc:
if not csp_vals:
if csp_ro:
add("MEDIUM", "CSP_REPORT_ONLY", "Content-Security-Policy-Report-Only",
"CSP is only in report-only mode; nothing is blocked",
"after reports are clean, send the same policy as Content-Security-Policy")
else:
add("MEDIUM", "CSP_MISSING", "Content-Security-Policy",
"no Content-Security-Policy: an injected <script> runs with full page access",
"start with a report-only policy (see the suggested header set), then enforce")
else:
for v in csp_vals:
d = parse_csp(v)
script = d.get("script-src", d.get("default-src"))
src_name = "script-src" if "script-src" in d else "default-src"
if script is None:
add("HIGH", "CSP_NO_SCRIPT_RESTRICTION", "Content-Security-Policy",
"policy has neither script-src nor default-src, so scripts are not restricted",
"add default-src 'self' and a script-src")
else:
low = [t.lower() for t in script]
has_nonce = any(t.startswith(("'nonce-", "'sha256-", "'sha384-", "'sha512-")) for t in low)
strict = "'strict-dynamic'" in low
wild = [t for t in low if t in ("*", "http:", "https:", "data:", "blob:") or t.startswith("http://")]
if wild and not strict:
add("HIGH", "CSP_WILDCARD_SCRIPT", "Content-Security-Policy",
"%s allows %s, so scripts load from anywhere" % (src_name, " ".join(wild)),
"list exact hosts or use nonces with 'strict-dynamic'")
if "'unsafe-inline'" in low and not has_nonce:
add("MEDIUM", "CSP_UNSAFE_INLINE", "Content-Security-Policy",
"%s has 'unsafe-inline' without a nonce/hash: inline XSS is not blocked" % src_name,
"move inline scripts to files or add nonces/hashes")
if "'unsafe-eval'" in low:
add("MEDIUM", "CSP_UNSAFE_EVAL", "Content-Security-Policy",
"%s has 'unsafe-eval' (eval/new Function allowed)" % src_name,
"remove it; replace eval-based libraries or templates")
obj = d.get("object-src", d.get("default-src"))
if obj is None or [t.lower() for t in obj] != ["'none'"]:
add("LOW", "CSP_OBJECT_SRC", "Content-Security-Policy",
"object-src is not 'none' (plugins/embeds can bypass script rules)", "add object-src 'none'")
if "base-uri" not in d:
add("LOW", "CSP_NO_BASE_URI", "Content-Security-Policy",
"no base-uri: an injected <base> tag can redirect relative script URLs",
"add base-uri 'self' (or 'none')")
# --- clickjacking
xfo_vals = resp.get_all("x-frame-options")
if doc:
if frame_ancestors is None and not xfo_vals:
add("MEDIUM", "CLICKJACKING_UNPROTECTED", "X-Frame-Options / frame-ancestors",
"page can be framed by any site (clickjacking)",
"Content-Security-Policy: frame-ancestors 'self' (and X-Frame-Options: SAMEORIGIN for old browsers)")
elif frame_ancestors is not None and any(t in ("*", "https:", "http:") for t in frame_ancestors):
add("MEDIUM", "FRAME_ANCESTORS_WILDCARD", "Content-Security-Policy",
"frame-ancestors %s allows framing by any site" % " ".join(frame_ancestors),
"frame-ancestors 'self' plus the exact partner origins")
if xfo_vals:
if len(xfo_vals) > 1 or "," in xfo_vals[0]:
add("MEDIUM", "XFO_INVALID", "X-Frame-Options",
"several X-Frame-Options values; some browsers then ignore the header", "send one value")
v = xfo_vals[0].strip().upper()
if v.startswith("ALLOW-FROM"):
add("MEDIUM" if frame_ancestors is None else "LOW", "XFO_ALLOW_FROM", "X-Frame-Options",
"ALLOW-FROM is not supported by current browsers (treated as no protection)",
"use CSP frame-ancestors 'self' https://partner.example")
elif v not in ("DENY", "SAMEORIGIN") and "," not in v:
add("MEDIUM", "XFO_INVALID", "X-Frame-Options", "unknown value %r is ignored" % xfo_vals[0],
"DENY or SAMEORIGIN")
# --- nosniff
xcto = resp.get("x-content-type-options")
if kind != "redirect":
if xcto is None:
add("MEDIUM" if kind in ("script", "style", "api") else "LOW", "XCTO_MISSING", "X-Content-Type-Options",
"no nosniff: browsers may guess a different content type (e.g. run an upload as script)",
"X-Content-Type-Options: nosniff")
elif xcto.strip().lower() != "nosniff":
add("LOW", "XCTO_INVALID", "X-Content-Type-Options", "value %r is ignored" % xcto,
"X-Content-Type-Options: nosniff")
# --- Referrer-Policy
if doc:
rp = resp.get("referrer-policy")
if rp is None:
add("LOW", "REFERRER_POLICY_MISSING", "Referrer-Policy",
"no Referrer-Policy (modern browsers default to strict-origin-when-cross-origin; older ones send full URLs)",
"Referrer-Policy: strict-origin-when-cross-origin")
else:
last = [t.strip().lower() for t in rp.split(",") if t.strip()][-1:] # last valid token wins
if last and last[0] in ("unsafe-url", "no-referrer-when-downgrade"):
add("MEDIUM", "REFERRER_POLICY_UNSAFE", "Referrer-Policy",
"%s sends full URLs (paths, query tokens) to other sites" % last[0],
"strict-origin-when-cross-origin (or no-referrer)")
# --- Permissions-Policy
if doc:
pp = resp.get("permissions-policy")
fp = resp.get("feature-policy")
if pp is None and fp is not None:
add("LOW", "FEATURE_POLICY_ONLY", "Feature-Policy",
"only the old Feature-Policy header is sent; current browsers read Permissions-Policy",
"Permissions-Policy: camera=(), microphone=(), geolocation=()")
elif pp is None:
add("LOW", "PERMISSIONS_POLICY_MISSING", "Permissions-Policy",
"powerful features (camera, microphone, geolocation) are not switched off for embedded content",
"Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()")
elif re.search(r"\b[a-z-]+\s+'(none|self)'", pp):
add("LOW", "PERMISSIONS_POLICY_SYNTAX", "Permissions-Policy",
"uses Feature-Policy syntax (camera 'none'); Permissions-Policy needs camera=()",
"rewrite as feature=() / feature=(self)")
elif re.search(r"\b(%s)=\*" % "|".join(PERMISSION_FEATURES), pp):
add("LOW", "PERMISSIONS_POLICY_WILDCARD", "Permissions-Policy",
"a powerful feature is allowed for every origin (=*)", "use =(self) or =()")
# --- COOP (only meaningful for top-level documents)
if doc and https and resp.status < 300 and resp.get("cross-origin-opener-policy") is None:
add("LOW", "COOP_MISSING", "Cross-Origin-Opener-Policy",
"pages opened with window.open keep a handle to this window (tabnabbing/XS-leaks)",
"Cross-Origin-Opener-Policy: same-origin (same-origin-allow-popups if you use OAuth/payment pop-ups)")
# --- cookies
for raw in resp.get_all("set-cookie"):
name, val, a = parse_cookie(raw)
sessionish = bool(SESSION_HINT.search(name))
deleting = a.get("max-age") == "0" or "1970" in a.get("expires", "")
if deleting:
continue
ss = a.get("samesite", "").lower()
prefix_bad = False
if name.startswith("__Host-") and ("secure" not in a or a.get("path") != "/" or "domain" in a):
add("HIGH", "COOKIE_PREFIX_VIOLATION", "Set-Cookie",
"%s: __Host- cookies need Secure, Path=/ and no Domain, so the browser rejects this one" % name,
"Set-Cookie: %s=...; Path=/; Secure; SameSite=Lax (no Domain)" % name)
prefix_bad = True
elif name.startswith("__Secure-") and "secure" not in a:
add("HIGH", "COOKIE_PREFIX_VIOLATION", "Set-Cookie",
"%s: __Secure- cookies need the Secure flag, so the browser rejects this one" % name, "add Secure")
prefix_bad = True
if ss == "none" and "secure" not in a:
add("HIGH", "COOKIE_SAMESITE_NONE_NO_SECURE", "Set-Cookie",
"%s: SameSite=None without Secure is rejected by browsers" % name, "add Secure (HTTPS only)")
elif https and "secure" not in a and not prefix_bad:
add("HIGH" if sessionish else "MEDIUM", "COOKIE_NO_SECURE", "Set-Cookie",
"%s: no Secure flag, the cookie is also sent over plain HTTP" % name, "add Secure")
if sessionish and "httponly" not in a and not CSRF_HINT.search(name):
add("MEDIUM", "COOKIE_NO_HTTPONLY", "Set-Cookie",
"%s looks like a session/auth cookie but is readable from JavaScript (XSS can steal it)" % name,
"add HttpOnly (unless the front end must read it, e.g. a double-submit CSRF token)")
if not ss:
add("LOW", "COOKIE_NO_SAMESITE", "Set-Cookie",
"%s: no SameSite attribute (browsers apply Lax by default, older ones send it cross-site)" % name,
"set SameSite=Lax explicitly (Strict for admin areas)")
if "domain" in a and sessionish and not prefix_bad:
add("LOW", "COOKIE_BROAD_DOMAIN", "Set-Cookie",
"%s is shared with every subdomain of %s" % (name, a["domain"].lstrip(".")),
"drop Domain= (host-only) or use the __Host- prefix")
# --- CORS
acao = resp.get("access-control-allow-origin")
acac = (resp.get("access-control-allow-credentials") or "").strip().lower() == "true"
req_origin = resp.request_headers.get("origin") or test_origin
if acao is not None:
a = acao.strip()
if a == "*" and acac:
add("MEDIUM", "CORS_WILDCARD_WITH_CREDENTIALS", "Access-Control-Allow-Origin",
"'*' with Allow-Credentials: true is invalid (browsers block it); it usually means someone will "
"'fix' it by reflecting the Origin", "return an allowlisted origin, or drop credentials for public data")
elif a.lower() == "null":
add("HIGH" if acac else "MEDIUM", "CORS_NULL_ORIGIN", "Access-Control-Allow-Origin",
"allows the 'null' origin, which sandboxed iframes and local files can send",
"never allowlist null")
elif a == "*":
if kind == "api" or acac or resp.get_all("set-cookie"):
add("LOW", "CORS_WILDCARD", "Access-Control-Allow-Origin",
"any site may read this response; fine for public data, not for anything per-user",
"allowlist origins if the endpoint ever returns user data")
else:
if req_origin and a == req_origin and not same_origin(a, resp.url):
untrusted = test_origin is not None and a == test_origin
add("HIGH" if (acac and untrusted) else ("MEDIUM" if untrusted else "LOW"),
"CORS_ORIGIN_REFLECTED" if untrusted else "CORS_ORIGIN_ECHO",
"Access-Control-Allow-Origin",
("the server echoed the untrusted test Origin %s, so it trusts any site%s" %
(a, "; with Allow-Credentials: true any site can read a logged-in user's data" if acac else ""))
if untrusted else "Origin %s was echoed; confirm it is on an allowlist (retest with --origin https://evil.example)" % a,
"compare Origin against an exact allowlist; never echo it unchecked")
vary = ",".join(resp.get_all("vary")).lower()
if "origin" not in vary and "*" not in vary:
add("LOW", "CORS_NO_VARY_ORIGIN", "Vary",
"per-origin Access-Control-Allow-Origin without Vary: Origin; a cache can serve one origin's answer to another",
"add Vary: Origin")
if acac and a not in ("*",) and a.lower().startswith("http://"):
add("MEDIUM", "CORS_HTTP_ORIGIN_TRUSTED", "Access-Control-Allow-Origin",
"credentials are shared with a plain-HTTP origin (%s), which a network attacker can impersonate" % a,
"allow only https:// origins")
methods = (resp.get("access-control-allow-methods") or "")
hdrs = (resp.get("access-control-allow-headers") or "")
if methods.strip() == "*" and acac or hdrs.strip() == "*" and acac:
add("LOW", "CORS_WILDCARD_METHODS", "Access-Control-Allow-Methods/Headers",
"'*' is treated literally when credentials are allowed", "list the methods/headers explicitly")
# --- disclosure and deprecated headers
srv = resp.get("server")
if srv and re.search(r"\d", srv):
add("LOW", "SERVER_VERSION_DISCLOSED", "Server",
"Server: %s reveals the software version" % srv, "hide the version (server_tokens off / ServerTokens Prod)")
for h in ("x-powered-by", "x-aspnet-version", "x-aspnetmvc-version", "x-generator"):
v = resp.get(h)
if v:
add("LOW", "TECH_DISCLOSED", h.title(), "%s: %s reveals the stack" % (h.title(), v), "remove the header")
xx = resp.get("x-xss-protection")
if xx and not xx.strip().startswith("0"):
add("LOW", "X_XSS_PROTECTION_DEPRECATED", "X-XSS-Protection",
"the XSS auditor is gone from browsers; '1; mode=block' can even create leaks in old ones",
"remove it or send X-XSS-Protection: 0, rely on CSP")
if resp.get("public-key-pins") or resp.get("public-key-pins-report-only"):
add("MEDIUM", "HPKP_DEPRECATED", "Public-Key-Pins",
"HPKP is removed from browsers and risky if any client still honours it", "remove the header")
if resp.get("expect-ct"):
add("LOW", "EXPECT_CT_DEPRECATED", "Expect-CT", "Expect-CT is obsolete", "remove the header")
return f
SUGGESTED = [ "Strict-Transport-Security: max-age=31536000; includeSubDomains", "Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'", "X-Content-Type-Options: nosniff", "X-Frame-Options: SAMEORIGIN", "Referrer-Policy: strict-origin-when-cross-origin", "Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()", "Cross-Origin-Opener-Policy: same-origin", "Set-Cookie: __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax", ]
PRESENCE = [("HSTS", "strict-transport-security"), ("CSP", "content-security-policy"), ("XFO", "x-frame-options"), ("nosniff", "x-content-type-options"), ("Referrer", "referrer-policy"), ("Permissions", "permissions-policy"), ("COOP", "cross-origin-opener-policy")]
def run(responses, test_origin=None, min_sev="LOW", ignore=()): out = [] for r in responses: fs = [x for x in check(r, test_origin) if SEV_ORDER[x["severity"]] >= SEV_ORDER[min_sev] and x["code"] not in ignore] fs.sort(key=lambda x: -SEV_ORDER[x["severity"]]) out.append((r, fs)) return out
def counts(fs): c = {"HIGH": 0, "MEDIUM": 0, "LOW": 0} for x in fs: c[x["severity"]] += 1 return c
def render_md(results, source):
allf = [x for _, fs in results for x in fs]
c = counts(allf)
o = ["# HTTP security headers: %s" % source, "",
"Responses: %d Findings: %d (HIGH %d, MEDIUM %d, LOW %d)" % (len(results), len(allf), c["HIGH"], c["MEDIUM"], c["LOW"]), "",
"| # | URL | Status | Kind | " + " | ".join(p[0] for p in PRESENCE) + " | Cookies | CORS |",
"|---|---|---|---|" + "---|" * len(PRESENCE) + "---|---|"]
for i, (r, fs) in enumerate(results, 1):
cells = []
for _, h in PRESENCE:
cells.append("yes" if r.get(h) is not None else "-")
if "frame-ancestors" in (r.get("content-security-policy") or "") and cells[2] == "-":
cells[2] = "CSP"
ck = len(r.get_all("set-cookie"))
cors = r.get("access-control-allow-origin") or "-"
o.append("| %d | %s | %d | %s | %s | %s | %s |" % (i, r.url or "(unknown)", r.status, r.kind,
" | ".join(cells), ck or "-", cors))
for i, (r, fs) in enumerate(results, 1):
cc = counts(fs)
o += ["", "## %d. %s %d (%s)%s" % (i, r.url or "(unknown URL)", r.status, r.kind,
", line %d" % r.line if r.line else ""), ""]
if not fs:
o.append("No findings.")
continue
o.append("HIGH %d, MEDIUM %d, LOW %d" % (cc["HIGH"], cc["MEDIUM"], cc["LOW"]))
o += ["", "| Sev | Code | Header | Problem | Fix |", "|---|---|---|---|---|"]
for x in fs:
o.append("| %s | %s | %s | %s | %s |" % (x["severity"], x["code"], x["header"],
x["problem"].replace("|", "/"), x["fix"].replace("|", "/")))
if any(r.kind in ("html", "unknown") and fs for r, fs in results):
o += ["", "## Suggested baseline for HTML pages (adapt CSP hosts and nonces before enforcing)", "", ""] + SUGGESTED + [""]
o += ["", "Offline check of captured headers only: TLS, the HSTS preload list, CSP in tags and "
"server-side Origin allowlists were not tested."]
return "\n".join(o) + "\n"
----------------------------------------------------------------------------- self-test
def self_test(): cases = []
def codes(text, url="https://example.com/", origin=None):
rs = load(text, url)
return [x["code"] for _, fs in run(rs, origin) for x in fs]
def case(name, fn):
try:
ok = bool(fn())
err = ""
except Exception as e: # noqa
ok, err = False, repr(e)
cases.append((name, ok, err))
good = ("HTTP/2 200\ncontent-type: text/html; charset=utf-8\n"
"strict-transport-security: max-age=31536000; includeSubDomains\n"
"content-security-policy: default-src 'self'; script-src 'self' 'nonce-abc'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'\n"
"x-content-typ
Truncated - read the full file at https://github.com/f/awesome-chatgpt-prompts/blob/main/prompts.csv.
