mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Гард «uncaught exception внутри карточки» жил в demo/serve.mjs с 2026-07-27 и не срабатывал ни разу в самом частом случае. Счётчик читался синхронно, а Playwright доставляет pageerror асинхронно по CDP: если исключение возникло после последнего обращения смока к странице, счётчик к моменту проверки нулевой, а browser.close() уносит недоставленное событие. В логе это видно дословно — EXC печатается после результата и до OK. finish() теперь делает round-trip по открытым страницам перед чтением счётчика. Страницы регистрируются там, где создаются: ссылок на них у finish(browser, out) нет, а менять сигнатуру нельзя — так её зовут 205 смоков. Medium-1 жёлтого ревью ТЗ закрыт расширением, а не оговоркой. Страницы, созданные смоком после launch(), регистрация в launchInternal не покрывает: smoke_zoom_flash печатал своё EXC2 мимо счётчика, три страницы smoke_svg_sandbox не имели слушателя вовсе. Документировать слепую зону в задаче, которая существует ради устранения слепой зоны, значит закрыть issue, оставив дефект. Наружу отдана одна функция watchPage(page): подписка и регистрация неразделимы, иначе появится страница, чьи исключения считаются, а доставки не ждёт никто. Разрыв оказался шире, чем в ревью: проверка по всему набору нашла ещё два файла со своей подпиской — smoke_cold_view_toggle и smoke_cold_view_vacuum. Они не слепая зона, их страница приходит из launchColdView и уже зарегистрирована, а свой счётчик они превращают в отдельное утверждение. Поэтому инвариант сформулирован как «ни одна страница не создаётся мимо гарда» и закреплён по всему набору, а не по двум названным файлам. reportPageErrors() из #407 стал асинхронным: второй читатель счётчика обязан ждать доставку так же, как finish(). Пять смоков получили await. Фикстура smoke_danger_confirmation приведена к объявленному типу: без binding и bindingMode _bindingHasHaPage падал на undefined.split(':') — два исключения, которых гард не видел. Дефекта поведения нет, все 15 мест в src/, создающих диалог, binding пишут; врала фикстура. Два отступления от ТЗ, каждое по измеренной причине. Пробы лежат в demo/guard/, а не demo/fixtures/: последний входит в корпус sourceFingerprint, и каждый файл там объявил бы устаревшими бандл, скриншот-индекс и golden-индекс — пробы же не касаются ни одного пикселя. Поведение доказывается в job со браузером, а не в npm test: job «Фронтенд» браузеры не ставит, и тест молча скипался бы — тот самый тихий успех, против которого вся задача. Issue: #404 User-Visible: no
82 lines
4.4 KiB
JavaScript
82 lines
4.4 KiB
JavaScript
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
|
||
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
|
||
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
|
||
// становится живым документом этого origin: <script> в нём получает доступ к
|
||
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
|
||
// и что обычный SVG при этом продолжает отображаться.
|
||
import { chromium } from 'playwright';
|
||
import { check, finish, watchPage } from './serve.mjs';
|
||
|
||
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||
<rect width="100" height="100" fill="#eee"/>
|
||
<script>
|
||
document.title = 'HOUSEPLAN_XSS_EXECUTED';
|
||
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
|
||
</script>
|
||
</svg>`;
|
||
|
||
// ровно тот набор, который отдаёт HouseplanContentView для .svg
|
||
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
|
||
|
||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||
const ctx = await browser.newContext();
|
||
|
||
async function serve(page, { csp }) {
|
||
await page.route('**/*', (route) => {
|
||
const url = route.request().url();
|
||
if (url.endsWith('/evil.svg')) {
|
||
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
|
||
if (csp) headers['Content-Security-Policy'] = CSP;
|
||
return route.fulfill({ status: 200, headers, body: EVIL });
|
||
}
|
||
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
|
||
});
|
||
}
|
||
|
||
// #404: три страницы этого смока не имели слушателя pageerror вовсе — они
|
||
// оставались слепой зоной и после починки гарда. Общий гард подписывает их и
|
||
// ждёт доставки перед вердиктом.
|
||
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
|
||
const before = watchPage(await ctx.newPage());
|
||
await serve(before, { csp: false });
|
||
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||
await before.waitForTimeout(200);
|
||
const noCsp = await before.evaluate(() => ({
|
||
title: document.title,
|
||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||
}));
|
||
|
||
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
|
||
const after = watchPage(await ctx.newPage());
|
||
await serve(after, { csp: true });
|
||
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||
await after.waitForTimeout(200);
|
||
const withCsp = await after.evaluate(() => ({
|
||
title: document.title,
|
||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||
}));
|
||
|
||
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
|
||
const card = watchPage(await ctx.newPage());
|
||
await serve(card, { csp: true });
|
||
await card.goto('https://ha.example/');
|
||
const drawn = await card.evaluate(async () => {
|
||
const img = new Image();
|
||
const ok = await new Promise((res) => {
|
||
img.onload = () => res(true);
|
||
img.onerror = () => res(false);
|
||
img.src = '/api/houseplan/content/plans/_/evil.svg';
|
||
});
|
||
return { loaded: ok, width: img.naturalWidth, title: document.title };
|
||
});
|
||
|
||
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
|
||
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
|
||
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
|
||
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
|
||
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
|
||
check('и имеет размеры', drawn.width, 100);
|
||
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
|
||
await finish(browser);
|