ЁЯПл The SchoolтА║ЁЯЫбя╕П SecurityтА║ЁЯЦНя╕П рдзрдбрд╛ 02 тАФ XSS рдЖрдгрд┐ Content-Security-Policy: code рдореНрд╣рдгреВрди рдЪрд╛рд▓рдгрд╛рд░реА comment
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯЦНя╕П рдзрдбрд╛ 02 тАФ XSS рдЖрдгрд┐ Content-Security-Policy: code рдореНрд╣рдгреВрди рдЪрд╛рд▓рдгрд╛рд░реА comment

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 16 рдкреИрдХреА рдзрдбрд╛ 02 ┬╖ рдорд╛рдЧреЗ: lesson-01-injection ┬╖ рдкреБрдвреЗ: lesson-03-browser-server


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбрд╛ 01, рдЖрдгрд┐ рдЕрд░реНрдЬ рдЦрд┐рдбрдХреАрд╡рд░рдЪреА рджреБрд╕рд░реА рдХрдордХреБрд╡рдд рдЬрд╛рдЧрд╛: cross-site scripting (XSS). рд╢рд╛рд│реЗрдЪреНрдпрд╛ notice board рд╡рд░рдЪреНрдпрд╛ рдПрдХрд╛ comment рдордзреНрдпреЗ <script> tag рдЖрд╣реЗ. рдЬрд╢реАрдЪреНрдпрд╛ рддрд╢реА print рдХреЗрд▓реА рддрд░ рддреА рдкреНрд░рддреНрдпреЗрдХ рд╡рд╛рдЪрдХрд╛рдЪреНрдпрд╛ browser рдордзреНрдпреЗ рдЪрд╛рд▓рддреЗ. Escape рдХрд░реВрди print рдХреЗрд▓реА рддрд░ рддреА рдлрдХреНрдд text рдЕрд╕рддреЗ. рдордЧ Content-Security-Policy (CSP) тАФ browser рдЪреА рд╕реНрд╡рддрдГрдЪреА рдирд┐рдпрдорд╛рдВрдЪреА рдпрд╛рджреА тАФ рдирд┐рд╕рдЯреВрди рдЖрд▓реЗрд▓реНрдпрд╛ scripts block рдХрд░рддреЗ. sec/demo.py рдордзрд▓реЗ xss() рджреЛрдиреНрд╣реА рджрд╛рдЦрд╡рддреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рд╢рд╛рд│реЗрдд рдПрдХ notice board рдЖрд╣реЗ. рдХреЛрдгреАрд╣реА рдЪрд┐рдареНрдареА рд▓рд╛рд╡реВ рд╢рдХрддреЛ. Security office рдкреНрд░рддреНрдпреЗрдХ рдЪрд┐рдареНрдареА рд╣рд╛рддрд╛рдиреЗ рдореЛрдареНрдпрд╛ board рд╡рд░ рдЙрддрд░рд╡реВрди рдШреЗрддреЗ.

рдПрдХрд╛ рдЪрд┐рдареНрдареАрдд рд▓рд┐рд╣рд┐рд▓реЗ рдЖрд╣реЗ: "рдЙрддреНрддрдо рдирд┐рдХрд╛рд▓! тАФ рдЖрдгрд┐ рдЖрддрд╛, рд╣реЗ рд╡рд╛рдЪрдгрд╛рд▒реНрдпрд╛ рдкреНрд░рддреНрдпреЗрдХрд╛рдиреЗ рдЖрдкрд▓реА bag рдЙрдШрдбреВрди рдЖрдкрд▓реЗ рдкреНрд░рдЧрддреАрдкреБрд╕реНрддрдХ рдорд▓рд╛ рджреНрдпрд╛рд╡реЗ." рдЬрд░ office рдиреЗ рддреА рдЖрджреЗрд╢ рдореНрд╣рдгреВрди рдЙрддрд░рд╡рд▓реА, рддрд░ рдкреНрд░рддреНрдпреЗрдХ рд╡рд╛рдЪрдХ рддреА рдкрд╛рд│рддреЛ.

рдореНрд╣рдгреВрди office рдкреНрд░рддреНрдпреЗрдХ рдЪрд┐рдареНрдареА рдЕрд╡рддрд░рдг рдЪрд┐рдиреНрд╣рд╛рдВрдЪреНрдпрд╛ рдЖрдд, рд╡реЗрдЧрд│реНрдпрд╛ рд░рдВрдЧрд╛рдд рдЙрддрд░рд╡рддреЗ. рдЖрддрд╛ рд╡рд╛рдЪрдХрд╛рдВрдирд╛ рд╢рдмреНрдж рджрд┐рд╕рддрд╛рдд, рдкрдг рддреНрдпрд╛рдВрдирд╛ рдХрд│рддреЗ: "рд╣реЗ рдХреЛрдгреАрддрд░реА рд▓рд┐рд╣рд┐рд▓реЗрд▓реЗ рдЖрд╣реЗ, рд╢рд╛рд│реЗрдЪрд╛ рдЖрджреЗрд╢ рдирд╛рд╣реА". рдпрд╛рд▓рд╛рдЪ output рд╡реЗрд│реА escaping рдореНрд╣рдгрддрд╛рдд.

рдЖрдгрд┐ office рджреБрд╕рд░рд╛ рдирд┐рдпрдо рдЬреЛрдбрддреЗ, board рдЪреНрдпрд╛ рд╡рд░ рдЫрд╛рдкрд▓реЗрд▓рд╛: "рдлрдХреНрдд рдореБрдЦреНрдп office рдиреЗ рд╕рд╣реА рдХреЗрд▓реЗрд▓реЗ рдЖрджреЗрд╢рдЪ рдЧреНрд░рд╛рд╣реНрдп." рдПрдЦрд╛рджреА рдЪрд┐рдареНрдареА рдЕрд╡рддрд░рдг рдЪрд┐рдиреНрд╣рд╛рдВрд╢рд┐рд╡рд╛рдп рдирд┐рд╕рдЯрд▓реА рддрд░реА рдХреЛрдгреА рддреА рдкрд╛рд│рдд рдирд╛рд╣реА тАФ рддрд┐рдЪреНрдпрд╛рд╡рд░ рд╕рд╣реА рдирд╕рддреЗ. рддреЛ рдирд┐рдпрдо рдореНрд╣рдгрдЬреЗ Content-Security-Policy. рддреЗ рдПрдХ рд╕реБрд░рдХреНрд╖рд╛ рдЬрд╛рд│реЗ рдЖрд╣реЗ. рдЕрд╡рддрд░рдг рдЪрд┐рдиреНрд╣реЗ рд╣рд╛рдЪ рдЦрд░рд╛ рдЙрдкрд╛рдп рдЖрд╣реЗ.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    c["ЁЯТм comment<br/>Great results! + a script tag"]
    c --> raw["тЭМ printed raw<br/>the tag stays a tag"]
    c --> esc["тЬЕ printed escaped<br/>angle brackets become text"]
    raw --> b1["ЁЯМР reader's browser<br/>runs the script"]
    esc --> b2["ЁЯМР reader's browser<br/>shows the words"]
    csp["ЁЯЫбя╕П CSP header<br/>script-src 'self' cdn.school.example"] -.->|"safety net: inline blocked"| b1

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l02

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг notice board рд╕рдЧрд│реНрдпрд╛рдВрдЪрд╛ рдЖрд╣реЗ. рдПрдХ рд╕рд╛рдард╡рд▓реЗрд▓реА comment рдкреНрд░рддреНрдпреЗрдХ рдкрд╛рд▓рдХрд╛рдЪреНрдпрд╛ browser рдордзреНрдпреЗ, рддреНрдпрд╛рдВрдЪреНрдпрд╛ login рдиреЗ рдЪрд╛рд▓реВ рд╢рдХрддреЗ. Script рддреНрдпрд╛рдВрдЪреНрдпрд╛ рдирд╛рд╡рд╛рдиреЗ /api/grades рд▓рд╛ call рдХрд░реВ рд╢рдХрддреЗ, рдХрд┐рдВрд╡рд╛ "fees рднрд░рд╛" link рдмрджрд▓реВ рд╢рдХрддреЗ. Server рд▓рд╛ рдХрд╛рд╣реАрдЪ рд╡рд┐рдЪрд┐рддреНрд░ рджрд┐рд╕рдд рдирд╛рд╣реА тАФ рдкреНрд░рддреНрдпреЗрдХ request рд╕реЛрдмрдд рдЦрд░реЗ session рдЕрд╕рддреЗ. Output рд╡реЗрд│реА escaping comment рд▓рд╛ code рдмрдирдгреНрдпрд╛рдкрд╛рд╕реВрди рдерд╛рдВрдмрд╡рддреЗ. CSP рдЦрд╛рддреНрд░реА рдХрд░рддреЗ рдХреА рдПрдЦрд╛рджреА рдЪреБрдХрд▓реЗрд▓реА рдЬрд╛рдЧрд╛ рдкреВрд░реНрдг breach рдмрдирдд рдирд╛рд╣реА.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

sec/web.py рдордзреНрдпреЗ render_unsafe(comment) f"<p>{comment}</p>" рдкрд░рдд рджреЗрддреЗ. render_safe(comment) f"<p>{html.escape(comment)}</p>" рдкрд░рдд рджреЗрддреЗ. csp_allows(policy, kind, source) рд╣рд╛ рдПрдХ рдЫреЛрдЯрд╛ CSP рд╡рд╛рдЪрдХ рдЖрд╣реЗ: рддреЛ script-src (рдХрд┐рдВрд╡рд╛ default-src) рд╢реЛрдзрддреЛ рдЖрдгрд┐ inline script, рдХрд┐рдВрд╡рд╛ source рдордзреВрди рдЖрд▓реЗрд▓реА script, рдЪрд╛рд▓реВ рд╢рдХрддреЗ рдХрд╛ рддреЗ рд╕рд╛рдВрдЧрддреЛ. рд╣реЗ рд╢рд┐рдХрд╡рдгреНрдпрд╛рд╕рд╛рдареАрдЪреЗ model рдЖрд╣реЗ тАФ рддреЗ nonces рдХрд┐рдВрд╡рд╛ hashes рд╡рд╛рдЪрдд рдирд╛рд╣реА.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 sec/demo.py xss
python3 - <<'EOF'
import sys; sys.path.insert(0, "sec"); from web import render_unsafe, render_safe, csp_allows
c = '<img src=x onerror="alert(1)">'
print("raw     тЖТ", render_unsafe(c))
print("escaped тЖТ", render_safe(c))
for policy in ("script-src 'self'", "script-src 'self' 'unsafe-inline'", "default-src 'self'", "script-src *"):
    inline = "runs" if csp_allows(policy, "inline", None) else "blocked"
    evil = "runs" if csp_allows(policy, "src", "https://evil.example") else "blocked"
    print(f"{policy:<36} inline тЖТ {inline:<7} ┬╖ evil.example тЖТ {evil}")
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

xss рд╣реЗ print рдХрд░рддреЗ:

тФАтФА a comment on the notice board: 'Great results! <script>fetch("/api/grades")</script>'
   rendered raw     тЖТ <p>Great results! <script>fetch("/api/grades")</script></p>
   rendered escaped тЖТ <p>Great results! &lt;script&gt;fetch(&quot;/api/grades&quot;)&lt;/script&gt;</p>
тФАтФА Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.school.example
   inline <script>тАж</script>             тЖТ blocked
   src    self                           тЖТ runs
   src    https://cdn.school.example     тЖТ runs
   src    https://evil.example           тЖТ blocked
   escape on output for the right context (HTML, attribute, JS, URL) тАФ CSP is the safety net, not the fix

рддреБрдордЪрд╛ snippet рд╣реЗ print рдХрд░рддреЛ:

raw     тЖТ <p><img src=x onerror="alert(1)"></p>
escaped тЖТ <p>&lt;img src=x onerror=&quot;alert(1)&quot;&gt;</p>
script-src 'self'                    inline тЖТ blocked ┬╖ evil.example тЖТ blocked
script-src 'self' 'unsafe-inline'    inline тЖТ runs    ┬╖ evil.example тЖТ blocked
default-src 'self'                   inline тЖТ blocked ┬╖ evil.example тЖТ blocked
script-src *                         inline тЖТ blocked ┬╖ evil.example тЖТ runs

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

XSS рд▓рд╛ <script> tag рд▓рд╛рдЧрдд рдирд╛рд╣реА тАФ onerror handler рдЕрд╕рд▓реЗрд▓рд╛ <img> рдкреБрд░реЗрд╕рд╛ рдЖрд╣реЗ, рдЖрдгрд┐ escaping рджреЛрдиреНрд╣реАрдВрдирд╛ рдирд┐рд╖реНрдкреНрд░рдн рдХрд░рддреЗ. 'unsafe-inline' рдЬреЛрдбрд▓реНрдпрд╛рдиреЗ inline code рд╕рд╛рдареА рд╕реБрд░рдХреНрд╖рд╛ рдЬрд╛рд│реЗ рдмрдВрдж рд╣реЛрддреЗ, рдЖрдгрд┐ script-src * рдореБрд│реЗ scripts рдХреЛрдгрддреНрдпрд╛рд╣реА host рд╡рд░реВрди load рд╣реЛрдК рд╢рдХрддрд╛рдд. рдЪрд╛рдВрдЧрд▓реНрдпрд╛ policy рдордзреНрдпреЗ рдпрд╛рддрд▓реЗ рдХрд╛рд╣реАрдЪ рдирд╕рддреЗ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ escaping framework рд▓рд╛ рдХрд░реВ рджреНрдпрд╛. Jinja2 (Flask рд╣реЗ .html templates рд╕рд╛рдареА рдЪрд╛рд▓реВ рдХрд░рддреЗ):

from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(loader=FileSystemLoader("templates"), autoescape=select_autoescape(["html"]))
# {{ comment }} in a template is now escaped; never add |safe to user data

React JSX рдордзрд▓рд╛ text escape рдХрд░рддреЗ. Users рдХрдбреВрди рдЖрд▓реЗрд▓рд╛ rich text рдЖрдзреА sanitiser рдордзреВрди рдЬрд╛рддреЛ:

<p>{comment}</p>                                   // тЬЕ escaped by React
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(richText) }} />   // only when you must

рдкреНрд░рддреНрдпреЗрдХ response рд╕рд╛рдареА nonce рдЕрд╕рд▓реЗрд▓рд╛ рдХрдбрдХ CSP (Express; helmet header set рдХрд░рддреЛ):

import crypto from 'node:crypto';
import helmet from 'helmet';
app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); next(); });
app.use(helmet.contentSecurityPolicy({ directives: {
  defaultSrc: ["'self'"],
  scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
  objectSrc: ["'none'"], baseUri: ["'none'"], frameAncestors: ["'none'"],
}}));
// in the template: <script nonce="<%= nonce %>">тАж</script>

Static site рд╕рд╛рдареА nginx рдиреЗ set рдХреЗрд▓реЗрд▓рд╛ рддреЛрдЪ header (inline scripts рдЕрдЬрд┐рдмрд╛рдд рдирд╛рд╣реАрдд):

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: CSP рдЖрдзреА report-only mode рдордзреНрдпреЗ рдЖрдгрд╛, рдПрдХ рдЖрдард╡рдбрд╛ reports рд╡рд╛рдЪрд╛, рддреБрдордЪреНрдпрд╛ рд╕реНрд╡рддрдГрдЪреНрдпрд╛ inline scripts рджреБрд░реБрд╕реНрдд рдХрд░рд╛ (рддреНрдпрд╛ files рдордзреНрдпреЗ рд╣рд▓рд╡рд╛ рдХрд┐рдВрд╡рд╛ рддреНрдпрд╛рдВрдирд╛ nonce рджреНрдпрд╛), рдордЧ enforce рдХрд░рд╛. Escaping рд╣реАрдЪ рд╕рдВрд░рдХреНрд╖рдгрд╛рдЪреА рдкрд╣рд┐рд▓реА рдлрд│реА рдареЗрд╡рд╛.

тПня╕П рдкреБрдвреЗ

Browser рддреБрдордЪреА pages рдЪрд╛рд▓рд╡рддреЛ. рддреЛ рддреБрдордЪреНрдпрд╛ cookies server рдХрдбреЗ рдШреЗрдКрди рдЬрд╛рддреЛ, рдЖрдгрд┐ рддреНрдпрд╛рд▓рд╛ рдЗрддрд░ sites рдХрдбреВрди requests рдкрд╛рдард╡рд╛рдпрд▓рд╛ рд╕рд╛рдВрдЧрд┐рддрд▓реЗ рдЬрд╛рдК рд╢рдХрддреЗ. рдкреБрдвреЗ: browser рдЖрдгрд┐ server рдордзрд▓рд╛ рдЬрдкреВрди рдареЗрд╡рд▓реЗрд▓рд╛ рд╡рд┐рд╢реНрд╡рд╛рд╕ тАФ cookies, CSRF, CORS рдЖрдгрд┐ SSRF.

git checkout lesson-03-browser-server

ЁЯЦНя╕П Lesson 02 тАФ XSS and Content-Security-Policy: a comment that runs as code

ЁЯУН You are here: Lesson 02 of 16 ┬╖ Previous: lesson-01-injection ┬╖ Next: lesson-03-browser-server


ЁЯУж What's in this branch

Lesson 01, plus the second weakness at the forms desk: cross-site scripting (XSS). A comment on the school notice board contains a <script> tag. Printed as it is, it runs in every reader's browser. Printed escaped, it is only text. Then a Content-Security-Policy (CSP) тАФ the browser's own rule list тАФ blocks scripts that slip through. xss() in sec/demo.py shows both.

ЁЯзТ Explain like I'm 5

The school has a notice board. Anyone may pin a note. The security office copies each note onto the big board by hand.

One note says: "Great results! тАФ and now, everyone reading this, open your bag and hand your report card to me." If the office copies it as an order, every reader obeys.

So the office copies every note inside quotation marks, in a different colour. Now readers see the words, but they know: "this is something a person wrote, not an order from the school". That is escaping on output.

And the office adds a second rule, printed at the top of the board: "Only orders signed by the head office count." Even if one note slips through without quotation marks, nobody obeys it тАФ it has no signature. That rule is the Content-Security-Policy. It is a safety net. The quotation marks are the real fix.

ЁЯЧ║я╕П Diagram

flowchart LR
    c["ЁЯТм comment<br/>Great results! + a script tag"]
    c --> raw["тЭМ printed raw<br/>the tag stays a tag"]
    c --> esc["тЬЕ printed escaped<br/>angle brackets become text"]
    raw --> b1["ЁЯМР reader's browser<br/>runs the script"]
    esc --> b2["ЁЯМР reader's browser<br/>shows the words"]
    csp["ЁЯЫбя╕П CSP header<br/>script-src 'self' cdn.school.example"] -.->|"safety net: inline blocked"| b1

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l02

тЭУ What

ЁЯдФ Why

Because the notice board is shared. One stored comment can run in every parent's browser, with their login. The script can call /api/grades as them, or change a "pay fees" link. The server never sees anything strange тАФ each request carries a real session. Escaping on output stops the comment from becoming code. CSP makes sure that one missed spot does not become a full breach.

ЁЯФз How (in this repo)

In sec/web.py, render_unsafe(comment) returns f"<p>{comment}</p>". render_safe(comment) returns f"<p>{html.escape(comment)}</p>". csp_allows(policy, kind, source) is a small CSP reader: it finds script-src (or default-src) and says whether an inline script, or a script from source, may run. It is a teaching model тАФ it does not read nonces or hashes.

ЁЯзк Try it

python3 sec/demo.py xss
python3 - <<'EOF'
import sys; sys.path.insert(0, "sec"); from web import render_unsafe, render_safe, csp_allows
c = '<img src=x onerror="alert(1)">'
print("raw     тЖТ", render_unsafe(c))
print("escaped тЖТ", render_safe(c))
for policy in ("script-src 'self'", "script-src 'self' 'unsafe-inline'", "default-src 'self'", "script-src *"):
    inline = "runs" if csp_allows(policy, "inline", None) else "blocked"
    evil = "runs" if csp_allows(policy, "src", "https://evil.example") else "blocked"
    print(f"{policy:<36} inline тЖТ {inline:<7} ┬╖ evil.example тЖТ {evil}")
EOF

тЬЕ Verify тАФ what you should see

xss prints:

тФАтФА a comment on the notice board: 'Great results! <script>fetch("/api/grades")</script>'
   rendered raw     тЖТ <p>Great results! <script>fetch("/api/grades")</script></p>
   rendered escaped тЖТ <p>Great results! &lt;script&gt;fetch(&quot;/api/grades&quot;)&lt;/script&gt;</p>
тФАтФА Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.school.example
   inline <script>тАж</script>             тЖТ blocked
   src    self                           тЖТ runs
   src    https://cdn.school.example     тЖТ runs
   src    https://evil.example           тЖТ blocked
   escape on output for the right context (HTML, attribute, JS, URL) тАФ CSP is the safety net, not the fix

Your snippet prints:

raw     тЖТ <p><img src=x onerror="alert(1)"></p>
escaped тЖТ <p>&lt;img src=x onerror=&quot;alert(1)&quot;&gt;</p>
script-src 'self'                    inline тЖТ blocked ┬╖ evil.example тЖТ blocked
script-src 'self' 'unsafe-inline'    inline тЖТ runs    ┬╖ evil.example тЖТ blocked
default-src 'self'                   inline тЖТ blocked ┬╖ evil.example тЖТ blocked
script-src *                         inline тЖТ blocked ┬╖ evil.example тЖТ runs

ЁЯПБ What you just proved

XSS does not need a <script> tag тАФ an <img> with an onerror handler is enough, and escaping neutralises both. Adding 'unsafe-inline' switches the safety net off for inline code, and script-src * lets scripts load from any host. A good policy has neither.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ let the framework escape for you. Jinja2 (Flask turns this on for .html templates):

from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(loader=FileSystemLoader("templates"), autoescape=select_autoescape(["html"]))
# {{ comment }} in a template is now escaped; never add |safe to user data

React escapes text in JSX. Rich text from users goes through a sanitiser first:

<p>{comment}</p>                                   // тЬЕ escaped by React
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(richText) }} />   // only when you must

A strict CSP with a per-response nonce (Express; helmet sets the header):

import crypto from 'node:crypto';
import helmet from 'helmet';
app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); next(); });
app.use(helmet.contentSecurityPolicy({ directives: {
  defaultSrc: ["'self'"],
  scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
  objectSrc: ["'none'"], baseUri: ["'none'"], frameAncestors: ["'none'"],
}}));
// in the template: <script nonce="<%= nonce %>">тАж</script>

The same header set by nginx for a static site (no inline scripts at all):

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;

ЁЯПн Why this matters in production: roll out CSP in report-only mode first, read the reports for a week, fix your own inline scripts (move them to files or give them a nonce), then enforce. Keep escaping as the first line of defence.

тПня╕П Next

The browser runs your pages. It also carries your cookies to the server, and it can be asked to send requests from other sites. Next: the careful trust between browser and server тАФ cookies, CSRF, CORS and SSRF.

git checkout lesson-03-browser-server
тЖР PreviousinjectionNext тЖТbrowser server

This page is the lesson's README from the lesson-02-xss-csp branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.