fix(security): SRI для CDN-скрипта сканера QR

Автоматическое ревью справедливо отметило загрузку 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, фиксация хеша сломала бы логин при их обновлении.
This commit is contained in:
Fringg
2026-07-29 22:14:32 +03:00
parent 0e4dc43335
commit b615927283
2 changed files with 17 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import {
retrieveLaunchParams, retrieveLaunchParams,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { blockButtonClass } from './blocks/buttonStyles'; import { blockButtonClass } from './blocks/buttonStyles';
import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner';
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); 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 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 { interface Props {
subscriptionUrl: string; subscriptionUrl: string;
isLight: boolean; isLight: boolean;
} }
interface Html5QrcodeInstance {
start: (
cameraIdOrConfig: { facingMode: string } | string,
config: { fps: number; qrbox: { width: number; height: number } },
onSuccess: (decoded: string) => void,
onError: () => void,
) => Promise<void>;
stop: () => Promise<void>;
clear: () => void;
}
export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
return; return;
} }
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode.
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; // Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой
const w = window as WindowWithHtml5; // целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете.
if (!w.Html5Qrcode) { const Html5Qrcode = await loadHtml5Qrcode();
const script = document.createElement('script'); if (!Html5Qrcode) {
script.src = HTML5_QRCODE_CDN;
document.head.appendChild(script);
await new Promise<void>((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject();
}).catch(() => undefined);
}
if (!w.Html5Qrcode) {
showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
return; return;
} }
setScanning(true); setScanning(true);
const scanner = new w.Html5Qrcode('tv-qr-reader'); const scanner = new Html5Qrcode('tv-qr-reader');
scannerRef.current = scanner; scannerRef.current = scanner;
const config = { fps: 10, qrbox: { width: 220, height: 220 } }; const config = { fps: 10, qrbox: { width: 220, height: 220 } };
try { try {

View File

@@ -12,6 +12,11 @@ import {
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); 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'; 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 { export interface Html5QrcodeInstance {
start: ( start: (
@@ -62,9 +67,14 @@ export async function loadHtml5Qrcode(): Promise<WindowWithHtml5['Html5Qrcode']
const script = document.createElement('script'); const script = document.createElement('script');
script.src = HTML5_QRCODE_CDN; script.src = HTML5_QRCODE_CDN;
script.integrity = HTML5_QRCODE_SRI;
script.crossOrigin = 'anonymous';
script.referrerPolicy = 'no-referrer';
document.head.appendChild(script); document.head.appendChild(script);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
script.onload = () => resolve(); script.onload = () => resolve();
// onerror срабатывает и при провале проверки целостности — вызывающий
// получит undefined и покажет «камера недоступна».
script.onerror = () => resolve(); script.onerror = () => resolve();
}); });
return w.Html5Qrcode; return w.Html5Qrcode;