@openclaw/zalo 2026.3.11 → 2026.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +2 -2
- package/src/monitor.webhook.test.ts +93 -2
- package/src/monitor.webhook.ts +34 -6
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -283,6 +283,7 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
283
283
|
|
|
284
284
|
try {
|
|
285
285
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
286
|
+
let saw429 = false;
|
|
286
287
|
for (let i = 0; i < 200; i += 1) {
|
|
287
288
|
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
|
288
289
|
method: "POST",
|
|
@@ -292,10 +293,15 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
292
293
|
},
|
|
293
294
|
body: "{}",
|
|
294
295
|
});
|
|
295
|
-
expect(response.status)
|
|
296
|
+
expect([401, 429]).toContain(response.status);
|
|
297
|
+
if (response.status === 429) {
|
|
298
|
+
saw429 = true;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
296
301
|
}
|
|
297
302
|
|
|
298
|
-
expect(
|
|
303
|
+
expect(saw429).toBe(true);
|
|
304
|
+
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
|
|
299
305
|
});
|
|
300
306
|
} finally {
|
|
301
307
|
unregister();
|
|
@@ -322,6 +328,91 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
322
328
|
}
|
|
323
329
|
});
|
|
324
330
|
|
|
331
|
+
it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
|
|
332
|
+
const unregister = registerTarget({ path: "/hook-preauth-rate" });
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
336
|
+
const saw429 = await postUntilRateLimited({
|
|
337
|
+
baseUrl,
|
|
338
|
+
path: "/hook-preauth-rate",
|
|
339
|
+
secret: "invalid-token", // pragma: allowlist secret
|
|
340
|
+
withNonceQuery: true,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(saw429).toBe(true);
|
|
344
|
+
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
|
345
|
+
});
|
|
346
|
+
} finally {
|
|
347
|
+
unregister();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
|
|
352
|
+
const unregister = registerTarget({
|
|
353
|
+
path: "/hook-preauth-split",
|
|
354
|
+
config: {
|
|
355
|
+
gateway: {
|
|
356
|
+
trustedProxies: ["127.0.0.1"],
|
|
357
|
+
},
|
|
358
|
+
} as OpenClawConfig,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
363
|
+
for (let i = 0; i < 130; i += 1) {
|
|
364
|
+
const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: {
|
|
367
|
+
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
368
|
+
"content-type": "application/json",
|
|
369
|
+
"x-forwarded-for": "203.0.113.10",
|
|
370
|
+
},
|
|
371
|
+
body: "{}",
|
|
372
|
+
});
|
|
373
|
+
if (response.status === 429) {
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: {
|
|
381
|
+
"x-bot-api-secret-token": "secret",
|
|
382
|
+
"content-type": "application/json",
|
|
383
|
+
"x-forwarded-for": "198.51.100.20",
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({ event_name: "message.unsupported.received" }),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(validResponse.status).toBe(200);
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
unregister();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
|
|
396
|
+
const unregister = registerTarget({ path: "/hook-auth-before-type" });
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
400
|
+
const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: {
|
|
403
|
+
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
404
|
+
"content-type": "text/plain",
|
|
405
|
+
},
|
|
406
|
+
body: "not-json",
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(response.status).toBe(401);
|
|
410
|
+
});
|
|
411
|
+
} finally {
|
|
412
|
+
unregister();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
325
416
|
it("scopes DM pairing store reads and writes to accountId", async () => {
|
|
326
417
|
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
|
|
327
418
|
pairingCreated: false,
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
17
17
|
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
18
18
|
} from "openclaw/plugin-sdk/zalo";
|
|
19
|
+
import { resolveClientIp } from "../../../src/gateway/net.js";
|
|
19
20
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
20
21
|
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
21
22
|
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
@@ -109,6 +110,10 @@ function recordWebhookStatus(
|
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
function headerValue(value: string | string[] | undefined): string | undefined {
|
|
114
|
+
return Array.isArray(value) ? value[0] : value;
|
|
115
|
+
}
|
|
116
|
+
|
|
112
117
|
export function registerZaloWebhookTarget(
|
|
113
118
|
target: ZaloWebhookTarget,
|
|
114
119
|
opts?: {
|
|
@@ -140,6 +145,33 @@ export async function handleZaloWebhookRequest(
|
|
|
140
145
|
targetsByPath: webhookTargets,
|
|
141
146
|
allowMethods: ["POST"],
|
|
142
147
|
handle: async ({ targets, path }) => {
|
|
148
|
+
const trustedProxies = targets[0]?.config.gateway?.trustedProxies;
|
|
149
|
+
const allowRealIpFallback = targets[0]?.config.gateway?.allowRealIpFallback === true;
|
|
150
|
+
const clientIp =
|
|
151
|
+
resolveClientIp({
|
|
152
|
+
remoteAddr: req.socket.remoteAddress,
|
|
153
|
+
forwardedFor: headerValue(req.headers["x-forwarded-for"]),
|
|
154
|
+
realIp: headerValue(req.headers["x-real-ip"]),
|
|
155
|
+
trustedProxies,
|
|
156
|
+
allowRealIpFallback,
|
|
157
|
+
}) ??
|
|
158
|
+
req.socket.remoteAddress ??
|
|
159
|
+
"unknown";
|
|
160
|
+
const rateLimitKey = `${path}:${clientIp}`;
|
|
161
|
+
const nowMs = Date.now();
|
|
162
|
+
if (
|
|
163
|
+
!applyBasicWebhookRequestGuards({
|
|
164
|
+
req,
|
|
165
|
+
res,
|
|
166
|
+
rateLimiter: webhookRateLimiter,
|
|
167
|
+
rateLimitKey,
|
|
168
|
+
nowMs,
|
|
169
|
+
})
|
|
170
|
+
) {
|
|
171
|
+
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
143
175
|
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
144
176
|
const target = resolveWebhookTargetWithAuthOrRejectSync({
|
|
145
177
|
targets,
|
|
@@ -150,16 +182,12 @@ export async function handleZaloWebhookRequest(
|
|
|
150
182
|
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
151
183
|
return true;
|
|
152
184
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
// Preserve the historical 401-before-415 ordering for invalid secrets while still
|
|
186
|
+
// consuming rate-limit budget on unauthenticated guesses.
|
|
156
187
|
if (
|
|
157
188
|
!applyBasicWebhookRequestGuards({
|
|
158
189
|
req,
|
|
159
190
|
res,
|
|
160
|
-
rateLimiter: webhookRateLimiter,
|
|
161
|
-
rateLimitKey,
|
|
162
|
-
nowMs,
|
|
163
191
|
requireJsonContentType: true,
|
|
164
192
|
})
|
|
165
193
|
) {
|