
Proof-of-concept and analysis of a stored XSS in Instatic's isSafeUrl() URL filter, where leading C0 control characters bypass javascript: scheme blocking.
isSafeUrl() via leading C0 control charactersAffected: Instatic v0.0.13 / commit 63ad5d6 (and all earlier revisions containing src/core/html-sanitize/index.ts)
Component: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
Class: CWE-79 (Stored XSS) via CWE-184 (Incomplete List of Disallowed Input)
isSafeUrl() is the single chokepoint that blocks javascript:, vbscript:
and data: URLs across the entire publisher. It normalises input with
.replace(/[\t\n\r]/g, '').trim() before testing the scheme prefix.
The WHATWG URL parser strips all leading C0 control characters
(U+0000–U+001F) and space before reading a scheme. JavaScript's
String.prototype.trim() removes only U+0009, U+000A, U+000B, U+000C, U+000D,
U+0020 and Unicode spaces — it leaves U+0000–U+0008 and U+000E–U+001F
in place.
So a URL prefixed with e.g. U+0001 is reported safe by the guard, while every
browser parses and executes it as the javascript: scheme.
Against the unmodified src/core/html-sanitize/index.ts:
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- guard reports "safe"
WHATWG URL scheme : javascript: <-- what the browser actually runs
safeUrl() output : "\x01javascript:alert(document.domain)" (NOT collapsed to "#")
27 of the 32 C0 control characters bypass the check. U+0000 is neutralised by
HTML attribute parsing (NUL → U+FFFD), leaving 26 reliably exploitable
prefixes (U+0001–U+0008, U+000E–U+001F). The same bypass defeats the
vbscript: and data: filters.
The existing suite (src/__tests__/publisher/utils.test.ts) covers case
folding and embedded tabs (java\tscript:) but never a leading control
character, which is why this was not caught.
base.link declares href: { type: 'url' } and LinkPropsSchema types it as
an unconstrained Type.String({ default: '#' }) — there is no write-time URL
validation. Therefore:
escapeProps() routes type: 'url' | 'image' | 'media' props to
isSafeUrl(value) ? value : '#' — the payload passes through raw and
deliberately un-HTML-escaped.base.link's render() emits `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>`.
safeUrl() re-checks with the same broken isSafeUrl(), then
escapeHtml() — which only escapes & < > " ' and does not touch control
characters.href attribute:
<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%5Cx01javascript%3Aalert%28document.domain%29" target="_self">Click me</a>All of these funnel through the same isSafeUrl():
| Sink | File |
|---|---|
Every url / image / media module prop (link href, button href, image src, video src/poster, form action, form redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| Arbitrary user-set custom HTML attributes on any node | src/core/htmlAttributes/attributes.ts:66 |
Markdown link/image href and src | src/core/markdown/renderMarkdown.ts:75 |
Site faviconUrl | src/core/publisher/render.ts:322 |
Every base module's safeUrl() call | src/modules/base/utils/escape.ts |
Privilege escalation from a low-privileged editor to full admin compromise.
The built-in Client role holds site.content.edit, which is enough to set
a link href or a custom HTML attribute. The injected URL then renders in the
admin editor canvas, which is a srcdoc iframe — same-origin with
/admin.
server/securityHeaders.ts:69-72 sets only
frame-ancestors 'none'; base-uri 'self'; object-src 'none' on /admin, with
an explicit code comment that a script-src policy is deliberately not set
yet. With no script-src, nothing stops a javascript: URL from executing
on the admin origin.
When an Owner or Admin opens the affected page in the editor and activates the
element, the payload runs same-origin with the admin SPA. The session cookie is
HttpOnly, so it cannot be read directly — but the payload can drive the admin
API as the victim (create an owner account, read secrets, or install a plugin,
whose server entrypoint is a path to code execution).
On the published site the impact is narrower: cspPlan.ts sets
script-src 'none' (or 'self'), which blocks javascript: URLs. However
server/publish/frontendInjections.ts:377 relaxes that to
'self' 'unsafe-inline' for any page carrying an inline script, and
'unsafe-inline' re-permits javascript: URLs — so published-site XSS is
reachable on those pages.
Note the attributes.ts docstring already identifies this exact threat model
("on the published site AND, more seriously, inside the admin editor canvas
(same-origin as /admin)") — the guard simply does not implement it
completely.
Confirmed in a browser (Chromium, srcdoc iframes reproducing each origin's CSP,
rendering the byte-for-byte output of the repo's own safeUrl()):
| Origin reproduced | CSP applied | Result |
|---|---|---|
Admin editor canvas (/admin) | none — as securityHeaders.ts emits | javascript: executed |
| Published page (baseline) | script-src 'none' — as cspPlan.ts emits | blocked (script-src-elem) |
| Published page with an inline script | script-src 'self' 'unsafe-inline' — as frontendInjections.ts:377 emits | see note |
The first two rows are observed results. The admin-origin execution reported
document.domain as the serving origin, confirming same-origin execution
rather than an opaque context.
Caveat on fidelity: the harness applies each policy via <meta http-equiv>,
whereas Instatic sends it as an HTTP response header. These are equivalent for
script-src enforcement, but a reproduction on a live bun run dev instance
would carry the genuine headers.
Strip the full C0 + space range instead of relying on trim(). This is
precisely what React's own isJavaScriptProtocol regex does with its
^[\u0000-\u001F ]* prefix — a useful cross-check that this is a
known, real-world bypass class rather than a theoretical one.
--- a/src/core/html-sanitize/index.ts
+++ b/src/core/html-sanitize/index.ts
@@ -30,7 +30,11 @@ export function escapeHtml(value: unknown): string {
* normalisation browsers apply during URL parsing.
*/
export function isSafeUrl(url: string): boolean {
- const normalized = url.replace(/[\t\n\r]/g, '').trim().toLowerCase()
+ const normalized = String(url ?? '')
+ .replace(/[\t\n\r]/g, '')
+ .replace(/^[\u0000-\u0020]+/, '')
+ .replace(/[\u0000-\u0020]+$/, '')
+ .toLowerCase()
return (
!normalized.startsWith('javascript:') &&
!normalized.startsWith('vbscript:') &&
Verified against the patched file: the payload is blocked, safeUrl()
collapses it to #, 0 of 99 control/space-prefixed dangerous URLs remain
accepted, and all 14 existing isSafeUrl test cases behave identically.
it('blocks javascript: behind a leading C0 control character', () => {
for (let i = 0x00; i <= 0x1f; i++) {
expect(isSafeUrl(String.fromCharCode(i) + 'javascript:alert(1)')).toBe(false)
}
})
Consider also setting a real script-src on /admin (the deferred follow-up
noted in server/securityHeaders.ts), which would contain this whole bug class
rather than this one instance.