Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

FeedsContactPrivacy© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
Instatic-Stored-XSS-CVE-2026-103931 — Proof-of-concept and analysis of a stored XSS in Instatic's isSafeUrl() URL filter, where leading C0 control characters bypass javascript: scheme blocking. | Kitploit
Tools/GitHubGitHub/overgrowncarrot1/instatic-stored-xss-cve-2026-103931
Static AnalysisVulnerability AnalysisCode AnalysisExploitationWeb Application ExploitationWeb Security
GitHubovergrowncarrot1/instatic-stored-xss-cve-2026-103931

Instatic-Stored-XSS-CVE-2026-103931

Proof-of-concept and analysis of a stored XSS in Instatic's isSafeUrl() URL filter, where leading C0 control characters bypass javascript: scheme blocking.

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
View Repository
1 day agoNot yet reviewed
Share

URL scheme filter bypass in isSafeUrl() via leading C0 control characters

Affected: 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)

Summary

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.

Proof of concept

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.

End-to-end path

base.link declares href: { type: 'url' } and LinkPropsSchema types it as an unconstrained Type.String({ default: '#' }) — there is no write-time URL validation. Therefore:

  1. escapeProps() routes type: 'url' | 'image' | 'media' props to isSafeUrl(value) ? value : '#' — the payload passes through raw and deliberately un-HTML-escaped.
  2. 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.
  3. The payload lands verbatim in the 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>

Affected sinks

All of these funnel through the same isSafeUrl():

SinkFile
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 nodesrc/core/htmlAttributes/attributes.ts:66
Markdown link/image href and srcsrc/core/markdown/renderMarkdown.ts:75
Site faviconUrlsrc/core/publisher/render.ts:322
Every base module's safeUrl() callsrc/modules/base/utils/escape.ts

Impact

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.

Verification

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 reproducedCSP appliedResult
Admin editor canvas (/admin)none — as securityHeaders.ts emitsjavascript: executed
Published page (baseline)script-src 'none' — as cspPlan.ts emitsblocked (script-src-elem)
Published page with an inline scriptscript-src 'self' 'unsafe-inline' — as frontendInjections.ts:377 emitssee 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.

Fix

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.

Suggested regression test

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)
  }
})

Defence in depth

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.

Download Tool