Browser APIs
Неправильное использование браузерных API может привести к утечке данных и персистентным XSS.
postMessage
postMessage используется для взаимодействия между окнами, iframe и вкладками, включая cross-origin контексты.
MUST
- Все гда проверять
originвходящего сообщения - Указывать конкретный
targetOriginпри отправке - Валидировать структуру и типы данных перед обработкой
- Обрабатывать только сообщения от явно разрешённых origins
FORBIDDEN
- Wildcard (
*) дляtargetOrigin - Проверка
originчерезincludes()илиstartsWith()— обходится подменой домена
Запрещённые практики
// ❌ FORBIDDEN: Wildcard targetOrigin
window.parent.postMessage(data, '*');
// ❌ FORBIDDEN: Нет проверки origin
window.addEventListener('message', (event) => {
processData(event.data); // Опасно!
});
// ❌ FORBIDDEN: Частичная проверка origin (обходится)
if (event.origin.includes('trusted.com')) {
// Пройдёт not-trusted.com.evil.com!
processData(event.data);
}
Правильный подход
// ✅ MUST: Отправка с явным targetOrigin
function sendDataToParent(data: unknown) {
window.parent.postMessage(data, 'https://trusted-parent.com');
}
// ✅ MUST: Приём с проверкой origin + валидация данных
import { z } from 'zod';
const ALLOWED_ORIGINS = [
'https://trusted-parent.com',
'https://trusted-iframe.com',
];
const MessageSchema = z.object({
type: z.literal('USER_ACTION'),
payload: z.object({
action: z.string(),
timestamp: z.number(),
}),
});
window.addEventListener('message', (event) => {
// 1. Точная проверка origin
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
// 2. Валидация структуры данных
const result = MessageSchema.safeParse(event.data);
if (!result.success) return;
// 3. Обработка только валидированных данных
processValidatedMessage(result.data);
});
Типобезопасная обёртка
import { z } from 'zod';
function createPostMessageHandler<T>(
allowedOrigins: string[],
schema: z.ZodSchema<T>,
handler: (data: T, origin: string) => void
) {
return (event: MessageEvent) => {
if (!allowedOrigins.includes(event.origin)) return;
const result = schema.safeParse(event.data);
if (!result.success) return;
handler(result.data, event.origin);
};
}
// Использование
const messageHandler = createPostMessageHandler(
ALLOWED_ORIGINS,
MessageSchema,
(data, origin) => {
console.log(`Valid message from ${origin}:`, data);
}
);
window.addEventListener('message', messageHandler);
// Очистка при размонтировании
useEffect(() => {
window.addEventListener('message', messageHandler);
return () => window.removeEventListener('message', messageHandler);
}, []);
Безопасная коммуникация с iframe
function EmbeddedWidget() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const IFRAME_ORIGIN = 'https://widget.trusted-service.com';
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== IFRAME_ORIGIN) return; // ✅ Точная проверка
const result = IframeMessageSchema.safeParse(event.data);
if (!result.success) return;
// Обработка валидированных данных
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
return (
<iframe
ref={iframeRef}
src={`${IFRAME_ORIGIN}/widget`}
sandbox="allow-scripts allow-same-origin"
title="Embedded Widget"
/>
);
}
Частые ошибки
// ❌ Ошибка 1: includes/startsWith для проверки origin
event.origin.startsWith('https://trusted'); // Пройдёт https://trusted-evil.com!
event.origin.includes('trusted.com'); // Пройдёт https://not-trusted.com.evil.com!
// ✅ Правильно: точное сравнение
const ALLOWED_ORIGINS = ['https://trusted.com'];
if (ALLOWED_ORIGINS.includes(event.origin)) { ... }
// ❌ Ошибка 2: Обработка всех postMessage на странице
window.addEventListener('message', (event) => {
doSomething(event.data); // Обрабатывает ВСЕ сообщения, включая от библиотек!
});
// ✅ Правильно: уникальный идентификатор источника
const OurMessageSchema = z.object({
source: z.literal('OUR_APP'), // Уникальный идентификатор
type: z.string(),
payload: z.unknown()
});
Clipboard API
MUST
- Операции чтения/записи буфера — только по явному действию пользователя (
click,keydown) - Читать содержимое буфера (
readText,read) только когда это необходимо для функциональности - Уведомлять пользователя при чтении буфера обмена
// ❌ FORBIDDEN: Чтение буфера без явного действия пользователя
useEffect(() => {
navigator.clipboard.readText().then(text => {
processClipboard(text); // Опасно! Не было действия пользователя
});
}, []);
// ✅ MUST: Только по явному действию
async function handlePasteClick() {
try {
const text = await navigator.clipboard.readText();
// Уведомляем пользователя
showNotification('Содержимое буфера вставлено');
processClipboard(text);
} catch {
// Доступ к буферу отклонён
}
}
<button onClick={handlePasteClick}>Вставить из буфера</button>
// ✅ Запись — только по явному действию
async function handleCopyClick(text: string) {
await navigator.clipboard.writeText(text);
}
Open Redirect
Если frontend обрабатывает URL редиректа из внешних источников (query-параметры, OAuth callback, deep links) — возникает риск перенаправления на фишинговый сайт.
?redirect=/dashboard → OK
?redirect=https://attacker.com → ОПАСНО
?redirect=//attacker.com → ОПАСНО (браузер добавит текущий протокол)
MUST
- Валидировать URL перед редиректом по whitelist разрешённых доменов
- Разрешать только относительные пути, начинающиеся с
/ - Запрещать пути с
//в начале
FORBIDDEN
- Редиректы на произвольные внешние домены
window.location = userInputбез проверки
// ❌ FORBIDDEN: Прямой редирект из внешних данных
const redirectUrl = searchParams.get('redirect');
router.push(redirectUrl); // Опасно!
window.location.href = userInput; // Опасно!
// ✅ MUST: Валидация перед редиректом
const ALLOWED_DOMAINS = ['example.com', 'app.example.com'];
function isSafeRedirectUrl(url: string): boolean {
// Разрешаем только относительные пути
if (url.startsWith('/') && !url.startsWith('//')) {
return true;
}
try {
const parsed = new URL(url);
return ALLOWED_DOMAINS.includes(parsed.hostname);
} catch {
return false;
}
}
const redirectUrl = searchParams.get('redirect') ?? '/dashboard';
router.push(isSafeRedirectUrl(redirectUrl) ? redirectUrl : '/dashboard');
Storage Events
MUST
storage события могут быть инициированы из другой вкладки или окна — все входящие данные считаются недоверенными и валидируются перед использованием.
// ❌ FORBIDDEN: Использование storage events без валидации
window.addEventListener('storage', (event) => {
if (event.key === 'config') {
applyConfig(JSON.parse(event.newValue!)); // Опасно!
}
});
// ✅ MUST: Валидация данных из storage events
import { z } from 'zod';
const ConfigSchema = z.object({
theme: z.enum(['light', 'dark']),
language: z.string(),
});
window.addEventListener('storage', (event) => {
if (event.key !== 'config' || !event.newValue) return;
try {
const result = ConfigSchema.safeParse(JSON.parse(event.newValue));
if (result.success) {
applyConfig(result.data);
}
} catch {
// Игнорируем невалидные данные
}
});