Security#
Ovellum is a build-time tool that turns Markdown into HTML. This page documents what we defend against, how, and where we explicitly don't.
Threat model#
Three parties interact with an Ovellum-built site:
| Party | Trust | Notes |
|---|---|---|
| Author | trusted | Writes ovellum.config.* and .md content. We trust their source files but defend against typos. |
| Build environment | trusted | ovellum build runs locally or in CI with the author's permissions. |
| Reader of the site | untrusted (from the site's PoV) | Visits the rendered HTML in a browser. We must not let attacker-controlled content reach them. |
The interesting attacker is the third-party contributor: someone
who can submit a PR that adds Markdown to a repo that builds with
Ovellum. A malicious <script> tag, an onclick handler, or a
javascript: href in such a PR must not survive into the rendered HTML
that visitors load after the PR merges.
We do not defend against authors who can already run arbitrary code
on the build host. If you control ovellum.config.ts, you control the
build — the entire config-load path is power equivalent to running the
file as a Node script.
HTML sanitization#
Every Markdown source runs through rehype-sanitize before any output is produced. The pipeline:
remark-parse → remark-rehype (allowDangerousHtml)
→ rehype-raw ← parses literal HTML strings into HAST
→ rehype-sanitize ← strips dangerous elements/attrs
→ rehype-slug
→ rehype-autolink-headings
→ custom (collect headings + shiki highlight)
→ rehype-stringify
What's stripped#
<script>,<object>,<embed><iframe>from any host other than the video-embed allowlist (see "What's kept" below) — and every iframe with a relative or non-http(s)srcon*event-handler attributes (onclick,onload, etc.)- URL schemes other than the allowlist on
href,src,cite,longDesc. Today the allowlist ishttp,https,irc,ircs,mailto,xmppforhref;http,httpsforsrcandcite.
data: URLs are not allowed anywhere, including <img src> —
because data:image/svg+xml can carry an <svg onload="…"> payload
that browsers execute.
What's kept#
The default schema permits the HTML elements authors actually use:
<details>, <summary>, <kbd>, <sup>, <sub>, <mark>, <abbr>,
tables, etc.
Ovellum also allows native media players — <video>, <audio>, and their
<source>/<track> children — so you can embed an mp4/webm/mp3 inline (see
Assets & downloads). Only presentational/playback
attributes are kept (controls, width, height, poster, preload,
loop, muted, autoplay, playsinline); src/poster go through the same
http(s)-only scheme check, and event handlers are still stripped — so a media
embed can't carry script.
For video embeds, <iframe> is allowed — but narrowed twice. The sanitizer
keeps it with a fixed attribute set (src, title, width, height,
loading, referrerpolicy, allow, allowfullscreen), then a second pass
removes any iframe whose src host isn't a known player — today
youtube.com, youtube-nocookie.com, and vimeo.com (with their www./
player. subdomains). Survivors are hardened automatically: loading="lazy",
referrerpolicy="strict-origin-when-cross-origin", and a responsive wrapper. A
relative or arbitrary-host iframe is dropped wholesale, so an embed can't point
at an attacker's page.
Order matters#
Sanitization runs before shiki highlighting. Shiki emits inline
style="…" attributes that the sanitizer would otherwise strip — by
running shiki second, its generated output stays intact while
author-authored HTML is locked down.
Shell-injection resistance#
Whenever Ovellum invokes an external binary (today: git for the
last-modified timestamp on each page), arguments are passed via
execFile as an array. We never build a command string and hand it to
a shell.
The practical consequence: a path containing $(...), backticks,
;cmd, or any other shell metacharacters is treated as a literal
filename argument to git, never interpreted by sh/zsh. Tests in
@ovellum/site pin this behavior by writing fixture files named
inject;touch PWNED;x.md and $(touch PWNED).md and asserting the
canary never appears.
Author-controlled HTML (site.headExtra)#
site.headExtra is injected into <head> on
every page verbatim — never escaped or sanitized — so analytics snippets and
similar third-party <script>/<link>/<meta> tags work as written. That makes
it a trust boundary, by design: it can run arbitrary script on every page.
Treat it like server configuration — only a site admin/author should set it, and
only to a literal string you control. Never derive headExtra from
untrusted input (reader-supplied content, request data, a CMS field, or
environment you don't own); doing so reintroduces exactly the injection the
sanitizer otherwise prevents. It's unset by default, so end-user docs ship with
nothing injected.
URL scheme allowlist in ovellum check#
Beyond render-time stripping, ovellum check flags links whose scheme
is on a deny list (javascript:, vbscript:, data:, file:) as
[SECURITY] issues so the author notices them in source and removes
them. This is defense in depth: the sanitizer keeps the rendered HTML
safe regardless, but flagging them at lint time means they don't ship
silently stripped.
Detection normalizes HTML-numeric entities (	 → tab) and
zero-width / BiDi / whitespace characters before matching, so attempts
like javas	cript: or \tjavascript: are caught.
See CLI → ovellum check for the
output format and exit codes.
What's out of scope today#
- Supply-chain pinning.
package.jsonuses caret ranges (the npm default); we don't pin to exact versions. Acceptable for a dev tool but worth revisiting once Ovellum is widely installed. - Symlinked Markdown in
input/. Passthrough assets are guarded — the build skips any file that resolves outside the content directory via a symlink — but a symlinked Markdown file insideinput/is still read and rendered. The mitigation is to trust the content directory; we don't try to detect that case. - Prototype pollution from config files. A malicious config with
"__proto__": {…}could in theory pollute prototypes, but theObject.keys-based merge in strict mode doesn't write toObject.prototype. We rely on this behavior rather than explicitly filtering keys. - Resource exhaustion from giant Markdown files. No size limits on
.mdinputs. A multi-GB Markdown file would slow the build but not compromise the output.
Reporting#
Found a security issue? For anything that could be used to execute code on a reader's browser, email the maintainer directly (contact in the project README). For non-exploitable concerns, a public GitHub issue is fine.