mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
fix v1.44.3: signed content paths — plans and PDFs load again (B1 regression)
The v1.43.0 auth fix closed the hole but left the DISPLAY path unauthenticated: HA authenticates by a Bearer header or an authSig signed path, and an <image href> / <a href> sends neither, so plan backgrounds and manual links returned 401. Reproduced live before the fix (fetch 401, Image onerror). - new WS houseplan/content/sign mints async_sign_path urls (24 h, bound to the connection's refresh token, only for our own endpoint) - the card resolves display urls through _display(): signed when known, requests a batched signature otherwise, re-renders when it lands, and drops all signatures every 12 h so long-lived wall tablets stay valid - houseplan-space-card signs its background too - backend test asserts the unsigned url is refused and the signed one returns the bytes WITHOUT an Authorization header
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -39,6 +39,7 @@ def async_register(hass: HomeAssistant) -> None:
|
|||||||
websocket_api.async_register_command(hass, ws_plan_set)
|
websocket_api.async_register_command(hass, ws_plan_set)
|
||||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||||
|
websocket_api.async_register_command(hass, ws_content_sign)
|
||||||
|
|
||||||
|
|
||||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||||
@@ -210,6 +211,46 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "houseplan/content/sign",
|
||||||
|
vol.Required("paths"): [str],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
|
"""Sign content paths so the BROWSER can fetch them.
|
||||||
|
|
||||||
|
Home Assistant authenticates HTTP requests by a Bearer header or an
|
||||||
|
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
|
||||||
|
and a plain <a href> can send neither, so after the content endpoint became
|
||||||
|
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
|
||||||
|
follow-up B1 regression, 2026-07-27 — reproduced live).
|
||||||
|
|
||||||
|
The card asks for signatures and uses the signed urls for display.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from homeassistant.components.http.auth import async_sign_path
|
||||||
|
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
token_id = getattr(connection, "refresh_token_id", None)
|
||||||
|
for path in msg["paths"][:200]:
|
||||||
|
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||||
|
continue # only ever sign our own content endpoint
|
||||||
|
clean = path.split("?", 1)[0]
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
|
||||||
|
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
|
||||||
|
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
|
||||||
|
except Exception as err: # noqa: BLE001 — signing must never break the card
|
||||||
|
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
|
||||||
|
continue
|
||||||
|
out[path] = signed
|
||||||
|
connection.send_result(msg["id"], {"urls": out})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "houseplan/files/cleanup",
|
vol.Required("type"): "houseplan/files/cleanup",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+17
-17
File diff suppressed because one or more lines are too long
+52
-3
@@ -348,6 +348,12 @@ class HouseplanCard extends LitElement {
|
|||||||
public connectedCallback(): void {
|
public connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
window.addEventListener('keydown', this._keyHandler);
|
window.addEventListener('keydown', this._keyHandler);
|
||||||
|
// signatures expire (24 h); refresh well before that on long-lived screens
|
||||||
|
clearInterval(this._resignTimer);
|
||||||
|
this._resignTimer = window.setInterval(() => {
|
||||||
|
this._signed = {};
|
||||||
|
this.requestUpdate();
|
||||||
|
}, 12 * 3600 * 1000);
|
||||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||||
clearInterval(this._cycleTimer);
|
clearInterval(this._cycleTimer);
|
||||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||||
@@ -361,6 +367,9 @@ class HouseplanCard extends LitElement {
|
|||||||
clearTimeout(this._kioskDotsTimer);
|
clearTimeout(this._kioskDotsTimer);
|
||||||
clearTimeout(this._kioskHoldTimer);
|
clearTimeout(this._kioskHoldTimer);
|
||||||
clearTimeout(this._reloadRetry);
|
clearTimeout(this._reloadRetry);
|
||||||
|
clearTimeout(this._signTimer);
|
||||||
|
clearInterval(this._resignTimer);
|
||||||
|
clearTimeout(this._toastTimer);
|
||||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||||
window.removeEventListener('hashchange', this._onHashChange);
|
window.removeEventListener('hashchange', this._onHashChange);
|
||||||
clearTimeout(this._holdTimer);
|
clearTimeout(this._holdTimer);
|
||||||
@@ -571,7 +580,7 @@ class HouseplanCard extends LitElement {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
bg: s.plan_url ? { href: this._display(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||||
rooms: s.rooms.map(scale),
|
rooms: s.rooms.map(scale),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -756,6 +765,46 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _reloadRetry?: number;
|
private _reloadRetry?: number;
|
||||||
|
/**
|
||||||
|
* Signed urls for the content endpoint (audit follow-up B1 regression).
|
||||||
|
* A browser cannot authenticate an <image href> or an <a href>: HA takes a
|
||||||
|
* Bearer header or an `authSig` signed path, and an element sends neither.
|
||||||
|
* So the card asks the backend to sign what it is about to display.
|
||||||
|
*/
|
||||||
|
private _signed: Record<string, string> = {};
|
||||||
|
private _signPending = new Set<string>();
|
||||||
|
private _signTimer?: number;
|
||||||
|
|
||||||
|
/** Display url: the signed variant when we have one, else the plain path. */
|
||||||
|
private _display(url: string | null | undefined): string {
|
||||||
|
const u = contentUrl(url);
|
||||||
|
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||||
|
if (this._signed[u]) return this._signed[u];
|
||||||
|
this._requestSignature(u);
|
||||||
|
return u; // first paint may 401; the signature lands and re-renders
|
||||||
|
}
|
||||||
|
|
||||||
|
private _requestSignature(url: string): void {
|
||||||
|
if (this._signPending.has(url) || !this.hass?.callWS) return;
|
||||||
|
this._signPending.add(url);
|
||||||
|
clearTimeout(this._signTimer);
|
||||||
|
// batch: a plan switch asks for several urls in the same tick
|
||||||
|
this._signTimer = window.setTimeout(() => {
|
||||||
|
const paths = [...this._signPending];
|
||||||
|
this._signPending.clear();
|
||||||
|
this.hass
|
||||||
|
.callWS({ type: 'houseplan/content/sign', paths })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!r?.urls) return;
|
||||||
|
this._signed = { ...this._signed, ...r.urls };
|
||||||
|
this.requestUpdate();
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||||
|
}, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-sign everything periodically: a wall tablet outlives a signature. */
|
||||||
|
private _resignTimer?: number;
|
||||||
private _dirtyPos = new Set<string>();
|
private _dirtyPos = new Set<string>();
|
||||||
|
|
||||||
private _persistLayout = debounce(() => {
|
private _persistLayout = debounce(() => {
|
||||||
@@ -4602,7 +4651,7 @@ class HouseplanCard extends LitElement {
|
|||||||
${d.pdfs && d.pdfs.length
|
${d.pdfs && d.pdfs.length
|
||||||
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
||||||
${d.pdfs.map(
|
${d.pdfs.map(
|
||||||
(p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
(p) => html`<a class="pdf" href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||||
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
||||||
)}</span></div>`
|
)}</span></div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
@@ -4839,7 +4888,7 @@ class HouseplanCard extends LitElement {
|
|||||||
<div class="pdfedit">
|
<div class="pdfedit">
|
||||||
${d.pdfs.map(
|
${d.pdfs.map(
|
||||||
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
||||||
<a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
<a href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||||
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
|
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
|
||||||
)}
|
)}
|
||||||
<label class="btn filebtn">
|
<label class="btn filebtn">
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ class HouseplanSpaceCard extends LitElement {
|
|||||||
|
|
||||||
public getCardSize(): number {
|
public getCardSize(): number {
|
||||||
const models = spaceModels(this._snap?.config || null);
|
const models = spaceModels(this._snap?.config || null);
|
||||||
|
// B1 follow-up: the plan <image> needs a signed url in a browser session
|
||||||
|
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
|
||||||
const sp = models.find((s) => s.id === this._config?.space);
|
const sp = models.find((s) => s.id === this._config?.space);
|
||||||
if (sp) {
|
if (sp) {
|
||||||
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
||||||
@@ -121,6 +123,27 @@ class HouseplanSpaceCard extends LitElement {
|
|||||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _signedBg: Record<string, string> = {};
|
||||||
|
private _signBgPending = new Set<string>();
|
||||||
|
|
||||||
|
/** Ask the backend for a signed content url and swap it in when it arrives. */
|
||||||
|
private _signBg(bg: { href: string }): void {
|
||||||
|
const raw = bg.href.split('?authSig=')[0];
|
||||||
|
if (!raw.startsWith('/api/houseplan/content/')) return;
|
||||||
|
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
|
||||||
|
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
|
||||||
|
this._signBgPending.add(raw);
|
||||||
|
this.hass
|
||||||
|
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
|
||||||
|
.then((r: any) => {
|
||||||
|
const url = r?.urls?.[raw];
|
||||||
|
if (!url) return;
|
||||||
|
this._signedBg = { ...this._signedBg, [raw]: url };
|
||||||
|
this.requestUpdate();
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | typeof nothing {
|
protected render(): TemplateResult | typeof nothing {
|
||||||
if (!this._config) return nothing;
|
if (!this._config) return nothing;
|
||||||
const cfg = this._snap?.config;
|
const cfg = this._snap?.config;
|
||||||
|
|||||||
@@ -177,3 +177,47 @@ async def test_files_migrate_copies_and_reports_mapping(
|
|||||||
resp2 = await client.receive_json()
|
resp2 = await client.receive_json()
|
||||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_content_signed_path_opens_without_a_bearer_header(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
|
||||||
|
) -> None:
|
||||||
|
"""B1 follow-up: a browser <image>/<a> sends no Authorization header.
|
||||||
|
|
||||||
|
The unsigned url must be refused and the signed one must work — otherwise
|
||||||
|
plan backgrounds and PDF links 401 on a real dashboard (reproduced live,
|
||||||
|
2026-07-27).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import CONTENT_URL, PLANS_DIR
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
plans = hass.config.path(PLANS_DIR)
|
||||||
|
|
||||||
|
def _write() -> None:
|
||||||
|
os.makedirs(plans, exist_ok=True)
|
||||||
|
with open(os.path.join(plans, "s1.png"), "wb") as fh:
|
||||||
|
fh.write(b"PNGDATA")
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(_write)
|
||||||
|
path = f"{CONTENT_URL}/plans/_/s1.png"
|
||||||
|
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [path]})
|
||||||
|
resp = await client.receive_json()
|
||||||
|
assert resp["success"], resp
|
||||||
|
signed = resp["result"]["urls"][path]
|
||||||
|
assert "authSig=" in signed
|
||||||
|
|
||||||
|
http = await hass_client_no_auth()
|
||||||
|
assert (await http.get(path)).status == 401 # unsigned: refused
|
||||||
|
ok = await http.get(signed)
|
||||||
|
assert ok.status == 200 and await ok.read() == b"PNGDATA"
|
||||||
|
|
||||||
|
# only our own endpoint may be signed
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/content/sign", "paths": ["/api/other/secret"]}
|
||||||
|
)
|
||||||
|
resp2 = await client.receive_json()
|
||||||
|
assert resp2["success"] and resp2["result"]["urls"] == {}
|
||||||
|
|||||||
Reference in New Issue
Block a user