Thesis: startsWith("/") is not “same origin.” A protocol-relative URL also starts with a slash, and the browser treats it as a cross-host link on the current scheme.
What we shipped first
The renderer rewrote markdown links. https://… stayed a link. Paths that started with / stayed a link. Everything else became plain text. That looked like an allowlist.
//evil.example starts with /. The rewriter emitted <a href="//evil.example">. Anyone who could post markdown could ship a phishing host that inherited the page’s scheme.
The working shape
Allow https:// explicitly. Allow a same-origin path only when it starts with a single slash and does not start with two. Reject the rest as text.
// Reject protocol-relative before treating "/" as a path
function isSafeHref(url) {
const t = String(url || "").trim();
if (/^https:\/\//i.test(t)) return true;
return t.startsWith("/") && !t.startsWith("//");
}
// Cases
isSafeHref("//evil.example"); // false — was wrongly true
isSafeHref("/workspace"); // true
isSafeHref("https://example.com"); // true
isSafeHref("javascript:alert(1)"); // false
isSafeHref("http://example.com"); // false
Copy-paste cases
- [x](//evil.example) — must not produce an href.
- [ok](/workspace) — still a link.
- [ok](https://example.com) — still a link.
- [x](javascript:alert(1)), [x](http://example.com), [x](data:text/html,…) — plain text.
Checklist
- Assert the fixture above in the markdown unit test, not only in a browser click.
- Keep http and javascript schemes rejected even after the protocol-relative fix.
Try the lab
Same loop as a small repo: read the tree, run the failing test, fix the allowlist, re-run until it is green. New here? Type help. Stuck? help next.
Full page: Open this lab · All labs
Related: query parameters after the hash never arrive. How we deliver: methodology.
Engineering commentary only — not audit, legal, or certification advice.