ThatAIguyA world of ideas

THE CONSCIOUSNESS ATLAS
Read. Experiment. Follow the evidence.

ConsciousnessLearn · Explore · Joseph Anady

Janady13/crawler-signal-reference / framework-http-security-headers.md

Source identity, revision, and recovered contents.

GitHub sourceSource text recovered

Source identity

FieldRecorded value
Source typeGitHub source
Original recordJanady13/crawler-signal-reference / framework-http-security-headers.md
DateNot specified in source
RepositoryJanady13/crawler-signal-reference
Revision5a13904b5b5d638761f3ffef01c51ed3a01fea87
Branchmain
Path or archive memberframework-http-security-headers.md
Original SHA-256b7d387869ac7c299acb646281b971e0798720c7b862da640443debc49e4394e4
Git blob SHA8fee6b68e7bd3f1b0f0dd5d516ebe803e5d226c5
Reading-copy SHA-256b7d387869ac7c299acb646281b971e0798720c7b862da640443debc49e4394e4

Original source ↗

Recovered source transcript

Download complete source text ↓2,397 lines · 104.7 KB
# framework-http-security-headers.md Comprehensive reference for the nine HTTP response headers that establish security guarantees and trust boundaries for a web origin: `Strict-Transport-Security` (HSTS) for transport, `Content-Security-Policy` (CSP) for resource and script control, `X-Frame-Options` for clickjacking defense, `X-Content-Type-Options` for MIME sniffing prevention, `Referrer-Policy` for outbound URL privacy, `Permissions-Policy` for browser capability control, and the cross origin isolation trio `Cross-Origin-Resource-Policy` (CORP), `Cross-Origin-Embedder-Policy` (COEP), and `Cross-Origin-Opener-Policy` (COOP). Built for Bubbles (Debian, Nginx 1.26+, self hosted origin at 169.155.162.118, no Cloudflare or third party CDN in front). Companion to framework-http-caching-headers.md, framework-http-content-headers.md, framework-http-seo-headers.md, UNIVERSAL-RANKING-FRAMEWORK.md, and SEO BUILD REFERENCE v2.4. Audience: humans configuring nginx, AI assistants generating or repairing nginx config, security auditors, and anyone troubleshooting "CSP blocking my GTM scripts", "site permanently broken after HSTS preload", "clickjacking warning from scanner", or "SharedArrayBuffer not available" anomalies on a self hosted stack. --- ## TABLE OF CONTENTS 1. Definition 2. Why It Matters 3. What This Covers 4. The Defense In Depth Mental Model (read this first) 5. Strict-Transport-Security (HSTS, transport security) 6. Content-Security-Policy (the biggest lever, script and resource control) 7. X-Frame-Options (clickjacking defense, mostly superseded by CSP frame-ancestors) 8. X-Content-Type-Options (MIME sniffing prevention) 9. Referrer-Policy (outbound URL privacy) 10. Permissions-Policy (browser capability control) 11. Cross-Origin-Resource-Policy (CORP, who can load this resource) 12. Cross-Origin-Embedder-Policy (COEP, what this document can embed) 13. Cross-Origin-Opener-Policy (COOP, what this document can share a window with) 14. The Cross Origin Isolation Pattern (COEP plus COOP plus CORP) 15. How These Headers Interact 16. Asset Class And Use Case Recipes 17. Bubbles Nginx Reference Block (paste ready) 18. Audit Checklist (60+ items) 19. Common Pitfalls 20. Diagnostic Commands (curl, observatory.mozilla.org, securityheaders.com, browser devtools) 21. Cross-References --- ## 1. DEFINITION Security and trust signal headers tell the browser what it may safely do with a response, what other origins may interact with it, what scripts may execute on it, what capabilities the page may use, and how transport must be secured. They operate as defense in depth: each one closes off a class of attack, and missing any one creates a gap that scanners and attackers will find. The nine headers split into four concerns: * **Transport**: `Strict-Transport-Security`. It answers "must this origin always be accessed over HTTPS?" * **Content execution and embedding**: `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`. They answer "what scripts may run, what frames may embed this page, may the browser sniff a different type than declared?" * **Privacy and capability**: `Referrer-Policy`, `Permissions-Policy`. They answer "how much referrer information leaks, which device APIs may this page use?" * **Cross origin isolation**: `Cross-Origin-Resource-Policy`, `Cross-Origin-Embedder-Policy`, `Cross-Origin-Opener-Policy`. They answer "who may load this resource, what may this document embed, what may share a window with this document?" Together these nine headers determine the security posture of an origin. Getting any one wrong opens a class of attack: clickjacking, XSS, MIME confusion, referrer leaks, capability abuse, Spectre style side channel attacks, or downgrade to HTTP. Getting them all right earns an A or A+ rating on observatory.mozilla.org and securityheaders.com, which several enterprise procurement processes now require for vendor approval. --- ## 2. WHY IT MATTERS Six independent pressures push correct security headers from "polish" to "required infrastructure" in 2025 and forward. **Enterprise procurement now scans for security headers.** Mid to large enterprises evaluating vendor websites routinely check securityheaders.com and observatory.mozilla.org as part of vendor onboarding. A grade of D or F is a fast path to elimination. An A or A+ is table stakes for credibility in any B2B context. SOC 2 audits, ISO 27001 reviews, and HIPAA risk assessments all include security header checks. **Search engines and AI crawlers prefer secure origins.** Google has confirmed HTTPS as a ranking signal since 2014 and progressively penalized mixed content. Bing, Perplexity, ClaudeBot, and GPTBot all crawl HTTPS preferentially and treat HTTP origins with suspicion. An origin without HSTS, especially without HSTS preload, signals operational immaturity to ranking systems. **Browser features are gated on security headers.** Modern web APIs (SharedArrayBuffer, high precision timers, WebAssembly threading, OPFS write access) require cross origin isolation, which requires COEP plus COOP. Sites that need these features (WebAssembly games, video editors, scientific computing apps) cannot function without the right headers. **XSS is still the number one web vulnerability.** Every OWASP Top 10 list since 2007 has included XSS or its variants. CSP is the single most effective mitigation against the entire class. A site without CSP is one stored XSS away from credential theft, session hijacking, or arbitrary code execution in every visitor's browser. **Clickjacking is trivial without X-Frame-Options or CSP frame-ancestors.** Any site that can be embedded in an iframe can be clickjacked. The attack overlays the target site in an invisible frame and tricks users into clicking elements they cannot see. Defense is a single header. Failure to set it has caused real money losses (cryptocurrency wallet drains, account takeovers). **Spectre and similar side channel attacks broke the assumption that cross origin data is opaque.** Before 2018, browsers assumed that an attacker on `evil.com` could not read the pixel data of an image loaded from `bank.com`. Spectre invalidated that assumption. The fix is the cross origin isolation pattern (COEP plus COOP plus CORP), which separates processes so a malicious page cannot reach a victim page's memory via timing attacks. **Cost of getting it wrong.** Misconfigured security headers produce loud security failures and silent ranking failures. Real examples: * HSTS preload submitted without testing all subdomains. The internal `admin.example.com` subdomain only served HTTP. After preload, all employee admins lost access for the six months it took to remove the domain from the preload list. * CSP rolled out in enforce mode without Report-Only testing. Google Tag Manager started failing silently. Analytics data lost for three weeks before anyone noticed. * No X-Frame-Options or CSP frame-ancestors. Attacker built an invisible iframe over the customer portal's withdraw button. Users clicking what looked like a free game button were actually authorizing transfers. * No `X-Content-Type-Options: nosniff`. User uploaded a .txt file that contained HTML and JavaScript. Browser sniffed it as HTML, rendered it, ran the script, stole every other user's session cookie. * Referrer-Policy unset, default behavior leaks full URL with query string (often containing session tokens, password reset codes, or authentication tokens) to every external image, script, and link. Customer data exposed in third party analytics logs. All preventable with the rules below. --- ## 3. WHAT THIS COVERS Each of the nine headers gets the same six part treatment: 1. **What it does**: the canonical RFC or W3C spec plus the practical implication. 2. **Syntax and directives**: every legal value, what it means, and when it is wrong. 3. **How to build it on Bubbles**: paste ready nginx config. 4. **How to verify it**: curl commands plus external scanner checks where applicable. 5. **How to troubleshoot**: the four or five failure modes seen in the field and how to recognize each. 6. **How to fix common breakage**: ordered repair steps. The cross origin isolation pattern (combining COEP, COOP, and CORP) gets its own dedicated section because the three headers must be tuned together. Asset class recipes are collected in Section 16. --- ## 4. THE DEFENSE IN DEPTH MENTAL MODEL (READ THIS FIRST) Security headers form layers. No single header is sufficient. Each layer addresses a different attack class, and defenses are stacked so that bypassing one still leaves the attacker facing the next. Internalize the layer model and every header decision becomes obvious. ``` Visitor's browser requests https://example.com/ | v ==================== TRANSPORT LAYER ==================== | v Strict-Transport-Security (HSTS) Force HTTPS only, prevent downgrade attacks Prevent man in the middle attacks on public Wi-Fi | v ==================== CONTENT EXECUTION LAYER ==================== | v Content-Security-Policy Control which scripts may execute Control which origins may be loaded as scripts, styles, images, frames Block inline scripts unless explicitly allowed (via nonce or hash) | v X-Content-Type-Options: nosniff Force the declared Content-Type to be authoritative Block MIME confusion attacks | v X-Frame-Options (or CSP frame-ancestors) Control whether this page may be embedded in an iframe Block clickjacking | v ==================== PRIVACY AND CAPABILITY LAYER ==================== | v Referrer-Policy Control how much referrer URL is sent to other origins Prevent token and PII leakage via outbound requests | v Permissions-Policy Control which browser APIs (camera, mic, geolocation, etc) may be used Prevent third party scripts from abusing capabilities | v ==================== CROSS ORIGIN ISOLATION LAYER ==================== | v Cross-Origin-Resource-Policy (on resources) Control who may load this resource Cross-Origin-Embedder-Policy (on documents) Require resources to opt in via CORP or CORS Cross-Origin-Opener-Policy (on documents) Isolate browsing context from cross origin windows | v ==> Cross origin isolated state ==> Unlocks SharedArrayBuffer, high precision timers, WebAssembly threading | v ==================== APPLICATION ==================== | v Render page, execute scripts within all the constraints above ``` Five rules govern the system: 1. **Stack the layers.** Even if your CSP is bulletproof, set HSTS so the connection cannot be downgraded. Even if HSTS is set, set CSP so XSS cannot execute. The point is overlap. 2. **Restrict by default, allow by exception.** Every header should start with the most restrictive policy that works. Expand only when a specific feature requires it. 3. **Test in Report-Only first.** CSP and CORP/COEP/COOP can break sites in subtle ways. Roll out in report only mode, monitor violations for at least a week, then enforce. 4. **HSTS preload is one way.** Submitting to the preload list is reversible but takes months. Test thoroughly before submitting. 5. **The `add_header` inheritance trap applies to every security header.** Any `add_header` in a location block wipes parent declarations. Use the snippet include pattern from the start. A correctly configured security header stack produces an A+ rating on observatory.mozilla.org and securityheaders.com, blocks XSS even if attackers find an injection vector, prevents clickjacking, prevents transport downgrade, controls capability abuse, and isolates the origin from cross origin attacks. The same response, the same protection, every visitor. --- ## 5. STRICT-TRANSPORT-SECURITY (HSTS, TRANSPORT SECURITY) ### 5.1 What It Does `Strict-Transport-Security` (HSTS) instructs the browser to access this origin only over HTTPS for a specified duration, ignoring any user attempt to use HTTP and refusing to bypass certificate errors. Defined in RFC 6797. ``` Strict-Transport-Security: max-age=31536000 Strict-Transport-Security: max-age=31536000; includeSubDomains Strict-Transport-Security: max-age=63072000; includeSubDomains; preload ``` The first time a browser receives the header over HTTPS, it remembers the origin and the max-age. Every subsequent attempt to visit `http://example.com/...` is automatically upgraded to `https://` before the request leaves the browser. Certificate validation errors cannot be clicked through. This prevents downgrade attacks (active man in the middle stripping HTTPS) and stops accidental HTTP requests from leaking anything sensitive. HSTS only applies to subsequent visits. The very first visit to a new origin is still vulnerable to a downgrade. The **preload list** solves this: browsers ship with a hardcoded list of origins that have HSTS pre activated, so even the first visit is HTTPS only. ### 5.2 Directives | Directive | Meaning | |---|---| | `max-age=<seconds>` | How long the browser remembers HSTS for this origin. Required. Common values: `0` (disable), `86400` (1 day, for testing), `2592000` (30 days), `31536000` (1 year), `63072000` (2 years, recommended for preload) | | `includeSubDomains` | HSTS applies to every subdomain. Required for preload list. Once enabled with a long max-age, every subdomain must serve HTTPS or it becomes inaccessible | | `preload` | Signals consent to be included in the browser preload list. Required for preload list submission. Without preload directive, the preload list submission is rejected | ### 5.3 The Preload List (Permanent Consequences Warning) The HSTS preload list is maintained by the Chromium project and used by Chrome, Edge, Firefox, Safari, Opera, and Brave. To submit a domain: 1. Serve HSTS on the apex domain over HTTPS with `max-age >= 31536000`, `includeSubDomains`, and `preload`. 2. Every HTTP request must redirect to HTTPS via 301. 3. Every subdomain (now and in the future, including `mail.`, `admin.`, `dev.`, `staging.`) must serve HTTPS with a valid certificate. 4. Submit at https://hstspreload.org/. 5. Wait weeks to months for inclusion in the next browser release. **Permanent consequences:** * Removal takes months after request and is not guaranteed within that timeframe. * If any subdomain serves only HTTP after preload, that subdomain is **permanently inaccessible** to every browser that has the preload list cached. * Adding a new subdomain that only does HTTP requires a TLS certificate from day one. * Internal subdomains used by employees (admin tools, dev environments, monitoring) become inaccessible without HTTPS. **Bubbles policy:** preload list submission is appropriate for established client production domains. It is **not appropriate** for `thatwebhostingguy.com` (the wildcard subdomain platform) because that platform spins up new subdomains constantly and not all may be production ready. For `thatwebhostingguy.com` use long max-age and includeSubDomains but skip preload. ### 5.4 The Ramp Up Strategy Do not deploy long max-age HSTS on day one. If something breaks, every browser that visited during that window is locked into the broken state for the full max-age duration. Ramp up: ``` # Week 1: testing only Strict-Transport-Security: max-age=86400 # Week 2 to 4: short commitment Strict-Transport-Security: max-age=2592000; includeSubDomains # Month 2: medium commitment Strict-Transport-Security: max-age=15768000; includeSubDomains # Month 3+: full commitment, ready for preload Strict-Transport-Security: max-age=63072000; includeSubDomains; preload ``` At each stage, verify every subdomain works over HTTPS, every internal tool still loads, and there are no certificate issues. Move to the next stage only after the current max-age has fully elapsed. ### 5.5 How To Build It On Bubbles For an established production site already on HTTPS: ```nginx server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name example.com www.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # HSTS: 2 years, all subdomains, preload list eligible add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; } # HTTP server redirects to HTTPS server { listen 80; listen [::]:80; server_name example.com www.example.com; return 301 https://$host$request_uri; } ``` **Critical:** HSTS must be sent ONLY on HTTPS responses. Sending HSTS on an HTTP response is per the spec ignored, but it indicates the server is confused. The HTTP server block above does not include `add_header Strict-Transport-Security`. For a new site rolling out HSTS for the first time: ```nginx # Week 1: short max-age, no preload add_header Strict-Transport-Security "max-age=86400" always; ``` After verifying everything works, progress through the stages in Section 5.4. For `thatwebhostingguy.com` wildcard platform (no preload): ```nginx server { listen 443 ssl; server_name *.thatwebhostingguy.com thatwebhostingguy.com; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; # No preload directive } ``` ### 5.6 How To Verify ```bash # 1. Confirm HSTS is sent on HTTPS curl -sI https://example.com/ | grep -i strict-transport-security # Expected: strict-transport-security: max-age=63072000; includeSubDomains; preload # 2. Confirm HSTS is NOT sent on HTTP (should be a 301 instead) curl -sI http://example.com/ | grep -iE "strict-transport-security|^HTTP|^Location" # Expected: HTTP/1.1 301 Moved Permanently # Expected: Location: https://example.com/ # Expected: NO strict-transport-security header # 3. Check max-age is at least 1 year (for preload eligibility) MAX_AGE=$(curl -sI https://example.com/ | grep -i strict-transport-security | grep -oE "max-age=[0-9]+" | cut -d= -f2) echo "max-age = $MAX_AGE seconds = $(echo "$MAX_AGE / 86400" | bc) days" # 4. Check preload status curl -s "https://hstspreload.org/api/v2/status?domain=example.com" | python3 -m json.tool # 5. Verify every subdomain serves HTTPS (required before preload) for sub in www mail admin dev staging api blog; do echo "=== $sub.example.com ===" curl -sI -m 5 "https://$sub.example.com/" 2>&1 | head -1 || echo "FAILED to connect" done # 6. Browser check: visit chrome://net-internals/#hsts and query example.com ``` ### 5.7 Troubleshooting **Symptom: HSTS preload submission rejected.** Causes: 1. Missing `preload` directive in the header. Add it. 2. Missing `includeSubDomains`. Add it. 3. max-age under 31536000. Increase to at least 31536000. 4. HTTP does not redirect to HTTPS, or redirects with 302 instead of 301. Fix the HTTP server block. 5. A subdomain (including one you forgot about) serves only HTTP or has an invalid certificate. Audit all subdomains. **Symptom: A subdomain became inaccessible after enabling HSTS with includeSubDomains.** That subdomain does not serve HTTPS. Either: 1. Provision a TLS certificate for it immediately. 2. Disable HSTS includeSubDomains (browsers will pick up the change on their next visit, but only after the current max-age expires). 3. If you preloaded already, you must remove from the preload list (months long process) or stand up HTTPS for the broken subdomain. The right answer is almost always option 1: get the certificate. **Symptom: Old browsers refuse to accept new certificate after expiry.** This is HSTS working as designed. With HSTS active, expired or invalid certificates cannot be clicked through. The user must wait until the certificate is renewed or visit a different browser. For Bubbles: ensure `certbot.timer` is active and tested: ```bash systemctl status certbot.timer sudo certbot renew --dry-run ``` **Symptom: HSTS header appears on HTTP responses.** Wrong but harmless per the spec. Browsers ignore HSTS over HTTP. Fix by removing the `add_header` from the HTTP server block. The HTTP server block should only redirect, not set headers. **Symptom: Removed includeSubDomains but old browsers still enforce it.** Browsers honor the cached value for the original max-age duration. The fix is to wait, or to issue a new header with `max-age=0` for a few days, which clears the HSTS state, then re enable with the desired (more permissive) configuration. ### 5.8 How To Fix Common Breakage **Case: Need to roll back HSTS quickly.** Set max-age to 0: ```nginx add_header Strict-Transport-Security "max-age=0" always; ``` Browsers that revisit pick up the new value and clear their stored HSTS state. Browsers that do not revisit retain the old value until it expires naturally. If preloaded, max-age=0 alone is insufficient. You must also remove from the preload list, which takes months. **Case: Subdomain forgotten during preload, now inaccessible.** Add HTTPS to the subdomain immediately: ```bash sudo certbot --nginx -d forgotten.example.com ``` This is faster than removing from the preload list. **Case: Want to test HSTS without committing.** Use the `Strict-Transport-Security-Report-Only` header. Note: this is a draft proposal, not widely supported. Better strategy: use a short max-age (86400 = 1 day) during testing and observe via browser devtools. --- ## 6. CONTENT-SECURITY-POLICY (THE BIGGEST LEVER, SCRIPT AND RESOURCE CONTROL) ### 6.1 What It Does `Content-Security-Policy` (CSP) is a declarative allow list of where the browser may load resources from. Defined in W3C CSP Level 3. It controls which scripts may execute, which styles may load, which images and fonts may be fetched, which origins may be embedded in frames, and many other resource boundaries. CSP is the single most effective defense against XSS, the most common web vulnerability class. ``` Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-aBc123Xyz' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' ``` A response with this CSP would: load resources only from same origin by default; allow scripts from same origin AND any inline script with the matching nonce; allow trust to propagate through dynamic script loading; allow same origin styles plus inline styles; allow images from same origin, data URLs, and any HTTPS origin; refuse to be embedded in any frame; restrict `<base>` to same origin; restrict form submission targets to same origin. CSP is the most powerful and most complex header in this framework. The configuration is per site and almost never trivially copyable between sites. Each origin needs a CSP tuned to its actual resource graph. ### 6.2 Directives The full directive reference. Each directive controls a resource type or a security behavior. **Fetch directives (control resource loading by type):** | Directive | Controls | |---|---| | `default-src` | Fallback for any fetch directive not explicitly set. Default for all others | | `script-src` | JavaScript files and inline scripts | | `script-src-elem` | JavaScript loaded via `<script>` elements (more specific than script-src) | | `script-src-attr` | Inline event handlers like `onclick=`. Should always be `'none'` | | `style-src` | CSS files and inline styles | | `style-src-elem` | CSS via `<link>` and `<style>` | | `style-src-attr` | Inline `style=` attributes | | `img-src` | Images | | `font-src` | Web fonts | | `connect-src` | XHR, fetch(), WebSocket, EventSource, sendBeacon targets | | `media-src` | `<audio>` and `<video>` sources | | `object-src` | `<object>`, `<embed>`, `<applet>`. Should always be `'none'` | | `frame-src` | `<iframe>` and `<frame>` sources | | `child-src` | Worker and frame sources (deprecated; use frame-src and worker-src) | | `worker-src` | Web Workers, Shared Workers, Service Workers | | `manifest-src` | PWA manifest URL | | `prefetch-src` | Prefetched resources (deprecated, removed from spec) | **Document directives (control page properties):** | Directive | Controls | |---|---| | `base-uri` | What `<base href="...">` may set as the base URL. Should always be `'self'` or `'none'` | | `sandbox` | Apply iframe sandbox restrictions to the document itself | **Navigation directives:** | Directive | Controls | |---|---| | `form-action` | URLs that forms may submit to | | `frame-ancestors` | What origins may embed this page in a frame (replaces X-Frame-Options) | **Reporting:** | Directive | Controls | |---|---| | `report-uri <url>` | URL to send violation reports (legacy) | | `report-to <group>` | Reporting API group to send violation reports (modern) | **Trusted Types:** | Directive | Controls | |---|---| | `require-trusted-types-for 'script'` | Requires Trusted Types for DOM XSS sinks | | `trusted-types <policy-name>` | Allow listed Trusted Types policies | ### 6.3 Source Values What can appear inside a directive's value list: | Value | Meaning | |---|---| | `'self'` | Same origin (scheme, host, port match) | | `'none'` | Nothing allowed | | `'unsafe-inline'` | Allow inline scripts and styles. Bypasses most XSS protection. **Avoid** | | `'unsafe-eval'` | Allow `eval()`, `setTimeout(string)`, `new Function()`. **Avoid** | | `'unsafe-hashes'` | Allow inline event handlers if they match a hash. Niche | | `'strict-dynamic'` | Trust propagates from nonce'd/hash'd scripts to scripts they load. The modern best practice | | `'nonce-<base64>'` | Allow inline scripts/styles that have this nonce attribute | | `'sha256-<base64>'` | Allow inline scripts/styles whose content hashes to this value | | `https:` | Any HTTPS origin | | `http:` | Any HTTP origin | | `data:` | Data URLs (`data:image/png;base64,...`). For images and fonts; **never** for scripts | | `blob:` | Blob URLs | | `filesystem:` | Filesystem URLs | | `mediastream:` | MediaStream URLs | | `https://example.com` | Specific origin | | `https://*.example.com` | Wildcard host | | `https://example.com:8443` | Specific port | | `*` | Any origin (use sparingly) | ### 6.4 The Nonce Plus strict-dynamic Pattern (Modern Best Practice) The single most important CSP pattern in 2026 is `nonce + 'strict-dynamic'` for script-src. It solves the impossible problem of allowing legitimate inline scripts (GTM bootstrap, analytics, custom inline data) without allowing arbitrary attacker injected scripts. How it works: 1. Server generates a fresh random nonce per response (e.g. 16 random bytes base64 encoded). 2. Server includes the nonce in the CSP header: `script-src 'nonce-aBc123XyZ' 'strict-dynamic'`. 3. Server emits inline scripts with the matching nonce attribute: `<script nonce="aBc123XyZ">...</script>`. 4. Browser allows these scripts. Attacker injected scripts (which do not know the nonce) are blocked. 5. `'strict-dynamic'` means: any script loaded by a nonce'd script is also trusted, recursively. So GTM (nonce'd) can inject its tags without you having to list every Google domain. Implementation requires generating the nonce server side. Nginx alone cannot do this; you need a sidecar (FastAPI, Lua via OpenResty, or similar) to inject a fresh nonce into both the header and the HTML body. FastAPI sidecar example: ```python import secrets import base64 from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse app = FastAPI() @app.get("/") async def index(request: Request): nonce = base64.b64encode(secrets.token_bytes(16)).decode() html = f''' <!DOCTYPE html> <html lang="en-US"> <head> <meta charset="utf-8"> <title>Example</title> <script nonce="{nonce}"> // GTM bootstrap with nonce (function(w,d,s,l,i){{w[l]=w[l]||[];w[l].push({{'gtm.start': new Date().getTime(),event:'gtm.js'}});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;j.setAttribute('nonce','{nonce}'); f.parentNode.insertBefore(j,f);}})(window,document,'script','dataLayer','GTM-PM56NF52'); </script> </head> <body>...</body> </html> ''' csp = ( f"default-src 'self'; " f"script-src 'nonce-{nonce}' 'strict-dynamic' https: 'unsafe-inline'; " f"style-src 'self' 'unsafe-inline'; " f"img-src 'self' data: https:; " f"font-src 'self'; " f"connect-src 'self' https://*.google-analytics.com https://*.googletagmanager.com; " f"frame-ancestors 'none'; " f"base-uri 'self'; " f"form-action 'self'; " f"object-src 'none'; " f"upgrade-insecure-requests" ) return HTMLResponse( content=html, headers={"Content-Security-Policy": csp} ) ``` The `'unsafe-inline'` plus `https:` after the nonce is a backward compatibility fallback: older browsers that do not understand `'strict-dynamic'` (none in 2026, but defense in depth) fall back to those values. Browsers that understand `'strict-dynamic'` ignore them. This is the documented pattern from web.dev/strict-csp. ### 6.5 Static Sites Without Server Side Nonce Generation Pure static sites (no FastAPI sidecar) cannot generate nonces. Three alternatives: **1. Hash based CSP for known inline scripts.** Compute the SHA256 hash of each inline script at build time, embed the hashes in the CSP header. Only works if scripts never change. GTM does not work this way because its content changes. ```nginx add_header Content-Security-Policy "script-src 'self' 'sha256-LjyKKKsXNcXyTtUH3GcRbE2QEm2X3MfFs6ZkPHfZQiM=' https://www.googletagmanager.com" always; ``` **2. Origin allow list for everything.** Less secure but simpler to maintain. ```nginx add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com" always; ``` This is what most Bubbles static sites use. `'unsafe-inline'` is not ideal but acceptable given the controlled content of the site. **3. Move dynamic responses to the FastAPI sidecar.** Pages that need nonce based CSP (logged in areas, dashboards) go through the sidecar; static marketing pages use origin allow list CSP. ### 6.6 Report-Only Mode (Mandatory For First Deploy) The `Content-Security-Policy-Report-Only` header has identical syntax to `Content-Security-Policy` but does not enforce. Violations are reported to the configured endpoint but resources still load. This is mandatory for the first deploy of any new CSP: it surfaces every violation without breaking the site. ```nginx # Report-only during testing add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline'; report-uri /csp-report" always; location = /csp-report { proxy_pass http://127.0.0.1:9090; } ``` The FastAPI sidecar receives reports as `application/csp-report` content type: ```python @app.post("/csp-report") async def csp_report(request: Request): report = await request.json() logging.warning(f"CSP violation: {report}") return Response(status_code=204) ``` After at least a week of monitoring with zero unexpected violations, switch from `Content-Security-Policy-Report-Only` to `Content-Security-Policy` (the enforcing variant) and the policy goes live. ### 6.7 The frame-ancestors Directive (Replaces X-Frame-Options) `frame-ancestors` controls which origins may embed this page in an iframe. It supersedes `X-Frame-Options` and is more flexible (supports multiple origins, wildcards, etc). ``` Content-Security-Policy: frame-ancestors 'none' Content-Security-Policy: frame-ancestors 'self' Content-Security-Policy: frame-ancestors https://partner.example.com Content-Security-Policy: frame-ancestors 'self' https://*.example.com ``` | Value | Meaning | |---|---| | `'none'` | No embedding allowed (equivalent to X-Frame-Options: DENY) | | `'self'` | Only same origin embedding (equivalent to X-Frame-Options: SAMEORIGIN) | | `https://example.com` | Only this specific origin | | `https://*.example.com` | Any subdomain of example.com | Modern browsers respect `frame-ancestors` over `X-Frame-Options` when both are present and conflict. Best practice: set both for belt and suspenders. ### 6.8 The upgrade-insecure-requests Directive ``` Content-Security-Policy: upgrade-insecure-requests ``` Tells the browser to automatically upgrade any `http://` resource references in the page to `https://`. Useful for fixing mixed content during a slow HTTPS migration. Once all content is on HTTPS, this directive is harmless and can be left in place. ### 6.9 How To Build It On Bubbles **Minimal CSP for static Bubbles site without third party scripts:** ```nginx add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always; ``` This is locked down. No third party resources. Only inline styles are allowed (which most templates need for theme injection). No inline scripts. **Standard Bubbles CSP with GTM, GA4, and common third parties:** ```nginx add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://www.googleadservices.com https://googleads.g.doubleclick.net https://connect.facebook.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https: blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://www.facebook.com; frame-src 'self' https://www.googletagmanager.com https://www.google.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always; ``` This is the typical Bubbles production CSP. Allows all the common analytics and tag manager domains. Still blocks `object-src` and `frame-ancestors`. **Strict CSP with nonce (requires FastAPI sidecar):** ```nginx location / { proxy_pass http://127.0.0.1:9090; # CSP header is generated by the sidecar per request with a fresh nonce # Nginx does not override } ``` The sidecar emits the CSP header with the nonce, as shown in Section 6.4. **Report-Only deployment:** ```nginx # First week: report only, see what breaks add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; report-uri /csp-report" always; # After validating: enforce # add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; report-uri /csp-report" always; ``` ### 6.10 How To Verify ```bash # 1. Confirm CSP is sent curl -sI https://example.com/ | grep -i content-security-policy # 2. Pretty print the directive list curl -sI https://example.com/ | grep -i content-security-policy | tr ';' '\n' | sed 's/^ */ /' # 3. Test in Chrome DevTools: # Open the site, open DevTools (F12), Console tab # Any CSP violations appear in red # Network tab: blocked resources show "blocked:csp" in Status # 4. Use external scanner echo "Visit: https://csp-evaluator.withgoogle.com/?csp=https://example.com/" # 5. Use observatory.mozilla.org echo "Visit: https://observatory.mozilla.org/analyze/example.com" # 6. Verify that 'strict-dynamic' is correctly understood (if used) # Look for 'strict-dynamic' in the CSP value curl -sI https://example.com/ | grep -i content-security-policy | grep -o "'strict-dynamic'" # 7. Test that a CSP violation is reported (for Report-Only) curl -sX POST https://example.com/csp-report \ -H "Content-Type: application/csp-report" \ -d '{"csp-report":{"document-uri":"https://example.com/","violated-directive":"script-src","blocked-uri":"https://evil.com/script.js"}}' # Should return 204 No Content ``` ### 6.11 Troubleshooting **Symptom: GTM not loading; console shows "Refused to execute inline script".** Cause: CSP `script-src` does not allow GTM's inline bootstrap or its loaded scripts. Fix: add `https://www.googletagmanager.com https://www.google-analytics.com` to script-src. If using strict CSP, ensure the GTM snippet has the matching nonce attribute. See Section 6.4. **Symptom: GA4 events not firing; no console errors but no data in GA.** Cause: CSP `connect-src` does not allow the GA endpoint. Fix: add `https://*.google-analytics.com https://*.analytics.google.com` to connect-src. Verify with Network tab in DevTools (look for blocked requests to `region1.google-analytics.com` or similar). **Symptom: External fonts not loading; falls back to system fonts.** Cause: CSP `font-src` does not allow the font origin. Fix: add the font origin (`https://fonts.gstatic.com` for Google Fonts) to font-src. **Symptom: Inline event handlers (`<button onclick="...">`) not working.** Cause: CSP blocks inline event handlers unless `'unsafe-inline'` or matching hash is allowed for script-src-attr. The modern recommendation is to move these to addEventListener calls in JavaScript. Fix: refactor inline handlers to use proper event listeners. Or add `script-src-attr 'unsafe-inline'` (less secure). **Symptom: Images displaying as broken links; console shows CSP block.** Cause: img-src does not allow the image origin. Fix: list the origin in img-src. For dynamic images from various CDNs, `img-src 'self' data: https:` is a common permissive value. **Symptom: Page works in development but breaks in production.** Cause: production CSP is stricter than development. Test environments often use permissive CSP for debugging. Fix: deploy in Report-Only mode first to surface every violation, fix them, then enforce. **Symptom: CSP report endpoint receiving millions of reports.** Causes: 1. Browser extensions injecting content trigger spurious violations. Filter reports by `source-file` to exclude extension origins. 2. A real ongoing XSS attack. Investigate immediately. 3. A legitimate resource was missed in the policy. Add it. **Symptom: Header value exceeds size limit.** Large CSP with many origins can exceed default header size limits (8 KB nginx default). Either increase the limit: ```nginx proxy_buffer_size 16k; proxy_buffers 8 16k; large_client_header_buffers 4 16k; ``` Or simplify the CSP by using wildcards (`https://*.googleapis.com` instead of listing each subdomain). ### 6.12 How To Fix Common Breakage **Case: GTM works in browser but CSP reports show violations from server side rendering.** The SSR pipeline does not include the nonce in script tags. Audit the template engine to ensure nonce attribute is injected on every inline script. **Case: Need to allow a third party script that loads other scripts dynamically.** Use `'strict-dynamic'` pattern (Section 6.4). The first script gets a nonce; any scripts it loads are trusted automatically. **Case: Site has hundreds of inline event handlers (legacy code).** Short term: add `'unsafe-inline'` to script-src-attr. Long term: refactor to addEventListener. **Case: CSP blocking legitimate AdSense scripts.** AdSense requires permissive CSP. The minimum directives needed: ``` script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://*.googleadservices.com https://*.googlesyndication.com https://*.doubleclick.net; img-src 'self' data: https:; frame-src https://googleads.g.doubleclick.net https://www.google.com; connect-src 'self' https://pagead2.googlesyndication.com https://*.googleadservices.com; ``` AdSense's full domain list changes occasionally; monitor reports. **Case: Want to use SharedArrayBuffer but it is undefined.** Cross origin isolation required. Implement COEP plus COOP (Section 14). CSP alone does not enable SharedArrayBuffer. --- ## 7. X-FRAME-OPTIONS (CLICKJACKING DEFENSE, MOSTLY SUPERSEDED BY CSP FRAME-ANCESTORS) ### 7.1 What It Does `X-Frame-Options` controls whether the browser may render this page inside a `<frame>`, `<iframe>`, `<embed>`, or `<object>`. Defined in RFC 7034. Superseded by CSP `frame-ancestors` for new development, but still widely deployed and respected. ``` X-Frame-Options: DENY X-Frame-Options: SAMEORIGIN ``` The clickjacking attack: attacker creates a page with an invisible iframe pointing at your site, overlaid with decoy elements. User clicks what they think is a button on the attacker's page, actually clicks an authorized action on your site (transfer money, change settings, accept terms). `X-Frame-Options: DENY` prevents the iframe from rendering at all. ### 7.2 Values | Value | Meaning | |---|---| | `DENY` | No site (including same origin) may embed this in a frame | | `SAMEORIGIN` | Only same origin pages may embed | | `ALLOW-FROM <uri>` | Deprecated. Only this specific origin may embed. Not supported in Chrome or Safari; use CSP frame-ancestors instead | ### 7.3 Relationship to CSP frame-ancestors `frame-ancestors` (in CSP) is more capable. It supports: * Multiple origins (X-Frame-Options can list only one). * Wildcards. * Granular path control. * Modern browsers respect it over X-Frame-Options when both are present and conflict. **Bubbles policy:** set both. The redundancy costs nothing and protects against older browsers or misconfigured intermediaries. ### 7.4 How To Build It On Bubbles For a site that should never be embedded: ```nginx add_header X-Frame-Options "DENY" always; # CSP equivalent (set both) add_header Content-Security-Policy "frame-ancestors 'none'" always; ``` For a site that may be embedded by same origin pages (most cases): ```nginx add_header X-Frame-Options "SAMEORIGIN" always; add_header Content-Security-Policy "frame-ancestors 'self'" always; ``` For a site that may be embedded by a specific partner: ```nginx # X-Frame-Options cannot do this safely in modern browsers # Use CSP frame-ancestors only add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example.com" always; ``` The Bubbles default in `snippets/common-security-headers.conf`: ```nginx add_header X-Frame-Options "SAMEORIGIN" always; ``` The CSP equivalent is set in the per site CSP. ### 7.5 How To Verify ```bash # 1. Confirm X-Frame-Options is sent curl -sI https://example.com/ | grep -i x-frame-options # Expected: x-frame-options: SAMEORIGIN # 2. Test actual frame blocking # Create a test HTML file locally: cat > /tmp/clickjack-test.html << 'EOF' <html><body> <h1>Clickjack test</h1> <iframe src="https://example.com/" width="800" height="600"></iframe> </body></html> EOF # Open the file in a browser; the iframe should be blocked or empty # 3. Verify in DevTools Network tab: the framed request shows "X-Frame-Options block" in Console # 4. Confirm CSP frame-ancestors agrees curl -sI https://example.com/ | grep -i content-security-policy | grep -o "frame-ancestors [^;]*" ``` ### 7.6 Troubleshooting **Symptom: Legitimate iframe of own site is blocked.** X-Frame-Options is DENY. Change to SAMEORIGIN. **Symptom: Embedded partner integration blocked.** ALLOW-FROM is not supported in Chrome or Safari. Use CSP frame-ancestors instead and remove ALLOW-FROM. **Symptom: Scanner reports missing X-Frame-Options.** Header is not set. Add to common-security-headers.conf and reload. **Symptom: X-Frame-Options and frame-ancestors disagree.** Modern browsers honor frame-ancestors. Older browsers honor X-Frame-Options. Set both to consistent values to avoid version specific behavior. ### 7.7 How To Fix Common Breakage **Case: Need to allow embedding from a partner site after launch.** Update CSP frame-ancestors (X-Frame-Options cannot list specific allowed origins): ```nginx add_header Content-Security-Policy "... frame-ancestors 'self' https://partner.example.com; ..." always; # Keep X-Frame-Options for older browsers: add_header X-Frame-Options "SAMEORIGIN" always; ``` The X-Frame-Options says "same origin or nothing" which is more restrictive than frame-ancestors allows, but modern browsers prefer frame-ancestors so the partner can embed. --- ## 8. X-CONTENT-TYPE-OPTIONS (MIME SNIFFING PREVENTION) ### 8.1 What It Does `X-Content-Type-Options: nosniff` tells the browser to treat the declared `Content-Type` header as authoritative and not attempt to guess (sniff) the content type from the body bytes. The only valid value is `nosniff`. Defined in WHATWG Fetch specification. ``` X-Content-Type-Options: nosniff ``` Without nosniff, the browser may sniff content. A response declared `text/plain` containing HTML and JavaScript may be sniffed as HTML and rendered, executing the JavaScript. This is the basis of the user upload XSS attack class discussed in framework-http-content-headers.md Section 9.5. With nosniff, declared types are honored. A file declared `text/plain` is displayed as text even if the body contains executable HTML. ### 8.2 Why It Is Always Set To nosniff The header has effectively one purpose: enable nosniff. There is no situation in 2026 where you want MIME sniffing. Always set: ``` X-Content-Type-Options: nosniff ``` ### 8.3 How To Build It On Bubbles In the shared security header snippet: ```nginx # /etc/nginx/snippets/common-security-headers.conf add_header X-Content-Type-Options "nosniff" always; ``` Included in every location across the Bubbles fleet. ### 8.4 How To Verify ```bash # Confirm header is present on every response curl -sI https://example.com/ | grep -i x-content-type-options # Expected: x-content-type-options: nosniff # Check across asset types for path in / /css/main.css /js/app.js /images/logo.png /downloads/file.pdf; do HEADER=$(curl -sI "https://example.com$path" 2>/dev/null | grep -i x-content-type-options) echo "$path: $HEADER" done # Verify behavior with a deliberately misdeclared file # (Test only on a non production site) # Create a .txt file containing HTML echo '<script>alert("test")</script>' > /var/www/sites/test.example.com/test.txt curl -sI https://test.example.com/test.txt | grep -iE "content-type|x-content-type-options" # Should show: content-type: text/plain # Should show: x-content-type-options: nosniff # Browser should display the text, NOT execute the script ``` ### 8.5 Troubleshooting **Symptom: Stored XSS via uploaded HTML file.** nosniff is missing OR Content-Type is wrong (declared as `text/html` when it should be `application/octet-stream` for downloads). Fix: ensure nosniff is set site wide, ensure user uploads are served with `Content-Type: application/octet-stream` and `Content-Disposition: attachment` (see framework-http-content-headers.md Section 9.5). **Symptom: Old browsers ignoring nosniff.** Internet Explorer and very old browsers had inconsistent nosniff support. Not a concern in 2026. **Symptom: Header missing on some file types.** The `add_header` inheritance trap. A location block redeclared headers without including the snippet. Audit: ```bash nginx -T 2>/dev/null | grep -B5 "add_header" | grep -B1 "x-content-type-options" | head -50 ``` ### 8.6 How To Fix Common Breakage **Case: Pen tester reports missing nosniff on some endpoints.** Add `include snippets/common-security-headers.conf` to every location that redeclares any add_header. **Case: A specific file needs sniffing for legacy reasons.** This is an antipattern. Fix the underlying Content-Type instead. nosniff should never be removed. --- ## 9. REFERRER-POLICY (OUTBOUND URL PRIVACY) ### 9.1 What It Does `Referrer-Policy` controls how much of the current URL (the referrer) is included in the `Referer` HTTP header on outbound requests (links, images, scripts, fetch). Defined in W3C Referrer Policy spec. Critical for privacy because URLs often contain session tokens, password reset codes, search queries, and other sensitive data. ``` Referrer-Policy: strict-origin-when-cross-origin Referrer-Policy: no-referrer Referrer-Policy: same-origin ``` The default browser behavior (when no header is set) varies by browser, but most modern browsers default to `strict-origin-when-cross-origin` since 2020. Setting the header explicitly removes the ambiguity. ### 9.2 The Eight Values | Value | Meaning | |---|---| | `no-referrer` | Never send Referer. Maximum privacy. Some analytics break | | `no-referrer-when-downgrade` | Send full URL except when going from HTTPS to HTTP. Legacy default | | `origin` | Send only the origin (`https://example.com`), no path or query | | `origin-when-cross-origin` | Send full URL same origin, origin only cross origin | | `same-origin` | Send full URL same origin, nothing cross origin | | `strict-origin` | Send origin only, except when HTTPS to HTTP (nothing) | | `strict-origin-when-cross-origin` | Full URL same origin, origin cross origin, nothing HTTPS to HTTP. **Modern default and recommendation** | | `unsafe-url` | Always send full URL. Maximum leakage. Never use | ### 9.3 Why strict-origin-when-cross-origin Is The Right Default The default value chosen by modern browsers and recommended by web.dev: * Within your own origin, full URL is sent (useful for analytics and internal tracking). * To other HTTPS origins, only your origin is sent (no path, no query, no token leakage). * To HTTP origins, nothing is sent (no transport downgrade leakage). For most sites this is correct. For sites that want even more privacy (e.g. anything healthcare related, anything with strong PII), use `same-origin` (nothing cross origin) or `no-referrer` (nothing ever). ### 9.4 How To Build It On Bubbles In the shared security header snippet: ```nginx # /etc/nginx/snippets/common-security-headers.conf add_header Referrer-Policy "strict-origin-when-cross-origin" always; ``` For a healthcare or HIPAA aware site, increase to `same-origin`: ```nginx add_header Referrer-Policy "same-origin" always; ``` For specific high sensitivity pages (password reset, account settings): ```nginx location ~ ^/(account|password-reset|verify) { include snippets/common-security-headers.conf; add_header Referrer-Policy "no-referrer" always; } ``` ### 9.5 How To Verify ```bash # 1. Confirm header is present curl -sI https://example.com/ | grep -i referrer-policy # Expected: referrer-policy: strict-origin-when-cross-origin # 2. Test actual referrer behavior # Visit your site, click an external link, check the destination's logs # Or use https://httpbin.org/headers as the click target echo "Visit your site, click a link to https://httpbin.org/headers" echo "Check the 'Referer' header in the response" # 3. Verify per page override works curl -sI https://example.com/account | grep -i referrer-policy ``` ### 9.6 Troubleshooting **Symptom: Analytics platform shows zero referrer data.** Referrer-Policy is set to `no-referrer` or `same-origin`, which is correctly preventing referrer from leaving the origin. Fix: this is by design. If analytics needs cross origin referrer, change to `strict-origin-when-cross-origin` (origin only) or accept the privacy tradeoff. **Symptom: Password reset URL with token appearing in third party logs.** Referrer-Policy not set, or set to `unsafe-url` or `no-referrer-when-downgrade`. The full URL including the token is being leaked. Fix: set to `strict-origin-when-cross-origin` site wide, or to `no-referrer` on the password reset endpoint specifically. **Symptom: Affiliate tracking does not record source.** Affiliate tracking systems rely on the Referer header. If your site sets `no-referrer`, the affiliate link's tracking pixel cannot identify the source. Fix: discuss tradeoffs. The affiliate cannot get full URL data; they can get origin. `strict-origin-when-cross-origin` is the compromise. ### 9.7 How To Fix Common Breakage **Case: Need to allow third party affiliates to know which page sent traffic.** Use `origin` or `strict-origin-when-cross-origin`. The affiliate sees your domain but not the specific page. **Case: Want to leak nothing across origins for compliance reasons.** Use `same-origin`. Combine with explicit click handling for affiliate tracking (passing source data in URL parameters rather than relying on Referer). --- ## 10. PERMISSIONS-POLICY (BROWSER CAPABILITY CONTROL) ### 10.1 What It Does `Permissions-Policy` controls which browser APIs and features may be used on this page, including by third party iframes and scripts. Replaces the older `Feature-Policy` header (same purpose, different syntax). Defined in W3C Permissions Policy spec. ``` Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() Permissions-Policy: geolocation=(self), camera=(self "https://video.example.com") ``` The header tells the browser: "even if a script on this page tries to access the camera, refuse unless explicitly allowed". This protects users from third party scripts (analytics, ads, social widgets) silently activating sensitive APIs. ### 10.2 Syntax The header value is a comma separated list of directives. Each directive has the form `feature=(<allowlist>)`. The allowlist is a space separated list of origins or special values inside parentheses. ``` Permissions-Policy: camera=(), microphone=(self), geolocation=(self "https://maps.example.com") ``` Allowlist values: | Value | Meaning | |---|---| | `()` (empty) | Feature disabled everywhere | | `(self)` | Feature allowed only in this document and same origin iframes | | `(*)` | Feature allowed everywhere including cross origin iframes | | `("https://example.com")` | Feature allowed in this origin | | `(self "https://example.com")` | Same origin plus the specified origin | ### 10.3 Common Directives The full list is large and growing. Most commonly configured: **Privacy sensitive APIs (lock down by default):** | Directive | Controls | |---|---| | `camera` | Camera access (getUserMedia) | | `microphone` | Microphone access (getUserMedia) | | `geolocation` | Geolocation API | | `payment` | Payment Request API | | `usb` | WebUSB API | | `bluetooth` | Web Bluetooth API | | `serial` | Web Serial API | | `hid` | WebHID API | | `midi` | Web MIDI API | | `magnetometer` | Magnetometer sensor | | `gyroscope` | Gyroscope sensor | | `accelerometer` | Accelerometer sensor | | `ambient-light-sensor` | Ambient light sensor | **Browser features (selectively enable):** | Directive | Controls | |---|---| | `fullscreen` | requestFullscreen() | | `picture-in-picture` | Picture in Picture API | | `autoplay` | Video and audio autoplay | | `clipboard-read` | Clipboard read API | | `clipboard-write` | Clipboard write API | | `notifications` | Web Notifications | | `push` | Push API | | `screen-wake-lock` | Screen Wake Lock API | **Privacy and tracking (block these):** | Directive | Controls | |---|---| | `interest-cohort` | FLoC (deprecated). Set to `()` to opt out historically | | `browsing-topics` | Topics API (FLoC's replacement). Set to `()` to opt out of tracking cohort calculation | | `attribution-reporting` | Attribution Reporting API | | `join-ad-interest-group` | Protected Audience API (FLEDGE) | | `run-ad-auction` | Protected Audience API (FLEDGE) | | `private-aggregation` | Private Aggregation API | **Performance:** | Directive | Controls | |---|---| | `unload` | Allow `unload` event handlers. Setting `()` blocks them, which improves bfcache eligibility and Core Web Vitals | | `sync-xhr` | Synchronous XMLHttpRequest. Setting `()` blocks (recommended) | | `cross-origin-isolated` | Whether the document may be cross origin isolated | ### 10.4 The Bubbles Baseline For a typical Bubbles site that does not need camera, mic, or other sensitive APIs: ```nginx add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), serial=(), hid=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=(), ambient-light-sensor=(), browsing-topics=(), interest-cohort=()" always; ``` This: * Disables every sensitive device API. * Opts out of FLoC and Topics tracking cohorts. * Leaves general browser features (fullscreen, autoplay, notifications) at default permissive. For a site that uses some features (e.g. video conferencing): ```nginx add_header Permissions-Policy "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=(), bluetooth=()" always; ``` For maximum lockdown plus performance optimization: ```nginx add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), browsing-topics=(), interest-cohort=(), unload=(), sync-xhr=()" always; ``` ### 10.5 How To Build It On Bubbles In the shared security header snippet: ```nginx # /etc/nginx/snippets/common-security-headers.conf add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), browsing-topics=(), interest-cohort=()" always; ``` For specific sites with different needs (medical practice using video conferencing, etc), override at the server level: ```nginx server { server_name telehealth.example.com; # Standard headers minus permissions, since we override include snippets/common-security-headers-no-permissions.conf; add_header Permissions-Policy "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=(), bluetooth=()" always; } ``` ### 10.6 How To Verify ```bash # 1. Confirm header is present curl -sI https://example.com/ | grep -i permissions-policy # 2. Pretty print curl -sI https://example.com/ | grep -i permissions-policy | tr ',' '\n' | sed 's/^ */ /' # 3. Test a blocked API in browser console (DevTools): # navigator.geolocation.getCurrentPosition(p => console.log(p), e => console.error(e)) # Should show error like "Geolocation is blocked by permissions policy" # 4. Check via observatory.mozilla.org or securityheaders.com echo "Visit: https://securityheaders.com/?q=https://example.com/" ``` ### 10.7 Troubleshooting **Symptom: Feature blocked unexpectedly.** Cause: Permissions-Policy disables it. Fix: add `(self)` or appropriate allowlist to the directive. **Symptom: Console warning "Error with Permissions-Policy header: Origin trial controlled feature not enabled: 'interest-cohort'".** Cause: browser does not recognize `interest-cohort` (it was deprecated). The warning is harmless; the directive is ignored by browsers that do not implement it. Fix: optionally remove `interest-cohort=()` from the header (Topics API replaced FLoC; use `browsing-topics=()`). **Symptom: Browser does not honor a third party iframe's request for camera.** Cause: parent document's Permissions-Policy does not allow camera in cross origin iframes. Fix: explicitly allow: `camera=(self "https://embedded.example.com")`. **Symptom: Header has wrong syntax (old Feature-Policy syntax).** Old syntax: `camera 'none'; microphone 'self'`. New syntax: `camera=(), microphone=(self)`. Fix: convert to new syntax. The two headers can coexist for older browsers but the new syntax is canonical. ### 10.8 How To Fix Common Breakage **Case: Browser console warning about unknown Permissions-Policy feature.** Browser does not recognize the feature name (maybe it is too new or too old). Unknown features are ignored, so the warning is harmless. To silence, remove the unknown feature. **Case: Need to allow geolocation on a specific page only.** Override at the location: ```nginx location = /find-store { include snippets/common-security-headers-base.conf; add_header Permissions-Policy "geolocation=(self), camera=(), microphone=(), payment=(), usb=(), bluetooth=()" always; } ``` **Case: Site uses Google Maps which requests geolocation.** Allow geolocation for the Google Maps origin: ```nginx add_header Permissions-Policy 'geolocation=(self "https://maps.googleapis.com"), camera=(), microphone=(), payment=()' always; ``` --- ## 11. CROSS-ORIGIN-RESOURCE-POLICY (CORP, WHO CAN LOAD THIS RESOURCE) ### 11.1 What It Does `Cross-Origin-Resource-Policy` (CORP) is set on a resource (image, script, stylesheet, font, JSON) to tell the browser which origins are allowed to load it. Defined in the WHATWG Fetch spec. Part of the cross origin isolation pattern. ``` Cross-Origin-Resource-Policy: same-origin Cross-Origin-Resource-Policy: same-site Cross-Origin-Resource-Policy: cross-origin ``` CORP differs from CORS: * **CORS** allows a foreign origin to load your resource (you opt them in via response headers). * **CORP** restricts who may load your resource (you opt out cross origin embedding by default). A resource with `Cross-Origin-Resource-Policy: same-origin` cannot be loaded by any other origin, even via `<img src>` or `<script src>`. This blocks Spectre style side channel attacks and resource theft. ### 11.2 Values | Value | Meaning | |---|---| | `same-origin` | Only the exact same origin (scheme, host, port) may load this | | `same-site` | Only the same site may load this (different subdomains OK, different sites not) | | `cross-origin` | Any origin may load this (default behavior) | ### 11.3 When To Set Each | Resource type | Recommended CORP | |---|---| | Private user content (logged in user's profile photo) | `same-origin` | | Site brand assets used only on the main site | `same-origin` or `same-site` | | Resources you want partner sites to embed (logos, badges) | `cross-origin` | | Public CDN assets used by many sites (fonts, libraries) | `cross-origin` | | Resources that must be loadable when the embedding site has COEP | `cross-origin` (or absent, but absent means the embedder might block via COEP) | ### 11.4 How To Build It On Bubbles For private user content: ```nginx location /user-content/ { add_header Cross-Origin-Resource-Policy "same-origin" always; } ``` For brand assets restricted to the main site: ```nginx location ~* \.(jpg|jpeg|png|webp|svg|woff2)$ { add_header Cross-Origin-Resource-Policy "same-site" always; } ``` For publicly embeddable resources (badges, logos that partners may embed): ```nginx location /embed/ { add_header Cross-Origin-Resource-Policy "cross-origin" always; } ``` The Bubbles default for a client site: `same-site` for all assets. This protects against hot linking and Spectre style attacks while allowing legitimate same site usage. ### 11.5 How To Verify ```bash # 1. Confirm CORP is set curl -sI https://example.com/images/logo.png | grep -i cross-origin-resource-policy # 2. Test that a cross origin embed fails # Create a test page on a different origin attempting to load the asset # Browser console will show: NotSameOriginAfterDefaultedToSameOriginByCoep # Or: blocked:NotSameOrigin # 3. Verify for all asset classes for path in /css/main.css /js/app.js /images/logo.png /fonts/main.woff2; do HEADER=$(curl -sI "https://example.com$path" 2>/dev/null | grep -i cross-origin-resource-policy) echo "$path: $HEADER" done ``` ### 11.6 Troubleshooting **Symptom: External site cannot embed our logo.** CORP is `same-origin` or `same-site`. Either: 1. Change to `cross-origin` for that specific path if external embedding is desired. 2. Refuse the embedding by maintaining the current value. **Symptom: Our own embedded resource on a different subdomain blocked.** CORP is `same-origin`. Change to `same-site` to allow subdomains. **Symptom: COEP is enabled, all images blocked.** COEP requires every cross origin resource to opt in via CORP. The blocked images are missing CORP. Either: 1. Add CORP to the resources. 2. Use CORS instead. 3. Switch COEP to `credentialless` mode. ### 11.7 How To Fix Common Breakage **Case: CDN fonts blocked after enabling COEP.** Google Fonts (when self hosted from googleapis.com) does not send CORP. Either: 1. Self host the fonts on Bubbles (recommended for performance anyway). 2. Use COEP `credentialless` mode. **Case: Want to allow only specific partners to embed.** CORP cannot do origin specific allowlisting. Use CORS instead: `Access-Control-Allow-Origin: https://partner.example.com`. --- ## 12. CROSS-ORIGIN-EMBEDDER-POLICY (COEP, WHAT THIS DOCUMENT CAN EMBED) ### 12.1 What It Does `Cross-Origin-Embedder-Policy` (COEP) is set on a document and controls how that document may load cross origin resources. Required for cross origin isolation, which unlocks SharedArrayBuffer, high precision timers, and WebAssembly threading. Defined in the WHATWG HTML spec. ``` Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Embedder-Policy: credentialless Cross-Origin-Embedder-Policy: unsafe-none ``` When COEP is enabled, every cross origin resource the document loads must explicitly opt in via: * CORP (`Cross-Origin-Resource-Policy: cross-origin` or compatible). * CORS (`Access-Control-Allow-Origin: <embedder origin>`). Without opt in, the browser refuses to load the resource. This prevents the embedder from accidentally pulling cross origin data into its process memory. ### 12.2 Values | Value | Meaning | |---|---| | `unsafe-none` | Default. No restriction. Cross origin resources load freely | | `require-corp` | Strict. Cross origin resources must opt in via CORP or CORS | | `credentialless` | Lenient. Cross origin requests sent without credentials (no cookies, no client certificates). Easier to adopt than require-corp | ### 12.3 require-corp vs credentialless **require-corp** is the original mode. Every cross origin resource (image, script, font, iframe) must send a CORP header or be CORS enabled. This is the most secure but hardest to deploy because every third party (CDN, font provider, image host, ad network) must cooperate. **credentialless** is the newer alternative (Chrome 96+, Firefox 119+). Cross origin resources are loaded without credentials (no cookies). Because the request is anonymous, the resource cannot leak the user's identity, and the browser does not need an explicit opt in from the server. Easier to adopt. **Bubbles recommendation:** start with `credentialless` if cross origin isolation is needed. Move to `require-corp` only if a specific third party requirement (analytics endpoint that needs cookies, etc) demands it. ### 12.4 How To Build It On Bubbles For sites that need SharedArrayBuffer or other isolated APIs (rare; mostly WebAssembly applications): ```nginx location / { add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Opener-Policy "same-origin" always; # CORP on every served resource } ``` For typical sites that do not need isolated APIs: ```nginx # Do not set COEP. Default unsafe-none is correct. ``` Most Bubbles sites have no need for cross origin isolation. Do not enable COEP unless a specific feature requires it. ### 12.5 How To Verify ```bash # 1. Check COEP is set curl -sI https://example.com/ | grep -i cross-origin-embedder-policy # 2. Verify cross origin isolation in browser console: # window.crossOriginIsolated # Expected: true (when COEP + COOP set correctly) # 3. Test SharedArrayBuffer availability: # typeof SharedArrayBuffer # Expected: "function" when isolated, "undefined" when not # 4. Check that resources still load (no COEP block errors in console) ``` ### 12.6 Troubleshooting **Symptom: After enabling COEP, half the images on the page fail to load.** Those images are cross origin without CORP. Either: 1. Self host the images. 2. Add CORP to them (requires control of the source server). 3. Switch to `credentialless` COEP. **Symptom: window.crossOriginIsolated is false despite setting COEP.** COEP requires COOP to be `same-origin` AND for the document not to have been opened by a cross origin opener. Verify both headers are set. **Symptom: Third party scripts fail with NetworkError.** Those scripts' origins do not send CORP, and the script needs credentials so credentialless does not work either. Move the script to your own origin (proxy it), or accept that you cannot have cross origin isolation while using that script. ### 12.7 How To Fix Common Breakage **Case: WebAssembly app needs SharedArrayBuffer but it is undefined.** Enable COEP plus COOP: ```nginx location /webapp/ { add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Opener-Policy "same-origin" always; } ``` Verify in console: `crossOriginIsolated === true`. **Case: COEP blocks Google Fonts.** Self host fonts: ```bash # Download woff2 files from Google Fonts to /var/www/sites/example.com/fonts/ # Reference locally in CSS ``` Or switch to `credentialless`. --- ## 13. CROSS-ORIGIN-OPENER-POLICY (COOP, WHAT THIS DOCUMENT CAN SHARE A WINDOW WITH) ### 13.1 What It Does `Cross-Origin-Opener-Policy` (COOP) controls whether this document shares a browsing context group with cross origin documents opened via `window.open` or that have opened this document. Required for cross origin isolation. Defined in the WHATWG HTML spec. ``` Cross-Origin-Opener-Policy: same-origin Cross-Origin-Opener-Policy: same-origin-allow-popups Cross-Origin-Opener-Policy: unsafe-none ``` When COOP is `same-origin`: * If this document opens a cross origin popup via `window.open`, the popup gets a different browsing context group. They cannot communicate via `window.opener`. * If this document was opened by a cross origin window, the opener relationship is severed: `window.opener` is null. * This prevents tab nabbing and reduces side channel attack surface. ### 13.2 Values | Value | Meaning | |---|---| | `unsafe-none` | Default. No isolation. Cross origin windows may communicate via window.opener | | `same-origin-allow-popups` | This document is isolated from cross origin openers, but popups it opens can still communicate back | | `same-origin` | Strict isolation. Required for cross origin isolation. No cross origin window communication | ### 13.3 How To Build It On Bubbles For typical Bubbles sites (defense against tab nabbing): ```nginx add_header Cross-Origin-Opener-Policy "same-origin" always; ``` This is included in the standard `snippets/common-security-headers.conf`.

Search equations, builds, manuscript text, and recovered sources.