Akira UI 2.6.0 shipped late, and the review is why
A commit said it vetted a link, and its test passed. A review before the tag found a bypass, and the same hole in the editor since 1.1.0.
The commit message read “fix(toast): hold the toast open when an action fails, and vet its link”. It came with a test. The test handed the guard javascript:alert(1), got # back, and passed.
Thirty-one commits sat on main waiting for the Akira UI 2.6.0 tag: area, bar, line and donut charts with a series palette, actions in the toast, tones on the Button, bleed on the Table, and the translatable “Required” legend a consumer needed. The tag waited for a security review of the diff since v2.5.0. That wait was the best call in the release.
The guard that said it vetted the link
// src/components/ui/toast.tsx, as the toast commit wrote it
const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
const NAVIGABLE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
function safeToastHref(href: string): string {
const scheme = SCHEME.exec(href.trim());
if (!scheme) {
return href;
}
return NAVIGABLE_SCHEMES.has(scheme[1].toLowerCase()) ? href : '#';
}
Read it the way a browser reads an href. Before the WHATWG URL parser looks for a scheme, it strips tabs, newlines and carriage returns from anywhere in the string, and C0 control characters and spaces from both ends. The regex does none of that. Put a tab between java and script and the regex finds no scheme, so the guard returns the value untouched. The browser removes the tab, sees javascript:alert(1), and runs it on click. A leading control character works the same way, because trim() leaves it in place.
The test used the literal string, the one input the regex already caught. It would have kept passing against this code indefinitely, because nobody had written a test for what an attacker would send. The review did.
This guard never reached npm. It does not exist in v2.5.0.
The older guard that did reach npm
Pulling the thread led to isSafeEditorUrl in src/components/ui/editor/extensions.ts. Same regex, same trim(). It covers every way a link enters the TipTap editor: paste, autolink, stored HTML and the link dialog. The editor docs said a javascript: address “is refused both on paste and in the link dialog”.
This one was published. It shipped with the editor in v1.1.0 on 7 August and stayed in every release through v2.5.0, six weeks on npm. The advisory is GHSA-r2w8-25qp-4gw4, rated medium, covering every version from 1.1.0 up to, but not including, 2.6.0. All of them are now deprecated on npm, and installing one prints a warning that names the advisory. If your app uses the editor, upgrade to 2.6.0.
One guard instead of two
The fix moved both checks into src/lib/safe-url.ts, so there is one implementation to get right instead of two:
// src/lib/safe-url.ts
const STRIPPED_BY_THE_URL_PARSER = /[\t\n\r]/g;
const EDGE_C0_OR_SPACE = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g;
const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
export const NAVIGABLE_SCHEMES = ['http', 'https', 'mailto', 'tel'];
export function normalizeUrl(url: string): string {
return url
.replace(STRIPPED_BY_THE_URL_PARSER, '')
.replace(EDGE_C0_OR_SPACE, '');
}
export function hasNavigableScheme(url: string): boolean {
const scheme = SCHEME.exec(normalizeUrl(url))?.[1]?.toLowerCase();
return scheme === undefined || NAVIGABLE_SCHEMES.includes(scheme);
}
The toast and the editor’s link dialog now store the normalized value, not the raw one. The new tests build their inputs with String.fromCharCode, so the tab and the control character are real bytes, and each of them fails when the old normalization is put back. A mutation run confirmed it.
The review of the fix caught one more problem. The first normalizeUrl trimmed only the leading end, so the link dialog began saving href="https://akira-io.com " with a trailing space. Nothing exploitable, but a regression the fix itself introduced. It was corrected before the merge.
A fix that stayed out
The wait also cost a fix. A chart color fix was in the queue: two series whose keys sanitize to the same custom property name painted in the same color. Its review found two regressions. With a consumer-supplied config, both series went back to one color, and the tooltip and legend showed the wrong series name. The fix was dropped. The bug is still open, and 2.6.0 ships with it as a known issue rather than trading it for two new ones.
The cost of waiting
The obvious objection: a consumer needed that legend, and every hour of review was an hour without it. True, and that cost was paid.
The alternative was an on-time tag that published a second guard with the same bypass, next to a first one already public for six weeks, under a test suite reporting both as safe.
A test checks the input someone thought of. The review before the tag is where someone thinks of the next one.