@openclaw/zalo 2026.3.10 → 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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.12
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.11
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.10
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.10",
3
+ "version": "2026.3.12",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "undici": "7.22.0",
7
+ "undici": "7.24.0",
8
8
  "zod": "^4.3.6"
9
9
  },
10
10
  "openclaw": {
@@ -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).toBe(401);
296
+ expect([401, 429]).toContain(response.status);
297
+ if (response.status === 429) {
298
+ saw429 = true;
299
+ break;
300
+ }
296
301
  }
297
302
 
298
- expect(getZaloWebhookStatusCounterSizeForTest()).toBe(1);
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,
@@ -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
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
154
- const nowMs = Date.now();
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
  ) {