From 1d1bb898e875238695c2f70cf495ce26bafa8e21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jun 2026 17:04:07 -0500 Subject: [PATCH 1/2] Harden the dev flasher's postMessage receiver Three receiver-side fixes for parity with the web.esphome.io review (esphome/dashboard#923), which shares this postMessage contract: - post() wraps postMessage in try/catch and falls back to '*', so a malformed origin= hash param (e.g. origin=null) can no longer throw and wedge the ready handshake before the retry interval starts. - A malformed firmware payload now calls stopReadyRetry() before bailing, so the opener stops receiving ready frames once it has clearly attached, instead of for up to 10s. - erase coerces with 'firmware.erase !== false' instead of '?? true', so a non-boolean erase (e.g. the string "false") no longer reads as truthy. handshake.test.mjs gains assertions for the first two (the erase path only runs against real hardware). --- flasher/src/main.ts | 14 ++++++++++++-- flasher/test/handshake.test.mjs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/flasher/src/main.ts b/flasher/src/main.ts index 02fd36ea0..d0621396d 100644 --- a/flasher/src/main.ts +++ b/flasher/src/main.ts @@ -117,7 +117,14 @@ const terminal = { function post(msg: OutboundMessage): void { // targetOrigin narrows from '*' to the opener's real origin once known; see // its declaration. Outbound frames carry no nonce (see protocol.ts). - opener?.postMessage(msg, targetOrigin); + try { + opener?.postMessage(msg, targetOrigin); + } catch { + // A malformed origin= hash param (e.g. origin=null) makes postMessage throw, + // which would wedge the ready handshake before the retry interval is set up. + targetOrigin = "*"; + opener?.postMessage(msg, "*"); + } } function setState(state: FlashState, detail: string): void { @@ -167,6 +174,9 @@ window.addEventListener("message", (ev: MessageEvent) => { if (!data || data.type !== "esphome-web-flash:firmware") return; if (data.nonce !== nonce) return; if (!isFlashParts(data.parts)) { + // The opener has attached and sent — stop re-announcing ready even though + // the payload is unusable, mirroring the accepted path below. + stopReadyRetry(); setState("error", "Received a malformed firmware payload."); return; } @@ -504,7 +514,7 @@ installBtn.addEventListener("click", async () => { data: new Uint8Array(p.data), address: p.address, })); - await runFlash(files, firmware.erase ?? true); + await runFlash(files, firmware.erase !== false); return; } const file = fileInput.files?.[0]; diff --git a/flasher/test/handshake.test.mjs b/flasher/test/handshake.test.mjs index d86d3df3c..56b8725c5 100644 --- a/flasher/test/handshake.test.mjs +++ b/flasher/test/handshake.test.mjs @@ -114,6 +114,18 @@ try { else if (enabledAfterBad) fail("install enabled after malformed payload"); else console.log("PASS: malformed payload rejected with error state"); + // 2b-ii. ...and the malformed payload stops the ready re-announce: the opener + // has clearly attached, so it must not keep receiving ready frames. + await a.evaluate(() => { + window.__msgs.length = 0; + }); + await new Promise((r) => setTimeout(r, 700)); + const readyAfterBad = (await a.evaluate(() => window.__msgs)).some( + (m) => m && m.type === "esphome-web-flash:ready", + ); + if (readyAfterBad) fail("ready still re-announced after malformed payload"); + else console.log("PASS: ready retry stopped after malformed payload"); + // 2c. a negative/non-integer address is rejected at the boundary await a.evaluate(() => { window.__b.postMessage( @@ -132,6 +144,26 @@ try { fail("negative address not rejected: " + label); else console.log("PASS: negative address rejected at boundary"); + // 2d. a malformed origin= hash param must not wedge the ready handshake: + // postMessage to a bad targetOrigin throws, and the receiver falls back to '*'. + { + const c = await browser.newPage(); + await c.goto(`${base}/opener.html`); + const [pop2] = await Promise.all([ + new Promise((res) => c.once("popup", res)), + c.evaluate((u) => window.__open(u), `${base}/#nonce=n2&origin=null`), + ]); + await pop2.waitForNetworkIdle({ idleTime: 300 }).catch(() => {}); + await new Promise((r) => setTimeout(r, 300)); + const ready2 = (await c.evaluate(() => window.__msgs)).find( + (m) => m && m.type === "esphome-web-flash:ready", + ); + if (!ready2) fail("ready not received when origin= hash param is malformed"); + else console.log("PASS: malformed origin falls back to '*', ready still sent"); + await pop2.close(); + await c.close(); + } + // 3. correct nonce accepted -> button enabled, state mirrored back await a.evaluate(() => { window.__b.postMessage( From 70c1250803a4704a0078f7e607caad8cc009a7e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jun 2026 17:19:12 -0500 Subject: [PATCH 2/2] Log and guard the post() origin fallback instead of swallowing The malformed-origin fallback caught every postMessage failure and silently downgraded targetOrigin to '*', masking unrelated errors, and the fallback post was itself unguarded. Log the caught error before falling back (outbound frames carry no nonce, so the broader audience leaks nothing) and wrap the fallback so a dead opener doesn't throw. --- flasher/src/main.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/flasher/src/main.ts b/flasher/src/main.ts index d0621396d..e3e594ae8 100644 --- a/flasher/src/main.ts +++ b/flasher/src/main.ts @@ -119,11 +119,19 @@ function post(msg: OutboundMessage): void { // its declaration. Outbound frames carry no nonce (see protocol.ts). try { opener?.postMessage(msg, targetOrigin); - } catch { + } catch (err) { // A malformed origin= hash param (e.g. origin=null) makes postMessage throw, - // which would wedge the ready handshake before the retry interval is set up. + // which would wedge the ready handshake. Fall back to '*' so frames keep + // flowing; outbound frames carry no nonce (see protocol.ts), so the broader + // audience leaks nothing. Log rather than swallow so an unrelated failure + // (not just a bad origin) is still visible. + console.error("Flasher postMessage failed; falling back to '*':", err); targetOrigin = "*"; - opener?.postMessage(msg, "*"); + try { + opener?.postMessage(msg, "*"); + } catch (err2) { + console.error("Flasher postMessage failed after origin fallback:", err2); + } } } @@ -174,7 +182,7 @@ window.addEventListener("message", (ev: MessageEvent) => { if (!data || data.type !== "esphome-web-flash:firmware") return; if (data.nonce !== nonce) return; if (!isFlashParts(data.parts)) { - // The opener has attached and sent — stop re-announcing ready even though + // The opener has attached and sent, so stop re-announcing ready even though // the payload is unusable, mirroring the accepted path below. stopReadyRetry(); setState("error", "Received a malformed firmware payload.");