fix(web): bind six dead controls programmatically, restrict client UIDs, clamp config inputs, add page checker to CI - #315
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate issues remain in the page checker and CI enforcement.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes six dead inline handlers, clamps configuration values to firmware limits, and adds embedded-page validation to CI.
Changes:
- Exports six handlers to
window. - Clamps sample intervals and relay durations.
- Adds page extraction, syntax, and handler checks to CI.
File summaries
| File | Summary | Final review comments |
|---|---|---|
TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino |
Handler exports and configuration clamps | None |
TankAlarm-112025-Server-BluesOpta/check_web_pages.py |
Embedded-page validation | Critical (1 vote): regex detection mishandles literals after keywords. Moderate (3 votes): event allow-list omits valid inline events. Moderate (2 votes): missing sketches do not fail CI. Moderate (1 vote): missing Node fails open. Moderate (1 vote): zero extracted pages can pass silently. |
.github/workflows/arduino-ci-112025.yml |
CI integration | Moderate (1 vote): build-firmware does not depend on the page-check job. |
Review details
Suppressed comments (3)
.github/workflows/arduino-ci-112025.yml:231
- This job is not included in
build-firmware'sneedslist, so on a push a failing page check can run in parallel while the firmware build still succeeds and commits artifacts. If this check is meant to prevent the regression from reaching the main branch/release path, make the artifact-producing job depend oncheck-web-pages(or otherwise enforce it as a required status).
check-web-pages:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Check embedded web pages (script syntax, inline handler reachability)
run: python3 TankAlarm-112025-Server-BluesOpta/check_web_pages.py
TankAlarm-112025-Server-BluesOpta/check_web_pages.py:178
- When Node is unavailable, this checker only prints a warning and still returns success, while the workflow does not install or pin Node. That makes the syntax-check portion fail open and can report a green CI result with zero JavaScript validation; missing required tooling should fail the job or be provisioned explicitly.
node = shutil.which('node')
if not node:
print('WARNING: node not found on PATH; skipping syntax checks', file=sys.stderr)
TankAlarm-112025-Server-BluesOpta/check_web_pages.py:187
- If
PAGE_REstops matching because the sketch declaration format changes,extract_pages(sk)can return no pages and the loop simply does nothing; the checker then reports success with0 pages checked. Assert that each required sketch yielded at least one page so parser drift cannot disable the check silently.
for name, page in extract_pages(sk).items():
if '<script' not in page and 'on' not in page:
continue
- Files reviewed: 2/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
e17e726 to
a8ca057
Compare
a8ca057 to
53c5f50
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new handler checker has false negatives that can allow unreachable inline handlers to pass CI.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
TankAlarm-112025-Server-BluesOpta/check_web_pages.py:34
- This pattern does not cover every inline
on*handler as documented: it requires=with no surrounding whitespace and hard-codes a subset of event names. For example, bothonclick = "missing()"andonpointerdown="missing()"currently produce no failure, allowing the same unreachable-handler regression to bypass CI. Match arbitraryon...attributes and optional whitespace around=instead.
HANDLER_RE = re.compile(
r'\bon(?:click|change|input|submit|keyup|keydown|keypress|load|blur|focus|mouseenter|mouseleave|mouseover|mouseout)'
r'=\\?["\']\s*(?:window\.)?([A-Za-z_$][\w$]*)\s*\(')
- Files reviewed: 2/3 changed files
- Comments generated: 1
- Review effort level: Balanced
53c5f50 to
878684e
Compare
… 1-based Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Newly activated handlers permit stored script injection, and the checker has reachability gaps.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 4
- Review effort level: Balanced
| @@ -2248,7 +2248,7 @@ funct)HTML" R"HTML(ion populateSensorDropdowns(){const sensorSelect = document.g | |||
| async funct)HTML" R"HTML(ion loadCalibrationData(){try{const response = await fetch('/api/calibration');if(!response.ok)throw new Error('Failed to load calibration data');const data = await response.json();calibrations = data.calibrations || [];calibrationLogs = data.logs || [];updateCalibrationStats();updateCalibrationTable();updateCalibrationLog();}catch(err){console.error('Error loading calibration data:',err);}} | |||
| funct)HTML" R"HTML(ion updateCalibrationStats(){const total = sensors.length > 0 ? new Set(sensors.map(t => `${t.client}:${t.sensorIndex}`)).size:0;const calibrated = calibrations.filter(c => c.hasLearnedCalibration).length;const learning = calibrations.filter(c => !c.hasLearnedCalibration && c.entryCount > 0).length;const uncalibrated = total - calibrated - learning;document.getElementById('statTotalTanks').textContent = total;document.getElementById('statCalibrated').textContent = calibrated;document.getElementById('statLearning').textContent = learning;document.getElementById('statUncalibrated').textContent = Math.max(0,uncalibrated);} | |||
| funct)HTML" R"HTML(ion updateCalibrationTable(){const tbody = document.getElementById('calibrationTableBody');tbody.innerHTML = '';if(calibrations.length === 0){tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--muted);">No calibration data yet. Add readings to start learning.</td></tr>';return;}calibrations.forEach(cal =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === cal.clientUid && t.sensorIndex === cal.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.label || 'Sensor ' + cal.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${cal.sensorIndex}`;const site = sensorInfo ? sensorInfo.site:'--';let statusClass = 'uncalibrated';let statusText = 'Uncalibrated';let warnings = [];if(cal.hasLearnedCalibration){statusClass = 'calibrated';statusText = cal.hasTempCompensation ? 'Calibrated+Temp' : 'Calibrated';if(cal.rSquared < 0.95){warnings.push('Low R² fit (<95%)');}if(cal.entryCount === 2){warnings.push('Only 2 data points');}}else if(cal.entryCount > 0){statusClass = 'learning';statusText = 'Learning';if(cal.entryCount === 1){warnings.push('Need 1 more point');}}const sensorRange = cal.maxSensorMa - cal.minSensorMa;const levelRange = cal.maxLevelInches - cal.minLevelInches;if(cal.hasLearnedCalibration && sensorRange < 4){warnings.push('Narrow sensor range (<4mA)');}let driftText = '--';let driftClass = 'low';if(cal.hasLearnedCalibration && cal.originalMaxValue > 0){const originalSlope = cal.originalMaxValue / 16.0;const drift = Math.abs((cal.learnedSlope - originalSlope)/ originalSlope * 100);driftText = drift.toFixed(1)+ '%';if(drift > 10)driftClass = 'high';else if(drift > 5)driftClass = 'medium';}let tempCoefText = '--';if(cal.hasTempCompensation && cal.learnedTempCoef !== undefined){tempCoefText = cal.learnedTempCoef.toFixed(4) + ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/°F';}else if(cal.tempEntryCount > 0){tempCoefText = `(${cal.tempEntryCount} pts)`;}let rangeText = '--';if(cal.entryCount >= 1){rangeText = `${cal.minSensorMa.toFixed(1)}-${cal.maxSensorMa.toFixed(1)} mA`;}let warningHtml = '';if(warnings.length > 0){warningHtml = `<span class="quality-warning" title="${warnings.join(', ')}">⚠️</span>`;}const sensorKey = `${cal.clientUid}:${cal.sensorIndex}`;tr.innerHTML = ` <td><a href="#" class="sensor-link" onclick="viewTankPoints('${sensorKey}');return false;" title="Click to view data points">${escapeHtml(sensorName)}</a>${warningHtml}</td><td>${escapeHtml(site)}</td><td><span class="calibration-status ${statusClass}">${statusText}</span></td><td title="Sensor range: ${rangeText}">${cal.entryCount}</td><td>${cal.hasLearnedCalibration ?(cal.rSquared * 100).toFixed(1)+ '%':'--'}</td><td>${cal.hasLearnedCalibration ? cal.learnedSlope.toFixed(3)+ ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/mA':'--'}</td><td title="Temperature coefficient (${getSensorUnit(cal.clientUid,cal.sensorIndex)} per °F deviation from 70°F)">${tempCoefText}</td><td><span class="drift-indicator ${driftClass}">${driftText}</span></td><td>${formatEpoch(cal.lastCalibrationEpoch)}</td><td><button class="btn-reset" onclick="resetCalibration('${cal.clientUid}',${cal.sensorIndex})" title="Reset calibration for this sensor">Reset</button></td> `;tbody.appendChild(tr);});} | |||
| funct)HTML" R"HTML(ion updateCalibrationLog(){const tbody = document.getElementById('logTableBody');const filter = document.getElementById('logSensorFilter').value;tbody.innerHTML = '';let filtered = calibrationLogs;if(filter){const [clientUid,sensorIdx] = filter.split(':');filtered = calibrationLogs.filter(log => log.clientUid === clientUid && log.sensorIndex === parseInt(sensorIdx));}if(filtered.length === 0){tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);">No calibration entries found.</td></tr>';return;}filtered.sort((a,b)=> b.timestamp - a.timestamp);filtered.forEach(log =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === log.clientUid && t.sensorIndex === log.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.site} - ${sensorInfo.label || 'Sensor ' + log.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${log.sensorIndex}`;const isValidReading = log.sensorReading >= 4 && log.sensorReading <= 20;const sensorDisplay = isValidReading ? log.sensorReading.toFixed(2)+ ' mA':(log.sensorReading ? `${log.sensorReading.toFixed(2)} mA (out of range)`:'-- (out of range)');const tempDisplay = log.temperatureF !== undefined && log.temperatureF !== null ? log.temperatureF.toFixed(1)+ '°F':'--';tr.innerHTML = ` <td>${formatEpoch(log.timestamp)}</td><td>${escapeHtml(sensorName)}</td><td title="${isValidReading ? '':'Not used for calibration(outside 4-20mA range)'}">${sensorDisplay}</td><td>${formatLevel(log.verifiedLevelInches,getSensorUnit(log.clientUid,log.sensorIndex))}</td><td>${tempDisplay}</td><td>${escapeHtml(log.notes || '--')}</td> `;if(!isValidReading){tr.style.opacity = '0.6';}tbody.appendChild(tr);});}document.getElementById('calibrationForm').addEventListener('submit',async(e)=>{e.preventDefault();const sensorKey = document.getElementById('sensorSelect').value;if(!sensorKey){showToast('Please select a sensor',true);return;}const [clientUid,sensorIndex] = sensorKey.split(':');const tank = sensors.find(t => `${t.client}:${t.sensorIndex}` === sensorKey);const isTankMode=document.getElementById('levelInputTank').style.display!=='none';let totalValue;if(isTankMode){const levelFeet = parseInt(document.getElementById('levelFeet').value)|| 0;const levelInches = parseFloat(document.getElementById('levelInches').value)|| 0;totalValue = levelFeet * 12 + levelInches;}else{totalValue = parseFloat(document.getElementById('levelValue').value)||0;}const timestampInput = document.getElementById('readingTimestamp').value;const note)HTML" R"HTML(s = document.getElementById('notes').value.trim();if(totalValue < 0){showToast('Invalid level value',true);return;}const payload ={clientUid:clientUid,sensorIndex:parseInt(sensorIndex),verifiedLevelInches:totalValue,notes:notes};if(tank && tank.sensorMa && tank.sensorMa >= 4 && tank.sensorMa <= 20){payload.sensorReading = tank.sensorMa;}if(timestampInput){payload.timestamp = Math.floor(new Date(timestampInput).getTime()/ 1000);}try{const response = await fetch('/api/calibration',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to submit calibration');}showToast('Calibration reading submitted successfully');document.getElementById('calibrationForm').reset();loadCalibrationData();}catch(err){console.error('Error submitting calibration:',err);showToast(err.message || 'Failed to submit calibration',true);}});document.getElementById('logSensorFilter').addEventListener('change',updateCalibrationLog);const now = new Date();now.setMinutes(now.getMinutes()- now.getTimezoneOffset());document.getElementById('readingTimestamp').value = now.toISOString().slice(0,16);await loadSensors();await loadCalibrationData();setInterval(loadCalibrationData,30000);funct)HTML" R"HTML(ion viewTankPoints(sensorKey){document.getElementById('logSensorFilter').value = sensorKey;updateCalibrationLog();document.getElementById('logTableBody').closest('.card').scrollIntoView({behavior:'smooth',block:'start'});showToast('Showing data points for selected tank');}async funct)HTML" R"HTML(ion resetCalibration(clientUid,sensorIndex){if(!confirm(`Reset calibration for sensor ${sensorIndex}? This will delete all calibration data for this sensor.`)){return;}try{const response = await fetch('/api/calibration',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientUid:clientUid,sensorIndex:sensorIndex})});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to reset calibration');}showToast('Calibration reset successfully');loadCalibrationData();}catch(err){console.error('Error resetting calibration:',err);showToast(err.message || 'Failed to reset calibration',true);}}})();})(); | |||
| funct)HTML" R"HTML(ion updateCalibrationLog(){const tbody = document.getElementById('logTableBody');const filter = document.getElementById('logSensorFilter').value;tbody.innerHTML = '';let filtered = calibrationLogs;if(filter){const [clientUid,sensorIdx] = filter.split(':');filtered = calibrationLogs.filter(log => log.clientUid === clientUid && log.sensorIndex === parseInt(sensorIdx));}if(filtered.length === 0){tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);">No calibration entries found.</td></tr>';return;}filtered.sort((a,b)=> b.timestamp - a.timestamp);filtered.forEach(log =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === log.clientUid && t.sensorIndex === log.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.site} - ${sensorInfo.label || 'Sensor ' + log.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${log.sensorIndex}`;const isValidReading = log.sensorReading >= 4 && log.sensorReading <= 20;const sensorDisplay = isValidReading ? log.sensorReading.toFixed(2)+ ' mA':(log.sensorReading ? `${log.sensorReading.toFixed(2)} mA (out of range)`:'-- (out of range)');const tempDisplay = log.temperatureF !== undefined && log.temperatureF !== null ? log.temperatureF.toFixed(1)+ '°F':'--';tr.innerHTML = ` <td>${formatEpoch(log.timestamp)}</td><td>${escapeHtml(sensorName)}</td><td title="${isValidReading ? '':'Not used for calibration(outside 4-20mA range)'}">${sensorDisplay}</td><td>${formatLevel(log.verifiedLevelInches,getSensorUnit(log.clientUid,log.sensorIndex))}</td><td>${tempDisplay}</td><td>${escapeHtml(log.notes || '--')}</td> `;if(!isValidReading){tr.style.opacity = '0.6';}tbody.appendChild(tr);});}document.getElementById('calibrationForm').addEventListener('submit',async(e)=>{e.preventDefault();const sensorKey = document.getElementById('sensorSelect').value;if(!sensorKey){showToast('Please select a sensor',true);return;}const [clientUid,sensorIndex] = sensorKey.split(':');const tank = sensors.find(t => `${t.client}:${t.sensorIndex}` === sensorKey);const isTankMode=document.getElementById('levelInputTank').style.display!=='none';let totalValue;if(isTankMode){const levelFeet = parseInt(document.getElementById('levelFeet').value)|| 0;const levelInches = parseFloat(document.getElementById('levelInches').value)|| 0;totalValue = levelFeet * 12 + levelInches;}else{totalValue = parseFloat(document.getElementById('levelValue').value)||0;}const timestampInput = document.getElementById('readingTimestamp').value;const note)HTML" R"HTML(s = document.getElementById('notes').value.trim();if(totalValue < 0){showToast('Invalid level value',true);return;}const payload ={clientUid:clientUid,sensorIndex:parseInt(sensorIndex),verifiedLevelInches:totalValue,notes:notes};if(tank && tank.sensorMa && tank.sensorMa >= 4 && tank.sensorMa <= 20){payload.sensorReading = tank.sensorMa;}if(timestampInput){payload.timestamp = Math.floor(new Date(timestampInput).getTime()/ 1000);}try{const response = await fetch('/api/calibration',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to submit calibration');}showToast('Calibration reading submitted successfully');document.getElementById('calibrationForm').reset();loadCalibrationData();}catch(err){console.error('Error submitting calibration:',err);showToast(err.message || 'Failed to submit calibration',true);}});document.getElementById('logSensorFilter').addEventListener('change',updateCalibrationLog);const now = new Date();now.setMinutes(now.getMinutes()- now.getTimezoneOffset());document.getElementById('readingTimestamp').value = now.toISOString().slice(0,16);await loadSensors();await loadCalibrationData();setInterval(loadCalibrationData,30000);funct)HTML" R"HTML(ion viewTankPoints(sensorKey){document.getElementById('logSensorFilter').value = sensorKey;updateCalibrationLog();document.getElementById('logTableBody').closest('.card').scrollIntoView({behavior:'smooth',block:'start'});showToast('Showing data points for selected tank');}window.viewTankPoints=viewTankPoints;async funct)HTML" R"HTML(ion resetCalibration(clientUid,sensorIndex){if(!confirm(`Reset calibration for sensor ${sensorIndex}? This will delete all calibration data for this sensor.`)){return;}try{const response = await fetch('/api/calibration',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientUid:clientUid,sensorIndex:sensorIndex})});if(!response.ok){const text = await response.text();throw new Error(text || 'Failed to reset calibration');}showToast('Calibration reset successfully');loadCalibrationData();}catch(err){console.error('Error resetting calibration:',err);showToast(err.message || 'Failed to reset calibration',true);}}window.resetCalibration=resetCalibration;})();})(); | |||
878684e to
45b0bc4
Compare
…lly in #315 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
45b0bc4 to
780a473
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Attribute escaping remains unsafe for persisted UIDs, and the checker silently accepts some unreachable handlers.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 4
- Review effort level: Balanced
| @@ -2247,11 +2258,12 @@ funct)HTML" R"HTML(ion formatLevel(value,unit){if(typeof value !== 'number' || ! | |||
| funct)HTML" R"HTML(ion populateSensorDropdowns(){const sensorSelect = document.getElementById('sensorSelect');const logSensorFilter = document.getElementById('logSensorFilter');sensorSelect.innerHTML = '<option value="">-- Select a sensor --</option>';logSensorFilter.innerHTML = '<option value="">All Sensors</option>';const uniqueTanks = new Map();sensors.forEach(t =>{const key = `${t.client}:${t.sensorIndex}`;if(!uniqueTanks.has(key)){uniqueTanks.set(key,{client:t.client,sensorIndex:t.sensorIndex,site:t.site,label:t.label || `Sensor ${t.sensorIndex}`,heightInches:t.heightInches || 0,currentValue:t.currentValue || 0,sensorMa:t.sensorMa || 0,lastUpdate:t.lastUpdate || 0,objectType:t.objectType||'tank',measurementUnit:t.measurementUnit||'inches',userNumber:t.userNumber||0});}});uniqueTanks.forEach((tank,key)=>{const option = document.createElement('option');option.value = key;const typeTag=tank.objectType==='tank'?'':'['+tank.objectType.toUpperCase()+'] ';option.textContent = `${typeTag}${tank.site} - ${tank.label}${tank.userNumber?' #'+tank.userNumber:''}`;sensorSelect.appendChild(option.cloneNode(true));logSensorFilter.appendChild(option);});}function updateLevelInput(){const sel=document.getElementById('sensorSelect').value;if(!sel){document.getElementById('levelInputTank').style.display='flex';document.getElementById('levelInputGeneric').style.display='none';return;}const [uid,tn]=sel.split(':');const tank=sensors.find(t=>t.client===uid&&t.sensorIndex===parseInt(tn));const ot=tank?tank.objectType:'tank';const mu=tank?tank.measurementUnit:'inches';if(ot==='tank'||mu==='inches'){document.getElementById('levelInputTank').style.display='flex';document.getElementById('levelInputGeneric').style.display='none';}else{document.getElementById('levelInputTank').style.display='none';document.getElementById('levelInputGeneric').style.display='block';document.getElementById('levelUnitLabel').textContent=mu.toUpperCase();}}document.getElementById('sensorSelect').addEventListener('change',updateLevelInput); | |||
| async funct)HTML" R"HTML(ion loadCalibrationData(){try{const response = await fetch('/api/calibration');if(!response.ok)throw new Error('Failed to load calibration data');const data = await response.json();calibrations = data.calibrations || [];calibrationLogs = data.logs || [];updateCalibrationStats();updateCalibrationTable();updateCalibrationLog();}catch(err){console.error('Error loading calibration data:',err);}} | |||
| funct)HTML" R"HTML(ion updateCalibrationStats(){const total = sensors.length > 0 ? new Set(sensors.map(t => `${t.client}:${t.sensorIndex}`)).size:0;const calibrated = calibrations.filter(c => c.hasLearnedCalibration).length;const learning = calibrations.filter(c => !c.hasLearnedCalibration && c.entryCount > 0).length;const uncalibrated = total - calibrated - learning;document.getElementById('statTotalTanks').textContent = total;document.getElementById('statCalibrated').textContent = calibrated;document.getElementById('statLearning').textContent = learning;document.getElementById('statUncalibrated').textContent = Math.max(0,uncalibrated);} | |||
| funct)HTML" R"HTML(ion updateCalibrationTable(){const tbody = document.getElementById('calibrationTableBody');tbody.innerHTML = '';if(calibrations.length === 0){tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--muted);">No calibration data yet. Add readings to start learning.</td></tr>';return;}calibrations.forEach(cal =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === cal.clientUid && t.sensorIndex === cal.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.label || 'Sensor ' + cal.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${cal.sensorIndex}`;const site = sensorInfo ? sensorInfo.site:'--';let statusClass = 'uncalibrated';let statusText = 'Uncalibrated';let warnings = [];if(cal.hasLearnedCalibration){statusClass = 'calibrated';statusText = cal.hasTempCompensation ? 'Calibrated+Temp' : 'Calibrated';if(cal.rSquared < 0.95){warnings.push('Low R² fit (<95%)');}if(cal.entryCount === 2){warnings.push('Only 2 data points');}}else if(cal.entryCount > 0){statusClass = 'learning';statusText = 'Learning';if(cal.entryCount === 1){warnings.push('Need 1 more point');}}const sensorRange = cal.maxSensorMa - cal.minSensorMa;const levelRange = cal.maxLevelInches - cal.minLevelInches;if(cal.hasLearnedCalibration && sensorRange < 4){warnings.push('Narrow sensor range (<4mA)');}let driftText = '--';let driftClass = 'low';if(cal.hasLearnedCalibration && cal.originalMaxValue > 0){const originalSlope = cal.originalMaxValue / 16.0;const drift = Math.abs((cal.learnedSlope - originalSlope)/ originalSlope * 100);driftText = drift.toFixed(1)+ '%';if(drift > 10)driftClass = 'high';else if(drift > 5)driftClass = 'medium';}let tempCoefText = '--';if(cal.hasTempCompensation && cal.learnedTempCoef !== undefined){tempCoefText = cal.learnedTempCoef.toFixed(4) + ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/°F';}else if(cal.tempEntryCount > 0){tempCoefText = `(${cal.tempEntryCount} pts)`;}let rangeText = '--';if(cal.entryCount >= 1){rangeText = `${cal.minSensorMa.toFixed(1)}-${cal.maxSensorMa.toFixed(1)} mA`;}let warningHtml = '';if(warnings.length > 0){warningHtml = `<span class="quality-warning" title="${warnings.join(', ')}">⚠️</span>`;}const sensorKey = `${cal.clientUid}:${cal.sensorIndex}`;tr.innerHTML = ` <td><a href="#" class="sensor-link" onclick="viewTankPoints('${sensorKey}');return false;" title="Click to view data points">${escapeHtml(sensorName)}</a>${warningHtml}</td><td>${escapeHtml(site)}</td><td><span class="calibration-status ${statusClass}">${statusText}</span></td><td title="Sensor range: ${rangeText}">${cal.entryCount}</td><td>${cal.hasLearnedCalibration ?(cal.rSquared * 100).toFixed(1)+ '%':'--'}</td><td>${cal.hasLearnedCalibration ? cal.learnedSlope.toFixed(3)+ ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/mA':'--'}</td><td title="Temperature coefficient (${getSensorUnit(cal.clientUid,cal.sensorIndex)} per °F deviation from 70°F)">${tempCoefText}</td><td><span class="drift-indicator ${driftClass}">${driftText}</span></td><td>${formatEpoch(cal.lastCalibrationEpoch)}</td><td><button class="btn-reset" onclick="resetCalibration('${cal.clientUid}',${cal.sensorIndex})" title="Reset calibration for this sensor">Reset</button></td> `;tbody.appendChild(tr);});} | |||
| document.getElementById('calibrationTableBody').addEventListener('click',e=>{const a=e.target.closest('a.sensor-link');if(a){e.preventDefault();viewTankPoints(a.dataset.key);return;}const b=e.target.closest('button.btn-reset');if(b)resetCalibration(b.dataset.uid,parseInt(b.dataset.idx,10));}); | |||
| function updateCalibrationTable(){const tbody = document.getElementById('calibrationTableBody');tbody.innerHTML = '';if(calibrations.length === 0){tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--muted);">No calibration data yet. Add readings to start learning.</td></tr>';return;}calibrations.forEach(cal =>{const tr = document.createElement('tr');const sensorInfo = sensors.find(t => t.client === cal.clientUid && t.sensorIndex === cal.sensorIndex);const sensorName = sensorInfo ? `${sensorInfo.label || 'Sensor ' + cal.sensorIndex}${sensorInfo.userNumber?' #'+sensorInfo.userNumber:''}`:`Sensor ${cal.sensorIndex}`;const site = sensorInfo ? sensorInfo.site:'--';let statusClass = 'uncalibrated';let statusText = 'Uncalibrated';let warnings = [];if(cal.hasLearnedCalibration){statusClass = 'calibrated';statusText = cal.hasTempCompensation ? 'Calibrated+Temp' : 'Calibrated';if(cal.rSquared < 0.95){warnings.push('Low R² fit (<95%)');}if(cal.entryCount === 2){warnings.push('Only 2 data points');}}else if(cal.entryCount > 0){statusClass = 'learning';statusText = 'Learning';if(cal.entryCount === 1){warnings.push('Need 1 more point');}}const sensorRange = cal.maxSensorMa - cal.minSensorMa;const levelRange = cal.maxLevelInches - cal.minLevelInches;if(cal.hasLearnedCalibration && sensorRange < 4){warnings.push('Narrow sensor range (<4mA)');}let driftText = '--';let driftClass = 'low';if(cal.hasLearnedCalibration && cal.originalMaxValue > 0){const originalSlope = cal.originalMaxValue / 16.0;const drift = Math.abs((cal.learnedSlope - originalSlope)/ originalSlope * 100);driftText = drift.toFixed(1)+ '%';if(drift > 10)driftClass = 'high';else if(drift > 5)driftClass = 'medium';}let tempCoefText = '--';if(cal.hasTempCompensation && cal.learnedTempCoef !== undefined){tempCoefText = cal.learnedTempCoef.toFixed(4) + ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/°F';}else if(cal.tempEntryCount > 0){tempCoefText = `(${cal.tempEntryCount} pts)`;}let rangeText = '--';if(cal.entryCount >= 1){rangeText = `${cal.minSensorMa.toFixed(1)}-${cal.maxSensorMa.toFixed(1)} mA`;}let warningHtml = '';if(warnings.length > 0){warningHtml = `<span class="quality-warning" title="${warnings.join(', ')}">⚠️</span>`;}const sensorKey = `${cal.clientUid}:${cal.sensorIndex}`;tr.innerHTML = ` <td><a href="#" class="sensor-link" data-key="${escapeHtml(sensorKey)}" title="Click to view data points">${escapeHtml(sensorName)}</a>${warningHtml}</td><td>${escapeHtml(site)}</td><td><span class="calibration-status ${statusClass}">${statusText}</span></td><td title="Sensor range: ${rangeText}">${cal.entryCount}</td><td>${cal.hasLearnedCalibration ?(cal.rSquared * 100).toFixed(1)+ '%':'--'}</td><td>${cal.hasLearnedCalibration ? cal.learnedSlope.toFixed(3)+ ' '+getSensorUnit(cal.clientUid,cal.sensorIndex)+'/mA':'--'}</td><td title="Temperature coefficient (${getSensorUnit(cal.clientUid,cal.sensorIndex)} per °F deviation from 70°F)">${tempCoefText}</td><td><span class="drift-indicator ${driftClass}">${driftText}</span></td><td>${formatEpoch(cal.lastCalibrationEpoch)}</td><td><button class="btn-reset" data-uid="${escapeHtml(cal.clientUid)}" data-idx="${cal.sensorIndex}" title="Reset calibration for this sensor">Reset</button></td> `;tbody.appendChild(tr);});} | |||
| @@ -1482,6 +1482,17 @@ static bool isValidClientUid(const char *clientUid) { | |||
| return false; | |||
| } | |||
|
|
|||
| // Only the characters Blues device UIDs use (dev: + IMEI digits, hex, ':' '_' '-' '.'). | |||
| // Keeps every stored UID safe to embed in HTML attributes, JSON, log lines and file names. | |||
| for (const char *p = clientUid + 4; *p; ++p) { | |||
…e, persisted UID validation Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
780a473 to
b62a0f0
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Legacy UID paths still permit stored script injection, and the new checker has a control-flow false negative.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 3
- Review effort level: Balanced
| @@ -2131,7 +2148,7 @@ window.toggleAlarmSection=function(id){const card=document.getElementById(`senso | |||
| window.removeAlarmSection=function(id){const card=document.getElementById(`sensor-${id}`);const alarmSection=card.querySelector('.alarm-section');const addAlarmBtn=card.querySelector('.add-alarm-btn');const addRelayBtn=card.querySelector('.add-relay-btn');const addMsgBtn=card.querySelector('.add-msg-btn');const relaySection=card.querySelector('.relay-section');const msgSection=card.querySelector('.msg-section');alarmSection.classList.remove('visible');addAlarmBtn.classList.remove('hidden');addRelayBtn.classList.add('hidden');addMsgBtn.classList.add('hidden');relaySection.classList.remove('visible');msgSection.classList.remove('visible');card.querySelector('.high-alarm').value='100';card.querySelector('.low-alarm').value='20';card.querySelector('.high-alarm-enabled').checked=true;card.querySelector('.low-alarm-enabled').checked=true;card.querySelector('.relay-target').value='';card.querySelector('.relay-trigger').value='any';card.querySelector('.relay-mode').value='momentary';['relay-1','relay-2','relay-3','relay-4'].forEach(cls=>{card.querySelector('.'+cls).checked=false;});}; | |||
| window.toggleRelaySection=function(id){const card=document.getElementById(`sensor-${id}`);const relaySection=card.querySelector('.relay-section');const addBtn=card.querySelector('.add-relay-btn');relaySection.classList.add('visible');addBtn.classList.add('hidden');}; | |||
| window.removeRelaySection=function(id){const card=document.getElementById(`sensor-${id}`);const relaySection=card.querySelector('.relay-section');const addBtn=card.querySelector('.add-relay-btn');relaySection.classList.remove('visible');addBtn.classList.remove('hidden');card.querySelector('.relay-target').value='';card.querySelector('.relay-trigger').value='any';card.querySelector('.relay-mode').value='momentary';['relay-1','relay-2','relay-3','relay-4'].forEach(cls=>{card.querySelector('.'+cls).checked=false;});['relay-duration-1','relay-duration-2','relay-duration-3','relay-duration-4'].forEach(cls=>{card.querySelector('.'+cls).value='0';});card.querySelector('.relay-max-on').value='0';card.querySelector('.relay-durations-section').style.display='block';card.querySelector('.relay-max-on-section').style.display='none';}; | |||
| function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;} | |||
| function escapeHtml(s){if(!s)return'';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');} | |||
| @@ -8557,6 +8578,7 @@ static bool loadFtpArchiveCached(uint16_t year, uint8_t month) { | |||
|
|
|||
| auto &cached = gFtpArchiveCache.sensors[gFtpArchiveCache.sensorCount]; | |||
| strlcpy(cached.clientUid, sensorObj["clientUid"] | "", sizeof(cached.clientUid)); | |||
| if (cached.clientUid[0] != '\0' && !isValidClientUid(cached.clientUid)) continue; // persisted before UID validation existed | |||
b62a0f0 to
731641b
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Number parsing misreads valid exponent inputs, and the checker has false-negative paths for unreachable handlers.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
TankAlarm-112025-Server-BluesOpta/check_web_pages.py:451
_rhs_kindtreats a call expression as a bare identifier because this match stops after the callee name. Thus(async()=>{ function make(){}; window.bad=make(); })()is reported as exportingbad, even thoughmake()may returnundefined; an inlinebad()then fails at runtime while this checker passes. Reject identifiers followed by a call/member-expression token (and add this case toselftest) unless the resulting value is provably callable.
m = re.match(r'\s*(async\s+)?([A-Za-z_$][\w$]*)\s*(=>)?', js[i:i + 80])
if m and m.group(2) not in ('undefined', 'null', 'true', 'false', 'new', 'void', 'typeof'):
return '' if m.group(3) else m.group(2)
- Files reviewed: 2/3 changed files
- Comments generated: 2
- Review effort level: Balanced
| @@ -2169,9 +2204,9 @@ window.removeInput=function(id){const card=document.getElementById(`input-${id}` | |||
| const addInputBtn=document.getElementById('addInputBtn');if(addInputBtn)addInputBtn.addEventListener('click',addInput); | |||
| /* CONFIG COLLECTION */ | |||
| function sensorKeyFromValue(value){switch(value){case 0:return 'digital';case 2:return 'current';case 3:return 'rpm';default:return 'analog';}} | |||
| function collectConfig(){if(!validatePowerCombination()){showToast('Invalid power/battery combination — see warning above',true);return null;}const sMinutes=Math.max(1,Math.min(1440,parseInt(document.getElementById('sampleMinutes').value,10)||30));const dailyOffsetMinutes=parseInt(document.getElementById('dailyOffsetSelect').value,10)||0;const _cU=((emailUtcMinutes()-dailyOffsetMinutes)%1440+1440)%1440;const reportHour=Math.floor(_cU/60);const reportMinute=_cU%60;const psRaw=document.getElementById('powerSource').value||'grid';const btStr=document.getElementById('batteryType').value||'agm';const bvNominal=parseInt(document.getElementById('batteryVoltage').value,10)||12;const vmon=document.getElementById('voltageMonitor').value||'none';const hasVin=vmon==='vin-divider';const hasRs485=vmon==='rs485';const isSolarOnly=(btStr==='none'&&psRaw.startsWith('solar'));/* battery type enum value matching TankAlarm_Battery.h (chemistry only) */const btEnumMap={'none':0,'agm':1,'flooded':2,'gel':3,'sla':4,'lifepo4':5,'li_ion':6,'lipo':7,'custom':8};const btEnum=btEnumMap[btStr]!==undefined?btEnumMap[btStr]:1;const cfg={productUid:document.getElementById('productUid').value.trim(),deviceUid:(document.getElementById('clientUid').value||'').trim(),site:document.getElementById('siteName').value.trim(),deviceLabel:document.getElementById('deviceLabel').value.trim()||'Unconfigured Client',clientFleet:(document.getElementById('clientFleet').value||'').trim(),serverFleet:document.getElementById('serverFleet').value.trim()||'tankalarm-server',sampleSeconds:sMinutes*60,reportHour:reportHour,reportMinute:reportMinute,dailyEmail:'',dailyReportOffsetMinutes:dailyOffsetMinutes,powerSupply:psRaw,solarPowered:psRaw.startsWith('solar'),mpptEnabled:psRaw.includes('mppt'),solarCharger:{enabled:hasRs485},voltageMonitor:vmon,batteryConfig:{enabled:btStr!=='none',batteryType:btEnum,batteryTypeName:btStr,nominalVoltage:(btStr==='none'||btStr==='lipo')?0:bvNominal},vinMonitor:{enabled:hasVin,pin:hasVin?parseInt(document.getElementById('vinPin').value)||0:0,r1Kohm:hasVin?parseFloat(document.getElementById('vinR1').value)||22:22,r2Kohm:hasVin?parseFloat(document.getElementById('vinR2').value)||47:47},solarOnlyConfig:{enabled:isSolarOnly,startupDebounceVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlyDebounceV').value)||10:10,startupDebounceSec:(isSolarOnly&&hasVin)?parseInt(document.getElementById('solarOnlyDebounceSec').value)||30:30,startupWarmupSec:(isSolarOnly&&!hasVin)?parseInt(document.getElementById('solarOnlyWarmupSec').value)||60:60,sensorGateVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySensorGateV').value)||11:11,sunsetVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySunsetV').value)||10:10,sunsetConfirmSec:isSolarOnly?parseInt(document.getElementById('solarOnlySunsetSec').value)||120:120,opportunisticReportHours:isSolarOnly?parseInt(document.getElementById('solarOnlyReportHours').value)||20:20,batteryFailureFallback:(!isSolarOnly&&psRaw.startsWith('solar')&&btStr!=='none')?!!document.getElementById('solarOnlyBatFail').checked:false,batteryFailureThreshold:parseInt(document.getElementById('solarOnlyBatFailCount').value)||10},sensors:[],clearButtonPin:-1,clearButtonActiveHigh:false}; | |||
| function collectConfig(){if(!validatePowerCombination()){showToast('Invalid power/battery combination — see warning above',true);return null;}const sMinutes=Math.max(1,Math.min(1092,parseInt(document.getElementById('sampleMinutes').value,10)||30));const dailyOffsetMinutes=parseInt(document.getElementById('dailyOffsetSelect').value,10)||0;const _cU=((emailUtcMinutes()-dailyOffsetMinutes)%1440+1440)%1440;const reportHour=Math.floor(_cU/60);const reportMinute=_cU%60;const psRaw=document.getElementById('powerSource').value||'grid';const btStr=document.getElementById('batteryType').value||'agm';const bvNominal=parseInt(document.getElementById('batteryVoltage').value,10)||12;const vmon=document.getElementById('voltageMonitor').value||'none';const hasVin=vmon==='vin-divider';const hasRs485=vmon==='rs485';const isSolarOnly=(btStr==='none'&&psRaw.startsWith('solar'));/* battery type enum value matching TankAlarm_Battery.h (chemistry only) */const btEnumMap={'none':0,'agm':1,'flooded':2,'gel':3,'sla':4,'lifepo4':5,'li_ion':6,'lipo':7,'custom':8};const btEnum=btEnumMap[btStr]!==undefined?btEnumMap[btStr]:1;const cfg={productUid:document.getElementById('productUid').value.trim(),deviceUid:(document.getElementById('clientUid').value||'').trim(),site:document.getElementById('siteName').value.trim(),deviceLabel:document.getElementById('deviceLabel').value.trim()||'Unconfigured Client',clientFleet:(document.getElementById('clientFleet').value||'').trim(),serverFleet:document.getElementById('serverFleet').value.trim()||'tankalarm-server',sampleSeconds:sMinutes*60,reportHour:reportHour,reportMinute:reportMinute,dailyEmail:'',dailyReportOffsetMinutes:dailyOffsetMinutes,powerSupply:psRaw,solarPowered:psRaw.startsWith('solar'),mpptEnabled:psRaw.includes('mppt'),solarCharger:{enabled:hasRs485},voltageMonitor:vmon,batteryConfig:{enabled:btStr!=='none',batteryType:btEnum,batteryTypeName:btStr,nominalVoltage:(btStr==='none'||btStr==='lipo')?0:bvNominal},vinMonitor:{enabled:hasVin,pin:hasVin?parseInt(document.getElementById('vinPin').value)||0:0,r1Kohm:hasVin?parseFloat(document.getElementById('vinR1').value)||22:22,r2Kohm:hasVin?parseFloat(document.getElementById('vinR2').value)||47:47},solarOnlyConfig:{enabled:isSolarOnly,startupDebounceVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlyDebounceV').value)||10:10,startupDebounceSec:(isSolarOnly&&hasVin)?parseInt(document.getElementById('solarOnlyDebounceSec').value)||30:30,startupWarmupSec:(isSolarOnly&&!hasVin)?parseInt(document.getElementById('solarOnlyWarmupSec').value)||60:60,sensorGateVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySensorGateV').value)||11:11,sunsetVoltage:(isSolarOnly&&hasVin)?parseFloat(document.getElementById('solarOnlySunsetV').value)||10:10,sunsetConfirmSec:isSolarOnly?parseInt(document.getElementById('solarOnlySunsetSec').value)||120:120,opportunisticReportHours:isSolarOnly?parseInt(document.getElementById('solarOnlyReportHours').value)||20:20,batteryFailureFallback:(!isSolarOnly&&psRaw.startsWith('solar')&&btStr!=='none')?!!document.getElementById('solarOnlyBatFail').checked:false,batteryFailureThreshold:parseInt(document.getElementById('solarOnlyBatFailCount').value)||10},sensors:[],clearButtonPin:-1,clearButtonActiveHigh:false}; | |||
bb1ce10 to
55905c0
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The configuration API can falsely accept missing UIDs, and the new checker has paths that silently accept unreachable handlers.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 3
- Review effort level: Balanced
| @@ -10792,6 +10833,10 @@ static void handleConfigPost(EthernetClient &client, const String &body) { | |||
| if (doc["client"] && doc["config"]) { | |||
55905c0 to
34b508a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
UID validation remains bypassable, missing-client requests can silently succeed, and the checker has a confirmed false-negative case.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 2
- Review effort level: Balanced
| for (const char *p = clientUid + 4; *p; ++p) { | ||
| if (!(isalnum((unsigned char)*p) || *p == ':' || *p == '_' || *p == '-' || *p == '.')) { | ||
| Serial.print(F("WARNING: Rejected client UID with unexpected character: ")); | ||
| serialPrintUntrusted(clientUid); | ||
| addServerSerialLog("Rejected invalid client UID (unexpected character)", "warn", "telemetry"); |
34b508a to
c596e8a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Missing UID handling remains bypassable, calibration writes still accept invalid UIDs, and the checker allows a non-callable browser global.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 2
- Review effort level: Balanced
| @@ -19435,6 +19508,7 @@ static void handleCalibrationGet(EthernetClient &client) { | |||
| } | |||
| } | |||
|
|
|||
| if (!isValidClientUid(uid.c_str())) continue; // entries logged before UID validation existed | |||
c596e8a to
18b5c43
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Missing-UID requests still return 200, some UID ingestion paths remain unvalidated, and the checker has false-positive and false-negative cases.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/3 changed files
- Comments generated: 3
- Review effort level: Balanced
| // Only the characters Blues device UIDs use (dev: + IMEI digits, hex, ':' '_' '-' '.'), and | ||
| // at least one of them: keeps every stored UID safe to embed in HTML attributes, JSON, log | ||
| // lines and file names, and rejects the bare prefix. |
…Ds, clamp config inputs, add page checker to CI Six page controls threw ReferenceError on click because their handlers were declared inside the page's async IIFE and never reachable from the inline onclick/onchange attributes that called them (S-W02): - /site-config: Expect Update, Remove Client (and Edit Config, click-to-copy UID) - /calibration: Reset (per sensor), sensor-name link (view data points) - /transmission-log: Cancel (pending config) - /historical: custom date range start/end Those attributes also spliced client UIDs into JavaScript source, so a UID containing a quote could run code once the handlers worked. The controls now carry their UID/key in HTML-escaped data attributes and are bound by a delegated listener inside the page IIFE; no inline handler and no window export is needed for them. isValidClientUid() additionally restricts UIDs to letters, digits, ':', '_', '-' and '.', so every UID the server stores (telemetry, alarms, daily) is safe to embed in attributes, JSON, log lines and file names; handleConfigPost applies the same check to operator-submitted UIDs before storing a snapshot (and rejects a config without a client UID instead of answering 200), and findOrCreateClientMetadata() refuses to create metadata for a malformed id, which covers OTA acknowledgements, location replies and /api/ota/expect, and the calibration POST/DELETE handlers, the client serial-log buffer and the unload handler validate the id before any lookup, write or notification. Config generator inputs are clamped to what the client firmware can store: - Sample Minutes max 1440 -> 1092 (sampleSeconds is a uint16_t; 1440 min = 86400 s does not fit and the client fell back to the default interval) - Momentary relay durations max 86400 -> 65535 and clamped in collectConfig (relayMomentarySeconds[] is a uint16_t; larger values became 0) New TankAlarm-112025-Server-BluesOpta/check_web_pages.py extracts every PROGMEM page from the server and viewer sketches, runs `node --check` on each script block, and asserts that every function called from an inline on<event> attribute (each call in the value, not only the first) resolves to a top-level function or a window.* assignment that executes on load (IIFE or load/DOMContentLoaded listener; not a function declaration, callback or expression-bodied arrow, and only when the assigned value is a function expression, an arrow, or a declared function). The tokeniser handles strings, templates, comments and regex literals after punctuation or keywords. It fails closed when node, a sketch, or any page is missing and carries a --selftest of 23 synthetic cases. A `check-web-pages` CI job runs the self-test and the scan on every push and pull request against any base branch; build-firmware depends on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ally too The dashboard's Update, Snooze Reminders, Clear Relay and Remove Client buttons and the client console's Edit Config / Edit Configuration and Approve Deletion buttons still spliced client UIDs into inline onclick JavaScript (the same pattern the first commit removed from the six dead controls; encodeURIComponent() does not encode a single quote either). They now carry the UID and, where needed, the sensor index and action in HTML-escaped data attributes and are handled by one delegated listener per page. The client console's UID readout is HTML-escaped as well. No page renders a client UID into JavaScript source any more. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…IDs at boot Six pages implemented escapeHtml() by serialising a text node, which escapes & < > but not quotes, so a stored UID containing a double quote could break out of an attribute value. Every page now uses the same five-character escaper (& < > " ') the dashboard already had; text output is unchanged. Records persisted before isValidClientUid() gained its character set (calibrations, calibration summaries, hot-tier history and FTP cache summaries, client config snapshots, client metadata) are validated when they are loaded at boot and skipped with a serial warning if their UID is not a valid device UID, so the pages never see one. The complete stored value is validated before it is copied into the fixed-size field, so an overlong id cannot pass as its truncated prefix, calibration log entries with an invalid id are omitted from /api/calibration, and the persisted sensor registry drops (and re-saves without) records whose id fails the check. Rejected ids are logged through a sanitising printer so a stray control byte cannot forge log lines. The config generator's cloud client picker, the last control that put a UID into an inline handler, carries it in a data attribute with a delegated listener like the others. isValidClientUid() also rejects the bare "dev:" prefix, and empty persisted ids are skipped rather than counted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18b5c43 to
9c0368b
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Missing config UIDs can still return 200, and the page checker accepts exports unreachable after an enclosing return.
Review details
Suppressed comments (1)
TankAlarm-112025-Server-BluesOpta/check_web_pages.py:477
- Unreachability is checked only on the innermost frame. For example,
(async()=>{ function dead(){} return; { window.dead=dead; } })()is accepted even thoughdeadis never exported, allowing the dead-handler regression this checker is meant to block. Propagate unreachability from every enclosing frame (and add this case to the self-test).
unreachable = stack[-1][4] if stack else top_unreachable
- Files reviewed: 2/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Summary
Functional fix for the six dead page controls (master review S-W02), a server-side character set for client UIDs, two config-generator input clamps that were silently exceeding client firmware limits, and a CI check so the dead-handler class of bug cannot come back.
Six controls that threw
ReferenceErroron clickEvery page script runs inside
(async () => { ... })();. Afunction name(){}declared there is not visible to an inlineonclick="name()", so the click threwReferenceError: name is not definedand nothing else reported it:/site-configexpectUpdate,deleteClient,copyUid/calibrationresetCalibration,viewTankPoints/transmission-logcancelPendingConfig/historicalonchange)renderLevelChart/(dashboard),/client-consolerequestUpdate,snoozeAlarm,clearRelays,deleteClient,approveDeletion/config-generatorfetchClientConfigThose attributes also spliced client UIDs into JavaScript source (
onclick="resetCalibration('${cal.clientUid}',…)"), so a UID containing'could run code the moment the handlers started working. The controls now carry their UID or key in HTML-escapeddata-*attributes and are bound by one delegatedclick/changelistener inside the page IIFE; the handler functions stay closure-local and no inline handler orwindow.*export is needed for them. The second commit gives the dashboard and client-console controls the same treatment (encodeURIComponent()does not encode a single quote either, so theEdit Configlinks were converted as well) and HTML-escapes the client console's UID readout. No page renders a client UID into JavaScript source any more. The third commit makes the attribute values themselves safe: six pages implementedescapeHtml()by serialising a text node, which does not escape quotes, so they now share the five-character escaper (& < > " ') the dashboard already had.Client UIDs are restricted at the source
isValidClientUid()(telemetry, alarm and daily ingestion) accepted anything afterdev:up to 47 characters. It now also requires letters, digits,:,_,-or., which covers Blues device UIDs (IMEI or hex), so every UID the server stores is safe to embed in attributes, JSON, log lines and file names.handleConfigPostapplies the same check to operator-submitted UIDs before storing a config snapshot, and a request that carries aconfigwithout a usableclientUID is answered with 400 instead of a silent 200;findOrCreateClientMetadata()refuses to create metadata for a malformed UID, which covers the OTA acknowledgement, location reply and/api/ota/expectpaths (every caller already handles a null result), and the calibration POST/DELETE handlers, the client serial-log buffer (serial_log/serial_acknotes) and the unload handler reject a malformed UID before any lookup, write or notification (those snapshots are rendered on the dashboard as "Configured Client" cards), the baredev:prefix is rejected, and records persisted before this validation existed (the sensor registry, calibrations and their summaries, hot-tier history and FTP cache summaries, client config snapshots, client metadata) are validated when they are loaded at boot, on the complete stored value before it is copied into the fixed-size field (so an overlong id cannot pass as its truncated prefix), and skipped (empty ids included) with a serial warning if their UID is not a valid device UID; calibration log entries with an invalid UID are omitted from/api/calibration, and a registry that dropped a record is marked dirty so it is re-saved clean. Rejected UIDs are logged through a sanitising printer (printable ASCII,\xNNotherwise, 64 chars max) so a stray control byte cannot forge a log line.Config generator clamps
1440→1092, in both the input'smaxattribute and thecollectConfigclamp (which now parses withNumber()and truncates, so exponent input such as1e3is read as 1000, not 1). The client storessampleSecondsin auint16_t; 1440 min = 86 400 s does not fit, sois<uint16_t>()failed and the client quietly fell back to its default interval (CLAUDE review M-08).86400→65535, andcollectConfignow clamps each of the four values to 0…65535 (parsed withNumber()and truncated). The client parsesrelayMomentaryDurations[]withas<uint16_t>(), which yields 0 for anything above 65535, so the firmware-side> 86400guard could never engage (CLAUDE review M-06).check_web_pages.py+ CI jobTankAlarm-112025-Server-BluesOpta/check_web_pages.pyextracts everyPROGMEMpage from the server and viewer sketches (joining the)HTML" R"HTML(segments), runsnode --checkon each<script>block, and asserts that every function called from an inlineon<event>attribute resolves to a depth-0function fn(or awindow.fn =assignment that actually executes on load:onclick="a(); b()",onclick="if (x) b()"), not only the first, after string literals, comments and regex literals in the value are blanked (alert('missing()')calls onlyalert); method calls (a.b()), callable browser globals (alert,open, … but notlocation) and${…}template splices (which run at render time; strings, regex literals and nested templates inside a splice are lexed so a{in a string or a regex cannot swallow the handler) are skipped;on<event>attribute is matched case-insensitively (onClick), quoted or unquoted (onclick=missing()), with optional whitespace around=, including attributes inside JS template strings;window.fn =counts only when it starts a statement (notif (x) window.fn = fn;,else window.fn = fn;,x && (window.fn = fn),y ? window.fn = fn : 0), every enclosing function body is an IIFE that is a statement of its own (notfalse && (() => {…})()) or aload/DOMContentLoadedlistener, and no conditional block (if/else/for/while/switch/catch) encloses it; an assignment inside an uncalled function, an ordinary callback, an expression-bodied arrow (.then(() => window.fn = fn)) or a branch that may not run does not;return/throwin the same block is unreachable and does not count;function name(counts only when it starts a statement; a named function expression (const holder = function dead(){}) does not exposedead;window.dead = undefined, a plain variable, or a function declared inside some other function does not count;${}), comments and regex literals, including regex literals after keywords such asreturn;node(for the scan and for--selftest), a missing sketch, or a sketch yielding no pages is a failure;--selftestruns 60 synthetic pages covering each of these rules.A new
check-web-pagesjob inarduino-ci-112025.yml(withactions/setup-node) runs the self-test and then the scan on every push and pull request;build-firmwaredepends on it, so firmware binaries are not built and committed while a page check fails. The workflow'spull_requesttrigger no longer filters on the base branch, so stacked PRs like this one get the same checks.Before this PR the scan reported exactly the six handlers above; after it,
17 pages checked, 0 problem(s)(the stylesheet page has nothing to check).Verification
/api/ota/expect; Remove Client → DELETE/api/client; Cancel → POST/api/config/cancel; custom-range dates → chart re-render; dashboard Update / Snooze / Clear Relay / Remove Client → their four endpoints; client console Approve Deletion → DELETE/api/client) with zeroerrorevents. A fixture row whose UID contains');alert(1);//renders as inert text, the handler receives it verbatim andalertis never called.5000→sampleSeconds: 65520; relay durations99999 / -5 / 3600 / abc→[65535, 0, 3600, 0]in the downloaded JSON.arduino:mbed_opta 4.5.0,--warnings all: 1,022,836 B (52%) flash on this stacked branch (+3040 B over the Website: unify page chrome and fix layout/style inconsistencies #314 build), 360,720 B (68%) static RAM (unchanged); warning set identical to themasterbuild (line numbers aside). CI'scompile-checkandcheck-web-pagesboth pass on this PR.check_web_pages.py --selftest: 60/60; the scan: 17 pages, 0 problems (the stylesheet page has no script and no handlers).Out of scope
The dashboard's Clear Relay sends the sensor's position in the
/api/clientsts[]array, which the client applies as its monitor-array index; a client whose sensors report out of order can clear the wrong monitor. That is pre-existing (the inline handler sent the same value) and needs a server/client contract change, so it is tracked as S-T06 in the TODO rather than fixed here.The client-side parse (
as<uint16_t>()with a dead> 86400guard) is a firmware change and stays with the client items in the TODO. S-W01 (config round-trip) and S-W03 (calibration identity) follow in #316.🤖 Generated with Claude Code