isSafeUrl() 中的 URL scheme 过滤器受影响版本: Instatic v0.0.13 / commit 63ad5d6(以及所有包含 src/core/html-sanitize/index.ts 的更早修订版本)
组件: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
类别: CWE-79(存储型 XSS),经由 CWE-184(不完整的禁止输入列表)
isSafeUrl() 是整个发布器中拦截 javascript:、vbscript: 和 data: URL 的唯一关卡。它在检测 scheme 前缀之前,先用 .replace(/[\t\n\r]/g, '').trim() 对输入进行规范化。
WHATWG URL 解析器在读取 scheme 之前会剥离所有前导 C0 控制字符(U+0000–U+001F)和空格。而 JavaScript 的 String.prototype.trim() 只移除 U+0009、U+000A、U+000B、U+000C、U+000D、U+0020 以及 Unicode 空格——它会保留 U+0000–U+0008 和 U+000E–U+001F。
因此,一个以例如 U+0001 为前缀的 URL 会被该防护判定为安全,而所有浏览器都会将其解析并执行为 javascript: scheme。
针对未修改的 src/core/html-sanitize/index.ts:
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- 防护报告为“安全”
WHATWG URL scheme : javascript: <-- 浏览器实际执行的 scheme
safeUrl() output : "\x01javascript:alert(document.domain)" (未折叠为 "#")
32 个 C0 控制字符中有 27 个可绕过该检查。U+0000 会被 HTML 属性解析中和(NUL → U+FFFD),剩下 26 个可稳定利用的前缀(U+0001–U+0008、U+000E–U+001F)。同样的绕过方法也能突破 vbscript: 和 data: 过滤器。
现有测试套件(src/__tests__/publisher/utils.test.ts)覆盖了大小写折叠和内嵌制表符(java\tscript:),但从未测试前导控制字符,这就是该问题未被发现的原因。
base.link 声明了 href: { type: 'url' },而 LinkPropsSchema 将其类型定义为不受约束的 Type.String({ default: '#' })——写入时没有 URL 校验。因此:
escapeProps() 将 type: 'url' | 'image' | 'media' 属性路由到 isSafeUrl(value) ? value : '#'——payload 会以原始形式且刻意不做 HTML 转义地通过。base.link 的 render() 输出 `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>`。safeUrl() 用同样有缺陷的 isSafeUrl() 重新检查,然后调用 escapeHtml()——后者只转义 & < > " ',不处理控制字符。href 属性:
<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%5Cx01javascript%3Aalert%28document.domain%29" target="_self">Click me</a>以下所有路径都汇聚到同一个 isSafeUrl():
| 汇聚点 | 文件 |
|---|---|
每个 url / image / media 模块属性(link href、button href、image src、video src/poster、form action、form redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| 任意节点上用户设置的自定义 HTML 属性 | src/core/htmlAttributes/attributes.ts:66 |
Markdown 链接/图片的 href 和 src | src/core/markdown/renderMarkdown.ts:75 |
站点 faviconUrl | src/core/publisher/render.ts:322 |
每个基础模块的 safeUrl() 调用 | src/modules/base/utils/escape.ts |
从低权限编辑者到完全管理员沦陷的权限提升。
内置的 Client 角色拥有 site.content.edit 权限,足以设置链接 href 或自定义 HTML 属性。注入的 URL 随后会在管理员编辑器画布中渲染,而该画布是一个 srcdoc iframe——与 /admin 同源。
server/securityHeaders.ts:69-72 在 /admin 上只设置了 frame-ancestors 'none'; base-uri 'self'; object-src 'none',并附有明确的代码注释说明 script-src 策略有意尚未设置。由于没有 script-src,没有任何机制阻止 javascript: URL 在管理员源上执行。
当 Owner 或 Admin 在编辑器中打开受影响的页面并激活该元素时,payload 会与管理员 SPA 同源地执行。会话 cookie 是 HttpOnly,因此无法直接读取——但 payload 可以以受害者身份驱动管理员 API(创建 owner 账户、读取机密,或安装插件,而插件的服务端入口点是一条通往代码执行的路径)。
在已发布的站点上,影响范围较窄:cspPlan.ts 设置了 script-src 'none'(或 'self'),会阻止 javascript: URL。然而 server/publish/frontendInjections.ts:377 对任何带有内联脚本的页面将其放宽为 'self' 'unsafe-inline',而 'unsafe-inline' 会重新允许 javascript: URL——因此在这些页面上,已发布站点的 XSS 是可触达的。
注意 attributes.ts 的文档字符串已经指出了这一确切的威胁模型(“在已发布站点上,以及更严重的是,在管理员编辑器画布内(与 /admin 同源)”)——该防护只是没有完整实现它。
已在浏览器中确认(Chromium,srcdoc iframe 复现各源的 CSP,渲染仓库自身 safeUrl() 的逐字节输出):
| 复现的源 | 应用的 CSP | 结果 |
|---|---|---|
管理员编辑器画布(/admin) | 无——与 securityHeaders.ts 输出一致 | javascript: 被执行 |
| 已发布页面(基线) | script-src 'none'——与 cspPlan.ts 输出一致 | 被阻止(script-src-elem) |
| 带内联脚本的已发布页面 | script-src 'self' 'unsafe-inline'——与 frontendInjections.ts:377 输出一致 | 见注释 |
前两行是观测结果。管理员源上的执行报告 document.domain 为服务源,确认了同源执行而非不透明上下文。
关于保真度的注意事项:测试装置通过 <meta http-equiv> 应用每个策略,而 Instatic 是将其作为 HTTP 响应头发送的。对于 script-src 的强制执行而言两者等价,但在真实的 bun run dev 实例上复现会携带真实的响应头。
剥离完整的 C0 + 空格范围,而不是依赖 trim()。这正是 React 自身的 isJavaScriptProtocol 正则用其 ^[\u0000-\u001F ]* 前缀所做的——这是一个有用的交叉验证,表明这是一个已知的、真实存在的绕过类别,而非理论上的。
--- 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:') &&
针对已修补的文件验证:payload 被阻止,safeUrl() 将其折叠为 #,99 个带控制/空格前缀的危险 URL 中 0 个仍被接受,且所有 14 个现有 isSafeUrl 测试用例行为完全一致。
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)
}
})
还应考虑在 /admin 上设置真正的 script-src(即 server/securityHeaders.ts 中注明的延后跟进项),这将遏制整个此类 bug,而不仅仅是这一个实例。