fix(security): harden nextcloud-talk webhook replay handling

This commit is contained in:
Peter Steinberger
2026-02-26 00:18:24 +01:00
parent 8f3310000a
commit d512163d68
7 changed files with 398 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import os from "node:os";
import {
createLoggerBackedRuntime,
type RuntimeEnv,
@@ -8,6 +9,7 @@ import {
} from "openclaw/plugin-sdk";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
import type {
@@ -31,6 +33,14 @@ function formatError(err: unknown): string {
return typeof err === "string" ? err : JSON.stringify(err);
}
function normalizeOrigin(value: string): string | null {
try {
return new URL(value).origin.toLowerCase();
} catch {
return null;
}
}
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
try {
const data = JSON.parse(body);
@@ -93,6 +103,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
? Math.floor(opts.maxBodyBytes)
: DEFAULT_WEBHOOK_MAX_BODY_BYTES;
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
const isBackendAllowed = opts.isBackendAllowed;
const shouldProcessMessage = opts.shouldProcessMessage;
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === HEALTH_PATH) {
@@ -116,6 +128,11 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
res.end(JSON.stringify({ error: "Missing signature headers" }));
return;
}
if (isBackendAllowed && !isBackendAllowed(headers.backend)) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid backend" }));
return;
}
const body = await readBody(req, maxBodyBytes);
@@ -146,6 +163,14 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
}
const message = payloadToInboundMessage(payload);
if (shouldProcessMessage) {
const shouldProcess = await shouldProcessMessage(message);
if (!shouldProcess) {
res.writeHead(200);
res.end();
return;
}
}
res.writeHead(200);
res.end();
@@ -233,12 +258,41 @@ export async function monitorNextcloudTalkProvider(
channel: "nextcloud-talk",
accountId: account.accountId,
});
const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
const replayGuard = createNextcloudTalkReplayGuard({
stateDir: core.state.resolveStateDir(process.env, os.homedir),
onDiskError: (error) => {
logger.warn(
`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
);
},
});
const { start, stop } = createNextcloudTalkWebhookServer({
port,
host,
path,
secret: account.secret,
isBackendAllowed: (backend) => {
if (!expectedBackendOrigin) {
return true;
}
const backendOrigin = normalizeOrigin(backend);
return backendOrigin === expectedBackendOrigin;
},
shouldProcessMessage: async (message) => {
const shouldProcess = await replayGuard.shouldProcessMessage({
accountId: account.accountId,
roomToken: message.roomToken,
messageId: message.messageId,
});
if (!shouldProcess) {
logger.warn(
`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
);
}
return shouldProcess;
},
onMessage: async (message) => {
core.channel.activity.record({
channel: "nextcloud-talk",