From 81581f5c9bdf119afb43e1b1d74ebcfa9934b86c Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 10 Jun 2026 22:53:25 +0300 Subject: [PATCH] fix: guard decodeURI(Component) against lone-surrogate crash (#667225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'String contained an illegal UTF-16 sequence' on first Mini App / site open for users with a truncated emoji in their Telegram name. The existing surrogate guard only patched the ENCODE direction (encodeURI/encodeURIComponent). But Telegram percent-encodes such names as CESU-8 (%ED%A0%BD) in tgWebAppData, and decodeURI/decodeURIComponent throw on those bytes in every engine (JSC wording is the reported one) — any decode of init-data-derived strings crashed into the ErrorBoundary. Not valibot: valibot 1.0.0 has no UTF-16 well-formedness validation (verified against the installed package), and upstream pins it exactly, so there is nothing to bump. decodeURI/decodeURIComponent now try the native decoder first and fall back to lenient WHATWG-style UTF-8 decoding with U+FFFD replacement — the same semantics URLSearchParams applies to the same bytes. Decoded values still flow through existing validation (getSafeRedirectPath etc), so nothing becomes less strict. --- src/utils/installEncodingSurrogateGuard.ts | 92 +++++++++++++++++++--- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/src/utils/installEncodingSurrogateGuard.ts b/src/utils/installEncodingSurrogateGuard.ts index 3604c60..8d506de 100644 --- a/src/utils/installEncodingSurrogateGuard.ts +++ b/src/utils/installEncodingSurrogateGuard.ts @@ -3,24 +3,38 @@ import { sanitizeSurrogates } from './sanitizeSurrogates'; /** * App-wide guard against the "String contained an illegal UTF-16 sequence" crash. * - * A lone (unpaired) UTF-16 surrogate — typically a truncated emoji in a backend - * name/remark that ends up in a subscription/connection URL — makes encodeURI / - * encodeURIComponent throw a URIError on iOS WebKit/JavaScriptCore (V8: "URI - * malformed"; Safari/JSC: "String contained an illegal UTF-16 sequence"). Any - * code path that encodes such a string crashes — including third-party libs we - * can't edit (e.g. qrcode.react calls encodeURI internally) and any encode call - * added later. The base64 idiom btoa(unescape(encodeURIComponent(x))) is covered + * A lone (unpaired) UTF-16 surrogate — typically a truncated emoji in a Telegram + * user name or a backend name/remark — crashes the URI functions on both engines + * (V8: "URI malformed"; iOS WebKit/JavaScriptCore: "String contained an illegal + * UTF-16 sequence"), in BOTH directions: + * + * - encodeURI / encodeURIComponent throw when the INPUT STRING contains a lone + * surrogate (truncated emoji in a subscription URL's remark, a user name fed + * into a link, qrcode.react's internal encodeURI, …). + * - decodeURI / decodeURIComponent throw when the INPUT BYTES percent-encode a + * lone surrogate (CESU-8 style %ED%A0%BD). Telegram itself produces such + * sequences in tgWebAppData when the user's name contains a truncated emoji, + * so any decode of init-data-derived strings — ours, @telegram-apps/sdk's, or + * a router's — crashes the Mini App on first open. + * + * The base64 idiom btoa(unescape(encodeURIComponent(x))) is covered * transitively, since its encodeURIComponent is guarded here. (We do NOT patch * btoa itself: it rejects every char > U+00FF — Cyrillic, emoji, even the U+FFFD * replacement — with a separate "Invalid character" error that sanitising * surrogates cannot fix, and nothing base64-encodes raw Unicode directly.) * * Instead of wrapping every (current and future) call site, we sanitise at the - * single chokepoint: the global encoders themselves. This is fail-safe, not - * fail-broken — for any well-formed string there are no lone surrogates, so the - * output is byte-for-byte identical; only strings that would otherwise have - * thrown get their lone surrogates replaced with U+FFFD (the same remedy as - * String.prototype.toWellFormed()). + * single chokepoint: the global codecs themselves. This is fail-safe, not + * fail-broken: + * - encoders: for any well-formed string the output is byte-for-byte identical; + * only strings that would otherwise have thrown get their lone surrogates + * replaced with U+FFFD (the same remedy as String.prototype.toWellFormed()). + * - decoders: the native decoder runs first and its result is returned + * unchanged; only when it THROWS do we fall back to lenient WHATWG-style + * UTF-8 decoding with U+FFFD replacement — the exact semantics + * URLSearchParams already applies to the same bytes. Validation that + * inspects the decoded value (e.g. getSafeRedirectPath) still runs on the + * fallback result, so nothing gets less strict. * * Must run before any rendering or network call. Idempotent. */ @@ -32,8 +46,62 @@ export function installEncodingSurrogateGuard(): void { const nativeEncodeURI = globalThis.encodeURI; const nativeEncodeURIComponent = globalThis.encodeURIComponent; + const nativeDecodeURI = globalThis.decodeURI; + const nativeDecodeURIComponent = globalThis.decodeURIComponent; globalThis.encodeURI = (uri: string): string => nativeEncodeURI(sanitizeSurrogates(String(uri))); globalThis.encodeURIComponent = (uriComponent: string | number | boolean): string => nativeEncodeURIComponent(sanitizeSurrogates(String(uriComponent))); + globalThis.decodeURI = (encodedURI: string): string => { + const input = String(encodedURI); + try { + return nativeDecodeURI(input); + } catch { + return decodePercentSequencesLeniently(input); + } + }; + globalThis.decodeURIComponent = (encodedURIComponent: string): string => { + const input = String(encodedURIComponent); + try { + return nativeDecodeURIComponent(input); + } catch { + return decodePercentSequencesLeniently(input); + } + }; +} + +const HEX_PAIR = /^[0-9a-fA-F]{2}$/; + +/** + * WHATWG-style lenient percent-decoding: collect %XX bytes (malformed escapes + * stay literal) and decode them as UTF-8 with U+FFFD replacement — the same + * semantics URLSearchParams applies. Used ONLY after the native decoder threw, + * so a replacement glyph in place of a broken emoji half beats taking the + * whole app down. Unlike decodeURI, the fallback also decodes reserved + * characters — acceptable, since the native result for that input was a crash. + */ +function decodePercentSequencesLeniently(input: string): string { + const bytes: number[] = []; + const encoder = new TextEncoder(); + let i = 0; + while (i < input.length) { + if (input[i] === '%' && HEX_PAIR.test(input.slice(i + 1, i + 3))) { + bytes.push(parseInt(input.slice(i + 1, i + 3), 16)); + i += 3; + continue; + } + // Consume the run of literal characters up to the next valid %XX escape. + let end = i; + while ( + end < input.length && + !(input[end] === '%' && HEX_PAIR.test(input.slice(end + 1, end + 3))) + ) { + end += 1; + } + for (const byte of encoder.encode(input.slice(i, end))) { + bytes.push(byte); + } + i = end; + } + return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(bytes)); }