Files
openclaw/extensions/synology-chat/src/security.ts
2026-03-17 15:06:22 -07:00

122 lines
3.5 KiB
TypeScript

/**
* Security module: token validation, rate limiting, input sanitization, user allowlist.
*/
import * as crypto from "node:crypto";
import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "../api.js";
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 = "openclaw-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;
}
}