Syllabus Lesson 65 of 156 · Content Security Policy and framing
Content Security Policy and framing

Write a policy

Why it matters A policy that is too loose lets the attack through; a policy that is too tight breaks your own page, and then someone switches CSP off "for now". The skill is writing the tightest policy that still lets your page work. You will do it in code, so it works for any page, and a checker will grade both halves.

Back to the guest list. A good host does not write "anyone in a nice coat". They look at who is actually invited and write those names, and nobody else. Here you do the same: look at what the page really loads, and allow exactly that.

The attack to stop

In Module 10 an attacker's text on a practice page carried script: a <script> block in the guestbook (Lesson 58), an <img onerror> that slipped past the toy filter (Lesson 61), and pretend code that sent cookies to prize.example (Lesson 62). Escaping fixed those. Here you add the second line, in case one spot is ever missed. Injected code usually arrives in one of three shapes, and the practice pages here contain all three:

  • an inline <script>...</script> block;
  • an inline event handler, an attribute such as onerror="..." on an image tag;
  • a <script src="https://prize.example/x.js"> pointing at the attacker's server.

A good policy blocks all three, and still lets the page load its own app script, its style sheet, its images, its payment library, its video frame and its one inline script.

Inline event handler: script written straight into an HTML attribute whose name starts with on, such as onerror or onclick. The browser runs it when that event happens. CSP treats it as inline script.

The practice page and the checker

practice_site(seed) from watched.leg3a_csp returns a page as a dict: its origin and a list of resources. Each resource has a kind (script, style, img, font, connect, frame), either a url or inline: True, and injected, which is True for the attacker's three. The page's own inline script carries a nonce: a random value only the real server knows (Lesson 66 explains it fully).

{"kind": "script", "url": "https://pay.example/lib.js", "injected": False}
{"kind": "script", "inline": True, "nonce": "y334r23xgecgnryn", "injected": False}
{"kind": "script", "inline": True, "handler": True, "injected": True}

evaluate(policy, page) plays the browser. It returns broken (needed resources your policy blocks) and attacks_run (injected ones it lets through). You want both empty. print_report(policy, page) from watched.leg3d_csp_more prints the same thing readably.

A plan

  1. Start every directive with 'self', so your own files load.
  2. For each needed outside URL, add its scheme and host, such as https://pay.example, to the directive for its kind. Not https: and not *: those let prize.example in too.
  3. For the page's own inline script, add 'nonce-VALUE' to script-src. Never 'unsafe-inline'.
  4. End with default-src 'self' for everything else, plus object-src 'none', base-uri 'none' and frame-ancestors 'none', which cost nothing on a page that needs none of them.

Notice what this does to the event handler: no source you can add allows onerror= attributes except 'unsafe-inline', so leaving that keyword out blocks them all. Notice too that each kind gets its own list. If your page fetches data from api.example, that host belongs in connect-src, not script-src; otherwise an attacker who can upload a file there could run it as a script.

A common misconception: "script-src https: is strict because it forces HTTPS". It only forces the scheme. Any attacker can host a script on an HTTPS address.

In other languages

Once you have the header, every server sets it the same way:

// JavaScript (Express)
app.use((req, res, next) => {
  res.set("Content-Security-Policy", "default-src 'self'; object-src 'none'; base-uri 'none'");
  next();
});
// Go (net/http)
w.Header().Set("Content-Security-Policy", "default-src 'self'; object-src 'none'; base-uri 'none'")
// PHP
header("Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none'");
# nginx
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'" always;

In Django, the django-csp package builds the header from settings and adds a fresh nonce to each request, which is how this site does it.

Recap

  • Allow exactly the hosts each kind of resource needs; never * or https: for scripts.
  • Allow your own inline script with a nonce; leaving out 'unsafe-inline' blocks injected blocks and event handlers.
  • Add object-src 'none', base-uri and frame-ancestors, which do not fall back.
  • Check both halves: nothing needed broken, nothing injected run.
Your turn

Write two functions:

  1. source_for(url): return the scheme and host of url, such as "https://cdn.example" for "https://cdn.example/v3.js?x=1" (no path, query or fragment).
  2. write_policy(page): return a CSP header string for a page from practice_site. Skip resources with injected set. For every other resource, use DIRECTIVE_FOR to find its directive; each directive starts with 'self' and gets 'nonce-VALUE' for an inline script, or source_for(url) for a URL on another origin (no duplicates). Join with "; ", starting with default-src 'self' and ending with object-src 'none', base-uri 'none' and frame-ancestors 'none'. Do not use 'unsafe-inline', * or https:.
Keep the last lines, which print a report for practice_site(seed=0). The tests try 20 different practice pages and a hand-made one, and check both that nothing needed is blocked and that no injected script runs.

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output