Learning path · Level 11 · Tutorial 2

Prevent XSS When Rendering External Content

Trace external content into React rendering and choose safe handling for plain text, justified rich HTML, and link destinations without relying on TypeScript alone.

  • Level: Intermediate
  • Duration: 22 min
  • 7 steps
Start tutorial Back to the path
Prevent XSS When Rendering External Content

What you will go through

  1. Trace Untrusted Content to a Rendering Sink Identify an untrusted source, trace it through a React component, and recognize an HTML-interpreting rendering sink. 3 min
  2. Keep Plain Text Out of HTML Sinks Render text-only external article fields with ordinary React JSX instead of raw HTML interpretation. 3 min
  3. Sanitize Only the Rich HTML You Need Use DOMPurify with a restricted formatting policy for the article preview's one justified rich HTML region. 4 min
  4. Preserve the Sanitization Boundary Keep DOMPurify as the final processing boundary for the narrow rich-HTML path, and prevent later markup from bypassing it. 2 min
  5. Parse Links and Allow Explicit Protocols Parse external link destinations, allow only this feature’s supported web protocols, and render noninteractive fallback text when the destination is rejected. 3 min
  6. Restrict Origins When the Feature Requires It Extend a parsed URL policy with an optional exact-origin requirement for navigation that must stay inside the application. 3 min
  7. Apply and Review the Rendering Boundaries Assemble and inspect a local article preview that applies the correct protection to each external field. 6 min

What you will learn

  • Identify an untrusted source and an executable rendering sink in a small front-end example.
  • Replace unnecessary raw HTML rendering with ordinary JSX text.
  • Apply a maintained sanitizer at an unavoidable rich-HTML rendering boundary.
  • Validate parsed link destinations against an explicit protocol and, where needed, origin policy.

Before you start

  • Read and Update the DOM Safely
  • Build a Local React App with TypeScript
  • Validate Unknown Data at Typed API Boundaries
  • Protect Sensitive Data at the Browser Boundary

Step 1 of 7

Trace Untrusted Content to a Rendering Sink

Identify an untrusted source, trace it through a React component, and recognize an HTML-interpreting rendering sink.

Locate the trust-boundary crossing

A trust boundary in the rendering path

Follow the value, not its label

Cross-site scripting (XSS) becomes possible when attacker-controlled content is interpreted as executable code in your application's browser context.

Treat an API response as an untrusted source, even when the API belongs to your organization. A URL value supplied by external content is also an untrusted source. Ownership, a TypeScript annotation, or a successful string-shape check does not establish that a value is safe for every browser context.

To assess a rendering path, trace one value from its source, through props and local variables, to the browser operation that interprets it.

Trace the data flow

A diagram of external article data flowing through a React component to an HTML-interpreting browser panel, with the final boundary highlighted.

The key question is where an external value first receives HTML interpretation.

Trace the complete rendering path

A string is not a safety guarantee

dangerouslySetInnerHTML is a rendering sink: it asks the browser to interpret a string as HTML. A prop and a local variable can make the path look ordinary, but they do not change the source of the value.

HTML interpretation can process more than visible elements. For example, an external element can contain an event attribute such as onerror, creating an executable path. You do not need to insert or trigger a script tag to recognize the risk.

A runtime check can establish that bodyHtml is a string, and ExternalArticle can describe its shape. Neither proves that its contents are safe for HTML interpretation or navigation.

From API-derived data to an HTML sink

Inspect this complete trace. It is deliberately unsafe and is shown only for analysis; do not use this rendering pattern.

tsx
// src/sampleArticle.ts
export type ExternalArticle = {
  title: string;
  summary: string;
  bodyHtml: string;
  url: string;
};

// This local fixture represents externally supplied API data.
export const sampleArticle: ExternalArticle = {
  title: "Field notes",
  summary: "Notes from a reader.",
  bodyHtml: "<p>Read the <strong>highlights</strong>.</p>",
  url: "https://resources.example/guide"
};

// src/ArticleCard.tsx
import type { ExternalArticle } from "./sampleArticle";

type ArticleCardProps = {
  article: ExternalArticle;
};

export const ArticleCard = ({ article }: ArticleCardProps) => {
  const htmlFromResponse = article.bodyHtml;

  return <section dangerouslySetInnerHTML={{ __html: htmlFromResponse }} />;
};

// src/App.tsx
import { ArticleCard } from "./ArticleCard";
import { sampleArticle } from "./sampleArticle";

const App = () => <ArticleCard article={sampleArticle} />;

export default App;

Practice: name the points in the trace

Map the rendering path

Match each part of the preceding flow to its role.

Tap an item and then its matching pair.

Apply the trace

Apply the trace

Which assessment correctly labels the API-to-component flow and evaluates the string type claim?

Step 2 of 7

Keep Plain Text Out of HTML Sinks

Render text-only external article fields with ordinary React JSX instead of raw HTML interpretation.

Use React’s text-rendering default

Plain text stays text

title and summary are plain text fields in this article preview. Even when external text contains characters that resemble markup, render it as an ordinary JSX expression.

React escapes string values placed in JSX. The browser displays the characters instead of interpreting them as HTML elements. This keeps a text-only requirement out of a raw HTML rendering sink.

Two rendering outcomes

A comparison between JSX text rendering, where markup-like characters remain visible, and raw HTML rendering, where the same characters create formatting.

The same external string has different results depending on whether React treats it as plain text or HTML.

Use ordinary JSX for title and summary

Replace the unnecessary sink

Replace raw HTML rendering for text-only fields with normal elements and JSX expressions. No sanitizer, manual escaping, or HTML interpretation is needed for this requirement.

Do not pre-escape a value before placing it in ordinary JSX. React already performs text escaping, and manual escaping can produce confusing double-escaped output. Output context still matters: plain text uses JSX; a justified rich HTML region and a link destination need separate handling decisions.

ArticleCard with plain-text fields

src/ArticleCard.tsx — replace the text-only rendering

tsx
import type { ExternalArticle } from "./sampleArticle";

type ArticleCardProps = {
  article: ExternalArticle;
};

export const ArticleCard = ({ article }: ArticleCardProps) => {
  return (
    <article>
      <h2>{article.title}</h2>
      <p>{article.summary}</p>
    </article>
  );
};

Tip

Keep contexts separate

Use ordinary JSX only because these fields have a plain-text requirement. Do not send every string through an HTML path “just in case.” Keeping title and summary out of the HTML-interpreting sink is simpler and preserves React’s safe text-rendering default.

Apply the plain-text pattern

Practice: remove the raw HTML path

Complete the text-only ArticleCard rendering with the appropriate JSX expressions.

tsx
export const ArticleCard = ({ article }: ArticleCardProps) => {
  return (
    <article>
      <h2></h2>
      <p></p>
    </article>
  );
};

Check the visible result

Final check: replace and predict

A previous version used raw HTML rendering for both text-only fields:

<h2 dangerouslySetInnerHTML={{ __html: article.title }} />
<p dangerouslySetInnerHTML={{ __html: article.summary }} />

Write the two ordinary-JSX replacement lines. Then predict what the page shows when article.title contains <em>Field notes</em>: state whether an em formatting element is created.

Write at least 40 characters (0/40).

Step 3 of 7

Sanitize Only the Rich HTML You Need

Use DOMPurify with a restricted formatting policy for the article preview's one justified rich HTML region.

Decide what actually needs HTML

A narrow reason for rich HTML

The article highlights region has a real formatting requirement: paragraphs, emphasis, and lists must remain visible. That makes it the one justified rich HTML region in this preview.

article.bodyHtml is still an untrusted source. Before it reaches the dedicated raw-HTML rendering sink, reduce it to exactly the formatting the feature needs. Title and summary do not have that requirement; they remain plain text in ordinary JSX.

A sanitizer is context-specific: it prepares this one rich HTML output boundary. It does not decide whether a link destination is acceptable.

The dedicated rich-HTML path

A pipeline shows external article HTML passing through a formatting-only filter before it appears in an article preview. Paragraphs, emphasis, and lists remain while attributes and unsupported elements are excluded.

Sanitize external bodyHtml only at the dedicated rich HTML boundary.

Install a maintained sanitizer and define the policy

Install DOMPurify

In your local React TypeScript project, install the maintained sanitizer package.

bash
npm install dompurify
Expected output
added 2 packages, and audited 144 packages in 1s

found 0 vulnerabilities

The package count, audit message, and timing vary by project. Confirm that the command completes successfully and records DOMPurify in your project dependencies.

Allow only the feature's formatting

Use a maintained sanitizer rather than a home-grown filter. Keep DOMPurify updated through your normal dependency maintenance process.

This highlights feature permits formatting only. The allowlist contains paragraph, emphasis, list, and line-break tags. It intentionally excludes links, images, and embedded content. ALLOWED_ATTR: [] is paired with ALLOW_ARIA_ATTR: false and ALLOW_DATA_ATTR: false so the feature's no-attribute policy also disables DOMPurify's default ARIA and data-* attribute families.

Highlights policy

This configuration is a product rule for article highlights, not a general-purpose HTML policy.

Only formatting required by the feature can remain at this rich HTML boundary.
Content in external HTMLPolicy resultExpected rendering
p and br elementsAllowedParagraph or line break remains
strong and em elementsAllowedEmphasis remains
ul, ol, and li elementsAllowedList structure remains
Event, style, ARIA, or data attributesExcludedAttribute is removed
a, img, or embedded elementsExcludedElement is removed; safe nested text can remain as text

Connect DOMPurify to the one raw-HTML sink

Create one explicit sanitization boundary

Replace the contents of src/ArticleCard.tsx with this component. cleanHtml is created from external bodyHtml immediately before the only raw-HTML rendering boundary.

The component continues to render title and summary as plain text. The separate url field is not rendered here: navigation gets its own URL policy later.

Sanitize immediately before rendering

src/ArticleCard.tsx — replace the file contents

tsx
import DOMPurify from "dompurify";
import type { ExternalArticle } from "./sampleArticle";

type ArticleCardProps = {
  article: ExternalArticle;
};

export const ArticleCard = ({ article }: ArticleCardProps) => {
  const cleanHtml = DOMPurify.sanitize(article.bodyHtml, {
    ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
    ALLOWED_ATTR: [],
    ALLOW_ARIA_ATTR: false,
    ALLOW_DATA_ATTR: false,
  });

  return (
    <article>
      <h2>{article.title}</h2>
      <p>{article.summary}</p>
      <section dangerouslySetInnerHTML={{ __html: cleanHtml }} />
    </article>
  );
};

Example

Read the result as a product rule

With the baseline fixture, <p>Read the <strong>highlights</strong>.</p>, the paragraph and bold word remain. If external markup includes an event attribute, that attribute is removed. If it uses an unsupported element, the element wrapper is removed; ordinary text inside such a wrapper can still be preserved as text.

That behavior is intentional: this feature preserves a small formatting vocabulary, not arbitrary HTML.

Review the sanitizer-to-sink connection

Apply the rich HTML boundary

Complete the sanitizer-to-sink connection and the review outcomes for the restricted highlights policy.

tsx
const cleanHtml = DOMPurify.(article.bodyHtml, {
  ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
  ALLOWED_ATTR: [],
  ALLOW_ARIA_ATTR: false,
  ALLOW_DATA_ATTR: false,
});

const review = {
  strongFormatting: "",
  onclickAttribute: "",
  unsupportedAnchorElement: "",
  anchorText: "",
};

return <section ={{ __html: cleanHtml }} />;

Step 4 of 7

Preserve the Sanitization Boundary

Keep DOMPurify as the final processing boundary for the narrow rich-HTML path, and prevent later markup from bypassing it.

Locate the sanitization boundary

The boundary is immediately before the sink

Treat timing as part of the boundary

A sanitization boundary is a timing rule as well as a tool choice. External bodyHtml remains an untrusted source until every operation that produces HTML is complete and DOMPurify processes that final value. Only then may the result cross into the dedicated rendering sink.

If untrusted markup is appended after sanitization, it bypasses the boundary. Keep this rich HTML path narrow: title and summary remain plain text in ordinary JSX; only the justified highlights region reaches the raw-HTML sink.

Trace the final HTML value

A safe HTML-processing path sends external markup through a formatting step and a filter before a browser card, while a red path adds markup after the filter.

Complete every HTML-producing operation before sanitization; only the sanitized result reaches the raw-HTML rendering sink.

Do not confuse the protections

Similar-looking protections have different jobs

These mechanisms answer different questions. Choose the handling based on the output context, not because a value has a particular TypeScript type.

No one mechanism replaces the others at a rich-HTML rendering boundary.
MechanismQuestion answeredResult here
Data-shape validationAre expected fields present?Does not establish HTML safety
JSX escapingShould this value be plain text?Markup-like characters display literally
HTML sanitizationWhich rich HTML structure may remain?Restricted formatting reaches the sink

A type is not an HTML safety decision

ExternalArticle says that bodyHtml is a string. A runtime shape check can establish that the field exists and has that shape. Neither establishes that its contents are safe for HTML interpretation.

Escaping is the right behavior when content is plain text. It is not a replacement for sanitization when the product intentionally renders rich HTML. Under the highlights policy, unsupported element wrappers can be removed while their safe text content remains visible.

Warning

Avoid pattern-based repairs

Do not write a regular expression or a home-grown “escape dangerous tags” function as a sanitizer. Arbitrary HTML is parsed structure, not a small string pattern. Use the maintained DOMPurify boundary and its explicit allowlist instead.

Repair a bypassed pipeline

Unsafe: markup added after sanitization

This pipeline looks close to correct, but its final concatenation reintroduces the original untrusted source after DOMPurify has finished. displayHtml is therefore not a sanitized value.

Post-sanitization concatenation bypasses the filter

Inside ArticleCard — unsafe review example

tsx
const cleanHtml = DOMPurify.sanitize(article.bodyHtml, {
  ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
  ALLOWED_ATTR: [],
  ALLOW_ARIA_ATTR: false,
  ALLOW_DATA_ATTR: false,
});

const displayHtml = `${cleanHtml}${article.bodyHtml}`;

return <section dangerouslySetInnerHTML={{ __html: displayHtml }} />;

Repair: sanitize the final HTML value

If a final operation genuinely produces HTML for the highlights feature, perform it first. Then sanitize that final value, store the result as cleanHtml, and send only cleanHtml to the rendering sink. In ordinary layouts, prefer JSX rather than creating additional HTML strings.

The final generated value is sanitized

Inside ArticleCard — rich HTML boundary

tsx
const finalHtml = `${article.bodyHtml}<br>`;
const cleanHtml = DOMPurify.sanitize(finalHtml, {
  ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
  ALLOWED_ATTR: [],
  ALLOW_ARIA_ATTR: false,
  ALLOW_DATA_ATTR: false,
});

return <section dangerouslySetInnerHTML={{ __html: cleanHtml }} />;

Tip

Keep the rich-HTML path narrow

The fixed <br> demonstrates the ordering rule. Do not move title or summary into this pipeline: render those fields as plain text with JSX. A narrow rich-HTML path is easier to review and protect.

Apply the boundary rule

Order the repaired rich-HTML pipeline

A review found that article.bodyHtml was concatenated after DOMPurify ran. Arrange the repair and review actions in the only safe sequence.

  1. Pass only cleanHtml to dangerouslySetInnerHTML.
  2. Sanitize finalHtml with the restricted allowlist and store the result in cleanHtml.
  3. Verify that the sink input is cleanHtml alone, not a concatenation with article.bodyHtml.
  4. Build finalHtml from article.bodyHtml and every required HTML-producing addition.

Step 5 of 7

Parse Links and Allow Explicit Protocols

Parse external link destinations, allow only this feature’s supported web protocols, and render noninteractive fallback text when the destination is rejected.

Treat href as a separate boundary

A destination is not just text

article.url is an untrusted source, even when it arrives in a value typed as string. React’s escaping of plain text does not establish a URL policy for an anchor’s href.

Treat navigation as its own rendering boundary: parse the candidate, decide whether the feature supports the parsed destination, then render a link only for an accepted result. A rejected value becomes noninteractive text.

Parse before the navigation sink

Diagram showing an external URL passing through parsing and a protocol gate before becoming a clickable link, while unsupported URLs become noninteractive text.

The URL policy sits between the untrusted source and the navigation rendering sink.

Tip

Do not trust a prefix

Fail closed: do not approve a raw value with a prefix test such as rawUrl.startsWith("https"). Browser parsing can resolve different textual forms into different destinations. This helper returns null whenever the input cannot satisfy its policy.

Use the accepted parsed href

Parse, then apply the policy

Create src/safeUrl.ts. The helper rejects blank input and parsing failures, then checks the parsed protocol against this feature’s exact allowlist: http: and https:. It returns the parser’s url.href, never the unchecked input.

HTTP is included here to support local development. This is the example feature’s URL policy, not a universal recommendation.

A fail-closed protocol helper

src/safeUrl.ts — create or replace the helper

typescript
const allowedProtocols = ["http:", "https:"];

export const getSafeHref = (
  rawUrl: string,
  sameOriginOnly = false,
): string | null => {
  void sameOriginOnly;

  if (rawUrl.trim() === "") {
    return null;
  }

  try {
    const url = new URL(rawUrl, window.location.href);

    if (!allowedProtocols.includes(url.protocol)) {
      return null;
    }

    return url.href;
  } catch {
    return null;
  }
};

Render a link only after acceptance

src/ArticleCard.tsx — add the import, then add the local value before return and the conditional branch inside the existing <article>. Keep the previously established plain-text and rich-HTML rendering unchanged.

tsx
import { getSafeHref } from "./safeUrl";

// Inside ArticleCard, before return:
const href = getSafeHref(article.url);

// Inside the returned <article>:
{href ? (
  <a href={href}>Read more</a>
) : (
  <span>Link unavailable</span>
)}

Inspect outcomes after parsing

Classify parsed destinations

The URL constructor resolves relative and protocol-relative forms against window.location.href before the protocol check. These are inspection examples: do not browse to unsupported destinations.

Destination examples

Results under this step’s protocol-only URL policy.

Allowed means the helper returns the parsed `url.href`; Rejected means it returns `null`.
InputPolicyExpected result
/articles/42Parsed protocol is http: or https:Allowed
https://resources.example/guideParsed protocol is https:Allowed
//outside.example/guideParsed protocol is http: or https:Allowed
javascript:alert(1)Unsupported protocolRejected
data:text/html,exampleUnsupported protocolRejected
Blank inputNo destination to parseRejected

Practice the URL policy

Complete the safe link path

Complete the parser base, the two exact protocol checks, the accepted parsed return value, the null fallback, and the classification of the unsupported destination.

tsx
export const getSafeHref = (
  rawUrl: string,
  sameOriginOnly = false,
): string | null => {
  void sameOriginOnly;

  if (rawUrl.trim() === "") {
    return null;
  }

  try {
    const url = new URL(rawUrl, );

    if (url.protocol !== "" && url.protocol !== "") {
      return null;
    }

    return ;
  } catch {
    return null;
  }
};

const href = getSafeHref(article.url);

const destination = href ? (
  <a href={href}>Read more</a>
) : (
  <span></span>
);

const blockedHref = getSafeHref("javascript:alert(1)");
const blockedStatus = blockedHref ? "Allowed" : "";

Step 6 of 7

Restrict Origins When the Feature Requires It

Extend a parsed URL policy with an optional exact-origin requirement for navigation that must stay inside the application.

Choose the policy for the feature

Protocol policy versus destination policy

A protocol check answers one question: “Is this a supported kind of web URL?” It does not answer: “Must this destination stay inside this application?”

A public Read more link can allow an external https: origin. An in-app destination feature can require a stricter URL policy: after parsing, its destination must have the current page’s exact origin.

Keep the protocol check for both policies. Enable the additional origin rule only for a feature that truly must remain in the current application.

Compare parsed origins

Diagram showing URL parsing followed by public-link and exact-origin in-app policy gates.

Compare parsed origins, not raw URL prefixes.

Compare exact parsed origins

Add the optional same-origin rule

In src/safeUrl.ts, retain the existing blank-input, parsing, and protocol checks. Add an exact parsed-origin comparison only when sameOriginOnly is enabled.

Both url.origin and window.location.origin contain the scheme, host, and port. Comparing these parsed values avoids errors from raw-string prefixes or hostname substrings. A destination accepted by this URL policy is permitted for this feature; it is not proof that a remote website is trustworthy.

A fail-closed helper with an origin option

src/safeUrl.ts — replace the helper with this version

typescript
const allowedProtocols = ["http:", "https:"];

export const getSafeHref = (
  rawUrl: string,
  sameOriginOnly = false,
): string | null => {
  if (rawUrl.trim() === "") {
    return null;
  }

  try {
    const url = new URL(rawUrl, window.location.href);

    if (!allowedProtocols.includes(url.protocol)) {
      return null;
    }

    if (sameOriginOnly && url.origin !== window.location.origin) {
      return null;
    }

    return url.href;
  } catch {
    return null;
  }
};

Resolve before comparing

Resolve before comparing

Assume the current page is https://app.example:5173/articles.

The URL constructor resolves relative and protocol-relative inputs before the URL policy evaluates them. Therefore, //outside.example/guide becomes an HTTPS destination under this current page, but its parsed origin is still external. These are inspection examples only; do not browse to them.

Destination outcomes

The public-resource policy calls getSafeHref(input). The in-app policy calls getSafeHref(input, true).

Expected results for two feature policies
InputPolicyExpected result
/articles/42Public resourceAllowed
/articles/42In-app destinationAllowed
https://resources.example/guidePublic resourceAllowed
https://resources.example/guideIn-app destinationRejected
//outside.example/guidePublic resourceAllowed
//outside.example/guideIn-app destinationRejected

Tip

Why raw-string checks fail

Exact origin means that scheme, host, and port all match. A different port is a different origin even when the hostname looks the same. Do not replace the parsed comparison with startsWith, includes, or a hostname substring check.

Practice: enforce and classify

Practice: enforce and classify

Complete the optional exact-origin rejection condition. Then classify each destination as Allowed or Rejected in this order: public resource / in-app destination. Assume the current page is https://app.example:5173/articles.

typescript
// `url` has already been parsed and passed the protocol check.
if () {
  return null;
}

return url.href;

const classifications = [
  "/articles/42: ",
  "https://resources.example/guide: ",
  "//outside.example/guide: ",
];
// Each pair is: public resource / in-app destination.

Step 7 of 7

Apply and Review the Rendering Boundaries

Assemble and inspect a local article preview that applies the correct protection to each external field.

Set up the external fixture

Start with a local project

Use your own local React + TypeScript project. If you completed the earlier steps, keep that project and replace the four files shown in this step. Otherwise, create a small Vite project with the optional setup below.

Every field in sampleArticle is an untrusted source, even though this local fixture is under your control. The same rendering boundaries apply when the values later arrive from an API.

Terminal — optional fresh project setup

Run these commands only if you need a fresh project. If you already have the project, run npm install dompurify from its root instead.

bash
npm create vite@latest xss-preview -- --template react-ts
cd xss-preview
npm install
npm install dompurify
Expected output
Need to install the following packages:
create-vite@latest
Ok to proceed? (y) y

Scaffolding project in ./xss-preview...
Done. Now run:

  cd xss-preview
  npm install
  npm run dev

added 180 packages, and audited 181 packages in 12s

found 0 vulnerabilities

added 2 packages, and audited 183 packages in 1s

found 0 vulnerabilities

Package versions, package counts, audit messages, and elapsed times vary by npm version and project state.

src/sampleArticle.ts — replace the file contents

Create or replace this file. It provides the external article fixture and its TypeScript shape.

typescript
export type ExternalArticle = {
  title: string;
  summary: string;
  bodyHtml: string;
  url: string;
};

export const sampleArticle: ExternalArticle = {
  title: "Field notes",
  summary: "Notes from a reader.",
  bodyHtml: "<p>Read the <strong>highlights</strong>.</p>",
  url: "https://resources.example/guide",
};

src/safeUrl.ts — replace the file contents

Create or replace this helper. It parses a candidate before enforcing the protocol and optional exact-origin URL policy, then fails closed with null.

typescript
const allowedProtocols = ["http:", "https:"];

export const getSafeHref = (
  rawUrl: string,
  sameOriginOnly = false,
): string | null => {
  if (rawUrl.trim() === "") {
    return null;
  }

  try {
    const url = new URL(rawUrl, window.location.href);

    if (!allowedProtocols.includes(url.protocol)) {
      return null;
    }

    if (sameOriginOnly && url.origin !== window.location.origin) {
      return null;
    }

    return url.href;
  } catch {
    return null;
  }
};

Render the integrated card

Give each field its own boundary

One component has three distinct output contexts. Render title and summary as plain text. Sanitize the justified highlights rich HTML immediately before its dedicated rendering sink. Pass the URL through the URL policy before creating a link.

The highlights policy allows only formatting tags and no attributes: it explicitly disables the default ARIA and data-* attribute families as well. Unsupported element wrappers can be removed while safe text content inside them can remain visible.

src/ArticleCard.tsx — replace the file contents

Create or replace the complete component. Links do not belong in the rich HTML region; the separate url field receives its own URL policy.

tsx
import DOMPurify from "dompurify";
import type { ExternalArticle } from "./sampleArticle";
import { getSafeHref } from "./safeUrl";

type ArticleCardProps = {
  article: ExternalArticle;
};

export const ArticleCard = ({ article }: ArticleCardProps) => {
  const cleanHtml = DOMPurify.sanitize(article.bodyHtml, {
    ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
    ALLOWED_ATTR: [],
    ALLOW_ARIA_ATTR: false,
    ALLOW_DATA_ATTR: false,
  });
  const href = getSafeHref(article.url);

  return (
    <article>
      <h2>{article.title}</h2>
      <p>{article.summary}</p>
      <section dangerouslySetInnerHTML={{ __html: cleanHtml }} />
      {href ? (
        <a href={href}>Read more</a>
      ) : (
        <span>Link unavailable</span>
      )}
    </article>
  );
};

Tip

Keep the roles separate

ExternalArticle establishes a data shape: all four values are strings. It does not decide whether a value belongs in text display, HTML interpretation, or navigation. Make that decision at each rendering sink, based on the feature requirement.

Run and see the initial result

src/App.tsx — replace the file contents

Replace the default app component with this composition. The second line deliberately applies the stricter same-origin URL policy to the fixture’s external destination.

tsx
import { ArticleCard } from "./ArticleCard";
import { sampleArticle } from "./sampleArticle";
import { getSafeHref } from "./safeUrl";

const App = () => {
  const sameOriginHref = getSafeHref(sampleArticle.url, true);

  return (
    <main>
      <ArticleCard article={sampleArticle} />
      <p>
        Same-origin URL policy: {sameOriginHref ? "Allowed" : "Rejected"}
      </p>
    </main>
  );
};

export default App;
Expected output
Field notes
Notes from a reader.
Read the highlights.
Read more
Same-origin URL policy: Rejected

Browser styling and spacing depend on the existing Vite CSS, but the listed text, bold formatting, link, and rejected status are the initial visible results.

Terminal — run the preview

After saving all four files, start the local development server from the project root and open the Local address shown in your terminal.

bash
npm run dev
Expected output
> xss-preview@0.0.0 dev
> vite

  VITE v6.0.0  ready in 180 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

The Vite version, startup time, and available port vary. If port 5173 is in use, Vite may select another local port.

Expected browser preview

Browser preview showing the article title, plain summary, retained bold highlights formatting, accepted Read more link, rejected same-origin status, and a small inset where markup-like summary text is displayed literally.

The baseline fixture renders the card. The inset illustrates the separate plain-text inspection check.

Inspect without browsing unsafe inputs

Inspect the boundary results

Use browser inspection rather than activating suspicious destinations. Change one fixture value at a time, refresh, observe the result, and then restore the baseline value.

  1. Set summary to <em>Reader note</em>. The angle brackets remain visible; no em formatting element is created.
  2. Set bodyHtml to <p onmouseover="alert(1)">Read the <strong>highlights</strong>.</p>. Bold formatting remains. In the Elements panel, confirm that the event attribute is absent; do not hover to test it.
  3. Set url to javascript:alert(1). The card shows Link unavailable.
  4. Restore the baseline external URL. The card’s public link is accepted, while the separate same-origin status remains rejected.

These are inspection examples, not destinations to browse.

URL policy comparison

Evaluate each input only after parsing it. The public-resource policy calls getSafeHref(input); the in-app policy calls getSafeHref(input, true). Do not navigate to these illustrative destinations.

Expected inspection outcomes under the stated URL policy.
InputPolicyExpected result
/articles/42Same-origin onlyAllowed
https://resources.example/guideWeb protocolsAllowed
https://resources.example/guideSame-origin onlyRejected
//outside.example/guideSame-origin onlyRejected
javascript:alert(1)Web protocolsRejected
data:text/html,exampleWeb protocolsRejected

Decision sequence

Choose handling from the output context, not from the TypeScript field type.
External fieldOutput contextHandling
titlePlain textOrdinary JSX
summaryPlain textOrdinary JSX
bodyHtmlRestricted rich HTMLSanitize final HTML before sink
urlNavigationParse and enforce URL policy

Final integration practice

Apply the rendering boundaries

Complete the integrated rendering boundaries.

tsx
const cleanHtml = (article.bodyHtml, {
  ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "br"],
  ALLOWED_ATTR: [],
  ALLOW_ARIA_ATTR: false,
  ALLOW_DATA_ATTR: false,
});
const href = (article.url);

return (
  <article>
    <h2>{article.title}</h2>
    <p></p>
    <section dangerouslySetInnerHTML={{ __html: cleanHtml }} />
    {href ? <a href={href}>Read more</a> : <span>Link unavailable</span>}
  </article>
);

Final review

Report and explain your review

In 2–5 sentences, report the results you observed for the markup-like summary, the inspected event attribute, the rejected destination, and the same-origin check. Then state the handling you completed for plain text, rich HTML, and the URL, and explain why declaring all four fields as string cannot replace those context-specific decisions.

Write at least 180 characters (0/180).

Tutorial completed

Congratulations! You completed: Prevent XSS When Rendering External Content

You assembled and reviewed an article preview with distinct protections for plain text, rich HTML, and link destinations.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate