mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
The diag widget runs in a null-origin sandboxed iframe, so it cannot carry a session and
its telemetry POST must stay unauthenticated. But the handler stored into a plain Map
keyed on a value taken from the request body, with no cap, no TTL and no eviction — an
unauthenticated caller could add entries until the process died. On this product a dead
server is a fleet-wide reconnect, so a bound here is a fleet-safety control.
Two changes:
- lib/bounded-snapshot-store.js: a "latest snapshot per key" store with a global entry cap
and a TTL, evicting least-recently-WRITTEN. The cap is GLOBAL rather than per-IP on
purpose — signage sites egress through one NAT address, so a per-IP limit punishes a
whole venue for one noisy panel and does nothing about a distributed writer. Same
reasoning the OTA download guard already documents ("NEVER per-IP (SNAT)"). A live panel
rewrites its key every 2.5s, so only entries the dashboard already treats as stale
(>15s) are ever eligible for eviction.
- The POST now answers 204 instead of res.json({ok:true}). The reporting widget ignores
the response (fetch(...).catch()), and services/activity.js activityLogger wraps
res.json — so this also stops an anonymous caller from writing one activity_log row, and
running two synchronous statements, per report.
Read contract unchanged: a live key returns its object, an unknown OR expired key returns
null — the shape frontend/js/views/device-detail.js already handles ("no report yet"), and
it treats anything older than 15s as stale regardless, so the 60s TTL is 4x looser than
what the UI honours. No client change; no rate limiter added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
73 lines
3 KiB
JavaScript
73 lines
3 KiB
JavaScript
'use strict';
|
|
|
|
// A bounded "latest snapshot per key" store for diagnostic data that arrives from
|
|
// UNAUTHENTICATED callers.
|
|
//
|
|
// The widget-telemetry store was a plain Map keyed on a value the caller supplies, with no
|
|
// cap, no TTL and no eviction: an unauthenticated caller could add entries until the
|
|
// process ran out of memory, and on this product a dead server means every screen in the
|
|
// fleet reconnects at once. A bound is therefore a fleet-safety control, not tidiness.
|
|
//
|
|
// Deliberately NOT rate-limited per IP. This codebase already learned that lesson for the
|
|
// OTA download guard ("NEVER per-IP (SNAT)"): signage sites egress through one NAT
|
|
// address, so a per-IP cap punishes a whole venue for one noisy panel and does nothing
|
|
// against a distributed writer. The GLOBAL entry cap is the honest bound — it makes the
|
|
// worst case a fixed amount of memory regardless of who is writing or from where.
|
|
//
|
|
// Eviction is least-recently-WRITTEN. Every live reporter rewrites its own key on each
|
|
// report, so under a flood the only entries eligible for eviction are ones already older
|
|
// than any consumer would treat as live.
|
|
|
|
function createStore({ max = 500, ttlMs = 60_000 } = {}) {
|
|
// Map preserves insertion order, and re-setting a key does NOT refresh that order, so
|
|
// delete-then-set is what makes the iteration order a true recency order.
|
|
const m = new Map();
|
|
let sweepTimer = null;
|
|
|
|
function set(key, value) {
|
|
if (m.has(key)) m.delete(key);
|
|
m.set(key, value);
|
|
// Evict the least-recently-written entries until we are back inside the cap.
|
|
while (m.size > max) {
|
|
const oldest = m.keys().next();
|
|
if (oldest.done) break;
|
|
m.delete(oldest.value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Returns null for a missing OR expired entry. Expiry is enforced on READ as well as by
|
|
// the sweep, so a stale value can never be served just because the sweep hasn't run.
|
|
function get(key, now = Date.now()) {
|
|
const v = m.get(key);
|
|
if (!v) return null;
|
|
const at = typeof v.receivedAt === 'number' ? v.receivedAt : 0;
|
|
if (now - at > ttlMs) { m.delete(key); return null; }
|
|
return v;
|
|
}
|
|
|
|
function sweep(now = Date.now()) {
|
|
let dropped = 0;
|
|
for (const [k, v] of m) {
|
|
const at = typeof v.receivedAt === 'number' ? v.receivedAt : 0;
|
|
if (now - at > ttlMs) { m.delete(k); dropped++; }
|
|
}
|
|
return dropped;
|
|
}
|
|
|
|
// unref() so the interval never holds the process open (same discipline as
|
|
// lib/log-coalescer.js and the other sweeps).
|
|
function startSweep(intervalMs = ttlMs) {
|
|
if (sweepTimer) return sweepTimer;
|
|
sweepTimer = setInterval(() => { try { sweep(); } catch (_) { /* never throw from a timer */ } }, intervalMs);
|
|
if (sweepTimer.unref) sweepTimer.unref();
|
|
return sweepTimer;
|
|
}
|
|
|
|
function stopSweep() { if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; } }
|
|
|
|
return { set, get, sweep, startSweep, stopSweep, size: () => m.size, max, ttlMs };
|
|
}
|
|
|
|
module.exports = { createStore };
|