From 15896f8475dfc475db0f62dca07b15e16f92c943 Mon Sep 17 00:00:00 2001 From: zqxuii <96630940+zqxuii@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:03:42 +0000 Subject: [PATCH] fix(markdown): block /\ protocol-relative redirect in safeUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit safeUrl allowed "/path" but blocked "//host" to keep links on-site. It missed the backslash form: browsers normalise \ to /, so href="/\evil.com" resolves to "//evil.com" — an off-site redirect from user-authored content blocks. \ isn't HTML-escaped and isn't matched by the link regex's \s, so it reaches safeUrl. Reject a leading /\ as well as //; add regression tests. --- lib/markdown.ts | 4 +++- tests/markdown.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/markdown.test.mjs diff --git a/lib/markdown.ts b/lib/markdown.ts index 8ed936d..002f516 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -12,7 +12,9 @@ const escapeHtml = (s: string): string => function safeUrl(raw: string): string | null { const u = raw.trim(); if (/^(https?:|mailto:)/i.test(u)) return u; - if (/^\/(?!\/)/.test(u)) return u; // "/path" but not "//host" + // "/path", but not "//host" or "/\host": browsers normalise the backslash to a + // slash, so href="/\evil.com" resolves to "//evil.com" (an off-site redirect). + if (/^\/(?![/\\])/.test(u)) return u; if (/^#/.test(u)) return u; return null; } diff --git a/tests/markdown.test.mjs b/tests/markdown.test.mjs new file mode 100644 index 0000000..afe946b --- /dev/null +++ b/tests/markdown.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderMarkdown } from "../lib/markdown.ts"; + +test("markdown blocks the backslash protocol-relative redirect", () => { + // href="/\evil.com" is normalised by browsers to "//evil.com" (off-site). + const link = renderMarkdown("[click](/\\evil.com)"); + assert.ok(!/href="\/\\/.test(link), "backslash URL must not become a link href"); + assert.ok(!link.includes("evil.com") || !link.includes("href"), "must not link off-site"); + const img = renderMarkdown("![x](/\\evil.com)"); + assert.ok(!/src="\/\\/.test(img), "backslash URL must not become an img src"); +}); + +test("markdown still blocks // and still allows legit relative/absolute links", () => { + assert.ok(!renderMarkdown("[x](//evil.com)").includes("href"), "// stays blocked"); + assert.ok(renderMarkdown("[x](/safe/path)").includes('href="/safe/path"'), "/path still works"); + assert.ok(renderMarkdown("[x](https://good.com)").includes('href="https://good.com"'), "https still works"); +}); + +test("markdown blocks javascript: URIs", () => { + assert.ok(!renderMarkdown("[x](javascript:alert(1))").toLowerCase().includes("javascript")); +});