feat(guild-events): add HMAC-signed webhook delivery and replay guards

This commit is contained in:
nav
2026-05-12 11:26:21 +00:00
parent 37ec670280
commit 7e458ad6d3
2 changed files with 36 additions and 3 deletions

View File

@@ -1,10 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto'; import { createHmac, randomUUID } from 'crypto';
import { FabricEventEnvelope } from './event-envelope'; import { FabricEventEnvelope } from './event-envelope';
@Injectable() @Injectable()
export class EventsService { export class EventsService {
private readonly logger = new Logger(EventsService.name); private readonly logger = new Logger(EventsService.name);
private readonly sentEventIds = new Map<string, number>();
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: { buildEnvelope(input: {
eventType: string; eventType: string;
@@ -32,21 +47,39 @@ export class EventsService {
data: Record<string, unknown>; data: Record<string, unknown>;
}): Promise<FabricEventEnvelope> { }): Promise<FabricEventEnvelope> {
const envelope = this.buildEnvelope(input); 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; const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
if (!webhookUrl) { if (!webhookUrl) {
this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`); this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`);
this.sentEventIds.set(envelope.event_id, now);
return envelope; return envelope;
} }
try { 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, { await fetch(webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', '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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`event delivery failed: ${message}`); this.logger.warn(`event delivery failed: ${message}`);

View File

@@ -72,7 +72,7 @@
## 4. 插件与扩展面(为 OpenclawPlugin 准备) ## 4. 插件与扩展面(为 OpenclawPlugin 准备)
- [x] Webhook 事件信封落地event_id/event_type/occurred_at/data - [x] Webhook 事件信封落地event_id/event_type/occurred_at/data
- [ ] HMAC 签名与重放防护 - [x] HMAC 签名与重放防护
- [ ] 出站重试队列(指数退避) - [ ] 出站重试队列(指数退避)
- [ ] Bot Token 入站调用鉴权 - [ ] Bot Token 入站调用鉴权