mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
fix: guard decodeURI(Component) against lone-surrogate crash (#667225)
'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.
This commit is contained in:
@@ -3,24 +3,38 @@ import { sanitizeSurrogates } from './sanitizeSurrogates';
|
|||||||
/**
|
/**
|
||||||
* App-wide guard against the "String contained an illegal UTF-16 sequence" crash.
|
* 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
|
* A lone (unpaired) UTF-16 surrogate — typically a truncated emoji in a Telegram
|
||||||
* name/remark that ends up in a subscription/connection URL — makes encodeURI /
|
* user name or a backend name/remark — crashes the URI functions on both engines
|
||||||
* encodeURIComponent throw a URIError on iOS WebKit/JavaScriptCore (V8: "URI
|
* (V8: "URI malformed"; iOS WebKit/JavaScriptCore: "String contained an illegal
|
||||||
* malformed"; Safari/JSC: "String contained an illegal UTF-16 sequence"). Any
|
* UTF-16 sequence"), in BOTH directions:
|
||||||
* 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
|
* - encodeURI / encodeURIComponent throw when the INPUT STRING contains a lone
|
||||||
* added later. The base64 idiom btoa(unescape(encodeURIComponent(x))) is covered
|
* 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
|
* 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
|
* btoa itself: it rejects every char > U+00FF — Cyrillic, emoji, even the U+FFFD
|
||||||
* replacement — with a separate "Invalid character" error that sanitising
|
* replacement — with a separate "Invalid character" error that sanitising
|
||||||
* surrogates cannot fix, and nothing base64-encodes raw Unicode directly.)
|
* surrogates cannot fix, and nothing base64-encodes raw Unicode directly.)
|
||||||
*
|
*
|
||||||
* Instead of wrapping every (current and future) call site, we sanitise at the
|
* Instead of wrapping every (current and future) call site, we sanitise at the
|
||||||
* single chokepoint: the global encoders themselves. This is fail-safe, not
|
* single chokepoint: the global codecs themselves. This is fail-safe, not
|
||||||
* fail-broken — for any well-formed string there are no lone surrogates, so the
|
* fail-broken:
|
||||||
* output is byte-for-byte identical; only strings that would otherwise have
|
* - encoders: for any well-formed string the output is byte-for-byte identical;
|
||||||
* thrown get their lone surrogates replaced with U+FFFD (the same remedy as
|
* only strings that would otherwise have thrown get their lone surrogates
|
||||||
* String.prototype.toWellFormed()).
|
* 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.
|
* Must run before any rendering or network call. Idempotent.
|
||||||
*/
|
*/
|
||||||
@@ -32,8 +46,62 @@ export function installEncodingSurrogateGuard(): void {
|
|||||||
|
|
||||||
const nativeEncodeURI = globalThis.encodeURI;
|
const nativeEncodeURI = globalThis.encodeURI;
|
||||||
const nativeEncodeURIComponent = globalThis.encodeURIComponent;
|
const nativeEncodeURIComponent = globalThis.encodeURIComponent;
|
||||||
|
const nativeDecodeURI = globalThis.decodeURI;
|
||||||
|
const nativeDecodeURIComponent = globalThis.decodeURIComponent;
|
||||||
|
|
||||||
globalThis.encodeURI = (uri: string): string => nativeEncodeURI(sanitizeSurrogates(String(uri)));
|
globalThis.encodeURI = (uri: string): string => nativeEncodeURI(sanitizeSurrogates(String(uri)));
|
||||||
globalThis.encodeURIComponent = (uriComponent: string | number | boolean): string =>
|
globalThis.encodeURIComponent = (uriComponent: string | number | boolean): string =>
|
||||||
nativeEncodeURIComponent(sanitizeSurrogates(String(uriComponent)));
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user