/** * Security module: token validation, rate limiting, input sanitization, user allowlist. */ import * as crypto from "node:crypto"; import { createFixedWindowRateLimiter, type FixedWindowRateLimiter, } from "@hanzo/bot/plugin-sdk/synology-chat"; export type DmAuthorizationResult = | { allowed: true } | { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" }; /** * Validate webhook token using constant-time comparison. * Prevents timing attacks that could leak token bytes. */ export function validateToken(received: string, expected: string): boolean { if (!received || !expected) return false; // Use HMAC to normalize lengths before comparison, // preventing timing side-channel on token length. const key = "bot-token-cmp"; const a = crypto.createHmac("sha256", key).update(received).digest(); const b = crypto.createHmac("sha256", key).update(expected).digest(); return crypto.timingSafeEqual(a, b); } /** * Check if a user ID is in the allowed list. * Allowlist mode must be explicit; empty lists should not match any user. */ export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean { if (allowedUserIds.length === 0) return false; return allowedUserIds.includes(userId); } /** * Resolve DM authorization for a sender across all DM policy modes. * Keeps policy semantics in one place so webhook/startup behavior stays consistent. */ export function authorizeUserForDm( userId: string, dmPolicy: "open" | "allowlist" | "disabled", allowedUserIds: string[], ): DmAuthorizationResult { if (dmPolicy === "disabled") { return { allowed: false, reason: "disabled" }; } if (dmPolicy === "open") { return { allowed: true }; } if (allowedUserIds.length === 0) { return { allowed: false, reason: "allowlist-empty" }; } if (!checkUserAllowed(userId, allowedUserIds)) { return { allowed: false, reason: "not-allowlisted" }; } return { allowed: true }; } /** * Sanitize user input to prevent prompt injection attacks. * Filters known dangerous patterns and truncates long messages. */ export function sanitizeInput(text: string): string { const dangerousPatterns = [ /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/gi, /you\s+are\s+now\s+/gi, /system:\s*/gi, /<\|.*?\|>/g, // special tokens ]; let sanitized = text; for (const pattern of dangerousPatterns) { sanitized = sanitized.replace(pattern, "[FILTERED]"); } const maxLength = 4000; if (sanitized.length > maxLength) { sanitized = sanitized.slice(0, maxLength) + "... [truncated]"; } return sanitized; } /** * Sliding window rate limiter per user ID. */ export class RateLimiter { private readonly limiter: FixedWindowRateLimiter; private readonly limit: number; constructor(limit = 30, windowSeconds = 60, maxTrackedUsers = 5_000) { this.limit = limit; this.limiter = createFixedWindowRateLimiter({ windowMs: Math.max(1, Math.floor(windowSeconds * 1000)), maxRequests: Math.max(1, Math.floor(limit)), maxTrackedKeys: Math.max(1, Math.floor(maxTrackedUsers)), }); } /** Returns true if the request is allowed, false if rate-limited. */ check(userId: string): boolean { return !this.limiter.isRateLimited(userId); } /** Exposed for tests and diagnostics. */ size(): number { return this.limiter.size(); } /** Exposed for tests and account lifecycle cleanup. */ clear(): void { this.limiter.clear(); } /** Exposed for tests. */ maxRequests(): number { return this.limit; } }