From b615927283d9ae530c03f6120b3c5794d92e0689 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Jul 2026 22:14:32 +0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20SRI=20=D0=B4=D0=BB=D1=8F=20CDN?= =?UTF-8?q?-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D1=80=D0=B0=20QR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Автоматическое ревью справедливо отметило загрузку html5-qrcode с CDN без проверки целостности: подмена файла на стороне CDN исполнилась бы в кабинете с полными правами страницы (доступ к сессии, initData Telegram). Скрипт подключается с integrity (sha384 сверен по фактическому файлу html5-qrcode@2.3.8), crossorigin=anonymous и referrerPolicy=no-referrer. При несовпадении хеша браузер откажется исполнять скрипт, и сканер деградирует в «камера недоступна» вместо запуска чужого кода. Заодно закрыт тот же изъян в исходном месте, откуда пришёл паттерн: TvQuickConnect грузил тот же файл с jsdelivr вообще без проверки. Он переведён на общий хелпер (файлы на unpkg и jsdelivr побайтово идентичны — сверил), дублирующийся загрузчик и интерфейс удалены. Виджеты telegram.org намеренно оставлены без SRI: это самообновляемые эндпоинты Telegram, фиксация хеша сломала бы логин при их обновлении. --- src/components/connection/TvQuickConnect.tsx | 33 +++++--------------- src/utils/qrScanner.ts | 10 ++++++ 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/components/connection/TvQuickConnect.tsx b/src/components/connection/TvQuickConnect.tsx index 56eb4e4..cdb7e09 100644 --- a/src/components/connection/TvQuickConnect.tsx +++ b/src/components/connection/TvQuickConnect.tsx @@ -6,6 +6,7 @@ import { retrieveLaunchParams, } from '@telegram-apps/sdk-react'; import { blockButtonClass } from './blocks/buttonStyles'; +import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner'; const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); @@ -19,24 +20,12 @@ function isTelegramMobile(): boolean { } const HAPP_TV_API = 'https://check.happ.su/sendtv'; -const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js'; interface Props { subscriptionUrl: string; isLight: boolean; } -interface Html5QrcodeInstance { - start: ( - cameraIdOrConfig: { facingMode: string } | string, - config: { fps: number; qrbox: { width: number; height: number } }, - onSuccess: (decoded: string) => void, - onError: () => void, - ) => Promise; - stop: () => Promise; - clear: () => void; -} - export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { const { t } = useTranslation(); const [code, setCode] = useState(''); @@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { return; } - // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode - type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; - const w = window as WindowWithHtml5; - if (!w.Html5Qrcode) { - const script = document.createElement('script'); - script.src = HTML5_QRCODE_CDN; - document.head.appendChild(script); - await new Promise((resolve, reject) => { - script.onload = () => resolve(); - script.onerror = () => reject(); - }).catch(() => undefined); - } - if (!w.Html5Qrcode) { + // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode. + // Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой + // целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете. + const Html5Qrcode = await loadHtml5Qrcode(); + if (!Html5Qrcode) { showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); return; } setScanning(true); - const scanner = new w.Html5Qrcode('tv-qr-reader'); + const scanner = new Html5Qrcode('tv-qr-reader'); scannerRef.current = scanner; const config = { fps: 10, qrbox: { width: 220, height: 220 } }; try { diff --git a/src/utils/qrScanner.ts b/src/utils/qrScanner.ts index a1dccb8..8152982 100644 --- a/src/utils/qrScanner.ts +++ b/src/utils/qrScanner.ts @@ -12,6 +12,11 @@ import { const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); export const HTML5_QRCODE_CDN = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js'; +// SRI-хеш ИМЕННО этого файла (sha384 от html5-qrcode@2.3.8/html5-qrcode.min.js). +// Без него подмена на стороне CDN исполнилась бы в кабинете с полными правами +// страницы. При несовпадении браузер откажется исполнять скрипт — сканер +// деградирует в «камера недоступна», а не запускает чужой код. +const HTML5_QRCODE_SRI = 'sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+'; export interface Html5QrcodeInstance { start: ( @@ -62,9 +67,14 @@ export async function loadHtml5Qrcode(): Promise((resolve) => { script.onload = () => resolve(); + // onerror срабатывает и при провале проверки целостности — вызывающий + // получит undefined и покажет «камера недоступна». script.onerror = () => resolve(); }); return w.Html5Qrcode;