Part 6 · 2 chapters · ~15 min
Client Security
XSS as a source reaching a sink, and the four layers that stop it: framework escaping, guarded sinks, Trusted Types and a strict nonce CSP; then the rest of the surface: dependencies at install and over time, third-party scripts, secrets in the bundle, tokens in the browser and the cheap headers.
13
XSS and the four layers
source to sink, and what stands between
- Sources and sinks: attacker-controlled data (URL, stored input, postMessage, other users' fields) reaching a sink that makes strings into code: innerHTML, eval, string timers, javascript: URLs, dangerouslySetInnerHTML.
- The framework escapes interpolated text; the gaps are escape hatches, URL attributes and direct DOM.
- Guarded sinks: a lint ban outside one sanitising module, DOMPurify with an allowlist, a URL scheme check.
- Trusted Types make sinks throw on plain strings at runtime, dependencies included.
- A strict CSP with a per-response nonce and strict-dynamic; allowlist CSPs are bypassable.
- Together: each layer fails one day; four at once is the incident you avoid.
code
// safe.ts: the one module allowed to touch HTML sinks
import DOMPurify from 'dompurify';
const policy = window.trustedTypes?.createPolicy('app', {
createHTML: (s: string) => DOMPurify.sanitize(s, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href'],
}),
});
export const safeHTML = (s: string) => ({ __html: policy ? policy.createHTML(s) : DOMPurify.sanitize(s) });
export function safeURL(u: string): string {
try {
const url = new URL(u, location.origin);
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : '#';
} catch { return '#'; }
}
// Comment.tsx
<a href={safeURL(user.website)}>{user.name}</a>
<div dangerouslySetInnerHTML={safeHTML(comment.html)} />code
# the response header (nonce generated per response, injected into each script tag) Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app dompurify; report-to csp
why 'unsafe-inline' and https: are there
They are fallbacks for old browsers: a browser that understands nonces ignores 'unsafe-inline', and one that understands strict-dynamic ignores host sources. Modern browsers get the strict policy; old ones get something rather than a broken page.
XSS AND THE LAYERS THAT STOP IT
where untrusted strings become code, and the four layers between an attacker's input and your user's session
swipe the figure sideways, or tap expand for full screen
1/6
sources, sinks
The sources and sinks: sources are anything the attacker controls (URL parameters and fragments, form input stored and shown to others, postMessage data, API fields from other users, third-party content); sinks are where a string becomes code or markup: innerHTML and outerHTML, insertAdjacentHTML, document.write, eval, new Function, setTimeout with a string, javascript: URLs in href or src, and in React dangerouslySetInnerHTML and href from user data. XSS is a source reaching a sink.
14
Dependencies, third parties and secrets
what you ship and did not write, and what you should not have shipped
- At install: frozen lockfile, install scripts off by default with an allowlist, a minimum release age.
- Over time: grouped automated updates, SCA failing only on reachable criticals, a new-dependency policy.
- Third-party scripts: inventory from CSP reports, SRI for versioned URLs, sandboxed cross-origin iframes, a gated tag manager.
- Secrets: the bundle is public; restrict publishable keys at the provider; scan the repo and the built bundle.
- Tokens: sessions in HttpOnly SameSite cookies; real credentials behind a BFF.
- Headers: frame-ancestors, COOP, Permissions-Policy, Referrer-Policy, HSTS, tested in CI.
code
# .npmrc ignore-scripts=true # pnpm-workspace.yaml (pnpm 10): only these may run install scripts onlyBuiltDependencies: - esbuild - sharp minimumReleaseAge: 4320 # minutes: 3 days before a new version is accepted # CI: scan the artefact users actually receive npx gitleaks detect --no-git --source dist/
the exercise
Open your production site, then the Network panel filtered to JS. List every script origin that is not yours, who added it, and what it can read on the checkout page. Then search the built bundle for "sk_", "secret" and "token".
DEPENDENCIES, THIRD PARTIES AND SECRETS
everything you ship that you did not write, and everything you shipped that you should not have
swipe the figure sideways, or tap expand for full screen
1/6
install
Dependencies at install: a lockfile committed and enforced (npm ci, frozen lockfile) so CI installs exactly what was reviewed; install scripts disabled by default (ignore-scripts, or pnpm's allowlist of packages permitted to run them) because postinstall runs arbitrary code on every developer laptop and CI runner; a minimum release age before a new version is accepted (most malicious publishes are caught within days).