mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 17:18:25 +00:00
fix(security): harden nextcloud-talk webhook replay handling
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user