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

Audit lab

Why it matters Most of the policies you meet in real work are not yours: you inherit them, or you are asked "is our CSP any good?". Reading a policy by eye is slow and easy to get wrong, especially with the fallback rules. A small, repeatable score lets you compare policies, spot the weakest quickly, and show a team exactly what to change.

Think of a home safety inspector with a clipboard. They do not redesign your house. They tick five boxes (smoke alarm, gas, wiring, locks, escape route), give a score, and hand you a short list of fixes. This lab is that clipboard for CSP.

Rubric: a fixed list of checks, each worth some points, applied the same way to every policy. Because it never changes, two people scoring the same header get the same number.

The five checks

Each check is worth 20 points, for a score out of 100. Use allows and effective_sources from watched.leg3a_csp, which already apply the fallback rules (Lesson 64). Compare sources lower-cased. Keywords keep their single quotes inside the Python string, so object-src 'none' comes back as ["'none'"], not ['none']. The test page is ORIGIN = "https://shopd.example".

NamePasses whenStops
inline-scriptsallows(header, {"kind": "script", "inline": True}, ORIGIN) is Falsean injected inline script
outside-scriptsallows(header, {"kind": "script", "url": "https://prize.example/x.js"}, ORIGIN) is Falsea script from the attacker's server
pluginsthe effective object-src is exactly ["'none'"]old plug-in content
base-urithe effective base-uri is exactly ["'none'"] or ["'self'"]an injected <base> tag
framingframe-ancestors is present, not empty, and has none of *, https:, http:clickjacking

The five policies

FIVE_POLICIES holds five realistic headers: an old shop that allows everything, a blog with script-src 'self' https:, a policy that only sets framing, a strict nonce-based policy, and this site's own (shortened). Try to predict each score before you run the code.

Here is an honest result in advance: floati.life's policy loses the framing points. That is not a hole on the real site, because it sends X-Frame-Options: DENY instead (Lesson 68), and this rubric only reads CSP. The lesson: a score is a tool for asking questions, not a verdict. The policy also keeps 'unsafe-eval', which the rubric does not score, for the in-browser Python.

Fixing the weakest

fix(header) should change as little as possible:

  1. If the header already scores 100, return it unchanged.
  2. Otherwise parse_policy it. Take the effective script sources (effective_sources(policy, "script-src"), which may come from default-src), drop any in DANGEROUS (compare lower-cased), and set script-src to what is left, or ["'self'"] if nothing is left. Named hosts and nonces survive.
  3. Only for checks that failed: set object-src to ["'none'"], base-uri to ["'self'"], frame-ancestors to ["'self'"].
  4. Leave every other directive as it was, and join back with "; ", each directive written as its name and sources separated by spaces.

Dropping 'unsafe-eval' is right for a shop that does not need it. On a real site, check first: some tools, like the Python runner here, truly need it. Write that decision down instead of quietly keeping it.

A common misconception: "a score of 100 means the site is safe". This rubric checks five common gaps. It does not see a nonce reused on every page, a trusted CDN that hosts anyone's files, or DOM injection that Trusted Types would catch. Use it to find the obvious problems fast, then read the policy properly.

In other languages

Parsing a header is the same small loop everywhere: split on ;, then on spaces, first name wins.

// JavaScript
function parsePolicy(header) {
  const policy = {};
  for (const part of header.split(";")) {
    const [name, ...sources] = part.trim().split(/\s+/);
    if (name && !(name.toLowerCase() in policy)) policy[name.toLowerCase()] = sources;
  }
  return policy;
}
// Go
policy := map[string][]string{}
for _, part := range strings.Split(header, ";") {
    bits := strings.Fields(part)
    if len(bits) == 0 { continue }
    name := strings.ToLower(bits[0])
    if _, seen := policy[name]; !seen { policy[name] = bits[1:] }
}
// Rust
let mut policy = std::collections::HashMap::new();
for part in header.split(';') {
    let mut bits = part.split_whitespace();
    if let Some(name) = bits.next() {
        policy.entry(name.to_lowercase()).or_insert(bits.collect::<Vec<_>>());
    }
}

Recap

  • Five checks catch the common gaps: inline scripts, outside scripts, plug-ins, base-uri and framing.
  • Always score with the fallback rules applied; reading by eye misses them.
  • Fix with the smallest change: clean the script list, add the missing directives, keep named hosts and nonces.
  • A score starts the conversation; context, such as X-Frame-Options or a library that needs eval, finishes it.
Your turn

Write three functions:

  1. score_policy(header): run the five checks from the table in the order of CHECKS. Return {"score": 20 * passed, "failed": [names of failed checks, in CHECKS order]}.
  2. weakest(policies): policies is a dict from name to header. Return the name with the lowest score; on a tie, the name that comes first in A to Z order.
  3. fix(header): follow the four steps above so the result scores 100, keeps named script hosts and nonces, and leaves other directives unchanged.
Keep the printing lines at the end: they score FIVE_POLICIES, name the weakest and print its fix. The tests also use 40 random policies from random_policy(seed).

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output