Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
649
extensions/synology-chat/src/webhook-handler.ts
Normal file
649
extensions/synology-chat/src/webhook-handler.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* Inbound webhook handler for Synology Chat outgoing webhooks.
|
||||
* Parses form-urlencoded/JSON body, validates security, delivers to agent.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import * as querystring from "node:querystring";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import * as synologyClient from "./client.js";
|
||||
import {
|
||||
validateToken,
|
||||
authorizeUserForDmWithIngress,
|
||||
sanitizeInput,
|
||||
RateLimiter,
|
||||
} from "./security.js";
|
||||
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
// 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>();
|
||||
|
||||
constructor(limit: number) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
private normalizeState(key: string, nowMs: number): InvalidTokenRateLimitState | undefined {
|
||||
const existing = this.state.get(key);
|
||||
if (!existing) {
|
||||
return undefined;
|
||||
}
|
||||
if (nowMs - existing.windowStartMs >= INVALID_TOKEN_WINDOW_MS) {
|
||||
this.state.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private touch(key: string, value: InvalidTokenRateLimitState): void {
|
||||
this.state.delete(key);
|
||||
this.state.set(key, value);
|
||||
while (this.state.size > INVALID_TOKEN_MAX_TRACKED_KEYS) {
|
||||
const oldestKey = this.state.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
this.state.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
isLocked(key: string, nowMs = Date.now()): boolean {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const existing = this.normalizeState(key, nowMs);
|
||||
return (existing?.count ?? 0) > this.limit;
|
||||
}
|
||||
|
||||
recordFailure(key: string, nowMs = Date.now()): boolean {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const existing = this.normalizeState(key, nowMs);
|
||||
const nextCount = (existing?.count ?? 0) + 1;
|
||||
const windowStartMs = existing?.windowStartMs ?? nowMs;
|
||||
this.touch(key, { count: nextCount, windowStartMs });
|
||||
return nextCount > this.limit;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
maxRequests(): number {
|
||||
return this.limit;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function parseQueryParams(req: IncomingMessage): Record<string, unknown> {
|
||||
try {
|
||||
const url = new URL(req.url ?? "", "http://localhost");
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseFormBody(body: string): Record<string, unknown> {
|
||||
return querystring.parse(body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseJsonBody(body: string): Record<string, unknown> {
|
||||
if (!body.trim()) {
|
||||
return {};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
throw new Error("Invalid JSON body");
|
||||
}
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||
throw new Error("Invalid JSON body");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function headerValue(header: string | string[] | undefined): string | undefined {
|
||||
return firstNonEmptyString(header);
|
||||
}
|
||||
|
||||
function extractTokenFromHeaders(req: IncomingMessage): string | undefined {
|
||||
const explicit =
|
||||
headerValue(req.headers["x-synology-token"]) ??
|
||||
headerValue(req.headers["x-webhook-token"]) ??
|
||||
headerValue(req.headers["x-openclaw-token"]);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const auth = headerValue(req.headers.authorization);
|
||||
if (!auth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bearerMatch = auth.match(/^Bearer\s+(.+)$/i);
|
||||
if (bearerMatch?.[1]) {
|
||||
return bearerMatch[1].trim();
|
||||
}
|
||||
return auth.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse/normalize incoming webhook payload.
|
||||
*
|
||||
* Supports:
|
||||
* - application/x-www-form-urlencoded
|
||||
* - application/json
|
||||
*
|
||||
* Token resolution order: body.token -> query.token -> headers
|
||||
* Field aliases:
|
||||
* - user_id <- user_id | userId | user
|
||||
* - text <- text | message | content
|
||||
*/
|
||||
function parsePayload(req: IncomingMessage, body: string): SynologyWebhookPayload | null {
|
||||
const contentType = normalizeLowercaseStringOrEmpty(req.headers["content-type"]);
|
||||
|
||||
let bodyFields: Record<string, unknown>;
|
||||
if (contentType.includes("application/json")) {
|
||||
bodyFields = parseJsonBody(body);
|
||||
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
bodyFields = parseFormBody(body);
|
||||
} else {
|
||||
// Fallback for clients with missing/incorrect content-type.
|
||||
// Try JSON first, then form-urlencoded.
|
||||
try {
|
||||
bodyFields = parseJsonBody(body);
|
||||
} catch {
|
||||
bodyFields = parseFormBody(body);
|
||||
}
|
||||
}
|
||||
|
||||
const queryFields = parseQueryParams(req);
|
||||
const headerToken = extractTokenFromHeaders(req);
|
||||
|
||||
const token =
|
||||
pickAlias(bodyFields, ["token"]) ?? pickAlias(queryFields, ["token"]) ?? headerToken;
|
||||
const userId =
|
||||
pickAlias(bodyFields, ["user_id", "userId", "user"]) ??
|
||||
pickAlias(queryFields, ["user_id", "userId", "user"]);
|
||||
const text =
|
||||
pickAlias(bodyFields, ["text", "message", "content"]) ??
|
||||
pickAlias(queryFields, ["text", "message", "content"]);
|
||||
|
||||
if (!token || !userId || !text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
channel_id:
|
||||
pickAlias(bodyFields, ["channel_id"]) ?? pickAlias(queryFields, ["channel_id"]) ?? undefined,
|
||||
channel_name:
|
||||
pickAlias(bodyFields, ["channel_name"]) ??
|
||||
pickAlias(queryFields, ["channel_name"]) ??
|
||||
undefined,
|
||||
user_id: userId,
|
||||
username:
|
||||
pickAlias(bodyFields, ["username", "user_name", "name"]) ??
|
||||
pickAlias(queryFields, ["username", "user_name", "name"]) ??
|
||||
"unknown",
|
||||
post_id: pickAlias(bodyFields, ["post_id"]) ?? pickAlias(queryFields, ["post_id"]) ?? undefined,
|
||||
timestamp:
|
||||
pickAlias(bodyFields, ["timestamp"]) ?? pickAlias(queryFields, ["timestamp"]) ?? undefined,
|
||||
text,
|
||||
trigger_word:
|
||||
pickAlias(bodyFields, ["trigger_word", "triggerWord"]) ??
|
||||
pickAlias(queryFields, ["trigger_word", "triggerWord"]) ??
|
||||
undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a JSON response. */
|
||||
function respondJson(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/** Send a no-content ACK. */
|
||||
function respondNoContent(res: ServerResponse) {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
|
||||
export interface WebhookHandlerDeps {
|
||||
account: ResolvedSynologyChatAccount;
|
||||
deliver: (msg: import("./inbound-context.js").SynologyInboundMessage) => Promise<string | null>;
|
||||
log?: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
bodyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP request handler for Synology Chat outgoing webhooks.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Parses form-urlencoded/JSON payload
|
||||
* 2. Validates token (constant-time)
|
||||
* 3. Checks user allowlist
|
||||
* 4. Checks rate limit
|
||||
* 5. Sanitizes input
|
||||
* 6. Immediately ACKs request (204)
|
||||
* 7. Delivers to the agent asynchronously and sends final reply via incomingUrl
|
||||
*/
|
||||
type SynologyWebhookAuthorization =
|
||||
| { ok: false; statusCode: number; error: string }
|
||||
| { ok: true; commandAuthorized: boolean };
|
||||
|
||||
type AuthorizedSynologyWebhook = {
|
||||
payload: SynologyWebhookPayload;
|
||||
body: string;
|
||||
commandAuthorized: boolean;
|
||||
preview: string;
|
||||
};
|
||||
|
||||
async function parseWebhookPayloadRequest(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
log?: WebhookHandlerDeps["log"];
|
||||
bodyTimeoutMs?: number;
|
||||
}): Promise<{ ok: false } | { ok: true; payload: SynologyWebhookPayload }> {
|
||||
const bodyResult = await readBody(params.req, params.bodyTimeoutMs);
|
||||
if (!bodyResult.ok) {
|
||||
params.log?.error("Failed to read request body", bodyResult.error);
|
||||
respondJson(params.res, bodyResult.statusCode, { error: bodyResult.error });
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
let payload: SynologyWebhookPayload | null;
|
||||
try {
|
||||
payload = parsePayload(params.req, bodyResult.body);
|
||||
} catch (err) {
|
||||
params.log?.warn("Failed to parse webhook payload", err);
|
||||
respondJson(params.res, 400, { error: "Invalid request body" });
|
||||
return { ok: false };
|
||||
}
|
||||
if (!payload) {
|
||||
respondJson(params.res, 400, { error: "Missing required fields (token, user_id, text)" });
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, payload };
|
||||
}
|
||||
|
||||
async function authorizeSynologyWebhook(params: {
|
||||
req: IncomingMessage;
|
||||
account: ResolvedSynologyChatAccount;
|
||||
payload: SynologyWebhookPayload;
|
||||
invalidTokenRateLimiter: InvalidTokenRateLimiter;
|
||||
rateLimiter: RateLimiter;
|
||||
log?: WebhookHandlerDeps["log"];
|
||||
}): Promise<SynologyWebhookAuthorization> {
|
||||
const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req);
|
||||
// Once a source has exhausted its invalid-token budget, reject all requests in the window.
|
||||
if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) {
|
||||
params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);
|
||||
return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
|
||||
}
|
||||
|
||||
if (!validateToken(params.payload.token, params.account.token)) {
|
||||
if (params.invalidTokenRateLimiter.recordFailure(invalidTokenRateLimitKey)) {
|
||||
params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);
|
||||
return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
|
||||
}
|
||||
params.log?.warn(`Invalid token from ${params.req.socket?.remoteAddress}`);
|
||||
return { ok: false, statusCode: 401, error: "Invalid token" };
|
||||
}
|
||||
|
||||
const auth = await authorizeUserForDmWithIngress({
|
||||
accountId: params.account.accountId,
|
||||
userId: params.payload.user_id,
|
||||
dmPolicy: params.account.dmPolicy,
|
||||
allowedUserIds: params.account.allowedUserIds,
|
||||
});
|
||||
if (!auth.senderAccess.allowed) {
|
||||
if (auth.senderAccess.reasonCode === "dm_policy_disabled") {
|
||||
return { ok: false, statusCode: 403, error: "DMs are disabled" };
|
||||
}
|
||||
if (params.account.dmPolicy === "allowlist" && params.account.allowedUserIds.length === 0) {
|
||||
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 with allowedUserIds=["*"].',
|
||||
};
|
||||
}
|
||||
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" };
|
||||
}
|
||||
|
||||
return { ok: true, commandAuthorized: auth.senderAccess.allowed };
|
||||
}
|
||||
|
||||
function sanitizeSynologyWebhookText(payload: SynologyWebhookPayload): string {
|
||||
let cleanText = sanitizeInput(payload.text);
|
||||
if (payload.trigger_word && cleanText.startsWith(payload.trigger_word)) {
|
||||
cleanText = cleanText.slice(payload.trigger_word.length).trim();
|
||||
}
|
||||
return cleanText;
|
||||
}
|
||||
|
||||
async function parseAndAuthorizeSynologyWebhook(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
account: ResolvedSynologyChatAccount;
|
||||
invalidTokenRateLimiter: InvalidTokenRateLimiter;
|
||||
rateLimiter: RateLimiter;
|
||||
log?: WebhookHandlerDeps["log"];
|
||||
bodyTimeoutMs?: number;
|
||||
}): Promise<{ ok: false } | { ok: true; message: AuthorizedSynologyWebhook }> {
|
||||
const parsed = await parseWebhookPayloadRequest(params);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const authorized = await authorizeSynologyWebhook({
|
||||
req: params.req,
|
||||
account: params.account,
|
||||
payload: parsed.payload,
|
||||
invalidTokenRateLimiter: params.invalidTokenRateLimiter,
|
||||
rateLimiter: params.rateLimiter,
|
||||
log: params.log,
|
||||
});
|
||||
if (!authorized.ok) {
|
||||
respondJson(params.res, authorized.statusCode, { error: authorized.error });
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const cleanText = sanitizeSynologyWebhookText(parsed.payload);
|
||||
if (!cleanText) {
|
||||
respondNoContent(params.res);
|
||||
return { ok: false };
|
||||
}
|
||||
const preview = cleanText.length > 100 ? `${truncateUtf16Safe(cleanText, 100)}...` : cleanText;
|
||||
return {
|
||||
ok: true,
|
||||
message: {
|
||||
payload: parsed.payload,
|
||||
body: cleanText,
|
||||
commandAuthorized: authorized.commandAuthorized,
|
||||
preview,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSynologyReplyDeliveryUserId(params: {
|
||||
account: ResolvedSynologyChatAccount;
|
||||
payload: SynologyWebhookPayload;
|
||||
log?: WebhookHandlerDeps["log"];
|
||||
}): Promise<string> {
|
||||
if (!params.account.dangerouslyAllowNameMatching) {
|
||||
return params.payload.user_id;
|
||||
}
|
||||
|
||||
const resolvedChatApiUserId = await synologyClient.resolveLegacyWebhookNameToChatUserId({
|
||||
incomingUrl: params.account.incomingUrl,
|
||||
mutableWebhookUsername: params.payload.username,
|
||||
allowInsecureSsl: params.account.allowInsecureSsl,
|
||||
log: params.log,
|
||||
});
|
||||
if (resolvedChatApiUserId !== undefined) {
|
||||
return String(resolvedChatApiUserId);
|
||||
}
|
||||
params.log?.warn(
|
||||
`Could not resolve Chat API user_id for "${params.payload.username}" — falling back to webhook user_id ${params.payload.user_id}. Reply delivery may fail.`,
|
||||
);
|
||||
return params.payload.user_id;
|
||||
}
|
||||
|
||||
async function processAuthorizedSynologyWebhook(params: {
|
||||
account: ResolvedSynologyChatAccount;
|
||||
deliver: WebhookHandlerDeps["deliver"];
|
||||
log?: WebhookHandlerDeps["log"];
|
||||
message: AuthorizedSynologyWebhook;
|
||||
}): Promise<void> {
|
||||
const authorizedWebhookUserId = params.message.payload.user_id;
|
||||
let deliveryUserId = authorizedWebhookUserId;
|
||||
try {
|
||||
deliveryUserId = await resolveSynologyReplyDeliveryUserId({
|
||||
account: params.account,
|
||||
payload: params.message.payload,
|
||||
log: params.log,
|
||||
});
|
||||
|
||||
const reply = await params.deliver({
|
||||
body: params.message.body,
|
||||
from: authorizedWebhookUserId,
|
||||
senderName: params.message.payload.username,
|
||||
provider: "synology-chat",
|
||||
chatType: "direct",
|
||||
accountId: params.account.accountId,
|
||||
commandAuthorized: params.message.commandAuthorized,
|
||||
chatUserId: deliveryUserId,
|
||||
});
|
||||
if (!reply) {
|
||||
return;
|
||||
}
|
||||
|
||||
await synologyClient.sendMessage(
|
||||
params.account.incomingUrl,
|
||||
reply,
|
||||
deliveryUserId,
|
||||
params.account.allowInsecureSsl,
|
||||
);
|
||||
const replyPreview = reply.length > 100 ? `${truncateUtf16Safe(reply, 100)}...` : reply;
|
||||
params.log?.info?.(
|
||||
`Reply sent to ${params.message.payload.username} (${deliveryUserId}): ${replyPreview}`,
|
||||
);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
|
||||
params.log?.error?.(
|
||||
`Failed to process message from ${params.message.payload.username}: ${errMsg}`,
|
||||
);
|
||||
await synologyClient.sendMessage(
|
||||
params.account.incomingUrl,
|
||||
"Sorry, an error occurred while processing your message.",
|
||||
deliveryUserId,
|
||||
params.account.allowInsecureSsl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
const { account, deliver, log } = deps;
|
||||
const rateLimiter = getRateLimiter(account);
|
||||
const invalidTokenRateLimiter = getInvalidTokenRateLimiter(account);
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
// Only accept POST
|
||||
if (req.method !== "POST") {
|
||||
respondJson(res, 405, { error: "Method not allowed" });
|
||||
return;
|
||||
}
|
||||
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
||||
req,
|
||||
res,
|
||||
inFlightLimiter: webhookInFlightLimiter,
|
||||
inFlightKey: getSynologyWebhookInFlightKey(account),
|
||||
});
|
||||
if (!requestLifecycle.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
let authorized: Awaited<ReturnType<typeof parseAndAuthorizeSynologyWebhook>>;
|
||||
try {
|
||||
authorized = await parseAndAuthorizeSynologyWebhook({
|
||||
req,
|
||||
res,
|
||||
account,
|
||||
invalidTokenRateLimiter,
|
||||
rateLimiter,
|
||||
log,
|
||||
bodyTimeoutMs: deps.bodyTimeoutMs,
|
||||
});
|
||||
} finally {
|
||||
// Only bound the pre-auth request pipeline; async reply delivery is outside webhook ingress.
|
||||
requestLifecycle.release();
|
||||
}
|
||||
if (!authorized.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
log?.info(
|
||||
`Message from ${authorized.message.payload.username} (${authorized.message.payload.user_id}): ${authorized.message.preview}`,
|
||||
);
|
||||
|
||||
// ACK immediately so Synology Chat won't remain in "Processing..."
|
||||
respondNoContent(res);
|
||||
await processAuthorizedSynologyWebhook({
|
||||
account,
|
||||
deliver,
|
||||
log,
|
||||
message: authorized.message,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user