diff --git a/flasher/src/main.ts b/flasher/src/main.ts index 02fd36ea0..e3e594ae8 100644 --- a/flasher/src/main.ts +++ b/flasher/src/main.ts @@ -117,7 +117,22 @@ 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 (err) { + // A malformed origin= hash param (e.g. origin=null) makes postMessage throw, + // 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 = "*"; + try { + opener?.postMessage(msg, "*"); + } catch (err2) { + console.error("Flasher postMessage failed after origin fallback:", err2); + } + } } function setState(state: FlashState, detail: string): void { @@ -167,6 +182,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, so 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 +522,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(