mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
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:
@@ -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<void>;
|
||||
stop: () => Promise<void>;
|
||||
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<void>((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 {
|
||||
|
||||
@@ -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<WindowWithHtml5['Html5Qrcode']
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = HTML5_QRCODE_CDN;
|
||||
script.integrity = HTML5_QRCODE_SRI;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.referrerPolicy = 'no-referrer';
|
||||
document.head.appendChild(script);
|
||||
await new Promise<void>((resolve) => {
|
||||
script.onload = () => resolve();
|
||||
// onerror срабатывает и при провале проверки целостности — вызывающий
|
||||
// получит undefined и покажет «камера недоступна».
|
||||
script.onerror = () => resolve();
|
||||
});
|
||||
return w.Html5Qrcode;
|
||||
|
||||
Reference in New Issue
Block a user