From 838711428036362e2049a363afe38b16b9110f49 Mon Sep 17 00:00:00 2001 From: SpencersServer Date: Thu, 29 Jan 2026 13:23:30 +0200 Subject: [PATCH] security: add pairing security improvements - Add rate limiting to pairing code verification: - Max 5 attempts per minute per channel - Prevents brute-force attacks on pairing codes - Add audit logging for pairing events: - Log pairing requests (new codes generated) - Log successful approvals - Log rejections (invalid codes, rate limited) - Return rateLimited flag from approveChannelPairingCode so callers can handle rate limiting appropriately --- src/pairing/pairing-store.ts | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 5ae89dbd9..6b67eaed0 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -7,11 +7,38 @@ import lockfile from "proper-lockfile"; import { getPairingAdapter } from "../channels/plugins/pairing.js"; import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import { logPairingEvent } from "../security/audit-log.js"; const PAIRING_CODE_LENGTH = 8; const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; const PAIRING_PENDING_MAX = 3; + +// SECURITY: Rate limiting for pairing code verification +const PAIRING_RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute +const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per minute +const pairingAttemptsByChannel = new Map(); + +function checkPairingRateLimit(channel: PairingChannel): boolean { + const key = String(channel); + const now = Date.now(); + const existing = pairingAttemptsByChannel.get(key); + + if (!existing || now - existing.windowStart > PAIRING_RATE_LIMIT_WINDOW_MS) { + // Start new window + pairingAttemptsByChannel.set(key, { count: 1, windowStart: now }); + return true; + } + + if (existing.count >= PAIRING_RATE_LIMIT_MAX_ATTEMPTS) { + // Rate limited + return false; + } + + // Increment count + existing.count += 1; + return true; +} const PAIRING_STORE_LOCK_OPTIONS = { retries: { retries: 10, @@ -411,6 +438,15 @@ export async function upsertChannelPairingRequest(params: { version: 1, requests: [...reqs, next], } satisfies PairingStore); + + // SECURITY: Log new pairing request + logPairingEvent({ + event: "pairing.request", + channel: String(params.channel), + userId: id, + pairingCode: code, + }); + return { code, created: true }; }, ); @@ -419,12 +455,24 @@ export async function upsertChannelPairingRequest(params: { export async function approveChannelPairingCode(params: { channel: PairingChannel; code: string; + userId?: string; env?: NodeJS.ProcessEnv; -}): Promise<{ id: string; entry?: PairingRequest } | null> { +}): Promise<{ id: string; entry?: PairingRequest; rateLimited?: boolean } | null> { const env = params.env ?? process.env; const code = params.code.trim().toUpperCase(); if (!code) return null; + // SECURITY: Check rate limit before processing + if (!checkPairingRateLimit(params.channel)) { + logPairingEvent({ + event: "pairing.rejected", + channel: String(params.channel), + userId: params.userId, + reason: "rate_limited", + }); + return { id: "", rateLimited: true }; + } + const filePath = resolvePairingPath(params.channel, env); return await withFileLock( filePath, @@ -445,6 +493,13 @@ export async function approveChannelPairingCode(params: { requests: pruned, } satisfies PairingStore); } + // SECURITY: Log failed pairing attempt + logPairingEvent({ + event: "pairing.rejected", + channel: String(params.channel), + userId: params.userId, + reason: "invalid_code", + }); return null; } const entry = pruned[idx]; @@ -459,6 +514,15 @@ export async function approveChannelPairingCode(params: { entry: entry.id, env, }); + + // SECURITY: Log successful pairing + logPairingEvent({ + event: "pairing.approved", + channel: String(params.channel), + userId: entry.id, + pairingCode: code, + }); + return { id: entry.id, entry }; }, );