diff --git a/src/web/assets/index_html.h b/src/web/assets/index_html.h
index b66e846..0445613 100644
--- a/src/web/assets/index_html.h
+++ b/src/web/assets/index_html.h
@@ -165,6 +165,13 @@ const $=s=>document.querySelector(s[0]==='#'||s[0]==='.'?s:'#'+s);
const esc=s=>String(s??'').replace(/[<>&"']/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]));
// null must never render as 0 -- that is why the firmware sends null in the first place.
const fmt=(v,d=1)=>v===null||v===undefined?'—':Number(v).toFixed(d);
+// esc() makes a URL safe to SIT in an attribute; it does not make it safe to FOLLOW.
+// javascript: and data: survive HTML-escaping intact and run on click. Only http(s)
+// is linked; anything else renders as plain text, which is the honest failure -- the
+// reader still sees what the feed offered without the page offering to run it.
+const safeUrl=u=>{if(!u)return null; // String(null) would resolve to a link reading "null"
+ try{const p=new URL(String(u),location.href).protocol;
+ return p==='http:'||p==='https:'?String(u):null}catch(e){return null}};
const sp=n=>String(n).replace(/\B(?=(\d{3})+(?!\d))/g,'\u2009');
const up=s=>s<3600?Math.floor(s/60)+' m':s<86400?(s/3600).toFixed(1)+' h':Math.floor(s/86400)+' d '+Math.round(s%86400/3600)+' h';
const kb=b=>b===null||b===undefined?'—':Math.round(b/1024)+' kB';
@@ -187,6 +194,31 @@ function goTab(name){
/// gets typed into the wrong section.
function togglePanel(id){panel=panel===id?null:id;devDraft=null;paint()}
+// Attribute-borne DATA, never attribute-borne CODE. The browser HTML-decodes an attribute
+// value before the JS parser compiles an inline handler, so a quote that esc() turned into
+// ' is a quote again by the time it runs -- and a device id here is driverId + '-' +
+// the serial number the inverter reported over RS485, filtered only to printable ASCII.
+// An apostrophe in that serial used to close the string and run whatever followed, in the
+// admin's authenticated session, with the Basic-auth token sitting in sessionStorage.
+//
+// data-* values are read back with getAttribute, which yields the decoded string and never
+// executable text, so the same bytes are inert. One delegated listener on document also
+// survives every innerHTML repaint, which per-element handlers do not.
+//
+// tools/check_web_js.py fails the build if ${...} ever appears inside an on* attribute
+// again -- the shape is what was wrong, so the shape is what is banned.
+document.addEventListener('click',e=>{
+ const t=e.target.closest('[data-act]');
+ if(!t)return;
+ const a=t.getAttribute('data-act'), v=t.getAttribute('data-val')||'';
+ if(a==='panel')togglePanel(v);
+ else if(a==='tab-panel'){goTab(t.getAttribute('data-tab'));togglePanel(v)}
+ else if(a==='remove-extra')removeExtraAt(Number(v));
+ else if(a==='save-device')saveDevice(Number(v));
+ else if(a==='log-filter'){logFilter=v;loadLogs(true)}
+ else if(a==='wiz-pick')wizPick(v,JSON.parse(t.getAttribute('data-opts')||'{}'));
+});
+
// ---------------- admin auth ----------------
// fetch() never raises the browser's Basic-auth dialog: a 401 is just a 401. So ask once, keep
// it for the tab only, and send the header ourselves.
@@ -485,7 +517,7 @@ function firstRunCard(here){
Wire A, B and ground to the inverter first, then let
the bridge look for it. Discovery listens at each driver's own line speed and reports what
answered; nothing is written to the inverter and nothing is changed until you confirm.
-
+
${here?'':''}
`;
}
@@ -738,8 +770,8 @@ function paintInverters(){
${!dev.label?`
Id ${esc(id)} — this is what the API, the MQTT topics and the Modbus unit mapping use.
`:''}
-
-
+
+
${panel==='m:'+id?`
Everything this driver declares it can read. A channel is listed because the inverter has it; an em dash means it has it but is not reporting a value right now.
@@ -775,12 +807,12 @@ function paintInverters(){
h+=`
${esc(e.label||'Inverter '+(i+2))}starts after a restart
- ${panel==='x:'+i?'':``}
+ ${panel==='x:'+i?'':``}
${esc((drv&&drv.display_name)||e.driver_id||'no driver chosen')}${
addr?' · address '+esc(addr):''} — added to the configuration, not polled yet. Correct it
here before the restart if anything is off, or remove it.
-
+
${panel==='x:'+i?deviceForm(i+1):''}
`;
@@ -851,10 +883,10 @@ function deviceForm(slot){
${drvId!==storedDrvId?'
A different driver from the one running. Its options below start at this driver\u2019s own defaults, not the stored ones.
':''}`;
h+=optionFields(drv,stored,'dv_o_');
h+=`
-
+
- ${primary?'':``}
+ ${primary?'':``}
${primary?'This is the first inverter, which every build has. Point it at a different driver rather than removing it.'
:'Removing it does not remove what it already published: the old entities stay in Home Assistant, available, showing their last value.'}
@@ -1046,7 +1078,7 @@ function paintHealth(){
show
${[['all','everything'],['warn','warnings & errors'],['bus','RS485 only']].map(([k,n])=>
- ``).join('')}
+ ``).join('')}
level
${Math.round((asset.size||0)/1024)} kB. Your browser downloads it and hands it to the bridge, which checks it against the checksum from the release before writing anything. That proves the image arrived intact — it is not a signature, and does not prove who built it.
diff --git a/tools/check_dashboard_layout.py b/tools/check_dashboard_layout.py
index 210d44b..33f4be8 100755
--- a/tools/check_dashboard_layout.py
+++ b/tools/check_dashboard_layout.py
@@ -24,6 +24,7 @@
# a runner image that stops shipping Chrome shows up as a red check rather than as a layout
# check that quietly stopped rendering anything.
+import json
import pathlib
import re
import subprocess
@@ -242,7 +243,7 @@
if(!inv.textContent.includes('starts after a restart')){
say('a configured row that has not started is nowhere on the page');
}
- if(![...inv.querySelectorAll('button')].some(b=>/removeExtraAt/.test(b.getAttribute('onclick')||''))){
+ if(![...inv.querySelectorAll('button')].some(b=>b.getAttribute('data-act')==='remove-extra')){
say('a configured row that has not started offers no way to remove it');
}
@@ -267,9 +268,9 @@
if(!pend[0].textContent.includes('Refused')){
say('the pending card names a running inverter: "'+pend[0].querySelector('b').textContent+'"');
}
- const btn=pend[0].querySelector('button[onclick^="removeExtraAt"]');
- if(!btn||btn.getAttribute('onclick')!=='removeExtraAt(1)'){
- say('the remove button points at the wrong configuration row: '+(btn&&btn.getAttribute('onclick')));
+ const btn=pend[0].querySelector('button[data-act="remove-extra"]');
+ if(!btn||btn.getAttribute('data-val')!=='1'){
+ say('the remove button points at the wrong configuration row: '+(btn&&btn.getAttribute('data-val')));
}
}
@@ -288,7 +289,7 @@
const pcard=[...document.querySelectorAll('#inv .card')]
.find(c=>c.textContent.includes('starts after a restart'));
const open=pcard&&[...pcard.querySelectorAll('button')]
- .find(b=>/togglePanel\('x:/.test(b.getAttribute('onclick')||''));
+ .find(b=>b.getAttribute('data-act')==='panel'&&(b.getAttribute('data-val')||'').startsWith('x:'));
if(!open) say('a pending row offers no way to correct it, only to delete it');
else{
open.click();
@@ -699,6 +700,88 @@ def report(label: str, verdict: str) -> int:
return 1
+# A serial number is bytes off the RS485 bus, and the only filter on them is "printable ASCII"
+# -- which includes the apostrophe. The dashboard used to build its per-device buttons as
+# onclick="togglePanel('m:${esc(id)}')", and esc() does NOT save that: the browser HTML-decodes
+# an attribute value before compiling the inline handler, so ' is an apostrophe again by the
+# time it runs. A device reporting the serial below closed the string and ran what followed, in
+# the admin's authenticated session, where sessionStorage holds the Basic-auth token.
+#
+# BOTH halves are asserted, because either alone passes for the wrong reason: the payload must
+# not run, AND the button must still open its panel. Escaping harder satisfies the first and
+# breaks the second; removing the button satisfies both and ships a dead dashboard.
+HOSTILE_SERIAL = "x');window.__pwned=1;//"
+
+HOSTILE_SERIAL_JS = r"""
+(function(){
+const fail=[];
+const say=m=>fail.push(m);
+const done=()=>{document.title=fail.length?'LAYOUT-FAIL '+fail.join(' || '):'LAYOUT-OK'};
+// Injected from HOSTILE_SERIAL, never spelled again. Review defeated the whole check by
+// changing one character of the fixture: the DOM scan probed a SEPARATE hardcoded literal,
+// so the two drifted apart and a live reintroduced XSS passed with RESULT: PASS. Two copies
+// of one string is one copy too many when a mismatch fails silent.
+const PAYLOAD=__PAYLOAD__;
+let tries=0;
+// The per-device cards live on the Inverters tab, so this has to go there first -- and wait for
+// loadInverters() to have filled the caches the cards render from.
+const tick=setInterval(()=>{
+ if(typeof goTab==='function' && tab!=='inv') goTab('inv');
+ let b=document.querySelector('[data-act="panel"][data-val^="m:"]');
+ if(!b && ++tries<=120) return;
+ clearInterval(tick);
+ try{
+ if(!b){say('no readings button rendered at all');done();return}
+ if(window.__pwned){say('the payload ran while the page was rendering');done();return}
+ // The defence that does not depend on how the source was written. A shape check in
+ // check_web_js.py can always be evaded by building the same string another way; this
+ // asks the RENDERED page whether any handler attribute ended up carrying bus bytes.
+ const scan=where=>{
+ for(const el of document.querySelectorAll('*')){
+ for(const a of el.attributes){
+ if(/^on/i.test(a.name) && a.value.indexOf(PAYLOAD)>=0)
+ say('a rendered '+a.name+' attribute on '+where+' carries the device serial: '
+ +a.value.slice(0,60));
+ }
+ }
+ };
+ // Every tab, not just this one. drawTab() paints only the active section, so a scan that
+ // stays on Inverters never sees what Live, Integrations, Health or Bridge render. None of
+ // them puts a device id in a handler today; the point is that a regression there would
+ // otherwise be invisible to the one check built to catch exactly that.
+ for(const t of ['live','inv','int','health','bridge']){
+ try{goTab(t)}catch(e){say('goTab('+t+') threw: '+e.message);continue}
+ scan(t);
+ }
+ goTab('inv');
+ // The sweep repaints, so the button captured before it is detached and a click on it
+ // never reaches the delegated listener on document. Re-query after the last repaint.
+ b=document.querySelector('[data-act="panel"][data-val^="m:"]');
+ if(!b){say('the readings button did not survive the tab sweep');done();return}
+ const want=b.getAttribute('data-val');
+ // If the fixture ever stops carrying the payload this whole check is vacuous, so it says so
+ // rather than passing quietly.
+ if(want.indexOf(PAYLOAD)<0){say('the fixture lost its payload, so nothing was tested: '+want);done();return}
+ b.click();
+ setTimeout(()=>{
+ if(window.__pwned) say('the payload ran when the readings button was clicked');
+ if(panel!==want) say('the readings button did not open its panel: panel='+panel);
+ // The settings button carries the identical payload and was equally exploitable, so a
+ // regression reintroduced in only that path must not pass here either.
+ const sb=document.querySelector('[data-act="panel"][data-val^="s:"]');
+ if(!sb){say('no settings button rendered');done();return}
+ sb.click();
+ setTimeout(()=>{
+ if(window.__pwned) say('the payload ran when the settings button was clicked');
+ if(panel!==sb.getAttribute('data-val')) say('the settings button did not open its panel');
+ done();
+ },250);
+ },250);
+ }catch(e){say('threw: '+e.message);done()}
+},25);})();
+"""
+
+
def main() -> int:
stripped = build_web.served_page()
stub = (ROOT / "tools" / "demo_fleet.js").read_text(encoding="utf-8")
@@ -770,6 +853,28 @@ def main() -> int:
verdict, _ = render(chrome, page, 1000, scratch, "int")
status |= report("integrations still reports what changed", verdict)
+ # The one device on this fleet whose id came off the bus rather than out of a config.
+ hostile = stub.replace(
+ "'eversolar_legacy-EU00T112345678'",
+ '"eversolar_legacy-' + HOSTILE_SERIAL + '"',
+ ).replace("'EU00T112345678'", '"' + HOSTILE_SERIAL + '"')
+ if "__pwned" not in hostile:
+ print(
+ "hostile serial: FAIL (the stub no longer carries the id this substitutes)"
+ )
+ status |= 1
+ else:
+ js = HOSTILE_SERIAL_JS.replace("__PAYLOAD__", json.dumps(HOSTILE_SERIAL))
+ page = build_page(stripped, hostile, "{soc:68,power:-1240}", js)
+ verdict, _ = render(chrome, page, 1000, scratch, "hostile")
+ status |= report("a hostile serial number cannot run script", verdict)
+
+ # A verdict, for the same reason check_web_js.py grew one: a failing check prints its
+ # own FAIL line and then the failure detail, and every check AFTER it prints OK -- so any
+ # tail of this output reads as green. That is not hypothetical in either tool. It was
+ # read as green here on 2026-08-29, on a branch whose whole point was that a gate which
+ # cannot fail is worse than no gate.
+ print(f"RESULT: {'PASS' if status == 0 else 'FAIL'}")
return status
diff --git a/tools/check_web_js.py b/tools/check_web_js.py
index 9168c11..61eebc0 100644
--- a/tools/check_web_js.py
+++ b/tools/check_web_js.py
@@ -29,6 +29,7 @@ def main() -> int:
with tempfile.TemporaryDirectory(prefix="heliograph-js-") as scratch:
for name in ASSETS:
source = (root / name).read_text()
+ status |= check_no_code_in_handlers(name, source)
scripts = re.findall(r"", source, re.S)
if not scripts:
print(f"{name}: FAIL (no