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

Browser APIs

Неправильное использование браузерных API может привести к утечке данных и персистентным XSS.

postMessage

postMessage используется для взаимодействия между окнами, iframe и вкладками, включая cross-origin контексты.

Правила

MUST: Всегда проверять origin входящего сообщения

FORBIDDEN: Использование wildcard (*) для targetOrigin

MUST: Проверять источник сообщения и ожидаемый формат данных

MUST: Обрабатывать только сообщения от явно разрешенных origin-доменов

MUST: Игнорировать сообщения с неожиданной структурой или типами данных

Запрещенные практики

// ❌ FORBIDDEN: Отправка данных без проверки origin
function sendDataToParent(data: unknown) {
window.parent.postMessage(data, '*'); // Опасно! Wildcard
}

// ❌ FORBIDDEN: Прием сообщений без проверки origin
window.addEventListener('message', (event) => {
const data = event.data; // Опасно! Не проверен origin
processData(data);
});

// ❌ FORBIDDEN: Частичная проверка origin
window.addEventListener('message', (event) => {
if (event.origin.includes('trusted.com')) { // Опасно! Обходится легко
processData(event.data);
}
});

Правильный подход

// ✅ MUST: Отправка с явным указанием origin
function sendDataToParent(data: unknown) {
const allowedOrigin = 'https://trusted-parent.com';
window.parent.postMessage(data, allowedOrigin);
}

// ✅ MUST: Прием с проверкой origin и валидацией
import { z } from 'zod';

const MessageSchema = z.object({
type: z.literal('USER_ACTION'),
payload: z.object({
action: z.string(),
timestamp: z.number()
})
});

const ALLOWED_ORIGINS = [
'https://trusted-parent.com',
'https://trusted-iframe.com'
];

window.addEventListener('message', (event) => {
// 1. Проверка origin
if (!ALLOWED_ORIGINS.includes(event.origin)) {
console.warn('Ignored message from untrusted origin:', event.origin);
return;
}

// 2. Валидация структуры данных
const result = MessageSchema.safeParse(event.data);
if (!result.success) {
console.warn('Ignored message with invalid structure:', result.error);
return;
}

// 3. Обработка валидированных данных
processValidatedMessage(result.data);
});

Типобезопасная обертка

// ✅ Создание типобезопасного обработчика postMessage
import { z } from 'zod';

function createPostMessageHandler<T>(
allowedOrigins: string[],
schema: z.ZodSchema<T>,
handler: (data: T, origin: string) => void
) {
return (event: MessageEvent) => {
// Проверка origin
if (!allowedOrigins.includes(event.origin)) {
return;
}

// Валидация данных
const result = schema.safeParse(event.data);
if (!result.success) {
return;
}

// Обработка валидированных данных
handler(result.data, event.origin);
};
}

// Использование
const ALLOWED_ORIGINS = [
'https://trusted-parent.com',
'https://trusted-iframe.com'
];

const UserActionSchema = z.object({
type: z.literal('USER_ACTION'),
payload: z.object({
action: z.string(),
userId: z.string(),
timestamp: z.number()
})
});

const messageHandler = createPostMessageHandler(
ALLOWED_ORIGINS,
UserActionSchema,
(data, origin) => {
console.log(`Received valid message from ${origin}:`, data);
// Безопасная обработка данных
}
);

window.addEventListener('message', messageHandler);

// Очистка при размонтировании
function cleanup() {
window.removeEventListener('message', messageHandler);
}

Работа с iframe

// ✅ Безопасная коммуникация с iframe
import { useEffect, useRef } from 'react';
import { z } from 'zod';

const IframeMessageSchema = z.object({
type: z.enum(['READY', 'DATA_UPDATE', 'ERROR']),
payload: z.unknown()
});

function EmbeddedWidget() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const IFRAME_ORIGIN = 'https://widget.trusted-service.com';

useEffect(() => {
const handler = (event: MessageEvent) => {
// ✅ MUST: Проверка origin
if (event.origin !== IFRAME_ORIGIN) {
return;
}

// ✅ MUST: Валидация структуры
const result = IframeMessageSchema.safeParse(event.data);
if (!result.success) {
return;
}

// Обработка сообщения от iframe
switch (result.data.type) {
case 'READY':
console.log('Widget ready');
break;
case 'DATA_UPDATE':
console.log('Data updated:', result.data.payload);
break;
case 'ERROR':
console.error('Widget error:', result.data.payload);
break;
}
};

window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);

const sendMessageToIframe = (data: unknown) => {
if (!iframeRef.current?.contentWindow) return;

// ✅ MUST: Указываем конкретный origin
iframeRef.current.contentWindow.postMessage(data, IFRAME_ORIGIN);
};

return (
<div>
<iframe
ref={iframeRef}
src={`${IFRAME_ORIGIN}/widget`}
sandbox="allow-scripts allow-same-origin"
title="Embedded Widget"
/>
<button onClick={() => sendMessageToIframe({ action: 'RELOAD' })}>
Reload Widget
</button>
</div>
);
}

Bi-directional communication

// ✅ Двусторонняя коммуникация между родителем и iframe

// Родительское окно (parent.tsx)
const ParentToChildSchema = z.object({
type: z.literal('CONFIG_UPDATE'),
config: z.record(z.unknown())
});

const ChildToParentSchema = z.object({
type: z.enum(['READY', 'DATA', 'ERROR']),
payload: z.unknown()
});

function ParentComponent() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const CHILD_ORIGIN = 'https://child.example.com';

// Слушаем сообщения от child
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== CHILD_ORIGIN) return;

const result = ChildToParentSchema.safeParse(event.data);
if (!result.success) return;

handleChildMessage(result.data);
};

window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);

// Отправляем сообщение child
const sendConfig = (config: Record<string, unknown>) => {
iframeRef.current?.contentWindow?.postMessage(
{ type: 'CONFIG_UPDATE', config },
CHILD_ORIGIN
);
};

return (
<iframe ref={iframeRef} src={`${CHILD_ORIGIN}/embed`} />
);
}

// Child окно (child.tsx)
function ChildComponent() {
const PARENT_ORIGIN = 'https://parent.example.com';

// Слушаем сообщения от parent
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== PARENT_ORIGIN) return;

const result = ParentToChildSchema.safeParse(event.data);
if (!result.success) return;

// Применяем конфигурацию от parent
applyConfig(result.data.config);
};

window.addEventListener('message', handler);

// Сообщаем parent, что готовы
window.parent.postMessage(
{ type: 'READY', payload: null },
PARENT_ORIGIN
);

return () => window.removeEventListener('message', handler);
}, []);
}

Частые ошибки

// ❌ Ошибка 1: Проверка origin через startsWith/includes
if (event.origin.startsWith('https://trusted')) {
// Опасно! Пройдет https://trusted-evil.com
}

if (event.origin.includes('trusted.com')) {
// Опасно! Пройдет https://not-trusted.com.evil.com
}

// ✅ Правильно: Точное сравнение
const ALLOWED_ORIGINS = ['https://trusted.com'];
if (ALLOWED_ORIGINS.includes(event.origin)) {
// OK
}

// ❌ Ошибка 2: Использование event.source без проверки
window.addEventListener('message', (event) => {
event.source.postMessage('reply', '*'); // Опасно!
});

// ✅ Правильно: Проверка origin и использование конкретного targetOrigin
window.addEventListener('message', (event) => {
if (!ALLOWED_ORIGINS.includes(event.origin)) return;

event.source?.postMessage('reply', event.origin);
});

// ❌ Ошибка 3: Обработка всех типов сообщений
window.addEventListener('message', (event) => {
// Обрабатывает ВСЕ postMessage на странице, включая от других библиотек!
doSomething(event.data);
});

// ✅ Правильно: Проверка типа сообщения
const OurMessageSchema = z.object({
source: z.literal('OUR_APP'), // Уникальный идентификатор
type: z.string(),
payload: z.unknown()
});

window.addEventListener('message', (event) => {
const result = OurMessageSchema.safeParse(event.data);
if (!result.success) return; // Игнорируем чужие сообщения

handleOurMessage(result.data);
});

📌 Ключевые моменты:

  • MUST: Всегда проверяйте origin входящих сообщений
  • FORBIDDEN: Wildcard (*) для targetOrigin
  • MUST: Валидация структуры данных перед обработкой
  • MUST: Использование whitelist разрешенных origins
  • Точное сравнение origins, не includes() или startsWith()