mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
The flap-limiter could 30-min quarantine a PAIRED, legitimate device on reconnect churn.
Behind Bold's single SNAT IP a repeated edge flush -> every device reconnects -> trips flap
-> quarantined -> a recoverable blip becomes a SUSTAINED FLEET-WIDE LOCKOUT we caused.
check(key, now, {paired}) now skips (and clears) the quarantine escalation for a paired
device — it still gets the brief soft cooldown if it truly hammers, but never the long
lockout. The register gate computes paired = device_id && validateDeviceToken(...) (a
matching STORED token, false for missing/mismatch) so a spoofed device_id can't claim the
exemption; unpaired/anon flapping (attacker / unprovisioned hammering) still quarantines.
Tests: unpaired flapper still quarantined; paired never quarantined (soft cooldown only);
paired creds RELEASE an in-flight quarantine; N paired devices from one SNAT IP all admitted
on reconnect and never quarantined across repeated flush cycles.
130 lines
7 KiB
JavaScript
130 lines
7 KiB
JavaScript
'use strict';
|
|
// #146 hardening (Item B) — sustained per-identity connection-frequency limiter, the
|
|
// TRIGGER fix. Complements (does not replace) the #142 burst throttle: that trips at
|
|
// reconnectBaseMax/reconnectWindowMs (5/10s); this catches a device flapping every
|
|
// 3-5s (~2-3/10s, which passes the burst throttle) over a LONG window
|
|
// (connectRateWindowMs, default 5min / connectRateMax, default 20).
|
|
//
|
|
// Keyed via the SNAT-safe identity fallback chain (device-identity.js) — device_id ->
|
|
// fingerprint -> device_token -> ONE global anon bucket. NEVER IP. Checked at the
|
|
// device:register gate BEFORE any heavy work (playlist build / acks / DB writes), so a
|
|
// refusal is cheap. Over the limit -> refuse with a backoff notice + disconnect.
|
|
//
|
|
// In-memory is acceptable BECAUSE Item A ends the prune-induced restart loop that used
|
|
// to wipe this state every ~40s before it could bite. Bounded: an idle sweep evicts
|
|
// stale buckets and the anonymous fallback is a single shared bucket (an anon flood is
|
|
// capped collectively, never one-bucket-per-attacker growth).
|
|
//
|
|
// #146 P0: a hard flapper (connectRateQuarantineTrips trips in a window) is QUARANTINED
|
|
// in-memory for connectRateQuarantineMs — a cheap, auto-clearing refusal, NOT a DB block.
|
|
// The devices.blocked column is the operator's deliberate, durable lever and is never
|
|
// written automatically.
|
|
|
|
const config = require('../config');
|
|
const { ANON_KEY } = require('./device-identity');
|
|
const { rollingCounter, bump, read } = require('./rolling-counter');
|
|
|
|
// key -> { hits: number[], blockedUntil, lastSeen, trips, tripWinStart, quarantinedUntil }
|
|
const state = new Map();
|
|
|
|
// #146 observability — throughput counters (total + rolling lastWindow).
|
|
const refusedCtr = rollingCounter(); // every allow:false, any reason
|
|
const quarantineStartsCtr = rollingCounter(); // each time a check first sets quarantinedUntil
|
|
const refuse = (now, obj) => { bump(refusedCtr, now); return obj; };
|
|
|
|
function maxFor(key) { return key === ANON_KEY ? config.connectRateAnonMax : config.connectRateMax; }
|
|
|
|
// Decide whether to allow this connection for `key`. Returns
|
|
// { allow: true }
|
|
// { allow: false, retryAfterMs, reason, tripped?, trips?, quarantined? }
|
|
// reason: 'quarantined' (in-memory time-limited), 'flap-cooldown' (post-trip), 'flap-rate'
|
|
// (the trip edge). `quarantined:true` marks the START of a quarantine (log once).
|
|
function check(key, now = Date.now(), opts = {}) {
|
|
if (!config.flapLimiterEnabled) return { allow: true }; // #146 P1.3 kill switch
|
|
// #148: a PAIRED + AUTHENTICATED device reconnecting is a legitimate client recovering
|
|
// (e.g. from an edge idle-reap / half-open TCP), NOT an attacker. Exempt it from the long
|
|
// 30-min QUARANTINE escalation — behind ONE SNAT IP a repeated edge flush would otherwise
|
|
// accumulate the whole paired fleet into quarantine at once, a self-inflicted fleet-wide
|
|
// lockout. It still gets the brief soft cooldown if it truly hammers; only the LONG lockout
|
|
// is waived. Unpaired/anon flapping (attacker / unprovisioned hammering) still quarantines.
|
|
const exemptQuarantine = !!opts.paired;
|
|
let s = state.get(key);
|
|
if (!s) { s = { hits: [], blockedUntil: 0, lastSeen: now, trips: 0, tripWinStart: now, quarantinedUntil: 0 }; state.set(key, s); }
|
|
s.lastSeen = now;
|
|
|
|
// #146 P0: quarantine is an IN-MEMORY, TIME-LIMITED refusal that AUTO-CLEARS — never a
|
|
// DB block. A stuck-then-recovered device comes back on its own after the window. This
|
|
// is safe in-memory now that Item A ended the prune-induced restart loop; a
|
|
// self-healing auto-action must NOT survive as a devices.blocked row.
|
|
if (now < s.quarantinedUntil) {
|
|
// #148: a device that presents valid paired creds is authenticated-legit — release any
|
|
// in-flight quarantine (e.g. tripped before it re-authed, or by a spoofer of its id).
|
|
if (exemptQuarantine) { s.quarantinedUntil = 0; s.trips = 0; }
|
|
else return refuse(now, { allow: false, retryAfterMs: s.quarantinedUntil - now, reason: 'quarantined' });
|
|
}
|
|
|
|
// Inside an enforced cooldown -> refuse cheaply.
|
|
if (now < s.blockedUntil) {
|
|
return refuse(now, { allow: false, retryAfterMs: s.blockedUntil - now, reason: 'flap-cooldown' });
|
|
}
|
|
|
|
// Sliding window of genuine connects.
|
|
s.hits = s.hits.filter((t) => now - t < config.connectRateWindowMs);
|
|
s.hits.push(now);
|
|
|
|
if (s.hits.length > maxFor(key)) {
|
|
s.blockedUntil = now + config.connectRateCooldownMs; // cooldown; a fresh burst must re-accumulate
|
|
s.hits = [];
|
|
if (now - s.tripWinStart > config.connectRateWindowMs) { s.tripWinStart = now; s.trips = 0; }
|
|
s.trips += 1;
|
|
// Escalate to a time-limited quarantine after N trips in the window (0 = off).
|
|
// #148: NEVER escalate a paired+authenticated device to the long lockout — the soft
|
|
// cooldown below is the most a legitimate reconnecting device ever gets.
|
|
if (!exemptQuarantine && config.connectRateQuarantineTrips > 0 && s.trips >= config.connectRateQuarantineTrips) {
|
|
s.quarantinedUntil = now + config.connectRateQuarantineMs;
|
|
bump(quarantineStartsCtr, now); // a quarantine event is visible even though the gauge decays
|
|
return refuse(now, { allow: false, retryAfterMs: config.connectRateQuarantineMs, reason: 'flap-rate', tripped: true, trips: s.trips, quarantined: true });
|
|
}
|
|
return refuse(now, { allow: false, retryAfterMs: config.connectRateCooldownMs, reason: 'flap-rate', tripped: true, trips: s.trips });
|
|
}
|
|
return { allow: true };
|
|
}
|
|
|
|
// #146: evict idle buckets so keyed state can't grow unbounded over churned identities.
|
|
function sweep(now = Date.now()) {
|
|
let n = 0;
|
|
for (const [k, s] of state) if (k !== ANON_KEY && now - s.lastSeen > config.connectRateIdleMs) { state.delete(k); n++; }
|
|
if (n > 0) console.log(`[flap] swept ${n} idle bucket(s); ${state.size} remain`);
|
|
return n;
|
|
}
|
|
let sweepTimer = null;
|
|
function startSweep() {
|
|
if (sweepTimer) return sweepTimer;
|
|
sweepTimer = setInterval(() => sweep(), config.connectRateIdleMs);
|
|
if (sweepTimer.unref) sweepTimer.unref();
|
|
return sweepTimer;
|
|
}
|
|
|
|
function reset() { // tests
|
|
state.clear();
|
|
Object.assign(refusedCtr, rollingCounter());
|
|
Object.assign(quarantineStartsCtr, rollingCounter());
|
|
}
|
|
function _size() { return state.size; }
|
|
// #146: soak observability — gauges (bucket/quarantine counts) + THROUGHPUT (refusals and
|
|
// quarantine-starts, total + last completed window). The throughput tells the flapper
|
|
// story on its own: a real Firestick reads as refusedLastWindow climbing while the gauge
|
|
// (quarantined) can stay 0.
|
|
function stats(now = Date.now()) {
|
|
let quarantined = 0;
|
|
for (const [, s] of state) if (now < s.quarantinedUntil) quarantined++;
|
|
const refused = read(refusedCtr, now);
|
|
const qstarts = read(quarantineStartsCtr, now);
|
|
return {
|
|
buckets: state.size, quarantined,
|
|
refusedTotal: refused.total, refusedLastWindow: refused.lastWindow,
|
|
quarantineStartsTotal: qstarts.total, quarantineStartsLastWindow: qstarts.lastWindow,
|
|
};
|
|
}
|
|
module.exports = { check, sweep, startSweep, reset, _size, stats };
|