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.
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.
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+202A–U+202E and U+2066–U+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.