CVE-2025-3029
Overview
Background
- Invisible / format characters
- Code points that render as nothing or as whitespace (controls, bidi marks, zero-width, variation selectors, unassigned glyphs). Shown unescaped in the URL bar, they can hide or reorder the visible origin.
- JS regex `/v` (unicodeSets) flag
- Enables set operations (
[A--B],\p{...}property escapes) and treats the pattern as operating on full Unicode scalar values rather than UTF-16 code units. - Surrogate pairs
- Astral code points (> U+FFFF) are two UTF-16 units (high + low surrogate). A code-unit-based pattern must enumerate explicit surrogate ranges; a scalar-aware
/vpattern uses\u{...}directly.
Root Cause Analysis
The display-sanitizing regexes in losslessDecodeURI were written partly in terms of UTF-16 surrogate code units and did not comprehensively cover the set of invisible/format characters, so certain crafted code points slipped through unescaped and were rendered in the address bar. The second replace call matched astral format/invisible characters by spelling out surrogate pairs (\ud804[\udcbd\udccd], \ud834[\udd73-\udd7a], [\udb40-\udb43][\udc00-\udfff], etc.) and an ad-hoc list of BMP code points. This approach is brittle: it both risks matching a lone surrogate rather than a full scalar value and omits whole categories of format/invisible characters. An attacker could therefore include specific Unicode characters in a URL that losslessDecodeURI failed to encode, letting invisible or deceptive glyphs render in the URL bar and hide the page’s true origin (advisory: Spoofing, CWE-290). The first replace also mixed a bare inside a /v set, which under unicodeSets semantics is fragile.
The patch rewrites both regexes to be scalar-value-aware and category-complete. The invisible-controls pattern switches literals to \u{...} braces (\u{0020}, \u{2800}, \u{FFFC}) so matching is on full scalar values. The format/invisible pattern is replaced with a /v set built on Unicode property escapes: [[\p{Format}--[\u{200C}\u{200D}]]...\p{Unassigned}\p{Private_Use}\u{E0000}-\u{E0FFF}\u{1F50F}-\u{1F513}\u{1F6E1}] — i.e. encode all Format characters except the legitimately needed zero-width joiner/non-joiner (U+200C/U+200D), plus unassigned, private-use, tag characters, and specific UI-mimicking emoji — expressed directly in scalar values instead of surrogate enumerations. An added comment warns that the patterns are Unicode-aware via /v and must work with full scalar values, never raw surrogates. This closes the gap so the previously-unescaped deceptive characters are now percent-encoded for display.
/v scalar-aware \p{Format}-based sets that cover the whole category, leaving no gap for deceptive glyphs to render.Attack Path
- Attacker Crafts a URL whose path/host contains specific invisible or format Unicode characters (e.g. astral tag characters U+E0000–E0FFF, or format characters outside the old explicit list) positioned to mask the true origin.
- Navigation Lures the victim to the URL (link, redirect); Firefox loads the page and prepares the address-bar display string.
- Sanitizer gap
losslessDecodeURI’s old regexes fail to encode the crafted characters, so they pass through to the rendered value (the diff proves these characters were previously unmatched). - Deceptive render The invisible/zero-width/directional characters render in the URL bar so the displayed origin differs from the real one.
- Primitive Address-bar origin spoofing: the user sees a trusted-looking origin for a page the attacker controls.
Impact Assessment
C:L/I:L/A:L.Files Changed
browser/components/urlbar/UrlbarInput.sys.mjs
Audit Directions
- Other URL/display sanitizersAudit any other place that decodes URLs or filenames for display (downloads panel, page info, permission prompts, notification origins) for the same surrogate-pair / incomplete-list pattern.
- `/v` regex correctnessReview other
/vor/uregexes in front-end code for stray raw surrogates or BMP-only assumptions that could leak astral characters. - Format-category completenessWhere invisible characters are filtered, confirm
\p{Format}/\p{Default_Ignorable_Code_Point}-based coverage rather than curated code-point lists, and re-check the ZWJ/ZWNJ (U+200C/U+200D) carve-out does not itself enable spoofing in RTL contexts.
Patch
diff --git a/browser/components/urlbar/UrlbarInput.sys.mjs b/browser/components/urlbar/UrlbarInput.sys.mjs
index d9a04c782c2..4b69136aa31 100644
--- a/browser/components/urlbar/UrlbarInput.sys.mjs
+++ b/browser/components/urlbar/UrlbarInput.sys.mjs
@@ -5065,6 +5065,10 @@ function losslessDecodeURI(aURI) {
}
}
+ // IMPORTANT: The following regular expressions are Unicode-aware due to /v.
+ // Avoid matching high or low surrogate pairs directly, always work with
+ // full Unicode scalar values.
+
// Encode potentially invisible characters:
// U+0000-001F: C0/C1 control characters
// U+007F-009F: commands
@@ -5083,7 +5087,7 @@ function losslessDecodeURI(aURI) {
// preserve them encoded.
value = value.replace(
// eslint-disable-next-line no-control-regex
- /[[\p{Separator}--\u0020]\p{Control}\u2800\ufffc]|\u0020(?=[\p{Other}\p{Separator}])|\s$/gv,
+ /[[\p{Separator}--\u{0020}]\p{Control}\u{2800}\u{FFFC}]|\u{0020}(?=[\p{Other}\p{Separator}])|\s$/gv,
encodeURIComponent
);
@@ -5094,21 +5098,18 @@ function losslessDecodeURI(aURI) {
// per bug 582186:
// U+00AD, U+034F, U+06DD, U+070F, U+115F-1160, U+17B4, U+17B5, U+180B-180E,
// U+2060, U+FEFF, U+200B, U+2060-206F, U+3164, U+FE00-FE0F, U+FFA0,
- // U+FFF0-FFFB, U+1D173-1D17A (U+D834 + DD73-DD7A),
- // U+E0000-E0FFF (U+DB40-DB43 + U+DC00-DFFF)
+ // U+FFF0-FFFB, U+1D173-1D17A, U+E0000-E0FFF
// Bidi control characters (RFC 3987 sections 3.2 and 4.1 paragraph 6):
// U+061C, U+200E, U+200F, U+202A-202E, U+2066-2069
// Other format characters in the Cf category that are unlikely to be rendered
// usefully:
- // U+0600-0605, U+08E2, U+110BD (U+D804 + U+DCBD),
- // U+110CD (U+D804 + U+DCCD), U+13430-13438 (U+D80D + U+DC30-DC38),
- // U+1BCA0-1BCA3 (U+D82F + U+DCA0-DCA3)
+ // U+0600-0605, U+08E2, U+110BD, U+110CD, U+13430-13438, U+1BCA0-1BCA3
// Mimicking UI parts:
- // U+1F50F-1F513 (U+D83D + U+DD0F-DD13), U+1F6E1 (U+D83D + U+DEE1)
+ // U+1F50F-1F513, U+1F6E1
// Unassigned codepoints, sometimes shown as empty glyphs.
value = value.replace(
// eslint-disable-next-line no-misleading-character-class
- /[\u00ad\u034f\u061c\u06dd\u070f\u115f\u1160\u17b4\u17b5\u180b-\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u206f\u3164\u0600-\u0605\u08e2\ufe00-\ufe0f\ufeff\uffa0\ufff0-\ufffb\p{Unassigned}\p{Private_Use}]|\ud804[\udcbd\udccd]|\ud80d[\udc30-\udc38]|\ud82f[\udca0-\udca3]|\ud834[\udd73-\udd7a]|[\udb40-\udb43][\udc00-\udfff]|\ud83d[\udd0f-\udd13\udee1]/gv,
+ /[[\p{Format}--[\u{200C}\u{200D}]]\u{034F}\u{115F}\u{1160}\u{17B4}\u{17B5}\u{180B}-\u{180D}\u{3164}\u{FE00}-\u{FE0F}\u{FFA0}\u{FFF0}-\u{FFFB}\p{Unassigned}\p{Private_Use}\u{E0000}-\u{E0FFF}\u{1F50F}-\u{1F513}\u{1F6E1}]/gv,
encodeURIComponent
);
return value;