fix(synology-chat): accept JSON/aliases and ACK webhook with 204

This commit is contained in:
memphislee09-source
2026-02-25 23:38:31 +08:00
committed by Peter Steinberger
parent a3bb7a5ee5
commit 92bf77d9a0
2 changed files with 268 additions and 42 deletions

View File

@@ -1,7 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
import type { ResolvedSynologyChatAccount } from "./types.js"; import type { ResolvedSynologyChatAccount } from "./types.js";
import { import {
clearSynologyWebhookRateLimiterStateForTest, clearSynologyWebhookRateLimiterStateForTest,
@@ -32,12 +31,17 @@ function makeAccount(
}; };
} }
function makeReq(method: string, body: string): IncomingMessage { function makeReq(
method: string,
body: string,
opts: { headers?: Record<string, string>; url?: string } = {},
): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & { const req = new EventEmitter() as IncomingMessage & {
destroyed: boolean; destroyed: boolean;
}; };
req.method = method; req.method = method;
req.headers = {}; req.headers = opts.headers ?? {};
req.url = opts.url ?? "/webhook/synology";
req.socket = { remoteAddress: "127.0.0.1" } as any; req.socket = { remoteAddress: "127.0.0.1" } as any;
req.destroyed = false; req.destroyed = false;
req.destroy = ((_: Error | undefined) => { req.destroy = ((_: Error | undefined) => {
@@ -77,6 +81,26 @@ function makeStalledReq(method: string): IncomingMessage {
return req; return req;
} }
function makeRes(): ServerResponse & { _status: number; _body: string } {
const res = {
_status: 0,
_body: "",
writeHead(statusCode: number, _headers?: Record<string, string>) {
res._status = statusCode;
},
end(body?: string) {
res._body = body ?? "";
},
} as any;
return res;
}
function makeFormBody(fields: Record<string, string>): string {
return Object.entries(fields)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}
const validBody = makeFormBody({ const validBody = makeFormBody({
token: "valid-token", token: "valid-token",
user_id: "123", user_id: "123",
@@ -185,6 +209,85 @@ describe("createWebhookHandler", () => {
expect(res._status).toBe(401); expect(res._status).toBe(401);
}); });
it("accepts application/json with alias fields", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({ accountId: "json-test-" + Date.now() }),
deliver,
log,
});
const req = makeReq(
"POST",
JSON.stringify({
token: "valid-token",
userId: "123",
name: "json-user",
message: "Hello from json",
}),
{ headers: { "content-type": "application/json" } },
);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
body: "Hello from json",
from: "123",
senderName: "json-user",
}),
);
});
it("accepts token from query when body token is absent", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({ accountId: "query-token-test-" + Date.now() }),
deliver,
log,
});
const req = makeReq(
"POST",
makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
{
headers: { "content-type": "application/x-www-form-urlencoded" },
url: "/webhook/synology?token=valid-token",
},
);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(deliver).toHaveBeenCalled();
});
it("accepts token from authorization header when body token is absent", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({ accountId: "header-token-test-" + Date.now() }),
deliver,
log,
});
const req = makeReq(
"POST",
makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
{
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: "Bearer valid-token",
},
},
);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(deliver).toHaveBeenCalled();
});
it("returns 403 for unauthorized user with allowlist policy", async () => { it("returns 403 for unauthorized user with allowlist policy", async () => {
await expectForbiddenByPolicy({ await expectForbiddenByPolicy({
account: { account: {
@@ -237,7 +340,7 @@ describe("createWebhookHandler", () => {
const req1 = makeReq("POST", validBody); const req1 = makeReq("POST", validBody);
const res1 = makeRes(); const res1 = makeRes();
await handler(req1, res1); await handler(req1, res1);
expect(res1._status).toBe(200); expect(res1._status).toBe(204);
// Second request should be rate limited // Second request should be rate limited
const req2 = makeReq("POST", validBody); const req2 = makeReq("POST", validBody);
@@ -266,12 +369,12 @@ describe("createWebhookHandler", () => {
const res = makeRes(); const res = makeRes();
await handler(req, res); await handler(req, res);
expect(res._status).toBe(200); expect(res._status).toBe(204);
// deliver should have been called with the stripped text // deliver should have been called with the stripped text
expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ body: "Hello there" })); expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ body: "Hello there" }));
}); });
it("responds 200 immediately and delivers async", async () => { it("responds 204 immediately and delivers async", async () => {
const deliver = vi.fn().mockResolvedValue("Bot reply"); const deliver = vi.fn().mockResolvedValue("Bot reply");
const handler = createWebhookHandler({ const handler = createWebhookHandler({
account: makeAccount({ accountId: "async-test-" + Date.now() }), account: makeAccount({ accountId: "async-test-" + Date.now() }),
@@ -283,8 +386,8 @@ describe("createWebhookHandler", () => {
const res = makeRes(); const res = makeRes();
await handler(req, res); await handler(req, res);
expect(res._status).toBe(200); expect(res._status).toBe(204);
expect(res._body).toContain("Processing"); expect(res._body).toBe("");
expect(deliver).toHaveBeenCalledWith( expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
body: "Hello bot", body: "Hello bot",

View File

@@ -1,6 +1,6 @@
/** /**
* Inbound webhook handler for Synology Chat outgoing webhooks. * Inbound webhook handler for Synology Chat outgoing webhooks.
* Parses form-urlencoded body, validates security, delivers to agent. * Parses form-urlencoded/JSON body, validates security, delivers to agent.
*/ */
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
@@ -69,36 +69,152 @@ async function readBody(req: IncomingMessage): Promise<
} }
} }
/** Parse form-urlencoded body into SynologyWebhookPayload. */ function firstNonEmptyString(value: unknown): string | undefined {
function parsePayload(body: string): SynologyWebhookPayload | null { if (Array.isArray(value)) {
const parsed = querystring.parse(body); for (const item of value) {
const normalized = firstNonEmptyString(item);
if (normalized) return normalized;
}
return undefined;
}
if (value === null || value === undefined) return undefined;
const str = String(value).trim();
return str.length > 0 ? str : undefined;
}
const token = String(parsed.token ?? ""); function pickAlias(record: Record<string, unknown>, aliases: string[]): string | undefined {
const userId = String(parsed.user_id ?? ""); for (const alias of aliases) {
const username = String(parsed.username ?? "unknown"); const normalized = firstNonEmptyString(record[alias]);
const text = String(parsed.text ?? ""); 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 {};
const parsed = JSON.parse(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 = String(req.headers["content-type"] ?? "").toLowerCase();
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; if (!token || !userId || !text) return null;
return { return {
token, token,
channel_id: parsed.channel_id ? String(parsed.channel_id) : undefined, channel_id:
channel_name: parsed.channel_name ? String(parsed.channel_name) : undefined, pickAlias(bodyFields, ["channel_id"]) ?? pickAlias(queryFields, ["channel_id"]) ?? undefined,
channel_name:
pickAlias(bodyFields, ["channel_name"]) ??
pickAlias(queryFields, ["channel_name"]) ??
undefined,
user_id: userId, user_id: userId,
username, username:
post_id: parsed.post_id ? String(parsed.post_id) : undefined, pickAlias(bodyFields, ["username", "user_name", "name"]) ??
timestamp: parsed.timestamp ? String(parsed.timestamp) : undefined, 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, text,
trigger_word: parsed.trigger_word ? String(parsed.trigger_word) : undefined, trigger_word:
pickAlias(bodyFields, ["trigger_word", "triggerWord"]) ??
pickAlias(queryFields, ["trigger_word", "triggerWord"]) ??
undefined,
}; };
} }
/** Send a JSON response. */ /** Send a JSON response. */
function respond(res: ServerResponse, statusCode: number, body: Record<string, unknown>) { function respondJson(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
res.writeHead(statusCode, { "Content-Type": "application/json" }); res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
/** Send a no-content ACK. */
function respondNoContent(res: ServerResponse) {
res.writeHead(204);
res.end();
}
export interface WebhookHandlerDeps { export interface WebhookHandlerDeps {
account: ResolvedSynologyChatAccount; account: ResolvedSynologyChatAccount;
deliver: (msg: { deliver: (msg: {
@@ -121,13 +237,13 @@ export interface WebhookHandlerDeps {
* Create an HTTP request handler for Synology Chat outgoing webhooks. * Create an HTTP request handler for Synology Chat outgoing webhooks.
* *
* This handler: * This handler:
* 1. Parses form-urlencoded body * 1. Parses form-urlencoded/JSON payload
* 2. Validates token (constant-time) * 2. Validates token (constant-time)
* 3. Checks user allowlist * 3. Checks user allowlist
* 4. Checks rate limit * 4. Checks rate limit
* 5. Sanitizes input * 5. Sanitizes input
* 6. Delivers to the agent via deliver() * 6. Immediately ACKs request (204)
* 7. Sends the agent response back to Synology Chat * 7. Delivers to the agent asynchronously and sends final reply via incomingUrl
*/ */
export function createWebhookHandler(deps: WebhookHandlerDeps) { export function createWebhookHandler(deps: WebhookHandlerDeps) {
const { account, deliver, log } = deps; const { account, deliver, log } = deps;
@@ -136,29 +252,36 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
return async (req: IncomingMessage, res: ServerResponse) => { return async (req: IncomingMessage, res: ServerResponse) => {
// Only accept POST // Only accept POST
if (req.method !== "POST") { if (req.method !== "POST") {
respond(res, 405, { error: "Method not allowed" }); respondJson(res, 405, { error: "Method not allowed" });
return; return;
} }
// Parse body // Parse body
const body = await readBody(req); const bodyResult = await readBody(req);
if (!body.ok) { if (!bodyResult.ok) {
log?.error("Failed to read request body", body.error); log?.error("Failed to read request body", bodyResult.error);
respond(res, body.statusCode, { error: body.error }); respondJson(res, bodyResult.statusCode, { error: bodyResult.error });
return; return;
} }
// Parse payload // Parse payload
const payload = parsePayload(body.body); let payload: SynologyWebhookPayload | null = null;
try {
payload = parsePayload(req, bodyResult.body);
} catch (err) {
log?.warn("Failed to parse webhook payload", err);
respondJson(res, 400, { error: "Invalid request body" });
return;
}
if (!payload) { if (!payload) {
respond(res, 400, { error: "Missing required fields (token, user_id, text)" }); respondJson(res, 400, { error: "Missing required fields (token, user_id, text)" });
return; return;
} }
// Token validation // Token validation
if (!validateToken(payload.token, account.token)) { if (!validateToken(payload.token, account.token)) {
log?.warn(`Invalid token from ${req.socket?.remoteAddress}`); log?.warn(`Invalid token from ${req.socket?.remoteAddress}`);
respond(res, 401, { error: "Invalid token" }); respondJson(res, 401, { error: "Invalid token" });
return; return;
} }
@@ -166,25 +289,25 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds); const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds);
if (!auth.allowed) { if (!auth.allowed) {
if (auth.reason === "disabled") { if (auth.reason === "disabled") {
respond(res, 403, { error: "DMs are disabled" }); respondJson(res, 403, { error: "DMs are disabled" });
return; return;
} }
if (auth.reason === "allowlist-empty") { if (auth.reason === "allowlist-empty") {
log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message"); log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message");
respond(res, 403, { respondJson(res, 403, {
error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.", error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.",
}); });
return; return;
} }
log?.warn(`Unauthorized user: ${payload.user_id}`); log?.warn(`Unauthorized user: ${payload.user_id}`);
respond(res, 403, { error: "User not authorized" }); respondJson(res, 403, { error: "User not authorized" });
return; return;
} }
// Rate limit // Rate limit
if (!rateLimiter.check(payload.user_id)) { if (!rateLimiter.check(payload.user_id)) {
log?.warn(`Rate limit exceeded for user: ${payload.user_id}`); log?.warn(`Rate limit exceeded for user: ${payload.user_id}`);
respond(res, 429, { error: "Rate limit exceeded" }); respondJson(res, 429, { error: "Rate limit exceeded" });
return; return;
} }
@@ -197,15 +320,15 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
} }
if (!cleanText) { if (!cleanText) {
respond(res, 200, { text: "" }); respondNoContent(res);
return; return;
} }
const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText; const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
log?.info(`Message from ${payload.username} (${payload.user_id}): ${preview}`); log?.info(`Message from ${payload.username} (${payload.user_id}): ${preview}`);
// Respond 200 immediately to avoid Synology Chat timeout // ACK immediately so Synology Chat won't remain in "Processing..."
respond(res, 200, { text: "Processing..." }); respondNoContent(res);
// Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout) // Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout)
try { try {