diff options
| author | Adam Soltys <[email protected]> | 2026-09-29 08:37:39 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-29 08:37:47 -0700 |
| commit | 4900ecb19ab9c6d279c8771b834fcb29e526a1e0 (patch) | |
| tree | a8e1067d3cadbd8eacc9d111da2507d7babadfbd /lib | |
| parent | e96ad1cea4192a608a7f15896e4b690b57bf8fc9 (diff) | |
| download | coinos-server-4900ecb19ab9c6d279c8771b834fcb29e526a1e0.tar.gz coinos-server-4900ecb19ab9c6d279c8771b834fcb29e526a1e0.zip | |
logging: throttle log lines that clients can trigger at will
Evicted-JWT replays, blocked sends from frozen or limited accounts and
failed payment retries each logged one line per request, so a looping client
could flood the log, and an evicted token replayed across a proxy pool made
atk-autoban.sh block every IP it saw. Add quiet()/suppressed() in
lib/logging.ts: the first occurrence per key logs as before, repeats within
the window are counted and reported on the next line that gets through. The
evicted-auth summary line is worded so the auto-banner ignores it.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/auth.ts | 16 | ||||
| -rw-r--r-- | lib/logging.ts | 21 | ||||
| -rw-r--r-- | lib/payments.ts | 16 |
3 files changed, 46 insertions, 7 deletions
diff --git a/lib/auth.ts b/lib/auth.ts index 915dcdc8..107c44f4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,6 +1,7 @@ import config from "$config"; import { db } from "$lib/db"; import { fail, getUser } from "$lib/utils"; +import { quiet, suppressed } from "$lib/logging"; import fastifyPassport from "@fastify/passport"; import jwt from "passport-jwt"; @@ -70,9 +71,18 @@ export const jwtStrategy = new jwt.Strategy( ) { // Distinctive, greppable line carrying the real source IP so the realtime // auto-banner (scripts/atk-autoban.sh) can insta-ban it at Cloudflare. - console.error( - `EVICTED_AUTH ${user.username} ${req.headers["cf-connecting-ip"] || req.ip}`, - ); + // Throttled per account. An evicted JWT replayed from a rotating proxy + // pool otherwise emits one line per request and + // has atk-autoban.sh place a Cloudflare block on every residential IP it + // sees, which protects nothing. First hit logs with its IP; afterwards + // one summary per 10 minutes, worded so the auto-banner ignores it. + const n = quiet(`evicted:${user.id}`); + if (n !== null) + console.error( + n + ? `evicted-auth ${user.username} ${suppressed(n)}` + : `EVICTED_AUTH ${user.username} ${req.headers["cf-connecting-ip"] || req.ip}`, + ); return next(null, false); } diff --git a/lib/logging.ts b/lib/logging.ts index c68a3199..af6ae942 100644 --- a/lib/logging.ts +++ b/lib/logging.ts @@ -23,6 +23,27 @@ export const shortError = (msg: any, max = 400): string => { return s.length > max ? `${s.slice(0, max)}…` : s; }; +// Per-key log throttle for lines an attacker can emit at will (evicted-JWT +// replays from a proxy pool, frozen-account send attempts, lnurl scans of +// missing usernames). Returns null while the key is inside its quiet window, +// otherwise the number of calls suppressed since the last emitted line — so +// the first occurrence always logs, and the next one after the window carries +// the suppressed count. In-memory: a restart simply logs again. +const quietUntil = new Map<string, { until: number; dropped: number }>(); +export const quiet = (key: string, ms = 10 * 60 * 1000): number | null => { + const now = Date.now(); + const q = quietUntil.get(key); + if (q && q.until > now) { + q.dropped++; + return null; + } + if (quietUntil.size > 10000) quietUntil.clear(); + quietUntil.set(key, { until: now + ms, dropped: 0 }); + return q?.dropped ?? 0; +}; +export const suppressed = (n: number | null) => + n ? `(+${n} similar suppressed)` : ""; + export const line = () => { const stack = new Error().stack; const stackLine = stack.split("\n")[1]; diff --git a/lib/payments.ts b/lib/payments.ts index 2c18f399..f2a05d50 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -21,7 +21,7 @@ const isWithdrawLocked = (type: string): string | null => { import { generate } from "$lib/invoices"; import ln from "$lib/ln"; import { quoteRoutingFee, xpayLayers } from "$lib/lnquote"; -import { err, l, shortError, warn } from "$lib/logging"; +import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging"; import { handleZap } from "$lib/nostr"; import { notify, nwcNotify } from "$lib/notifications"; import { squarePayment } from "$lib/square"; @@ -143,13 +143,19 @@ export const debit = async ({ if (type !== PaymentType.internal) { const lockedKind = isWithdrawLocked(type); if (lockedKind) { - warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind); + const n = quiet(`block:${user.id}:lock`, 5 * 60 * 1000); + if (n !== null) + warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind, suppressed(n)); fail("External withdrawals temporarily disabled"); } } if (frozen || (amount > userLimit && !whitelisted)) { - warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit); + // Per-account throttle: a frozen throwaway retrying sends every few + // seconds is expected noise, one line per 5 min keeps the tripwire. + const n = quiet(`block:${user.id}:frozen`, 5 * 60 * 1000); + if (n !== null) + warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, suppressed(n)); fail("Problem sending payment"); } @@ -166,7 +172,9 @@ export const debit = async ({ await withLimitLock(type, async () => { const serverLimit = Number.parseInt((await g(`${type}:limit`)) ?? "0", 10) || 0; if (amount > serverLimit) { - warn("Blocking", user.username, amount, hash, user.id, type, "serverLimit", serverLimit); + const n = quiet(`block:${user.id}:server`, 5 * 60 * 1000); + if (n !== null) + warn("Blocking", user.username, amount, hash, user.id, type, "serverLimit", serverLimit, suppressed(n)); fail("Problem sending payment"); } await db.decrBy(`${type}:limit`, amount); |
