The tab character that defeats your XSS filter: allowlist schemes, escape everything
A link like [click](java	script:alert(1)) sails through a naive javascript: blocklist, because the browser strips the tab before it resolves the scheme and your string comparison doesn't. Here is how TaskKit's hand-written Markdown renderer stays safe: allowlist the four schemes that are safe, normalize the URL to the string the browser will actually see, and escape every text run by default.
Published
Render this Markdown link with a naive sanitizer and see what happens:
[click me](java	script:alert(document.cookie))
That 	 is a tab character sitting in the middle of the word “javascript”. A sanitizer that blocks dangerous links by checking if (url.startsWith("javascript:")) looks at the string java\tscript:alert(...), sees that it does not start with javascript:, and happily emits <a href="java\tscript:alert(document.cookie)">. Then the browser resolves that href, strips the tab because it ignores control characters inside scheme names, reads javascript:, and runs the code.
The filter and the browser disagreed about what the string said, and the browser wins that argument every time. This is the core reason URL sanitization is harder than it looks, and it’s why TaskKit’s Markdown tool doesn’t try to blocklist bad links at all. Here’s what it does instead, and why each piece is there.
Blocklists lose
The instinct when you’re rendering user-controlled Markdown to HTML is to enumerate the dangerous things and reject them. Block javascript:. Block data:. Block vbscript:. Ship it.
Every item on that list is bypassable, and the bypasses are not exotic:
- Casing.
JavaScript:,JaVaScRiPt:. Scheme matching is case-insensitive in the browser, so your lowercase comparison misses these unless you normalize first. - Leading and interior whitespace.
javascript:with a leading space, or thejava\tscript:from above. Browsers tolerate and strip a range of whitespace and control characters around and inside the scheme. - HTML entities in the source.
javascript:is the letterjwritten as an entity. If your pipeline decodes entities after your check runs, the check saw something safe and the browser sees something that isn’t. - Schemes you didn’t think of.
vbscript:in old IE,data:text/html;base64,...to smuggle a whole document,blob:, and whatever the next one turns out to be.
That last point is the fatal one. A blocklist is a bet that you can name every dangerous scheme, including the ones that don’t exist yet. You can’t. A blocklist fails open: anything you forgot to list gets through. For a security boundary you want the opposite. You want it to fail closed, so the thing you forgot about is rejected by default.
Allowlist the four schemes that are safe
You cannot enumerate every dangerous scheme, but you can easily enumerate the safe ones, because there are about four. Here is the whole allowlist:
const ALLOWED_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
An href that navigates to http, https, mailto, or tel can’t execute script. Relative URLs and anchors (/about, ./docs, #section) can’t either, and they have no scheme to check. Everything else, every scheme not on that four-item list, is rejected without needing to know what it does. javascript: is rejected not because it’s on a bad list but because it isn’t on the good one. So is vbscript:, so is data:, so is the scheme invented next year. The unknown fails closed.
That’s the whole sanitizeUrl function, in order:
export function sanitizeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (trimmed === "") return "";
// Relative paths and anchors have no scheme and can't execute. Pass them.
if (trimmed.startsWith("#") || trimmed.startsWith("/") ||
trimmed.startsWith("./") || trimmed.startsWith("../")) {
return trimmed;
}
// Strip control characters that some browsers ignore when matching schemes.
const cleaned = trimmed.replace(/[\0-\037\177]/g, "");
const colon = cleaned.indexOf(":");
if (colon === -1) return cleaned; // no scheme, e.g. "example.com/x"
const scheme = cleaned.slice(0, colon + 1).toLowerCase();
if (ALLOWED_SCHEMES.includes(scheme)) return cleaned;
return ""; // unknown scheme: reject
}
Two lines carry most of the weight. Take them one at a time.
Match the string the browser will actually resolve
const cleaned = trimmed.replace(/[\0-\037\177]/g, "");
That regex removes the C0 control characters (U+0000 through U+001F) and DEL (U+007F). These are exactly the bytes a browser silently discards while parsing a URL scheme. The comment in the source says it plainly: strip control characters that some browsers ignore when matching schemes.
These characters aren’t dangerous by themselves. What matters is that your matcher and the browser agree on what the string says. If the author wrote java\tscript: and you check the raw bytes, you see a scheme of java\tscript: that matches nothing. Fine so far, because in an allowlist that just means “reject.” But the moment you extract the scheme by finding the first colon and lowercasing it, you want the substring you compare to be the one the browser will compare. So you normalize first, the same way the browser does. That way the string you approve is the string that actually gets resolved, and your view of the URL can’t quietly drift from the browser’s.
Then the lowercase:
const scheme = cleaned.slice(0, colon + 1).toLowerCase();
JaVaScRiPt: and javascript: are the same scheme to a browser, so they have to be the same scheme to your check. Lowercasing before the allowlist comparison is what makes the mixed-case bypass a non-event. The allowlist itself is all-lowercase, so any casing of a safe scheme matches and any casing of an unsafe one doesn’t.
The tests are blunt about all of this:
expect(sanitizeUrl("JAVASCRIPT:alert(1)")).toBe(""); // casing
expect(sanitizeUrl("java\tscript:alert(1)")).toBe(""); // interior tab
expect(sanitizeUrl("java\nscript:alert(1)")).toBe(""); // interior newline
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe(""); // not on the list
expect(sanitizeUrl("data:text/html,<script>…")).toBe(""); // not on the list
The second layer: escape everything by default
Scheme allowlisting protects the href. It does nothing for the rest of the document, and the rest of the document is also attacker-controlled. So the renderer’s other rule is that every character of text is HTML-escaped on the way out, and raw HTML in the source is never passed through as markup.
The inline parser handles the constructs it understands (emphasis, code spans, links, autolinks) explicitly, and its default branch, the one that runs for ordinary text, is a single line:
state.output += escapeHtml(ch);
escapeHtml neutralizes the five characters that matter in HTML:
export function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
So a source line of <script>alert("xss")</script> never becomes a tag. The < isn’t recognized as the start of any Markdown construct, falls through to the default branch, and comes out as <. The rest is inert text. Same for <img src=x onerror=alert(1)>: it’s not Markdown, so it’s escaped into visible, harmless characters rather than an element with an event handler. There is no “allow some safe HTML tags” mode, because that reopens the entire tag-and-attribute sanitization problem the escaping was there to avoid.
This is escape-by-default: handle the constructs you recognize, escape everything else. The tempting alternative, passing HTML through and stripping out the bad parts, inverts the safety. Escape-by-default is safe until you make a mistake; strip-the-bad-parts is unsafe until you catch every one.
Sanitize, then escape, into the attribute
Here’s a subtlety that a lot of hand-rolled renderers miss. When a link’s URL survives sanitization, it still gets escaped again before it lands in the attribute:
state.output += `<a href="${escapeHtml(safeUrl)}"${titleAttr}>${labelHtml}</a>`;
safeUrl already passed the scheme allowlist, so why escape it? Because passing the scheme check does not mean the URL is free of characters that are dangerous in attribute context. A perfectly valid https: URL can contain a double quote, or an ampersand, or a <. Dropped into href="..." raw, a " closes the attribute early and lets an attacker append onmouseover=... or a new attribute entirely. The allowlist was answering “can this scheme execute script.” escapeHtml is answering a different question: “can this string break out of the quotes I’m about to put it in.” You need both answers, so the code runs both checks.
The test that pins this down is the boring-looking one:
expect(render("[x](https://example.com?a=1&b=2)")).toContain("a=1&b=2");
A completely legitimate URL, and the & still gets encoded, because an unescaped & in an attribute is an HTML correctness bug at best and the tail of an entity-based injection at worst.
Why hand-write this at all
The honest answer is not “libraries bad.” A mature stack, a real CommonMark parser feeding DOMPurify, is the correct choice for a lot of projects, and it has had far more adversarial eyes on it than any code in this post. If you can afford the dependency and the bytes, and especially if you need to allow some user HTML, reach for it.
TaskKit hand-writes the renderer for reasons specific to what it is. It’s a browser-only tool with a no-dependencies, nothing-leaves-the-tab posture, the feature set is bounded (it renders a known subset of Markdown, not arbitrary HTML), and the output is built by string concatenation of already-escaped pieces rather than by parsing HTML into a DOM and pruning it. That last property is what makes the small size defensible: there is no path where raw source HTML becomes live markup, because the only way anything becomes markup is that the renderer explicitly wrote the tag. The attack surface is the set of tags the renderer emits, and that set is small and fixed.
The trade you’re accepting is real and worth saying out loud. A hand-written renderer covers the common 90 percent of Markdown and diverges from the full spec on edge cases (reference-style links, setext headings, some nesting). It is only safe because its output model is “concatenate escaped strings,” and the moment someone adds a feature that sets innerHTML on an unescaped fragment, or adds a “raw HTML passthrough” mode, every guarantee here evaporates. The safety isn’t a property of the code being short. It’s a property of two invariants: escape every text run, and never emit a URL scheme you didn’t allowlist. Keep those two and the renderer stays safe. Break either and no amount of the other saves you.
The rules, in one place
If you’re building anything that turns text you didn’t write into HTML:
- Allowlist URL schemes, never blocklist them. Enumerate the few that are safe (
http,https,mailto,tel, plus relative and anchor URLs). Reject everything else by default, including schemes that don’t exist yet. - Normalize before you match. Strip the control characters browsers ignore and lowercase the scheme, so the string you approve is the string the browser resolves. Your comparison and the browser’s have to agree.
- Escape every text run by default. Recognize the specific constructs you support; escape literally everything else. Never pass source HTML through as markup.
- Escape again for context. A value that’s safe by one measure (its scheme) can still be dangerous by another (breaking out of the attribute it lands in). Those are separate checks, and you need both.
None of it is clever. But get all four right and the renderer is safe by construction; miss one and you’re a forgotten scheme away from running a stranger’s JavaScript in your users’ tabs.
You can paste hostile Markdown into the Markdown editor and watch it come out inert.
Related TaskKit writing
- Browser-only Markdown to PDF with Mermaid: the same renderer, taken all the way to a downloadable PDF
- You can’t cancel synchronous JavaScript: another “the browser does what it does, plan around it” story
- URL-fragment permalinks: treating a decoded URL as hostile input, same posture as treating a Markdown link as hostile