Luthor treats every URL in a document as untrusted input. A document may have been pasted from a hostile page, synced from another machine, or loaded from a store the host does not control — so "the user typed it" is never assumed.
obsidian:// deliberately?The link extension validates every URL at each entry point — paste,
auto-linking while typing, and the programmatic insertLink /
updateLink / updateLinkByKey commands — with one default validator
that accepts:
http:, https:, mailto:, tel: absolute URLs#section)//example.com/path)Everything else is rejected, including javascript:, data:, and
vbscript: URLs in any casing or whitespace disguise (JaVaScRiPt:,
java\tscript:). Rejected URLs never enter the document model, so they
also never appear in markdown, JSON, or HTML output.
As a second layer, Lexical's own LinkNode renders any non-allowlisted
scheme that reaches the model (for example from a pre-existing document)
as an inert about:blank anchor. The raw URL still round-trips through
JSON and markdown untouched — a host rendering exported markdown with
its own pipeline must apply its own URL policy there.
validateUrl is a plain function, so hosts that need a custom protocol
opt in explicitly. isSafeUrl is exported as a building block:
import { isSafeUrl, linkExtension } from "@lyfie/luthor-headless";
linkExtension.configure({
validateUrl: (url) =>
isSafeUrl(url, {
allowedSchemes: ["http", "https", "mailto", "tel", "obsidian"],
}),
});The iframe and YouTube embed commands only ever accept http(s) URLs.
Documents, however, can carry an arbitrary src into the model through
importJSON or pasted HTML (importDOM) — those paths deliberately do
not rewrite the value, so loading and saving a document never mutates
it. Instead, the src is sanitized at the DOM boundary: anything that
is not http(s) renders (and exports to HTML) as about:blank.
sanitizeUrlForAttribute(url, { allowedSchemes }) implements that gate
and is exported for hosts building their own embed nodes.
htmlToJSON sanitizes markup before converting it, using a hand-written
allowlist pass (sanitizeHtmlImportDocument) applied to the parsed —
inert — document:
script, style, svg, math,
object, embed, template, form controls, and other elements whose
payload is executable or meaningless as document text.on*) and srcdoc are always removed;
everything else outside a small allowlist (plus inert data-* /
aria-*) is removed; style values carrying url(...),
expression(...), or @import are dropped whole.a[href] goes through the link scheme allowlist (a hostile
anchor is unwrapped to plain text); iframe[src] through the embed
allowlist; img[src] rejects script-capable absolute schemes while
keeping data:image/* and relative references, so pasted screenshots
survive.The policy only widens, never narrows, through options:
import { htmlToJSON } from "@lyfie/luthor-headless";
// Widen deliberately for a trusted source…
htmlToJSON(html, {
sanitize: { allowedLinkSchemes: ["http", "https", "obsidian"] },
});
// …or disable entirely for markup the host itself generated.
htmlToJSON(trustedCmsMarkup, { sanitize: false });Luthor sanitizes what it converts. It is not a general-purpose HTML
sanitizer: markup that survives this pass still has to be understood by
the Lexical conversion to reach the document, and the pass makes no
promises about HTML used outside htmlToJSON.
The same DOM-boundary rule covers every remaining place a document can supply a URL:
[](url) in markdown) — linkHref has
no Lexical-side sanitization, so the rendered and exported anchors are
scheme-gated while the model keeps the raw value.![[card:url]]) — same treatment for the card anchor.href="#" and navigate through the host
adapter, so they never carry a document-supplied URL.FileDropUploadExtension writes the host's returned filename into the
body as ![[filename]]. The wikilink syntax has no escape mechanism, so
the reserved characters [ ] # ^ | and control characters are replaced
with - before insertion (sanitizeEmbedTarget) — a file named
x]]y.png would otherwise close the embed early and corrupt the body on
the next save. Hosts should apply the same normalization server-side, or
the stored name and the body reference will disagree.
Envelopes preserve unsupported nodes inside <!-- luthor:meta -->
comments. Payload > characters are written as their JSON \u003e
escape so document text containing --> cannot terminate the comment
early and spill markup into the host's page. JSON.parse restores the
value exactly, so round-trips stay lossless.