Перейти к основному содержимому

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 {
// Игнорируем невалидные данные
}
});