From f91ddefbfb039f006cc3197521371b189b1187f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 11:29:27 -0400 Subject: [PATCH] fix(zalo): bound hosted media expiry clocks --- extensions/zalo/src/outbound-media.test.ts | 44 ++++++++++++++++++++++ extensions/zalo/src/outbound-media.ts | 26 +++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/extensions/zalo/src/outbound-media.test.ts b/extensions/zalo/src/outbound-media.test.ts index 276b65fdbd3..f038bf35961 100644 --- a/extensions/zalo/src/outbound-media.test.ts +++ b/extensions/zalo/src/outbound-media.test.ts @@ -151,6 +151,50 @@ describe("zalo outbound hosted media", () => { expect(secondResponse.res.statusCode).toBe(404); }); + it("rejects hosted media preparation when the expiry would exceed a valid Date", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + try { + await expect( + prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/zalo-webhook", + maxBytes: 1024, + }), + ).rejects.toThrow(/expiry/); + + expect(loadOutboundMediaFromUrlMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not serve hosted media when the current clock is invalid", async () => { + const hostedUrl = await prepareHostedZaloMediaUrl({ + mediaUrl: "https://example.com/photo.png", + webhookUrl: "https://gateway.example.com/zalo-webhook", + maxBytes: 1024, + }); + const { pathname, search } = new URL(hostedUrl); + const response = createMockResponse(); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN); + try { + const handled = await tryHandleHostedZaloMediaRequest( + { + method: "GET", + url: `${pathname}${search}`, + } as never, + response.res as never, + ); + + expect(handled).toBe(true); + expect(response.res.statusCode).toBe(410); + expect(response.res.end).toHaveBeenCalledWith("Expired"); + } finally { + dateNow.mockRestore(); + } + }); + it("rejects hosted media requests with the wrong token", async () => { const hostedUrl = await prepareHostedZaloMediaUrl({ mediaUrl: "https://example.com/photo.png", diff --git a/extensions/zalo/src/outbound-media.ts b/extensions/zalo/src/outbound-media.ts index 19cfd912f38..368546ad5a6 100644 --- a/extensions/zalo/src/outbound-media.ts +++ b/extensions/zalo/src/outbound-media.ts @@ -3,6 +3,10 @@ import { rmSync } from "node:fs"; import { readdir, readFile, stat, unlink } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import { join } from "node:path"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; @@ -64,6 +68,10 @@ async function deleteHostedZaloMediaEntry(id: string): Promise { } async function cleanupExpiredHostedZaloMedia(nowMs = Date.now()): Promise { + const now = asDateTimestampMs(nowMs); + if (now === undefined) { + return; + } let fileNames: string[]; try { fileNames = await readdir(ZALO_OUTBOUND_MEDIA_DIR); @@ -79,7 +87,8 @@ async function cleanupExpiredHostedZaloMedia(nowMs = Date.now()): Promise try { const metadataRaw = await readFile(resolveHostedZaloMediaMetadataPath(id), "utf8"); const metadata = JSON.parse(metadataRaw) as HostedZaloMediaMetadata; - if (metadata.expiresAt <= nowMs) { + const expiresAt = asDateTimestampMs(metadata.expiresAt); + if (expiresAt === undefined || expiresAt <= now) { await deleteHostedZaloMediaEntry(id); } } catch { @@ -141,6 +150,15 @@ export async function prepareHostedZaloMediaUrl(params: { await ensureHostedZaloMediaDir(); await cleanupExpiredHostedZaloMedia(); + const now = asDateTimestampMs(Date.now()); + const expiresAt = + now === undefined + ? undefined + : resolveExpiresAtMsFromDurationMs(ZALO_OUTBOUND_MEDIA_TTL_MS, { nowMs: now }); + if (expiresAt === undefined) { + throw new Error("Zalo outbound media expiry could not be resolved"); + } + const media = await loadOutboundMediaFromUrl(params.mediaUrl, { maxBytes: params.maxBytes, ...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}), @@ -161,7 +179,7 @@ export async function prepareHostedZaloMediaUrl(params: { routePath, token, contentType: media.contentType, - expiresAt: Date.now() + ZALO_OUTBOUND_MEDIA_TTL_MS, + expiresAt, } satisfies HostedZaloMediaMetadata); } catch (error) { await deleteHostedZaloMediaEntry(id); @@ -210,7 +228,9 @@ export async function tryHandleHostedZaloMediaRequest( return true; } - if (entry.metadata.expiresAt <= Date.now()) { + const now = asDateTimestampMs(Date.now()); + const expiresAt = asDateTimestampMs(entry.metadata.expiresAt); + if (now === undefined || expiresAt === undefined || expiresAt <= now) { await deleteHostedZaloMediaEntry(id); res.statusCode = 410; res.end("Expired");