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
This commit is contained in:
parent
083b2d99cd
commit
8387114280
@ -7,11 +7,38 @@ import lockfile from "proper-lockfile";
|
|||||||
import { getPairingAdapter } from "../channels/plugins/pairing.js";
|
import { getPairingAdapter } from "../channels/plugins/pairing.js";
|
||||||
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js";
|
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js";
|
||||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||||
|
import { logPairingEvent } from "../security/audit-log.js";
|
||||||
|
|
||||||
const PAIRING_CODE_LENGTH = 8;
|
const PAIRING_CODE_LENGTH = 8;
|
||||||
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
|
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
|
||||||
const PAIRING_PENDING_MAX = 3;
|
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<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
|
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 = {
|
const PAIRING_STORE_LOCK_OPTIONS = {
|
||||||
retries: {
|
retries: {
|
||||||
retries: 10,
|
retries: 10,
|
||||||
@ -411,6 +438,15 @@ export async function upsertChannelPairingRequest(params: {
|
|||||||
version: 1,
|
version: 1,
|
||||||
requests: [...reqs, next],
|
requests: [...reqs, next],
|
||||||
} satisfies PairingStore);
|
} satisfies PairingStore);
|
||||||
|
|
||||||
|
// SECURITY: Log new pairing request
|
||||||
|
logPairingEvent({
|
||||||
|
event: "pairing.request",
|
||||||
|
channel: String(params.channel),
|
||||||
|
userId: id,
|
||||||
|
pairingCode: code,
|
||||||
|
});
|
||||||
|
|
||||||
return { code, created: true };
|
return { code, created: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -419,12 +455,24 @@ export async function upsertChannelPairingRequest(params: {
|
|||||||
export async function approveChannelPairingCode(params: {
|
export async function approveChannelPairingCode(params: {
|
||||||
channel: PairingChannel;
|
channel: PairingChannel;
|
||||||
code: string;
|
code: string;
|
||||||
|
userId?: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<{ id: string; entry?: PairingRequest } | null> {
|
}): Promise<{ id: string; entry?: PairingRequest; rateLimited?: boolean } | null> {
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const code = params.code.trim().toUpperCase();
|
const code = params.code.trim().toUpperCase();
|
||||||
if (!code) return null;
|
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);
|
const filePath = resolvePairingPath(params.channel, env);
|
||||||
return await withFileLock(
|
return await withFileLock(
|
||||||
filePath,
|
filePath,
|
||||||
@ -445,6 +493,13 @@ export async function approveChannelPairingCode(params: {
|
|||||||
requests: pruned,
|
requests: pruned,
|
||||||
} satisfies PairingStore);
|
} satisfies PairingStore);
|
||||||
}
|
}
|
||||||
|
// SECURITY: Log failed pairing attempt
|
||||||
|
logPairingEvent({
|
||||||
|
event: "pairing.rejected",
|
||||||
|
channel: String(params.channel),
|
||||||
|
userId: params.userId,
|
||||||
|
reason: "invalid_code",
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const entry = pruned[idx];
|
const entry = pruned[idx];
|
||||||
@ -459,6 +514,15 @@ export async function approveChannelPairingCode(params: {
|
|||||||
entry: entry.id,
|
entry: entry.id,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SECURITY: Log successful pairing
|
||||||
|
logPairingEvent({
|
||||||
|
event: "pairing.approved",
|
||||||
|
channel: String(params.channel),
|
||||||
|
userId: entry.id,
|
||||||
|
pairingCode: code,
|
||||||
|
});
|
||||||
|
|
||||||
return { id: entry.id, entry };
|
return { id: entry.id, entry };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user