Security

Cross-Site Scripting (XSS)

Cross-site scripting occurs when attacker-controlled content is executed as code in another user's browser. Stored, reflected, and DOM-based XSS differ in where the unsafe value comes from, but all require careful output handling.

What Matters

  • Escape untrusted values for the exact HTML, attribute, URL, or JavaScript context.
  • Avoid inserting untrusted text into inline JavaScript or event-handler attributes.
  • Use a maintained HTML sanitiser only when users are intentionally allowed to submit markup.
  • Add a Content Security Policy as defence in depth, not as a replacement for escaping.
  • Review frontend DOM updates such as innerHTML, not only server-rendered templates.

Practical Example

PHP example
<?php

declare(strict_types=1);

function safeComment(string $comment): string
{
    return htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

echo safeComment('<img src=x onerror=alert(1)>') . PHP_EOL;

// Prints:
// &lt;img src=x onerror=alert(1)&gt;

In Application Work

Stored comments, rich-text editors, search pages, admin dashboards, and email previews are frequent XSS review areas. Remember that an admin user is still a valuable XSS target.

Three Common XSS Shapes

Reflected XSS appears immediately in a response, such as a search page rendering an unsafe query term. Stored XSS is saved first, such as a comment later rendered in an admin screen. DOM-based XSS happens when browser JavaScript inserts unsafe data into the page.

reflected: request value -> unsafe HTML response
stored:    saved value -> later unsafe HTML response
DOM-based: browser value -> unsafe DOM update

The source may differ, but the review question is the same: where does data become executable browser content?

Rich Text Needs Sanitisation

Escaping is correct when markup should display as text. If users are intentionally allowed to submit formatting such as links or emphasis, use a maintained HTML sanitiser with a narrow allow-list. Do not build a sanitiser from regular expressions.

Review Browser-Side Sinks

Server-side escaping is not enough when JavaScript later places a value into innerHTML, outerHTML, or a similar API. Prefer text APIs such as textContent unless HTML is genuinely required and sanitised.

Content Security Policy is useful defence in depth. It does not make unsafe rendering acceptable.

What To Check

Before moving on, make sure you can:

  • describe reflected, stored, and DOM-based XSS;
  • escape plain text at the rendering boundary;
  • recognise dangerous inline-script and DOM contexts;
  • use sanitisation only for intentionally allowed markup;
  • distinguish reflected, stored, and DOM-based review paths.

Output Context Determines The Defense

Cross-site scripting occurs when untrusted data is interpreted as active browser content. The correct encoding depends on where the value is inserted. HTML text, an HTML attribute, a URL parameter, JavaScript source, CSS, and a DOM property are different parsers with different escaping rules.

For HTML text, use the template engine's automatic escaping or htmlspecialchars() with an explicit character set and appropriate flags. Do not reuse that encoded text inside JavaScript or a URL and assume it remains safe. Encoding is performed at the final output boundary for the actual context.

Quoted attributes still require HTML attribute encoding. Some attributes, such as event handlers and srcdoc, are executable contexts and should not receive untrusted strings at all. URL-valued attributes need an allowed-scheme check in addition to attribute encoding; an encoded javascript: URL can remain dangerous when the browser navigates to it.

Keep Data Out Of Script Source

Building a script block with string concatenation forces one value through JavaScript parsing and HTML parsing. Prefer an external script that reads data from a safe DOM location or a JSON script element handled by framework utilities. When server-rendered JSON is necessary, use a serializer designed for embedding and escape characters that can terminate the HTML element.

Do not write your own sequence of quote replacements. JavaScript has strings, template literals, comments, regular expressions, and executable expressions. A serializer can produce data; it cannot make arbitrary user text safe as program source.

Inline script also makes a strict Content Security Policy harder to deploy. External scripts with nonces or hashes provide a clearer boundary and reduce the number of places where executable text can appear.

Rich Text Requires Sanitization

Encoding rich text displays its markup literally, so applications that intentionally allow formatting need an HTML sanitizer. Sanitization parses the fragment and permits a small set of elements, attributes, and URL schemes. A regular expression cannot safely model browser HTML parsing.

Use a maintained sanitizer with a documented policy. Remove event attributes, dangerous URL schemes, active embedding elements, and style features the product does not need. Sanitize when accepting or rendering according to the library's guidance, and store enough information to re-sanitize content when the policy or parser changes.

Sanitization changes over time as browser behavior and bypasses are discovered. Keep the library updated and retain regression payloads. Administrator-authored content is not automatically trusted; a compromised privileged account or imported template can affect every viewer.

Safe And Unsafe DOM APIs

On the client, prefer textContent for text and property assignment for well-defined non-executable values. APIs such as innerHTML, outerHTML, insertAdjacentHTML, and string arguments to setTimeout() create parsing or execution sinks. Treat them as security-sensitive and centralize unavoidable use behind sanitization.

Framework escaping protects ordinary interpolation but escape hatches such as raw HTML rendering deliberately disable it. Review each escape hatch with the same care as innerHTML. Component boundaries do not sanitize values automatically when a developer requests raw markup.

DOM-based XSS can occur without a dangerous server response. A script may read a fragment, query parameter, postMessage payload, local storage value, or third-party widget data and send it to an unsafe sink. Browser-side dataflow belongs in the threat model.

When users can supply links, parse the URL and allow only schemes the feature needs, usually https and perhaps http. Relative URLs may be acceptable for internal navigation. Reject control characters and ambiguous parser results. Attribute encoding happens after scheme validation.

Open redirects are not identical to XSS, but they can support phishing and token leakage. Map redirect identifiers to server-owned destinations or validate origins strictly. Add rel="noopener noreferrer" where appropriate for untrusted external links opened in a new tab.

Content Security Policy As Defense In Depth

A restrictive CSP can block many injected scripts and provide violation reports. A strong policy avoids unsafe-inline, uses nonces or hashes for required scripts, limits script origins, and restricts object embedding and base URLs. Deploy reporting first, remove violations, then enforce.

CSP does not replace contextual encoding or sanitization. Policies can be weakened by allowed third-party script hosts, JSONP endpoints, unsafe DOM patterns, or overly broad directives. Treat CSP as another layer that reduces exploitability when an output mistake remains.

Trusted Types can further constrain DOM XSS sinks in supported browsers by requiring approved creation paths for HTML or script-related values. Adoption needs an inventory of current sinks and third-party code.

Cookies And Impact Reduction

Session cookies should normally use HttpOnly so injected JavaScript cannot read them directly, plus Secure and an appropriate SameSite setting. XSS can still perform authenticated actions from the victim's page, so cookie flags reduce some consequences but do not neutralize the vulnerability.

Limit token exposure in browser storage and avoid placing credentials in URLs, where they can enter history and referrer logs. Authorization checks on the server remain necessary even when the user interface hides controls.

Testing XSS Defenses

Build tests around each output context. Include characters such as <, >, quotes, ampersands, line separators, and strings that try to close the surrounding element. For rich text, keep known sanitizer bypass cases and verify both allowed formatting and removed active content.

Inspect the rendered DOM in a real browser, not only response source. Browser parsing can repair malformed markup in ways a string assertion misses. Exercise client-side routes with hostile query strings, fragments, messages, and stored values. Static analysis can help locate raw rendering APIs and unsafe DOM sinks.

Review CSP reports without logging full sensitive URLs or page data. Run security tests after template-engine, sanitizer, browser-framework, or CSP changes. A result is correct only when hostile input remains inert in the final browser context.

Third-Party Scripts Share The Page's Authority

A script loaded from an allowed third-party origin normally runs with the same DOM access as first-party code. It can read rendered customer data, change forms, and issue authenticated requests. Minimize third-party script inventory, load it only on pages that need it, and review how it is updated.

Subresource Integrity can pin a static cross-origin script to an expected hash, but it does not fit scripts whose provider changes content at the same URL. Self-hosting may improve change control when licensing and update processes allow it. CSP origin allowlists alone do not make a compromised allowed host harmless.

Sandboxed iframes and tightly validated postMessage communication can isolate widgets. Check the sender origin and message structure; never accept * as a convenient trust policy for sensitive actions. Include tag managers in the threat model because they can introduce executable code outside the ordinary application deployment.

Template Boundaries Need Ownership

Assign one layer to render each value. A controller that pre-escapes text and a template that escapes again can produce broken output, while a template that assumes pre-escaped HTML may later receive raw user text. Keep raw domain values separate from trusted HTML fragments and name the latter explicitly. In code review, trace a stored comment, display name, and URL from input to final DOM so the responsible encoder or sanitizer is unambiguous.

After this lesson, you should be able to select encoding by output context, sanitize intentionally allowed HTML, avoid executable DOM sinks, validate URL schemes, deploy CSP as defense in depth, and verify server-rendered and DOM-based XSS protections in a browser.

Practice

Practice: Render Search Results Safely

Render a search term and result title without allowing HTML execution.

Requirements

  • Escape both values for HTML text.
  • Do not remove angle brackets with ad hoc string replacements.
  • Show a malicious search term being rendered as text.
Show solution

Escaping preserves the text while preventing it from becoming markup.

PHP example
<?php

declare(strict_types=1);

function html(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function resultHeading(string $term, string $title): string
{
    return '<h2>Results for ' . html($term) . '</h2><p>' . html($title) . '</p>';
}

echo resultHeading('<script>alert(1)</script>', 'Desk <Lamp>') . PHP_EOL;

// Prints:
// <h2>Results for &lt;script&gt;alert(1)&lt;/script&gt;</h2><p>Desk &lt;Lamp&gt;</p>

The same rule applies to values loaded from a database: stored data may still contain attacker-controlled text.