import type { IncomingMessage, ServerResponse } from "node:http"; import * as querystring from "node:querystring"; import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-ingress"; import * as synologyClient from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string { returntypeof value === "string" ? value.trim().toLowerCase() : "";
}
// One rate limiter per account, created lazily const rateLimiters = new Map<string, RateLimiter>(); const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>(); const webhookInFlightLimiter = createWebhookInFlightLimiter(); const PREAUTH_MAX_BODY_BYTES = 64 * 1024; const PREAUTH_BODY_TIMEOUT_MS = 5_000; const PREAUTH_MAX_REQUESTS_PER_MINUTE = 10; const INVALID_TOKEN_WINDOW_MS = 60_000; const INVALID_TOKEN_MAX_TRACKED_KEYS = 5_000;
type InvalidTokenRateLimitState = {
count: number;
windowStartMs: number;
};
class InvalidTokenRateLimiter { private readonly limit: number; private readonly state = new Map<string, InvalidTokenRateLimitState>();
function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
let rl = rateLimiters.get(account.accountId); if (!rl || rl.maxRequests() !== account.rateLimitPerMinute) {
rl?.clear();
rl = new RateLimiter(account.rateLimitPerMinute);
rateLimiters.set(account.accountId, rl);
} return rl;
}
function getInvalidTokenRateLimiter(account: ResolvedSynologyChatAccount): InvalidTokenRateLimiter { const limit = Math.min(account.rateLimitPerMinute, PREAUTH_MAX_REQUESTS_PER_MINUTE);
let rl = invalidTokenRateLimiters.get(account.accountId); if (!rl || rl.maxRequests() !== limit) {
rl?.clear();
rl = new InvalidTokenRateLimiter(limit);
invalidTokenRateLimiters.set(account.accountId, rl);
} return rl;
}
export function clearSynologyWebhookRateLimiterStateForTest(): void { for (const limiter of rateLimiters.values()) {
limiter.clear();
}
rateLimiters.clear(); for (const limiter of invalidTokenRateLimiters.values()) {
limiter.clear();
}
invalidTokenRateLimiters.clear();
webhookInFlightLimiter.clear();
}
export function getSynologyWebhookRateLimiterCountForTest(): number { return rateLimiters.size + invalidTokenRateLimiters.size;
}
function getSynologyWebhookInvalidTokenRateLimitKey(req: IncomingMessage): string { return req.socket?.remoteAddress ?? "unknown";
}
function getSynologyWebhookInFlightKey(account: ResolvedSynologyChatAccount): string { // Synology webhook ingress is typically a single upstream per account, and this // handler does not have a trusted-proxy-aware client IP config. Keep the shared // pre-auth concurrency budget scoped per account instead of keying on a fragile // remoteAddress value that can collapse behind proxies or to "unknown". return account.accountId;
}
/** Read the full request body as a string. */
async function readBody(
req: IncomingMessage,
timeoutMs = PREAUTH_BODY_TIMEOUT_MS,
): Promise<
| { ok: true; body: string }
| {
ok: false;
statusCode: number;
error: string;
}
> { try { const body = await readRequestBodyWithLimit(req, {
maxBytes: PREAUTH_MAX_BODY_BYTES,
timeoutMs,
}); return { ok: true, body };
} catch (err) { if (isRequestBodyLimitError(err)) { return {
ok: false,
statusCode: err.statusCode,
error: requestBodyErrorToText(err.code),
};
} return {
ok: false,
statusCode: 400,
error: "Invalid request body",
};
}
}
function firstNonEmptyString(value: unknown): string | undefined { if (Array.isArray(value)) { for (const item of value) { const normalized = firstNonEmptyString(item); if (normalized) { return normalized;
}
} return undefined;
} if (value === null || value === undefined) { return undefined;
} const str = typeof value === "string" ? value.trim() : ""; return str.length > 0 ? str : undefined;
}
function pickAlias(record: Record<string, unknown>, aliases: string[]): string | undefined { for (const alias of aliases) { const normalized = firstNonEmptyString(record[alias]); if (normalized) { return normalized;
}
} return undefined;
}
const auth = authorizeUserForDm(
params.payload.user_id,
params.account.dmPolicy,
params.account.allowedUserIds,
); if (!auth.allowed) { if (auth.reason === "disabled") { return { ok: false, statusCode: 403, error: "DMs are disabled" };
} if (auth.reason === "allowlist-empty") {
params.log?.warn( "Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message",
); return {
ok: false,
statusCode: 403,
error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.",
};
}
params.log?.warn(`Unauthorized user: ${params.payload.user_id}`); return { ok: false, statusCode: 403, error: "User not authorized" };
}
if (!params.rateLimiter.check(params.payload.user_id)) { // Keep a separate post-auth budget so authenticated users are still throttled per sender.
params.log?.warn(`Rate limit exceeded for user: ${params.payload.user_id}`); return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.