fix(portal): pre-merge security hardening from code review
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated, proxy-reachable session-minting path (and a linked project's open link grants the whole client's scope), so it must be explicitly enabled in dev. - Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was browser-only) and guard a non-dict signed body (was an uncaught AttributeError → 500, reachable if SECRET_KEY is the insecure default). - Escape operator-set strings (location/rule/event names) before innerHTML + Leaflet tooltips — they're client-facing, so a name with markup was stored XSS in the client's browser. Global esc() helper applied at every injection point. - WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history rows now whitelisted like the other scoped endpoints. - Preview-client slug uses the full project id (an 8-char prefix could collide two projects onto one client). Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default off; templates parse; scoped scrubbing intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,8 @@
|
||||
<script>
|
||||
// Theme toggle. Pages can listen for 'portal-theme' to re-skin canvases/maps.
|
||||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
||||
// HTML-escape operator-set strings (location/rule names) before innerHTML/tooltip injection.
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||
function togglePortalTheme() {
|
||||
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
const next = cur === 'light' ? 'dark' : 'light';
|
||||
|
||||
@@ -268,7 +268,7 @@ function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso
|
||||
|
||||
// ---- alert limits (the active thresholds, read-only) ---------------------
|
||||
function fmtThreshold(r) {
|
||||
const m = EV_METRIC[r.metric] || r.metric;
|
||||
const m = EV_METRIC[r.metric] || esc(r.metric);
|
||||
const cmp = r.comparison === 'below' ? 'below' : 'above';
|
||||
let s = `${m} ${cmp} ${r.threshold_db} dB`;
|
||||
if (r.duration_s) s += ` for ${r.duration_s}s`;
|
||||
@@ -287,7 +287,7 @@ async function loadThresholds() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'panel px-3.5 py-2.5 text-sm flex items-center gap-2.5';
|
||||
row.innerHTML = `<span class="w-1.5 h-1.5 rounded-full bg-seismo-orange shrink-0"></span>
|
||||
<span class="text-[var(--text)]">${r.name || 'Alert'}</span>
|
||||
<span class="text-[var(--text)]">${esc(r.name || 'Alert')}</span>
|
||||
<span class="text-[var(--text-dim)] font-mono text-xs">${fmtThreshold(r)}</span>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
@@ -309,14 +309,14 @@ async function loadEvents() {
|
||||
if (!events.length) { list.innerHTML = '<div class="text-sm text-[var(--text-dim)]">No alerts have fired.</div>'; return; }
|
||||
list.innerHTML = '';
|
||||
for (const e of events) {
|
||||
const m = EV_METRIC[e.metric] || e.metric;
|
||||
const m = EV_METRIC[e.metric] || esc(e.metric);
|
||||
const active = e.status === 'active';
|
||||
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
||||
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
||||
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'panel px-3.5 py-2.5 text-sm ' + (active ? 'border-[rgba(220,38,38,0.4)]' : '');
|
||||
row.innerHTML = `<div class="${active ? 'text-[var(--lvl-bad)] font-medium' : 'text-[var(--text)]'}">${e.rule_name || 'Alert'} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
|
||||
row.innerHTML = `<div class="${active ? 'text-[var(--lvl-bad)] font-medium' : 'text-[var(--text)]'}">${esc(e.rule_name || 'Alert')} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
|
||||
<div class="text-xs text-[var(--text-dim)] font-mono mt-0.5">${when}${peak}</div>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -96,9 +96,9 @@ function updateMarker(loc) {
|
||||
const m = markersById[loc.id]; if (!m) return;
|
||||
const st = liveState[loc.id];
|
||||
m.setStyle({ fillColor: levelColor(st) });
|
||||
let label = `<b>${loc.name}</b>`;
|
||||
let label = `<b>${esc(loc.name)}</b>`;
|
||||
if (st) {
|
||||
if (st.status === 'measuring') label += ` · ${st.leqStr} dB Leq`;
|
||||
if (st.status === 'measuring') label += ` · ${esc(st.leqStr)} dB Leq`;
|
||||
else if (st.status === 'stopped') label += ' · stopped';
|
||||
else if (st.status === 'nodevice') label += ' · no device';
|
||||
else label += ' · offline';
|
||||
@@ -180,7 +180,7 @@ if (withCoords.length) {
|
||||
markersById[l.id] = L.circleMarker([la, lo], {
|
||||
radius: 7, fillColor: levelColor(liveState[l.id]), color: '#fff',
|
||||
weight: 2, opacity: 0.9, fillOpacity: 0.95,
|
||||
}).addTo(map).bindTooltip(l.name, { direction: 'top', offset: [0, -6] });
|
||||
}).addTo(map).bindTooltip(esc(l.name), { direction: 'top', offset: [0, -6] });
|
||||
pts.push([la, lo]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user