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