Problem and When It Applies
A React community application has three rendering paths:
- Ordinary comments are stored in a database and should appear exactly as text.
- Moderator announcements may use a limited rich-text vocabulary: paragraphs, emphasis, lists, and HTTPS links.
- A search page reads
qfromlocation.searchand displays “Results for …” without waiting for a server-rendered response.
A review finds ordinary comments and announcement previews flowing into dangerouslySetInnerHTML or innerHTML. The search banner also concatenates the query into an HTML string. Explain how to find every source-to-sink path, distinguish the attack lifecycles, choose encoding or sanitization for each context, and add controls that reduce the impact of a missed sink.
The basic answer covers browser rendering and frontend ownership. Server-side validation, authorization, and content storage remain required boundaries, but they do not make an unsafe DOM sink safe. This is a frontend question because the decisive skill is understanding browser parsing, framework escape hatches, DOM injection sinks, CSP, and testable rendering contracts.
Stored, reflected, and DOM-based are useful labels, but they describe different axes. “Stored” and “reflected” describe how attacker-controlled data reaches a victim. “DOM-based” describes a client-side execution path in which JavaScript moves data into an injection sink. A stored or reflected payload can still execute through a DOM sink, so the labels are not always mutually exclusive.
What the Interviewer Is Evaluating
The first signal is whether the candidate draws a data-flow boundary before listing security headers. A strong answer identifies sources such as database fields, URL parameters, fragments, postMessage, and third-party responses, then identifies sinks such as innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, and string-based timers. The question becomes: can attacker-controlled data reach a parser that treats it as markup, script, or a script URL?
The second signal is choosing the defense from the intended data type. Plain comments are text, so they should reach a text sink or normal JSX interpolation. Rich announcements intentionally contain HTML, so entity encoding would break the feature; they require a maintained HTML sanitizer with a narrow policy immediately before the trusted rendering boundary. URL values need protocol and destination validation in addition to the correct output context.
The third signal is whether the candidate understands framework limits. React escapes normal string interpolation, but dangerouslySetInnerHTML is an explicit escape hatch. A framework cannot rescue a raw HTML string or validate every javascript: or data: URL supplied to a dangerous property.
The final signal is layered verification. CSP can limit which scripts execute, and Trusted Types can make selected injection sinks reject raw strings. Neither control repairs a permissive sanitizer policy or an unsafe policy function. A strong answer removes avoidable sinks, narrows the remaining sink, deploys browser controls, and proves the result with source-to-sink review and hostile payload tests.
Clarifying Questions Before Answering
- Which fields are text and which fields intentionally contain HTML? If every field is text, remove all raw
HTML rendering. If announcements need formatting, define an exact tag, attribute, and URL policy before choosing a sanitizer.
- Who can author announcements? Moderator-only input lowers exposure but does not make it trusted. A stolen
moderator session, compromised import, migration, or API bug can still persist malicious content.
- Are announcement links allowed to leave the site? The base solution permits only HTTPS links. Allowing
mail, custom protocols, images, video, or embedded frames requires a wider policy and additional isolation.
- Where is sanitization performed today? Write-time sanitization can reject bad input early, but the
rendering boundary still needs a guarantee that every value reaching the raw HTML sink was sanitized by the current policy.
- Which browsers must be supported? CSP is widely useful. Trusted Types support and rollout behavior must be
checked against the actual browser matrix; unsupported clients still depend on safe sinks and sanitization.
- Which third-party scripts are required? A strict script policy is easier when the application has a small,
audited script set. Tag managers and inline snippets change the CSP migration plan and expand the impact of any successful XSS.
- What does “fixed” mean operationally? The answer should include regression tests, CSP violation monitoring,
sanitizer updates, ownership of policy changes, and a method to find newly introduced sinks.
30-Second Answer Framework
“I would inventory untrusted sources and executable sinks, then fix each path according to the value’s intended type. Ordinary comments and the search query are text, so React interpolation or textContent should render them without HTML parsing. The announcement feature genuinely needs limited HTML, so I would send it through a maintained allowlist sanitizer and expose one narrow rich-text renderer; links also get protocol validation. I would remove other innerHTML calls, deploy a nonce- or hash-based CSP in report-only mode before enforcement, and use Trusted Types where the browser matrix supports it to reject raw strings at DOM sinks. Finally I would test stored, URL-driven, malformed markup, event-handler, SVG, and script-URL payloads while checking that the allowed formatting still works.”
Step-by-Step Deep Dive
Step 1: Map every source to every parsing sink
Begin with a small data-flow table, not a list of payload strings:
| Path | Attacker-controlled source | Current sink | Intended type | Required change |
|---|---|---|---|---|
| Comment card | Database comment body | Raw HTML renderer | Text | JSX interpolation or textContent |
| Announcement | Database announcement body | Raw HTML renderer | Limited HTML | Allowlist sanitization, then one reviewed sink |
| Search banner | location.search | HTML string concatenation | Text | textContent or a text React child |
| Announcement link | Rich-text href | URL-bearing attribute | HTTPS URL | Parse, validate protocol, and sanitize attribute |
Repository review should search for direct sinks and wrappers around them. Names such as safeHtml are not proof; trace the value to the transformation that created it. Also inspect indirect entry points: Markdown renderers, rich-text editors, preview components, localization messages with markup, DOM parser calls, SVG, template utilities, analytics snippets, and code that copies URL or message data into the page.
Classify incidents after the path is known:
- A malicious comment persisted in the database and shown to other users has a stored lifecycle.
- A request parameter copied into an immediate server response has a reflected lifecycle.
- A query or fragment read by client JavaScript and passed to
innerHTMLhas a DOM-based execution path.
This classification helps incident response, but the remediation decision still comes from the source, output context, and sink.
Step 2: Make text remain text
Ordinary comments and the search label do not need markup. Use normal React children:
function CommentBody({ body }: { body: string }) {
return <p>{body}</p>
}
function SearchSummary({ query }: { query: string }) {
return <p>Results for “{query}”</p>
}Outside React, use a text sink:
summaryNode.textContent = queryThe browser now receives data as text rather than reparsing it as HTML. Do not “clean” the string with a regular expression and continue assigning it to innerHTML; HTML parsing has too many elements, attributes, encodings, and malformed-input recovery rules for that approach to be reliable.
Context still matters. Text safety does not automatically make a value safe in an event handler, script block, CSS rule, or URL. Avoid placing untrusted values in executable contexts. When a value belongs in a safe attribute, hardcode the attribute name and use the framework property or setAttribute only after applying the validation required by that attribute.
Step 3: Isolate the one feature that requires HTML
The announcement requirement cannot use plain text because selected formatting must survive. Define the policy before implementation:
- Allowed tags: paragraph, strong emphasis, emphasis, unordered and ordered lists, list items, and anchors.
- Allowed attributes: only
href,title, and the link attributes added by the renderer. - Allowed link protocol in the base scenario: HTTPS.
- Disallowed content: scripts, event-handler attributes, inline styles, frames, forms, SVG, and arbitrary media.
Then centralize the raw HTML sink:
function RichAnnouncement({ dirtyHtml }: { dirtyHtml: string }) {
const cleanHtml = sanitizeRichText(dirtyHtml, RICH_TEXT_POLICY)
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />
}sanitizeRichText represents a maintained, parser-based sanitizer configured with that policy. OWASP recommends DOMPurify as one option. The security property comes from the sanitizer and its configuration, not from the variable name or TypeScript type.
Sanitize immediately before the reviewed rendering boundary. Write-time sanitization can provide an additional check, but relying on it alone leaves gaps for old rows, imports, migrations, alternate APIs, and policy changes. If the system stores sanitized output for performance, record the sanitizer and policy version so older content can be reprocessed after a security update.
Do not modify the HTML with string concatenation after sanitization. Adding a link, highlight, or wrapper by editing the sanitized string can reintroduce an unsafe construct. Build trusted UI outside the raw HTML region, or pass the final content through the sanitizer again.
Step 4: Treat URLs as structured input
HTML sanitization must cover link attributes, but the product policy should also be explicit. Parse a candidate URL against a known base, inspect the resulting protocol, and accept only the protocols the feature needs. In this scenario, only HTTPS survives.
Encoding a dangerous URL does not make its scheme acceptable. A value can be correctly encoded for an href and still use a script-bearing protocol. The URL decision and the HTML-attribute context are separate checks. For external links opened in a new tab, add the appropriate relationship attributes through the renderer; that limits opener access but does not replace XSS prevention.
Avoid user-controlled script URLs entirely. eval, Function, string-based setTimeout, string-based setInterval, and dynamic script source construction should not receive untrusted values. These paths need removal or a small predefined mapping, not a general-purpose sanitizer.
Step 5: Add CSP as a second line of defense
A CSP can restrict script execution if a sink is missed. Prefer a response header and design a policy around the application’s actual resources. A simplified direction is:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{per-response-random-value}';
object-src 'none';
base-uri 'none'The nonce must be unpredictable and unique per response, and only approved scripts receive it. A hash-based policy can suit stable inline content. Avoid weakening the policy with broad host lists or unsafe-inline just to silence violations.
Start with Content-Security-Policy-Report-Only, collect violations, remove unexpected inline code, and then enforce. Report-only mode provides deployment evidence but blocks nothing. CSP remains defense in depth: allowed scripts still have the page’s privileges, browser support differs by feature, and a permissive policy can leave the original exploit working.
Step 6: Use Trusted Types to constrain DOM injection
Where supported, add the CSP directive:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-rich-textEnforcement makes covered DOM injection sinks reject ordinary strings. The application creates only the named policy, and that policy delegates HTML creation to the approved sanitizer. This changes a silent unsafe assignment into a visible exception and makes new raw-string sinks easier to catch in tests and monitoring.
A policy that returns input unchanged defeats the control. A broad default policy can also hide legacy paths by automatically converting every string. Use a default policy temporarily during migration for diagnostics, then move callers to explicit trusted values and remove avoidable sinks.
Trusted Types does not cover every possible way to execute code, and older browsers may lack enforcement. The baseline remains safe rendering, narrow sanitization, URL validation, and removal of executable string APIs.
Step 7: Reduce impact outside the rendering function
Session cookies should normally use HttpOnly, Secure, and an appropriate SameSite policy. HttpOnly can stop injected JavaScript from directly reading that cookie, but an active XSS can still send requests as the user or read data available to the page. Cookie attributes reduce impact; they do not close the injection path.
Server authorization must protect every sensitive action even when the frontend hides a control. Sanitize or validate at server boundaries where appropriate, and keep stored attacker input labeled as untrusted. Third-party scripts execute with the page’s privileges, so reduce their number, scope them to required routes, audit updates, and include them in the CSP design.
Step 8: Verify both security and intended formatting
Build a test corpus that covers different parser paths:
<img src=x onerror=alert(1)>
<a href="javascript:alert(1)">open</a>
<svg onload=alert(1)></svg>
"><script>alert(1)</script>
malformed tags and mixed character encodingsUse inert test callbacks in an isolated test environment rather than real destructive behavior. Verify:
- Plain comments display every character and create no elements.
- Search parameters remain text after normal navigation, encoded URLs, browser history, and hydration.
- Allowed announcement tags survive while scripts, event handlers, styles, SVG, frames, and unsafe URLs are
removed.
- The sanitizer output is not mutated before reaching the raw HTML sink.
- CSP report-only telemetry is understood, then enforcement blocks an intentionally introduced test sink.
- Trusted Types enforcement throws on a raw string and accepts only the approved sanitized policy output.
- Existing rich-text fixtures, links, screen-reader structure, and copy-paste behavior still work.
Add static review for new sinks, unit tests for the sanitizer policy, integration tests for each rendering path, and browser tests against the supported matrix. Keep the sanitizer patched and rerun the hostile corpus when the browser, framework, sanitizer, editor, or policy changes.
High-Quality Sample Answer
“I would first identify untrusted sources and the browser APIs that parse them. The database comment body, announcement HTML, and q from location.search are sources. innerHTML, dangerouslySetInnerHTML, and any script or script-URL constructor are the sinks I would trace.
Comments and the search banner are text features. I would render them as React children or assign textContent, so the browser never interprets their characters as markup. The announcement has a different contract because it permits limited HTML. I would define a small allowlist for paragraphs, emphasis, lists, and HTTPS links, pass the final value through a maintained parser-based sanitizer, and keep one reviewed raw HTML component. The sanitizer would also remove event handlers, styles, SVG, frames, and unsafe URL schemes. No code would concatenate markup after that step.
I would describe the malicious stored comment as a stored lifecycle and the search query path as DOM-based. If the server copied a request value into its immediate HTML response, that would be reflected. These labels help find exposure, while the fix still comes from the output context and sink.
For defense in depth, I would roll out a nonce- or hash-based CSP through report-only mode, remove violations, then enforce it. Where our browser support allows it, I would require Trusted Types for script sinks and permit only a named policy that calls the approved sanitizer. I would not use CSP, HttpOnly cookies, or a Trusted Types policy that returns input unchanged as the primary fix.
My verification would include stored payloads, URL payloads, malformed markup, event attributes, SVG, and script URLs. I would assert that text paths create no elements, allowed formatting survives, disallowed content is removed, raw string assignments fail under Trusted Types, and the enforced CSP blocks an intentionally introduced test sink. I would also keep the sanitizer updated and make new injection sinks a code-review and static-analysis checkpoint.”
Common Mistakes
- Escaping every value the same way → browsers parse HTML, attributes, URLs, CSS, and JavaScript under
different rules → keep data out of executable contexts and apply the defense required by the exact sink.
- Sending plain text through
innerHTMLafter removing script tags → event attributes, malformed markup,
SVG, URL schemes, and parser behavior remain → replace the sink with JSX text rendering or textContent.
- Encoding rich text → the markup appears literally and the feature breaks → **use a maintained HTML
sanitizer with a narrow allowlist when HTML is an explicit product requirement.**
- Trusting moderator-only content → compromised accounts, imports, migrations, and API defects can persist
hostile markup → treat stored content as untrusted at the rendering boundary.
- Sanitizing and then concatenating more HTML → the later mutation can recreate an executable construct →
make sanitization the final transformation before the reviewed sink.
- Validating an
hrefonly by HTML encoding → an encoded value can still carry an unacceptable protocol →
parse the URL and allow only required schemes and destinations.
- Treating React auto-escaping as universal → escape hatches and direct DOM APIs bypass normal string
interpolation → inventory every raw HTML and executable string path.
- Relying on CSP alone → weak policies, allowed scripts, or unsupported features can leave the sink
exploitable → remove unsafe sinks first and use CSP as an additional barrier.
- Creating a Trusted Types policy that returns its input → the browser sees a trusted object without a
trustworthy transformation → restrict policy names and delegate to the reviewed sanitizer.
- Checking only that the alert payload stopped → one payload does not cover URL schemes, malformed markup,
alternate elements, or regressions in allowed formatting → **maintain a varied hostile corpus and positive rich-text fixtures.**
Follow-up Questions
Follow-up 1: How would you migrate a legacy application with hundreds of innerHTML assignments?
Inventory sinks and rank them by reachable untrusted sources, user exposure, and privilege. Replace text-only paths first. Introduce one reviewed rich-text boundary for the remaining legitimate HTML. Deploy CSP and Trusted Types in report-only or diagnostic mode to reveal runtime paths that static search misses. A temporary default Trusted Types policy may log legacy calls, but it should not silently approve unchanged strings; migrate callers to explicit policies and remove the temporary compatibility path.
Follow-up 2: What changes if announcements must allow images and embedded video?
The trust contract expands. Define allowed image origins, URL schemes, dimensions, loading behavior, and privacy rules. Proxying images can reduce direct third-party requests. Embedded video should use a small provider allowlist and a sandboxed frame with only required capabilities. The sanitizer policy, CSP directives, consent behavior, and test corpus all change. Arbitrary frames, styles, and provider HTML should not enter the existing announcement renderer.
Follow-up 3: What if a strict CSP breaks analytics and tag-manager scripts?
Run the proposed policy in report-only mode and classify each violation by business owner and script purpose. Remove unused scripts, replace inline snippets with approved external modules, and give required scripts a per-response nonce or stable hash as appropriate. A broad wildcard or unsafe-inline restores compatibility by giving up much of the security boundary. If a tag manager can inject arbitrary scripts, document that it remains a privileged code path and restrict who can publish through it.
Follow-up 4: The server already sanitizes announcements. Why keep a frontend boundary?
The raw HTML component needs a verifiable input contract regardless of where transformation runs. Alternate APIs, old rows, imports, cache entries, migrations, or a changed policy can bypass a write-time assumption. Encapsulate the sink so callers can provide only output from the current sanitizer, test that contract, and version stored sanitized content if it is reused. The server check reduces bad data entering the system; the rendering boundary prevents an unverified value from reaching the parser.
Follow-up 5: How would you investigate a production XSS report?
Preserve the reported URL, content identifier, browser, CSP report, and relevant deployment version without executing the payload in a normal account. Reproduce in an isolated environment, trace the exact source-to-sink path, and determine whether the payload was stored, request-driven, or supplied by a third party. Remove or disable the vulnerable rendering path, invalidate malicious stored content where necessary, rotate exposed credentials, review sensitive actions performed during the exposure window, then add the payload class to the regression corpus before restoring the feature.