From d37872fd4fa80d781377fc2b2169b3af22b77394 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 8 Jun 2026 11:11:56 +0300 Subject: [PATCH] fix(cabinet): harden global encoders against lone UTF-16 surrogates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lone (unpaired) UTF-16 surrogate — a truncated emoji in a backend name/remark embedded in a subscription/connection URL — makes encodeURI/encodeURIComponent throw a URIError on iOS WebKit (V8: 'URI malformed'; Safari/JSC: 'String contained an illegal UTF-16 sequence'). qrcode.react calls encodeURI internally, so such a value crashed the QR render and tripped the page-level ErrorBoundary ('Something went wrong / String contained an illegal UTF-16 sequence / Try again'). Rather than wrap each (current and future) call site — and third-party libs we can't edit — sanitise at the single chokepoint: patch the global encodeURI/encodeURIComponent at bootstrap to replace lone surrogates with U+FFFD (same remedy as toWellFormed()). Fail-safe, not fail-broken: verified byte-identical output for all well-formed strings (URLs unaffected) and no-throw on lone surrogates for the real call patterns (qrcode.react and btoa(unescape(encodeURIComponent(...)))). A fast path skips the rewrite when no surrogate code units are present, so the hot path (every request URL) is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/main.tsx | 7 ++++ src/utils/installEncodingSurrogateGuard.ts | 39 +++++++++++++++++++ src/utils/sanitizeSurrogates.ts | 45 ++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/utils/installEncodingSurrogateGuard.ts create mode 100644 src/utils/sanitizeSurrogates.ts diff --git a/src/main.tsx b/src/main.tsx index 022c153..d4981db 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { isFullscreen, } from '@telegram-apps/sdk-react'; import { clearStaleSessionIfNeeded } from './utils/token'; +import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard'; import { useAuthStore } from './store/auth'; import { AppWithNavigator } from './AppWithNavigator'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -29,6 +30,12 @@ import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegra import { applyTelegramLanguage } from './i18n'; import './styles/globals.css'; +// Harden the global encoders against lone UTF-16 surrogates (truncated emoji in +// backend names/remarks) BEFORE anything renders or fetches — otherwise such a +// string crashes any encodeURI/encodeURIComponent/btoa path on iOS WebKit, +// including qrcode.react's internal encodeURI. See installEncodingSurrogateGuard. +installEncodingSurrogateGuard(); + // Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome). // @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally. // Without this, init() throws LaunchParamsRetrieveError on affected devices. diff --git a/src/utils/installEncodingSurrogateGuard.ts b/src/utils/installEncodingSurrogateGuard.ts new file mode 100644 index 0000000..3604c60 --- /dev/null +++ b/src/utils/installEncodingSurrogateGuard.ts @@ -0,0 +1,39 @@ +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 + * 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()). + * + * Must run before any rendering or network call. Idempotent. + */ +export function installEncodingSurrogateGuard(): void { + const flag = '__surrogateEncoderGuardInstalled'; + const g = globalThis as typeof globalThis & Record; + if (g[flag]) return; + g[flag] = true; + + const nativeEncodeURI = globalThis.encodeURI; + const nativeEncodeURIComponent = globalThis.encodeURIComponent; + + globalThis.encodeURI = (uri: string): string => nativeEncodeURI(sanitizeSurrogates(String(uri))); + globalThis.encodeURIComponent = (uriComponent: string | number | boolean): string => + nativeEncodeURIComponent(sanitizeSurrogates(String(uriComponent))); +} diff --git a/src/utils/sanitizeSurrogates.ts b/src/utils/sanitizeSurrogates.ts new file mode 100644 index 0000000..ea4fc1d --- /dev/null +++ b/src/utils/sanitizeSurrogates.ts @@ -0,0 +1,45 @@ +/** + * Replace unpaired UTF-16 surrogates with the Unicode replacement char (U+FFFD). + * + * Why: a lone surrogate — e.g. a truncated emoji in a server/profile name embedded + * in a subscription URL's `#remark` — makes `encodeURI()` throw. On V8 the message is + * "URI malformed"; on iOS WebKit (JavaScriptCore, used by Telegram on iOS) it is + * "String contained an illegal UTF-16 sequence". `qrcode.react` calls `encodeURI` + * internally (qrcodegen `toUtf8ByteArray`), so such a string crashes the QR render and + * trips the page-level ErrorBoundary. `btoa(unescape(encodeURIComponent(...)))` and the + * Telegram native bridge throw on the same input. + * + * Sanitising keeps the link working (the surrogate only ever lives in a cosmetic remark) + * and renders a replacement glyph instead of taking the whole page down. Implemented + * without lookbehind for broad WebView compatibility. + */ +// Any surrogate code unit at all (paired or not). The vast majority of strings +// (ASCII URLs, ids, tokens) contain none, so this lets us bail out in O(1) before +// the per-char rebuild — important because the global encoder guard runs this on +// every encodeURIComponent/encodeURI/btoa call. +const SURROGATE_RANGE = /[\uD800-\uDFFF]/; + +export function sanitizeSurrogates(value: string): string { + if (!SURROGATE_RANGE.test(value)) return value; + + let result = ''; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + // High surrogate: valid only if immediately followed by a low surrogate. + const next = value.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + result += value[i] + value[i + 1]; + i++; + } else { + result += '�'; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + // Low surrogate with no preceding high surrogate. + result += '�'; + } else { + result += value[i]; + } + } + return result; +}