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".
| Name | Passes when | Stops |
|---|---|---|
inline-scripts | allows(header, {"kind": "script", "inline": True}, ORIGIN) is False | an injected inline script |
outside-scripts | allows(header, {"kind": "script", "url": "https://prize.example/x.js"}, ORIGIN) is False | a script from the attacker's server |
plugins | the effective object-src is exactly ["'none'"] | old plug-in content |
base-uri | the effective base-uri is exactly ["'none'"] or ["'self'"] | an injected <base> tag |
framing | frame-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:
- If the header already scores 100, return it unchanged.
- Otherwise
parse_policyit. Take the effective script sources (effective_sources(policy, "script-src"), which may come fromdefault-src), drop any inDANGEROUS(compare lower-cased), and setscript-srcto what is left, or["'self'"]if nothing is left. Named hosts and nonces survive. - Only for checks that failed: set
object-srcto["'none'"],base-urito["'self'"],frame-ancestorsto["'self'"]. - 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-uriand 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-Optionsor a library that needs eval, finishes it.
Write three functions:
score_policy(header): run the five checks from the table in the order ofCHECKS. Return{"score": 20 * passed, "failed": [names of failed checks, in CHECKS order]}.weakest(policies):policiesis 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.fix(header): follow the four steps above so the result scores 100, keeps named script hosts and nonces, and leaves other directives unchanged.
FIVE_POLICIES, name the weakest and print its fix. The tests also use 40 random policies from random_policy(seed).
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.