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 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 as javascript:.
  • HTML attributes and Markdown link destinations decode character references, so javascript:, java	script:, and //host become javascript:, javascript:, and //host.

Both are undone before the scheme is examined, references first so a decoded tab is stripped too. Every numeric reference is decoded, with or without its trailing ;, as are the named &colon;, &Tab;, &NewLine;, &sol;, &bsol;, &lt;, and &gt;. The check decodes once. A passing URL comes back as authored, trimmed and with control characters stripped, so a consumer that decodes it once sees exactly what was checked; returning a decoded copy would let a second decode turn javascript&#38;#58; into javascript:. 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.

Authored Markdown is parsed as GFM. Its already-decoded destinations are checked without another reference decode; nested labels and HTML blocks receive the same policy. Reference definitions are resolved at each use: links use the navigation policy and images use the asset policy, so a shared definition can remain valid for one and be blocked for the other. Source beyond 16,384 UTF-16 code units, 2,048 syntax delimiters (a backslash escape counts once), or 256 characters of container prefix degrades to inert text rather than entering the parser.

sanitizeParsedNavigationHref and sanitizeParsedAssetUrl apply the same policies to a destination a Markdown parser has already decoded, without decoding it again, and exceedsParseBudget from @elastic/isomer-sdk/markdown reports whether source is past the parse budget above. A pack rendering Markdown with a parser of its own uses them in place of copies:

import {
  sanitizeParsedAssetUrl,
  sanitizeParsedNavigationHref,
} from '@elastic/isomer-sdk';
import { exceedsParseBudget } from '@elastic/isomer-sdk/markdown';

const urlTransform = (url: string, key: string) =>
  (key === 'src' ? sanitizeParsedAssetUrl : sanitizeParsedNavigationHref)(
    url
  ) ?? '';

const render = (source: string) =>
  exceedsParseBudget(source) ? (
    <pre>{source}</pre>
  ) : (
    <Markdown {...{ urlTransform }}>{source}</Markdown>
  );
		

The policy accepts a relative path, and React, HTML, and Markdown keep it as a link, but Slack has no page to resolve one against. Without a base, a relative link on Slack prints its label alone and is dropped, with no finding. A host that knows where its paths live passes baseUrl, an absolute http or https URL, to renderSlackEnvelope, the runtime's slack.render and slack.renderNode, or markdownContentToSlackBlocks:

runtime.surfaces.slack.render(composition, {
  baseUrl: 'https://kibana.example/s/team/',
});
		

Each navigation href is checked by the policy first. One that passes and is relative resolves with new URL(href, baseUrl), and the result links only when it stays on the base's origin and passes the policy and Slack's absolute-URL check again. A fragment, BLOCKED_HREF included, never resolves, since it names a place in a document Slack does not show, and protocol-relative forms stay rejected. Images do not resolve: Slack draws none inline, and an asset reaches it as an upload. The base is read as URL parses it, so a default port or an uppercase host is fine; one that is not an http or https URL with a DNS or IPv6 host, or that carries credentials, throws an IsomerError with the code INVALID_BASE_URL.

import {
  assetUrl,
  BLOCKED_HREF,
  NAVIGATION_HREF_RULE,
  navigationHref,
  sanitizeAssetUrl,
  sanitizeNavigationHref,
} from '@elastic/isomer-sdk';

schema: z.object({
  type: z.literal('thumbnail'),
  src: assetUrl(),
  href: navigationHref({ max: 2048 })
    .describe(`Where the thumbnail links: ${NAVIGATION_HREF_RULE}.`)
    .optional(),
}),
sanitize: (node) => {
  const src = sanitizeAssetUrl(node.src);
  if (!src) return null;
  return node.href === undefined
    ? { ...node, src }
    : { ...node, src, href: sanitizeNavigationHref(node.href) ?? BLOCKED_HREF };
},
		

A refinement's message never reaches the authoring JSON Schema, so each schema describes itself with its rule: navigationHref() as An https, http, or mailto URL, or a relative path. and assetUrl() as An https, http, or data:image URL, or a relative path. A field that sets its own description replaces that one, so end it with NAVIGATION_HREF_RULE or ASSET_URL_RULE, as above. max bounds the length.

Returning null from sanitize drops the node entirely, which is the right answer when the unsafe field was the whole point of it. A blocked href becomes BLOCKED_HREF instead, so the thumbnail still renders without a destination.

Primitives for where sanitize runs · Composition and validation for how refinements reach an agent