mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
The auth limiters are app.use middleware that return 429 before the handler that writes activity_log, so a rejection left no trace anywhere — the limit suppressed the record of itself. Four production IPs sit at exactly ten logins a minute and there was no way to tell whether that is one attacker or an office whose staff share an egress address, which is the difference between the limiter working and the limiter locking out customers. The rejection count does not answer that. The number of distinct accounts per IP does: one account hammered is the limiter doing its job, several accounts each denied a few times is a shared egress. Both are now recorded, and a platform-admin-only endpoint reads the tally back. Identifiers are salted-hashed with a per-process salt and only ever counted, so this cannot accumulate into a roster of a customer's addresses. Memory is bounded per key and overall, and says when a count was capped rather than silently undercounting. Behaviour is unchanged: same status, same body, and the recording is wrapped so telemetry can never break the limiter. A test asserts ten through then 429 with the identical response shape, since a diagnostic that alters what it measures is worse than none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
81 lines
3.4 KiB
JavaScript
81 lines
3.4 KiB
JavaScript
'use strict';
|
|
|
|
// Why this exists: the auth limiters run as app.use middleware BEFORE the handler that writes
|
|
// activity_log, so a 429 leaves no trace anywhere. The limit censors the evidence of itself —
|
|
// four production IPs sit at exactly 10 logins/min and there is no way to tell whether that is
|
|
// one attacker or a NATed site whose staff are colliding on a shared egress IP.
|
|
//
|
|
// This records rejections so the question becomes measurable. The discriminating signal is NOT
|
|
// the rejection count, it is how many DISTINCT accounts a single IP is rejected for:
|
|
//
|
|
// 1 identifier, many rejections -> someone hammering one account (the limiter is working)
|
|
// many identifiers, few each -> a shared egress IP; real users are being denied
|
|
//
|
|
// Identifiers are salted-hashed and only ever counted, never stored or logged in the clear —
|
|
// this is a diagnostic, not an audit trail, and it must not become a place where credentials
|
|
// or a roster of a customer's email addresses accumulate.
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const MAX_KEYS = 2000; // distinct endpoint|ip pairs held
|
|
const MAX_IDS_PER_KEY = 64; // enough to tell "one" from "many"; caps memory per key
|
|
const IDLE_MS = 60 * 60 * 1000; // drop a key after an hour of quiet
|
|
|
|
// Per-process salt: makes the digests useless outside this process lifetime, so nothing
|
|
// persisted or logged can be walked back to an address.
|
|
const SALT = crypto.randomBytes(16);
|
|
const digest = (s) => crypto.createHash('sha256').update(SALT).update(String(s).toLowerCase()).digest('hex').slice(0, 16);
|
|
|
|
const state = new Map();
|
|
|
|
function prune(now) {
|
|
for (const [k, v] of state) if (now - v.lastSeen > IDLE_MS) state.delete(k);
|
|
if (state.size <= MAX_KEYS) return;
|
|
// Still oversized: evict the least recently seen.
|
|
const byAge = [...state.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen);
|
|
for (let i = 0; i < byAge.length - MAX_KEYS; i++) state.delete(byAge[i][0]);
|
|
}
|
|
|
|
// Returns the running tally for this endpoint+ip, so the caller can log it.
|
|
function recordRejection({ endpoint, ip, identifier }, now = Date.now()) {
|
|
const key = `${endpoint}|${ip}`;
|
|
let e = state.get(key);
|
|
if (!e) { e = { rejections: 0, ids: new Set(), idsTruncated: false, firstSeen: now, lastSeen: now }; state.set(key, e); }
|
|
e.rejections++;
|
|
e.lastSeen = now;
|
|
if (identifier) {
|
|
if (e.ids.size < MAX_IDS_PER_KEY) e.ids.add(digest(identifier));
|
|
else e.idsTruncated = true;
|
|
}
|
|
if (state.size > MAX_KEYS) prune(now);
|
|
return {
|
|
endpoint, ip,
|
|
rejections: e.rejections,
|
|
distinctIdentifiers: e.ids.size,
|
|
identifiersTruncated: e.idsTruncated,
|
|
windowMs: now - e.firstSeen,
|
|
};
|
|
}
|
|
|
|
// Read-only view for a debug endpoint or a test. No digests are exposed — only counts.
|
|
function snapshot() {
|
|
return [...state.entries()].map(([key, e]) => {
|
|
const i = key.lastIndexOf('|');
|
|
return {
|
|
endpoint: key.slice(0, i),
|
|
ip: key.slice(i + 1),
|
|
rejections: e.rejections,
|
|
distinctIdentifiers: e.ids.size,
|
|
identifiersTruncated: e.idsTruncated,
|
|
firstSeen: e.firstSeen,
|
|
lastSeen: e.lastSeen,
|
|
// The whole point: many identifiers from one IP reads as a shared egress, not an attack.
|
|
likelySharedEgress: e.ids.size >= 3,
|
|
};
|
|
}).sort((a, b) => b.rejections - a.rejections);
|
|
}
|
|
|
|
function reset() { state.clear(); }
|
|
|
|
module.exports = { recordRejection, snapshot, reset, MAX_IDS_PER_KEY, MAX_KEYS };
|