242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
/**
|
|
* SSH abuse counters — single source of truth for failure_count + lock_until.
|
|
*
|
|
* Design rationale (rev 4):
|
|
* Three scope kinds tracked per failure event, in one transaction:
|
|
* conn:<connId> — always enforce
|
|
* userhost:<uid>|<host>|<user> — always enforce
|
|
* globalhost:<host>|<user> — enforce only for global connections;
|
|
* notification-only when paired with a
|
|
* user-owned connection (cross-user DoS
|
|
* mitigation without letting one user lock
|
|
* out another's globals).
|
|
*
|
|
* `isLocked(connectionId)` reads ONLY the 'conn' scope row — abuse on a
|
|
* different user's connection to the same host should not block this one.
|
|
*
|
|
* This module owns ALL failure_count / lock_until state. ssh_connections has
|
|
* no such columns; do not add them.
|
|
*
|
|
* Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1).
|
|
*/
|
|
import type Database from 'better-sqlite3';
|
|
|
|
export interface AbuseThresholds {
|
|
windowMinutes: number;
|
|
failureThreshold: number;
|
|
lockMinutes: number;
|
|
}
|
|
|
|
export type AbuseScopeKind = 'conn' | 'userhost' | 'globalhost';
|
|
|
|
export interface RecordFailureArgs {
|
|
connectionId: string;
|
|
/** NULL for global connections; non-null for user-owned. */
|
|
ownerId: string | null;
|
|
userId: string;
|
|
host: string;
|
|
username: string;
|
|
/** Defaults to new Date(). */
|
|
now?: Date;
|
|
}
|
|
|
|
export interface RecordFailureResult {
|
|
/** True if any enforced scope is now locked (after this update). */
|
|
locked: boolean;
|
|
/** First enforced scope that is locked, priority conn > userhost > globalhost. */
|
|
lockedScope?: AbuseScopeKind;
|
|
/** ISO8601 timestamp when the lock expires (from lockedScope row). */
|
|
lockUntil?: string;
|
|
/** True if globalhost scope just transitioned to threshold this call. */
|
|
notifyAdmin?: boolean;
|
|
}
|
|
|
|
export interface IsLockedResult {
|
|
locked: boolean;
|
|
/** ISO8601 timestamp when the lock expires. */
|
|
until?: string;
|
|
}
|
|
|
|
interface RawRow {
|
|
scope_key: string;
|
|
scope_kind: AbuseScopeKind;
|
|
enforce_lock: number;
|
|
failure_count: number;
|
|
failure_window_start: string | null;
|
|
lock_until: string | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface SshAbuseRepo {
|
|
checkAndRecordFailure(args: RecordFailureArgs): RecordFailureResult;
|
|
isLocked(connectionId: string, now?: Date): IsLockedResult;
|
|
/** Clear the 'conn' scope counter for a successful connection. */
|
|
recordSuccess(connectionId: string, now?: Date): void;
|
|
/** Admin: drop a single scope row (force unlock). Returns true if a row was removed. */
|
|
reset(scopeKey: string): boolean;
|
|
/** Inspect a single scope row (admin / tests). */
|
|
getByScopeKey(scopeKey: string): RawRow | null;
|
|
}
|
|
|
|
function connKey(id: string) {
|
|
return `conn:${id}`;
|
|
}
|
|
function userhostKey(userId: string, host: string, username: string) {
|
|
return `userhost:${userId}|${host}|${username}`;
|
|
}
|
|
function globalhostKey(host: string, username: string) {
|
|
return `globalhost:${host}|${username}`;
|
|
}
|
|
|
|
export function createAbuseRepo(
|
|
db: Database.Database,
|
|
thresholds: AbuseThresholds,
|
|
): SshAbuseRepo {
|
|
if (thresholds.windowMinutes <= 0 || thresholds.failureThreshold <= 0 || thresholds.lockMinutes <= 0) {
|
|
throw new Error('abuse: thresholds must be positive');
|
|
}
|
|
const windowMs = thresholds.windowMinutes * 60_000;
|
|
const lockMs = thresholds.lockMinutes * 60_000;
|
|
const threshold = thresholds.failureThreshold;
|
|
|
|
const selectStmt = db.prepare(`SELECT * FROM ssh_abuse_counters WHERE scope_key = ?`);
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO ssh_abuse_counters
|
|
(scope_key, scope_kind, enforce_lock, failure_count, failure_window_start, lock_until, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const updateStmt = db.prepare(`
|
|
UPDATE ssh_abuse_counters
|
|
SET failure_count = ?,
|
|
failure_window_start = ?,
|
|
lock_until = ?,
|
|
updated_at = ?
|
|
WHERE scope_key = ?
|
|
`);
|
|
const deleteStmt = db.prepare(`DELETE FROM ssh_abuse_counters WHERE scope_key = ?`);
|
|
|
|
interface ScopeOutcome {
|
|
scopeKey: string;
|
|
scopeKind: AbuseScopeKind;
|
|
enforce: boolean;
|
|
locked: boolean;
|
|
lockUntil?: string;
|
|
/** True if this call pushed count from <threshold to >=threshold. */
|
|
justHitThreshold: boolean;
|
|
}
|
|
|
|
function applyToScope(
|
|
scopeKey: string,
|
|
scopeKind: AbuseScopeKind,
|
|
enforce: boolean,
|
|
nowIso: string,
|
|
nowMs: number,
|
|
): ScopeOutcome {
|
|
const row = selectStmt.get(scopeKey) as RawRow | undefined;
|
|
const enforceFlag = enforce ? 1 : 0;
|
|
let newCount = 1;
|
|
let windowStart = nowIso;
|
|
let lockUntil: string | null = null;
|
|
let prevCount = 0;
|
|
if (row) {
|
|
prevCount = row.failure_count;
|
|
const winStartMs = row.failure_window_start ? Date.parse(row.failure_window_start) : 0;
|
|
const lockUntilMs = row.lock_until ? Date.parse(row.lock_until) : 0;
|
|
// If currently locked, leave lock_until untouched; just bump count.
|
|
if (lockUntilMs > nowMs) {
|
|
newCount = row.failure_count + 1;
|
|
windowStart = row.failure_window_start ?? nowIso;
|
|
lockUntil = row.lock_until;
|
|
} else if (winStartMs > 0 && nowMs - winStartMs < windowMs) {
|
|
// Same window — increment.
|
|
newCount = row.failure_count + 1;
|
|
windowStart = row.failure_window_start ?? nowIso;
|
|
if (newCount >= threshold) {
|
|
lockUntil = new Date(nowMs + lockMs).toISOString();
|
|
}
|
|
} else {
|
|
// Window expired or no prior window — fresh start.
|
|
newCount = 1;
|
|
windowStart = nowIso;
|
|
}
|
|
updateStmt.run(newCount, windowStart, lockUntil, nowIso, scopeKey);
|
|
} else {
|
|
insertStmt.run(scopeKey, scopeKind, enforceFlag, 1, nowIso, null, nowIso);
|
|
}
|
|
const justHit = prevCount < threshold && newCount >= threshold;
|
|
return {
|
|
scopeKey,
|
|
scopeKind,
|
|
enforce,
|
|
locked: lockUntil != null && Date.parse(lockUntil) > nowMs,
|
|
lockUntil: lockUntil ?? undefined,
|
|
justHitThreshold: justHit,
|
|
};
|
|
}
|
|
|
|
return {
|
|
checkAndRecordFailure(args) {
|
|
const now = args.now ?? new Date();
|
|
const nowIso = now.toISOString();
|
|
const nowMs = now.getTime();
|
|
const isGlobal = args.ownerId === null;
|
|
const tx = db.transaction((): RecordFailureResult => {
|
|
const connOut = applyToScope(connKey(args.connectionId), 'conn', true, nowIso, nowMs);
|
|
const userOut = applyToScope(
|
|
userhostKey(args.userId, args.host, args.username),
|
|
'userhost',
|
|
true,
|
|
nowIso,
|
|
nowMs,
|
|
);
|
|
const globalOut = applyToScope(
|
|
globalhostKey(args.host, args.username),
|
|
'globalhost',
|
|
isGlobal,
|
|
nowIso,
|
|
nowMs,
|
|
);
|
|
// Aggregate: first enforced + locked scope, priority conn > userhost > globalhost.
|
|
const ordered = [connOut, userOut, globalOut];
|
|
const lockedEnforced = ordered.find((o) => o.enforce && o.locked);
|
|
const result: RecordFailureResult = { locked: false };
|
|
if (lockedEnforced) {
|
|
result.locked = true;
|
|
result.lockedScope = lockedEnforced.scopeKind;
|
|
result.lockUntil = lockedEnforced.lockUntil;
|
|
}
|
|
if (globalOut.justHitThreshold) {
|
|
result.notifyAdmin = true;
|
|
}
|
|
return result;
|
|
});
|
|
return tx();
|
|
},
|
|
|
|
isLocked(connectionId, now) {
|
|
const nowMs = (now ?? new Date()).getTime();
|
|
const row = selectStmt.get(connKey(connectionId)) as RawRow | undefined;
|
|
if (!row || row.lock_until == null) return { locked: false };
|
|
if (Date.parse(row.lock_until) <= nowMs) return { locked: false };
|
|
return { locked: true, until: row.lock_until };
|
|
},
|
|
|
|
recordSuccess(connectionId) {
|
|
// Clearing the conn-scope counter on success lets a successful connection
|
|
// 'forgive' prior failures within the window. userhost/globalhost remain
|
|
// unchanged — they track host-level patterns, not per-connection state.
|
|
deleteStmt.run(connKey(connectionId));
|
|
},
|
|
|
|
reset(scopeKey) {
|
|
const r = deleteStmt.run(scopeKey);
|
|
return r.changes > 0;
|
|
},
|
|
|
|
getByScopeKey(scopeKey) {
|
|
const r = selectStmt.get(scopeKey) as RawRow | undefined;
|
|
return r ?? null;
|
|
},
|
|
};
|
|
}
|