@@ -2,27 +2,22 @@ import { timingSafeEqual } from "node:crypto";
22import type { IncomingMessage , ServerResponse } from "node:http" ;
33import type { OpenClawConfig } from "openclaw/plugin-sdk" ;
44import {
5- createBoundedCounter ,
65 createDedupeCache ,
76 createFixedWindowRateLimiter ,
8- readJsonBodyWithLimit ,
7+ createWebhookAnomalyTracker ,
8+ readJsonWebhookBodyOrReject ,
9+ applyBasicWebhookRequestGuards ,
910 registerWebhookTarget ,
10- rejectNonPostWebhookRequest ,
11- requestBodyErrorToText ,
1211 resolveSingleWebhookTarget ,
1312 resolveWebhookTargets ,
13+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS ,
14+ WEBHOOK_RATE_LIMIT_DEFAULTS ,
1415} from "openclaw/plugin-sdk" ;
1516import type { ResolvedZaloAccount } from "./accounts.js" ;
1617import type { ZaloFetch , ZaloUpdate } from "./api.js" ;
1718import type { ZaloRuntimeEnv } from "./monitor.js" ;
1819
19- const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000 ;
20- const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120 ;
21- const ZALO_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS = 4_096 ;
2220const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000 ;
23- const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25 ;
24- const ZALO_WEBHOOK_COUNTER_MAX_TRACKED_KEYS = 4_096 ;
25- const ZALO_WEBHOOK_COUNTER_TTL_MS = 6 * 60 * 60_000 ;
2621
2722export type ZaloWebhookTarget = {
2823 token : string ;
@@ -44,39 +39,31 @@ export type ZaloWebhookProcessUpdate = (params: {
4439
4540const webhookTargets = new Map < string , ZaloWebhookTarget [ ] > ( ) ;
4641const webhookRateLimiter = createFixedWindowRateLimiter ( {
47- windowMs : ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS ,
48- maxRequests : ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS ,
49- maxTrackedKeys : ZALO_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS ,
42+ windowMs : WEBHOOK_RATE_LIMIT_DEFAULTS . windowMs ,
43+ maxRequests : WEBHOOK_RATE_LIMIT_DEFAULTS . maxRequests ,
44+ maxTrackedKeys : WEBHOOK_RATE_LIMIT_DEFAULTS . maxTrackedKeys ,
5045} ) ;
5146const recentWebhookEvents = createDedupeCache ( {
5247 ttlMs : ZALO_WEBHOOK_REPLAY_WINDOW_MS ,
5348 maxSize : 5000 ,
5449} ) ;
55- const webhookStatusCounters = createBoundedCounter ( {
56- maxTrackedKeys : ZALO_WEBHOOK_COUNTER_MAX_TRACKED_KEYS ,
57- ttlMs : ZALO_WEBHOOK_COUNTER_TTL_MS ,
50+ const webhookAnomalyTracker = createWebhookAnomalyTracker ( {
51+ maxTrackedKeys : WEBHOOK_ANOMALY_COUNTER_DEFAULTS . maxTrackedKeys ,
52+ ttlMs : WEBHOOK_ANOMALY_COUNTER_DEFAULTS . ttlMs ,
53+ logEvery : WEBHOOK_ANOMALY_COUNTER_DEFAULTS . logEvery ,
5854} ) ;
5955
6056export function clearZaloWebhookSecurityStateForTest ( ) : void {
6157 webhookRateLimiter . clear ( ) ;
62- webhookStatusCounters . clear ( ) ;
58+ webhookAnomalyTracker . clear ( ) ;
6359}
6460
6561export function getZaloWebhookRateLimitStateSizeForTest ( ) : number {
6662 return webhookRateLimiter . size ( ) ;
6763}
6864
6965export function getZaloWebhookStatusCounterSizeForTest ( ) : number {
70- return webhookStatusCounters . size ( ) ;
71- }
72-
73- function isJsonContentType ( value : string | string [ ] | undefined ) : boolean {
74- const first = Array . isArray ( value ) ? value [ 0 ] : value ;
75- if ( ! first ) {
76- return false ;
77- }
78- const mediaType = first . split ( ";" , 1 ) [ 0 ] ?. trim ( ) . toLowerCase ( ) ;
79- return mediaType === "application/json" || Boolean ( mediaType ?. endsWith ( "+json" ) ) ;
66+ return webhookAnomalyTracker . size ( ) ;
8067}
8168
8269function timingSafeEquals ( left : string , right : string ) : boolean {
@@ -110,16 +97,13 @@ function recordWebhookStatus(
11097 path : string ,
11198 statusCode : number ,
11299) : void {
113- if ( ! [ 400 , 401 , 408 , 413 , 415 , 429 ] . includes ( statusCode ) ) {
114- return ;
115- }
116- const key = `${ path } :${ statusCode } ` ;
117- const next = webhookStatusCounters . increment ( key ) ;
118- if ( next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0 ) {
119- runtime ?. log ?.(
120- `[zalo] webhook anomaly path=${ path } status=${ statusCode } count=${ String ( next ) } ` ,
121- ) ;
122- }
100+ webhookAnomalyTracker . record ( {
101+ key : `${ path } :${ statusCode } ` ,
102+ statusCode,
103+ log : runtime ?. log ,
104+ message : ( count ) =>
105+ `[zalo] webhook anomaly path=${ path } status=${ statusCode } count=${ String ( count ) } ` ,
106+ } ) ;
123107}
124108
125109export function registerZaloWebhookTarget ( target : ZaloWebhookTarget ) : ( ) => void {
@@ -137,7 +121,13 @@ export async function handleZaloWebhookRequest(
137121 }
138122 const { targets, path } = resolved ;
139123
140- if ( rejectNonPostWebhookRequest ( req , res ) ) {
124+ if (
125+ ! applyBasicWebhookRequestGuards ( {
126+ req,
127+ res,
128+ allowMethods : [ "POST" ] ,
129+ } )
130+ ) {
141131 return true ;
142132 }
143133
@@ -161,41 +151,34 @@ export async function handleZaloWebhookRequest(
161151 const rateLimitKey = `${ path } :${ req . socket . remoteAddress ?? "unknown" } ` ;
162152 const nowMs = Date . now ( ) ;
163153
164- if ( webhookRateLimiter . isRateLimited ( rateLimitKey , nowMs ) ) {
165- res . statusCode = 429 ;
166- res . end ( "Too Many Requests" ) ;
154+ if (
155+ ! applyBasicWebhookRequestGuards ( {
156+ req,
157+ res,
158+ rateLimiter : webhookRateLimiter ,
159+ rateLimitKey,
160+ nowMs,
161+ requireJsonContentType : true ,
162+ } )
163+ ) {
167164 recordWebhookStatus ( target . runtime , path , res . statusCode ) ;
168165 return true ;
169166 }
170-
171- if ( ! isJsonContentType ( req . headers [ "content-type" ] ) ) {
172- res . statusCode = 415 ;
173- res . end ( "Unsupported Media Type" ) ;
174- recordWebhookStatus ( target . runtime , path , res . statusCode ) ;
175- return true ;
176- }
177-
178- const body = await readJsonBodyWithLimit ( req , {
167+ const body = await readJsonWebhookBodyOrReject ( {
168+ req,
169+ res,
179170 maxBytes : 1024 * 1024 ,
180171 timeoutMs : 30_000 ,
181172 emptyObjectOnEmpty : false ,
173+ invalidJsonMessage : "Bad Request" ,
182174 } ) ;
183175 if ( ! body . ok ) {
184- res . statusCode =
185- body . code === "PAYLOAD_TOO_LARGE" ? 413 : body . code === "REQUEST_BODY_TIMEOUT" ? 408 : 400 ;
186- const message =
187- body . code === "PAYLOAD_TOO_LARGE"
188- ? requestBodyErrorToText ( "PAYLOAD_TOO_LARGE" )
189- : body . code === "REQUEST_BODY_TIMEOUT"
190- ? requestBodyErrorToText ( "REQUEST_BODY_TIMEOUT" )
191- : "Bad Request" ;
192- res . end ( message ) ;
193176 recordWebhookStatus ( target . runtime , path , res . statusCode ) ;
194177 return true ;
195178 }
179+ const raw = body . value ;
196180
197181 // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
198- const raw = body . value ;
199182 const record = raw && typeof raw === "object" ? ( raw as Record < string , unknown > ) : null ;
200183 const update : ZaloUpdate | undefined =
201184 record && record . ok === true && record . result
0 commit comments