From 7e458ad6d3a4854f05969370a62450ea0a408222 Mon Sep 17 00:00:00 2001 From: nav Date: Tue, 12 May 2026 11:26:21 +0000 Subject: [PATCH] feat(guild-events): add HMAC-signed webhook delivery and replay guards --- .../src/events/events.service.ts | 37 ++++++++++++++++++- docs/TODO-backend-center-guild.md | 2 +- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Fabric.Backend.Guild/src/events/events.service.ts b/Fabric.Backend.Guild/src/events/events.service.ts index a5ae968..d24ea66 100644 --- a/Fabric.Backend.Guild/src/events/events.service.ts +++ b/Fabric.Backend.Guild/src/events/events.service.ts @@ -1,10 +1,25 @@ import { Injectable, Logger } from '@nestjs/common'; -import { randomUUID } from 'crypto'; +import { createHmac, randomUUID } from 'crypto'; import { FabricEventEnvelope } from './event-envelope'; @Injectable() export class EventsService { private readonly logger = new Logger(EventsService.name); + private readonly sentEventIds = new Map(); + + private cleanupSentCache(now: number): void { + const ttlMs = 10 * 60 * 1000; + for (const [eventId, ts] of this.sentEventIds.entries()) { + if (now - ts > ttlMs) this.sentEventIds.delete(eventId); + } + } + + private signWebhook(payload: string, timestamp: string, nonce: string): string { + const secret = process.env.FABRIC_WEBHOOK_SECRET; + if (!secret) return ''; + const canonical = ['POST', '/webhook/events', timestamp, nonce, payload].join('\n'); + return createHmac('sha256', secret).update(canonical).digest('hex'); + } buildEnvelope(input: { eventType: string; @@ -32,21 +47,39 @@ export class EventsService { data: Record; }): Promise { const envelope = this.buildEnvelope(input); + const now = Date.now(); + this.cleanupSentCache(now); + + if (this.sentEventIds.has(envelope.event_id)) { + this.logger.warn(`skip duplicate event_id: ${envelope.event_id}`); + return envelope; + } const webhookUrl = process.env.FABRIC_WEBHOOK_URL; if (!webhookUrl) { this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`); + this.sentEventIds.set(envelope.event_id, now); return envelope; } try { + const timestamp = new Date(now).toISOString(); + const nonce = randomUUID(); + const payload = JSON.stringify(envelope); + const signature = this.signWebhook(payload, timestamp, nonce); + await fetch(webhookUrl, { method: 'POST', headers: { 'content-type': 'application/json', + 'x-fabric-version': '1', + 'x-fabric-timestamp': timestamp, + 'x-fabric-nonce': nonce, + 'x-fabric-signature': signature, }, - body: JSON.stringify(envelope), + body: payload, }); + this.sentEventIds.set(envelope.event_id, now); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.warn(`event delivery failed: ${message}`); diff --git a/docs/TODO-backend-center-guild.md b/docs/TODO-backend-center-guild.md index b81ba6c..570e1fa 100644 --- a/docs/TODO-backend-center-guild.md +++ b/docs/TODO-backend-center-guild.md @@ -72,7 +72,7 @@ ## 4. 插件与扩展面(为 OpenclawPlugin 准备) - [x] Webhook 事件信封落地(event_id/event_type/occurred_at/data) -- [ ] HMAC 签名与重放防护 +- [x] HMAC 签名与重放防护 - [ ] 出站重试队列(指数退避) - [ ] Bot Token 入站调用鉴权