fix(cabinet): harden global encoders against lone UTF-16 surrogates

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)
This commit is contained in:
c0mrade
2026-06-08 11:11:56 +03:00
parent 35428cc27d
commit d37872fd4f
3 changed files with 91 additions and 0 deletions

View File

@@ -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<string, unknown>;
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)));
}

View File

@@ -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 += '<27>';
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
// Low surrogate with no preceding high surrogate.
result += '<27>';
} else {
result += value[i];
}
}
return result;
}