mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a top-level document of Home Assistant's own origin, so a <script> inside it reaches the session's localStorage and API. Uploading needs write access, which by default every authenticated user has. SVG responses now carry a sandbox CSP; only SVG, because a CSP on a PDF can break the browser's viewer and a raster image has nothing to disable. Verified in Chromium both ways: the script runs without the header and does not with it. HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside the config transaction — a cancelled dialog or a rejected save left the stored url serving new bytes, and every new icon shared one 'new' folder, so two of them attaching manual.pdf pointed at one file. Uploads take a free name, a new icon gets a per-dialog staging folder promoted on an accepted save, and config/set collects superseded and aged-orphan attachments like it does plans. HP-1454-03: the debounce spaced out the starts of a write, not the writes. A save slower than 500 ms let the next edit go out with the same expected_rev; the server accepted the first, rejected the second, and the conflict handler reloaded over the local copy. Writes are chained now — one in flight, each with the revision the previous returned. HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect change or a dragged vertex left open boundaries and their glow cuts at old coordinates. It keys on the rendered model object now — the same invalidation the model cache already has, not a second strategy. The fingerprint also gained an O(1) geometry roll-up per room. HP-1454-05: outer collections were capped, inner ones were not. Limits for poly points, open_to, controls, pdfs, text and url lengths, plus a total serialized size cap; legacy is dropped server-side. HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a 50 MB manual no longer costs ~100 MB of RSS per transfer. HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the per-room fill override. HP-1454-08: layout had no revision on point-wise writes and no event, leaving static cards stale forever; it now keeps a revision, returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only walked existing spaces, so a deleted space kept its warning. HP-1454-10: serialize-javascript pinned past two advisories. Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and smoke_render_parity (both verified failing against a v1.45.4 build), six pure tests for attachment collection and inner limits, four HA-harness tests for the CSP, non-overwriting uploads, the size cap and layout revisions. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
79 lines
4.1 KiB
JavaScript
79 lines
4.1 KiB
JavaScript
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
|
||
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
|
||
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
|
||
// становится живым документом этого origin: <script> в нём получает доступ к
|
||
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
|
||
// и что обычный SVG при этом продолжает отображаться.
|
||
import { chromium } from 'playwright';
|
||
import { check, finish } 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>' });
|
||
});
|
||
}
|
||
|
||
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
|
||
const before = 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 = 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 = 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);
|