ЁЯПл The SchoolтА║ЁЯЫбя╕П SecurityтА║ЁЯНк рдзрдбрд╛ 03 тАФ Cookies, CSRF, CORS рдЖрдгрд┐ SSRF: browser рдЖрдгрд┐ server рдордзрд▓рд╛ рдЬрдкреВрди рдареЗрд╡рд▓реЗрд▓рд╛ рд╡рд┐рд╢реНрд╡рд╛рд╕
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯНк рдзрдбрд╛ 03 тАФ Cookies, CSRF, CORS рдЖрдгрд┐ SSRF: browser рдЖрдгрд┐ server рдордзрд▓рд╛ рдЬрдкреВрди рдареЗрд╡рд▓реЗрд▓рд╛ рд╡рд┐рд╢реНрд╡рд╛рд╕

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 16 рдкреИрдХреА рдзрдбрд╛ 03 ┬╖ рдорд╛рдЧреЗ: lesson-02-xss-csp ┬╖ рдкреБрдвреЗ: lesson-04-authentication


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

рдзрдбреЗ 01тАУ02, рдЖрдгрд┐ рдЕрд░реНрдЬ рдЦрд┐рдбрдХреАрдЪрд╛ рд╢реЗрд╡рдЯрдЪрд╛ рдзрдбрд╛. рдЪрд╛рд░ рдирд┐рдпрдВрддреНрд░рдгреЗ, рдПрдХ рдХрд▓реНрдкрдирд╛ тАФ рдХреЛрдг рдХрд╛рдп рдкрд╛рдард╡реВ рд╢рдХрддреЛ, рдЖрдгрд┐ рдХреБрдареВрди:

sec/demo.py рдордзрд▓реЗ browser() рдЪрд╛рд░рд╣реА рджрд╛рдЦрд╡рддреЗ.

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

рдХрддрд░рд┐рдирд╛рдХрдбреЗ рд╢рд╛рд│реЗрдЪрд╛ рдПрдХ visitor badge рдЖрд╣реЗ (session cookie). Office рддреНрдпрд╛ badge рд▓рд╛ рддреАрди рдирд┐рдпрдо рджреЗрддреЗ:

рдкреНрд░рддреНрдпреЗрдХ рдЕрдзрд┐рдХреГрдд form рд╡рд░ рдХрддрд░рд┐рдирд╛рдЪреНрдпрд╛ badge рд╢реА рдЬреБрд│рдгрд╛рд░рд╛ рдПрдХ рдЧреБрдкреНрдд рд╢рд┐рдХреНрдХрд╛ рдЕрд╕рддреЛ (CSRF token). рджреБрд╕рд▒реНрдпрд╛ рд╢рд╛рд│реЗрдиреЗ рдмрдирд╡рд▓реЗрд▓реНрдпрд╛ рдЦреЛрдЯреНрдпрд╛ form рд╡рд░ рдЬреБрд│рдгрд╛рд░рд╛ рд╢рд┐рдХреНрдХрд╛ рдирд╕рддреЛ, рдореНрд╣рдгреВрди office рддреЛ рдирд╛рдХрд╛рд░рддреЗ.

Office рдЖрдкрд▓реА рдЙрддреНрддрд░реЗ рд╡рд╛рдЪреВ рд╢рдХрдгрд╛рд▒реНрдпрд╛ рдореИрддреНрд░реАрдкреВрд░реНрдг рд╢рд╛рд│рд╛рдВрдЪреА рдпрд╛рджреА рдкрдг рдареЗрд╡рддреЗ (CORS). рд▓рдХреНрд╖рд╛рдд рдареЗрд╡рд╛: рддреА рдпрд╛рджреА рд╡рд╛рдЪрдХрд╛рдВрдЪреЗ browsers рддрдкрд╛рд╕рддрд╛рдд тАФ office рдирд╛рд╣реА. рддреНрдпрд╛рдореБрд│реЗ рдХреЛрдгреА рдереЗрдЯ office рдкрд░реНрдпрдВрдд рдЪрд╛рд▓рдд рдпреЗрдгреНрдпрд╛рдкрд╛рд╕реВрди рдерд╛рдВрдмрдд рдирд╛рд╣реА.

рд╢реЗрд╡рдЯреА, office рдХрдзреА рдХрдзреА visitors рд╕рд╛рдареА рд╡рд╕реНрддреВ рдЖрдгрддреЗ: "рдХреГрдкрдпрд╛ рдпрд╛ рдкрддреНрддреНрдпрд╛рд╡рд░реВрди рдорд╛рдЭрд╛ photo рдЖрдгрд╛." рдЕрдзрд┐рдХрд╛рд░реА рдЖрдзреА рдкрддреНрддрд╛ рддрдкрд╛рд╕рддреЛ. рдлрдХреНрдд рд╢рд╛рд│реЗрдЪреЗ photo рджреБрдХрд╛рди рдЪрд╛рд▓рддреЗ. рдЗрдорд╛рд░рддреАрдЪреНрдпрд╛ рдЖрддрд▓реЗ рдкрддреНрддреЗ тАФ server room, staff рдЪреА рдХрд┐рд▓реНрд▓реНрдпрд╛рдВрдЪреА рдкреЗрдЯреА тАФ рдиреЗрд╣рдореА рдирд╛рдХрд╛рд░рд▓реЗ рдЬрд╛рддрд╛рдд. рд╣реА рддрдкрд╛рд╕рдгреА SSRF рдерд╛рдВрдмрд╡рддреЗ.

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

flowchart LR
    b["ЁЯМР Katrina's browser<br/>cookie: HttpOnly ┬╖ Secure ┬╖ SameSite=Lax"]
    ev["ЁЯП┤ other site<br/>fake form, no token"]
    subgraph srv["ЁЯПл school server"]
      csrf["ЁЯФП CSRF check<br/>token matches session?"]
      cors["ЁЯУЬ CORS header<br/>allow-list of origins"]
      ssrf["ЁЯзн SSRF guard<br/>https ┬╖ allow-listed host ┬╖ no private IPs"]
    end
    b -->|"form + token"| csrf
    ev -->|"no token"| csrf
    cors -.->|"browser enforces"| b
    ssrf -->|"allowed"| shop["ЁЯЦ╝я╕П images.school.example"]
    ssrf -.->|"refused"| meta["ЁЯФТ 169.254.169.254<br/>10.0.0.5"]

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

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

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг browser рдорджрдд рдХрд░рдгрд╛рд░рд╛ рдЖрд╣реЗ: рддреЛ рддреБрдордЪреНрдпрд╛ site рд╡рд░рдЪреНрдпрд╛ рдкреНрд░рддреНрдпреЗрдХ request рд▓рд╛ cookies рдЬреЛрдбрддреЛ, рдЕрдЧрджреА рджреБрд╕рд▒реНрдпрд╛ site рдиреЗ рд╕реБрд░реВ рдХреЗрд▓реЗрд▓реНрдпрд╛ requests рд▓рд╛ рд╕реБрджреНрдзрд╛. рдЖрдгрд┐ servers рдорджрдд рдХрд░рдгрд╛рд░реЗ рдЖрд╣реЗрдд: рддреНрдпрд╛рдВрдирд╛ рдЬреЗ рд╕рд╛рдВрдЧрд┐рддрд▓реЗ рддреЗ рддреЗ fetch рдХрд░рддрд╛рдд. рдЗрдерд▓реЗ рдкреНрд░рддреНрдпреЗрдХ рдирд┐рдпрдВрддреНрд░рдг рддреА рдорджрдд рдЕрдЯреАрд╡рд░ рджреЗрддреЗ тАФ рдлрдХреНрдд рд╣реА site, рдлрдХреНрдд рд╣рд╛ form, рдлрдХреНрдд рд╣рд╛ host. рддреА рдирд╕рддреАрд▓ рддрд░, login рдХреЗрд▓реЗрд▓реЗ рдкрд╛рд▓рдХ рдЪреБрдХреАрдЪреЗ page рдЙрдШрдбреВрди рдирдХрд│рдд рд╕реНрд╡рддрдГрдЪрд╛ password рдмрджрд▓реВ рд╢рдХрддрд╛рдд, рдЖрдгрд┐ photo preview рддреБрдордЪреА cloud credentials рд╡рд╛рдЪреВ рд╢рдХрддреЛ.

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

sec/web.py рдордзреНрдпреЗ: cookie_ok(header) Set-Cookie header рд╡рд╛рдЪрддреЗ рдЖрдгрд┐ рдирд╕рд▓реЗрд▓реЗ flags рдпрд╛рджреАрдд рджреЗрддреЗ. csrf_token(session_id) рдореНрд╣рдгрдЬреЗ HMAC-SHA256(SECRET, session_id), 16 hex рдЕрдХреНрд╖рд░рд╛рдВрдкрд░реНрдпрдВрдд рдХрд╛рдкрд▓реЗрд▓реЗ; csrf_ok() hmac.compare_digest рдиреЗ рддреБрд▓рдирд╛ рдХрд░рддреЗ. cors_ok(origin) рдЕрдЪреВрдХ allow-list (https://school.example) рддрдкрд╛рд╕рддреЗ. ssrf_ok(url) URL parse рдХрд░рддреЗ, https рдирд╕рд▓реЗрд▓реЗ рд╕рдЧрд│реЗ рдирд╛рдХрд╛рд░рддреЗ, private, loopback рдХрд┐рдВрд╡рд╛ link-local IP addresses рдирд╛рдХрд╛рд░рддреЗ, рдордЧ host allow-list (images.school.example) рдордзреНрдпреЗ рдЕрд╕рдгреЗ рдЖрд╡рд╢реНрдпрдХ рдХрд░рддреЗ.

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

python3 sec/demo.py browser
python3 - <<'EOF'
import sys; sys.path.insert(0, "sec"); from web import cookie_ok, csrf_token, csrf_ok, cors_ok, ssrf_ok
for h in ("sid=1; Secure", "sid=1; HttpOnly; Secure; SameSite=Strict"):
    print(f"{h:<42} тЖТ {cookie_ok(h)}")
print("Katrina's token works for Dipika's session тЖТ", csrf_ok("sess-dipika", csrf_token("sess-katrina")))
print("http://school.example (not https)          тЖТ", cors_ok("http://school.example"))
for u in ("https://127.0.0.1/", "https://192.168.1.1/router", "file:///etc/passwd", "https://images.school.example.evil.example/x"):
    print(f"{u:<45} тЖТ {ssrf_ok(u)}")
EOF

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

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

тФАтФА Set-Cookie: session=abc123                                   тЖТ missing httponly, secure, samesite
тФАтФА Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax   тЖТ ok
тФАтФА CSRF: the form carries a token tied to the session тЖТ valid True ┬╖ forged False ┬╖ missing False
тФАтФА CORS allow-list: https://school.example тЖТ True ┬╖ https://evil.example тЖТ False
тФАтФА SSRF: the server fetches a photo URL a user typed тАФ check it first
   https://images.school.example/p/7.jpg      тЖТ (True, 'allowed')
   http://images.school.example/p/7.jpg       тЖТ (False, 'only https')
   https://169.254.169.254/latest/            тЖТ (False, 'private / internal address')
   https://10.0.0.5/admin                     тЖТ (False, 'private / internal address')
   https://evil.example/x                     тЖТ (False, 'host not on the allow-list')

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

sid=1; Secure                              тЖТ (False, ['httponly', 'samesite'])
sid=1; HttpOnly; Secure; SameSite=Strict   тЖТ (True, [])
Katrina's token works for Dipika's session тЖТ False
http://school.example (not https)          тЖТ False
https://127.0.0.1/                            тЖТ (False, 'private / internal address')
https://192.168.1.1/router                    тЖТ (False, 'private / internal address')
file:///etc/passwd                            тЖТ (False, 'only https')
https://images.school.example.evil.example/x  тЖТ (False, 'host not on the allow-list')

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

рдПрдХрд╛ session рд╕рд╛рдареА рдмрдирд╡рд▓реЗрд▓рд╛ token рджреБрд╕рд▒реНрдпрд╛ session рд╕рд╛рдареА рдирд┐рд░реБрдкрдпреЛрдЧреА рдЕрд╕рддреЛ. CORS рдпрд╛рджреА рдЕрдЪреВрдХ рдЬреБрд│рдгреА рдорд╛рдЧрддреЗ тАФ https:// рдРрд╡рдЬреА http:// рд╕реБрджреНрдзрд╛ рдирд╛рдХрд╛рд░рд▓реЗ рдЬрд╛рддреЗ. рдЖрдгрд┐ SSRF рдкрд╣рд╛рд░реЗрдХрд░реА рдЕрд╕рд╛ host рдирд╛рдХрд╛рд░рддреЛ рдЬреЛ рдкрд░рд╡рд╛рдирдЧреА рдЕрд╕рд▓реЗрд▓реНрдпрд╛ рдирд╛рд╡рд╛рдиреЗ рдлрдХреНрдд рд╕реБрд░реВ рд╣реЛрддреЛ: рддрдкрд╛рд╕рдгреА рдкреВрд░реНрдг host рдЪреА рддреБрд▓рдирд╛ рдХрд░рддреЗ, prefix рдЪреА рдирд╛рд╣реА. рдореБрдЦреНрдп рдХрд╛рдо allow-list рдХрд░рддреЗ; private-address рдирд┐рдпрдо рд╣реЗ рджреБрд╕рд░реЗ рдХреБрд▓реВрдк рдЖрд╣реЗ.

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

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

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ Flask рдЖрдгрд┐ Express рдордзрд▓реЗ cookie flags:

app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax")
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 } }));

CSRF middleware тАФ Django рдордзреНрдпреЗ рддреЗ default рдиреЗ рдЪрд╛рд▓реВ рдЕрд╕рддреЗ (MIDDLEWARE рдордзреНрдпреЗ CsrfViewMiddleware рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ form рдордзреНрдпреЗ {% csrf_token %} рдареЗрд╡рд╛); Flask Flask-WTF рд╡рд╛рдкрд░рддреЗ:

from flask_wtf.csrf import CSRFProtect
CSRFProtect(app)          # every POST/PUT/PATCH/DELETE must carry the token

рдЕрдЪреВрдХ allow-list рд╕рд╣ CORS (Express cors package). рд▓рдХреНрд╖рд╛рдд рдареЗрд╡рд╛: рд╣реЗ рдлрдХреНрдд browsers рдирд╛ рддреЗ рдХрд╛рдп рд╡рд╛рдЪреВ рд╢рдХрддрд╛рдд рддреЗ рд╕рд╛рдВрдЧрддреЗ; route рдЕрдЬреВрдирд╣реА session рдЖрдгрд┐ permissions рддрдкрд╛рд╕рддреЛ:

import cors from 'cors';
app.use('/api', cors({ origin: ['https://school.example'], credentials: true }));

Resolved addresses рд╕реБрджреНрдзрд╛ рддрдкрд╛рд╕рдгрд╛рд░рд╛ SSRF рдкрд╣рд╛рд░реЗрдХрд░реА (Python), рдЬреЛ redirects рдирд╛рдХрд╛рд░рддреЛ:

import ipaddress, socket
from urllib.parse import urlparse
ALLOWED = {"images.school.example"}
def safe_to_fetch(url):
    u = urlparse(url)
    if u.scheme != "https" or u.hostname not in ALLOWED: return False
    for info in socket.getaddrinfo(u.hostname, 443):
        ip = ipaddress.ip_address(info[4][0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return False
    return True
# then fetch with redirects off, e.g. requests.get(url, allow_redirects=False, timeout=5)

EC2 instance рд╡рд░ 1 рдЪреНрдпрд╛ hop limit рд╕рд╣ IMDSv2 рдЖрд╡рд╢реНрдпрдХ рдХрд░рд╛:

aws ec2 modify-instance-metadata-options --instance-id i-0123456789abcdef0 \
    --http-tokens required --http-put-response-hop-limit 1 --http-endpoint enabled

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: cookie flags рдПрдХрджрд╛рдЪ, framework config рдордзреНрдпреЗ set рдХрд░рд╛. рд╕реНрд╡рддрдГрдЪреЗ рд▓рд┐рд╣рд┐рдгреНрдпрд╛рдРрд╡рдЬреА framework рдЪреЗ CSRF рд╕рдВрд░рдХреНрд╖рдг рдЪрд╛рд▓реВ рдХрд░рд╛. рдмрд╛рд╣реЗрд░ рдЬрд╛рдгрд╛рд░реЗ fetches allow-list рдЕрд╕рд▓реЗрд▓реНрдпрд╛ рдПрдХрд╛рдЪ function рдорд╛рдЧреЗ рдареЗрд╡рд╛ тАФ рдХрд┐рдВрд╡рд╛ рдлрдХреНрдд internet рдкрд░реНрдпрдВрдд рдкреЛрд╣реЛрдЪреВ рд╢рдХрдгрд╛рд▒реНрдпрд╛ proxy рдорд╛рдЧреЗ тАФ рдЖрдгрд┐ рддреБрдордЪреНрдпрд╛ launch templates рдордзреНрдпреЗ IMDSv2 default рдХрд░рд╛.

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

рдЕрд░реНрдЬ рдЦрд┐рдбрдХреА рдкреВрд░реНрдг рдЭрд╛рд▓реА. рдЖрддрд╛ рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреА: рд╢рд╛рд│реЗрд▓рд╛ рдХрд╕реЗ рдХрд│рддреЗ рдХреА рд╣реА рдЦрд░реЛрдЦрд░ рдХрддрд░рд┐рдирд╛рдЪ рдЖрд╣реЗ тАФ рдЪреЛрд░рд╛рд▓рд╛ рд╡рд╛рдкрд░рддрд╛ рдпреЗрдгрд╛рд░ рдирд╛рд╣реАрдд рдЕрд╕реЗ рд╕рд╛рдард╡рд▓реЗрд▓реЗ passwords, MFA рдЖрдгрд┐ passkeys, sessions рдЖрдгрд┐ tokens.

git checkout lesson-04-authentication

ЁЯНк Lesson 03 тАФ Cookies, CSRF, CORS and SSRF: careful trust between browser and server

ЁЯУН You are here: Lesson 03 of 16 ┬╖ Previous: lesson-02-xss-csp ┬╖ Next: lesson-04-authentication


ЁЯУж What's in this branch

Lessons 01тАУ02, plus the last lesson of the forms desk. Four controls, one idea тАФ who may send what, and from where:

browser() in sec/demo.py shows all four.

ЁЯзТ Explain like I'm 5

Katrina has a visitor badge for the school (the session cookie). The office gives the badge three rules:

Every official form also has a secret stamp that matches Katrina's badge (the CSRF token). A fake form made by another school has no matching stamp, so the office refuses it.

The office also keeps a list of friendly schools that may read its replies (CORS). Note: the readers' browsers check that list тАФ not the office. It does not stop anyone from walking up to the office directly.

Last, the office sometimes fetches things for visitors: "please collect my photo from this address". The officer checks the address first. Only the school photo shop is allowed. Addresses inside the building тАФ the server room, the staff key box тАФ are always refused. That check stops SSRF.

ЁЯЧ║я╕П Diagram

flowchart LR
    b["ЁЯМР Katrina's browser<br/>cookie: HttpOnly ┬╖ Secure ┬╖ SameSite=Lax"]
    ev["ЁЯП┤ other site<br/>fake form, no token"]
    subgraph srv["ЁЯПл school server"]
      csrf["ЁЯФП CSRF check<br/>token matches session?"]
      cors["ЁЯУЬ CORS header<br/>allow-list of origins"]
      ssrf["ЁЯзн SSRF guard<br/>https ┬╖ allow-listed host ┬╖ no private IPs"]
    end
    b -->|"form + token"| csrf
    ev -->|"no token"| csrf
    cors -.->|"browser enforces"| b
    ssrf -->|"allowed"| shop["ЁЯЦ╝я╕П images.school.example"]
    ssrf -.->|"refused"| meta["ЁЯФТ 169.254.169.254<br/>10.0.0.5"]

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

тЭУ What

ЁЯдФ Why

Because the browser is helpful: it adds cookies to every request to your site, even requests that another site started. And because servers are helpful: they fetch what they are asked to. Each control here makes that help conditional тАФ only this site, only this form, only this host. Without them, a logged-in parent who opens the wrong page can change their own password without knowing, and a photo preview can read your cloud credentials.

ЁЯФз How (in this repo)

In sec/web.py: cookie_ok(header) reads a Set-Cookie header and lists the missing flags. csrf_token(session_id) is HMAC-SHA256(SECRET, session_id), cut to 16 hex characters; csrf_ok() compares with hmac.compare_digest. cors_ok(origin) checks an exact allow-list (https://school.example). ssrf_ok(url) parses the URL, refuses anything that is not https, refuses IP addresses that are private, loopback or link-local, then requires the host to be on the allow-list (images.school.example).

ЁЯзк Try it

python3 sec/demo.py browser
python3 - <<'EOF'
import sys; sys.path.insert(0, "sec"); from web import cookie_ok, csrf_token, csrf_ok, cors_ok, ssrf_ok
for h in ("sid=1; Secure", "sid=1; HttpOnly; Secure; SameSite=Strict"):
    print(f"{h:<42} тЖТ {cookie_ok(h)}")
print("Katrina's token works for Dipika's session тЖТ", csrf_ok("sess-dipika", csrf_token("sess-katrina")))
print("http://school.example (not https)          тЖТ", cors_ok("http://school.example"))
for u in ("https://127.0.0.1/", "https://192.168.1.1/router", "file:///etc/passwd", "https://images.school.example.evil.example/x"):
    print(f"{u:<45} тЖТ {ssrf_ok(u)}")
EOF

тЬЕ Verify тАФ what you should see

browser prints:

тФАтФА Set-Cookie: session=abc123                                   тЖТ missing httponly, secure, samesite
тФАтФА Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax   тЖТ ok
тФАтФА CSRF: the form carries a token tied to the session тЖТ valid True ┬╖ forged False ┬╖ missing False
тФАтФА CORS allow-list: https://school.example тЖТ True ┬╖ https://evil.example тЖТ False
тФАтФА SSRF: the server fetches a photo URL a user typed тАФ check it first
   https://images.school.example/p/7.jpg      тЖТ (True, 'allowed')
   http://images.school.example/p/7.jpg       тЖТ (False, 'only https')
   https://169.254.169.254/latest/            тЖТ (False, 'private / internal address')
   https://10.0.0.5/admin                     тЖТ (False, 'private / internal address')
   https://evil.example/x                     тЖТ (False, 'host not on the allow-list')

Your snippet prints:

sid=1; Secure                              тЖТ (False, ['httponly', 'samesite'])
sid=1; HttpOnly; Secure; SameSite=Strict   тЖТ (True, [])
Katrina's token works for Dipika's session тЖТ False
http://school.example (not https)          тЖТ False
https://127.0.0.1/                            тЖТ (False, 'private / internal address')
https://192.168.1.1/router                    тЖТ (False, 'private / internal address')
file:///etc/passwd                            тЖТ (False, 'only https')
https://images.school.example.evil.example/x  тЖТ (False, 'host not on the allow-list')

ЁЯПБ What you just proved

A token made for one session is useless for another session. The CORS list is an exact match тАФ even http:// instead of https:// is refused. And the SSRF guard refuses a host that only starts with the allowed name: the check compares the whole host, not a prefix. The allow-list does the heavy work; the private-address rule is the second lock.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ cookie flags in Flask and in Express:

app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax")
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 } }));

CSRF middleware тАФ Django has it on by default (keep CsrfViewMiddleware in MIDDLEWARE and {% csrf_token %} in every form); Flask uses Flask-WTF:

from flask_wtf.csrf import CSRFProtect
CSRFProtect(app)          # every POST/PUT/PATCH/DELETE must carry the token

CORS with an exact allow-list (Express cors package). Remember: this only tells browsers what they may read; the route still checks the session and permissions:

import cors from 'cors';
app.use('/api', cors({ origin: ['https://school.example'], credentials: true }));

An SSRF guard that checks the resolved addresses too (Python), and refuses redirects:

import ipaddress, socket
from urllib.parse import urlparse
ALLOWED = {"images.school.example"}
def safe_to_fetch(url):
    u = urlparse(url)
    if u.scheme != "https" or u.hostname not in ALLOWED: return False
    for info in socket.getaddrinfo(u.hostname, 443):
        ip = ipaddress.ip_address(info[4][0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return False
    return True
# then fetch with redirects off, e.g. requests.get(url, allow_redirects=False, timeout=5)

Require IMDSv2 with a hop limit of 1 on an EC2 instance:

aws ec2 modify-instance-metadata-options --instance-id i-0123456789abcdef0 \
    --http-tokens required --http-put-response-hop-limit 1 --http-endpoint enabled

ЁЯПн Why this matters in production: set cookie flags once, in the framework config. Turn on the framework's CSRF protection instead of writing your own. Put outbound fetches behind one function with an allow-list тАФ or a proxy that can only reach the internet тАФ and make IMDSv2 the default in your launch templates.

тПня╕П Next

The forms desk is done. Now the ID desk: how the school knows it is really Katrina тАФ passwords stored so a thief cannot use them, MFA and passkeys, sessions and tokens.

git checkout lesson-04-authentication
тЖР Previousxss cspNext тЖТauthentication

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