URL trust
URLs are the composition's only interactivity and media-loading channel, so every URL-bearing field goes through one policy — and through it twice.
| Field kind | Accepts |
|---|---|
Navigation (href-like) |
https:, http:, mailto:, and relative paths |
Asset (src-like) |
https:, http:, relative paths, and data:image/* |
Relative paths are allowed because registered views link into the host's own apps with them, and because deployment-internal assets — a service behind the host's network boundary — are legitimate sources a host resolves or uploads itself. data:image/* is allowed because embedded chart snapshots are inert in the src and image_url sinks renderers emit.
Everything else is rejected: non-image data: URIs, blob:, protocol-relative //host, and scheme-obfuscated forms.
A Zod refinement, so an agent-composed composition fails validation with a message it can act on. assetUrl() and navigationHref() build the schemas; ASSET_URL_MESSAGE and NAVIGATION_HREF_MESSAGE are the copy.
Render-time sanitization, so a renderer invoked with a node that never passed the validator still cannot emit an unsafe URL. sanitizeAssetUrl and sanitizeNavigationHref return the normalized URL or null, and a primitive's sanitize hook runs before every render on every surface. A navigation href that fails is replaced with BLOCKED_HREF ('#'), an inert same-document link, so the label stays visible without a destination.
Neither layer alone is enough: validation can be skipped by a host that renders a node directly, and sanitization gives an agent nothing to learn from.
The check runs against a normalized string, because two decoders sit between the composition and a rendered sink:
- Browsers strip ASCII control characters when parsing URLs — tab, newline, and carriage return anywhere, other controls at the edges — so
jav\tascript:reaches the DOM asjavascript:. - Markdown link destinations decode character entities in some consumers, so
javascript:becomesjavascript:.
Both are undone before the scheme is examined. Unencoded < or > is rejected outright rather than stripped: those must be percent-encoded in a URL, so their presence means the value is malformed or a parser-confusion attempt — an unterminated <dest would otherwise read as a relative path.
Protocol-relative forms are rejected including the backslash variants browsers fold into /, since //host and \\host resolve onto a foreign host rather than naming a relative path.
import { assetUrl, navigationHref, sanitizeAssetUrl } from '@elastic/isomer-sdk';
schema: z.object({
type: z.literal('thumbnail'),
src: assetUrl(),
href: navigationHref().optional(),
}),
sanitize: (node) => {
const src = sanitizeAssetUrl(node.src);
return src ? { ...node, src } : null;
},
Returning null from sanitize drops the node entirely, which is the right answer when the unsafe field was the whole point of it.
Primitives for where sanitize runs · Composition and validation for how refinements reach an agent