diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2cda9a6c9..a855e961439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Plugins/runtime-deps: keep bundled provider policy config loading from staging plugin runtime dependencies, so config reads no longer fail on locked-down `/var/lib/openclaw/plugin-runtime-deps` directories. Fixes #74971. Thanks @eurojojo. - Memory/runtime-deps: retain the native `node-llama-cpp` runtime only when local memory search is configured, so packaged installs can repair local embeddings without relying on unreachable global npm installs. Fixes #74777. Thanks @LLagoon3. - Gateway/startup: skip pre-bind web-fetch provider discovery for credential-free `tools.web.fetch` config, so Docker/Kubernetes gateways bind even when optional fetch limits are present. Fixes #74896. Thanks @KoykL. +- Signal: bound `signal-cli` installer release and archive downloads with explicit timeouts, declared and streamed size checks, and partial-file cleanup. Fixes #54153. Thanks @jinduwang1001-max and @juan-flores077. - Slack: require bot-authored room messages with `allowBots=true` to come from an explicitly channel-allowlisted bot or from a room where an explicit Slack owner is present, so broad bot relays cannot run unattended. Fixes #59284. Thanks @andrewhong-translucent. - Signal: derive `getAttachment` HTTP response caps from `channels.signal.mediaMaxMb` with base64 headroom, so inbound photos and videos no longer drop behind the 1 MiB RPC default. Fixes #73564. Thanks @heyhudson. - Signal: keep the long-lived receive SSE monitor open while idle instead of applying the 10s RPC/check deadline, so `signal-cli` 0.14.3 event streams no longer reconnect before inbound messages arrive. Fixes #74741. Thanks @fgabelmannjr and @k7n4n5t3w4rt. diff --git a/extensions/signal/src/install-signal-cli.test.ts b/extensions/signal/src/install-signal-cli.test.ts index 13bc21f0662..aee0b7cd4ea 100644 --- a/extensions/signal/src/install-signal-cli.test.ts +++ b/extensions/signal/src/install-signal-cli.test.ts @@ -2,10 +2,26 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import JSZip from "jszip"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import * as tar from "tar"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ReleaseAsset } from "./install-signal-cli.js"; -import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./install-signal-cli.js"; + +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +const { + downloadToFile, + extractSignalCliArchive, + installSignalCliFromRelease, + looksLikeArchive, + pickAsset, +} = await import("./install-signal-cli.js"); const SAMPLE_ASSETS: ReleaseAsset[] = [ { @@ -39,6 +55,26 @@ const SAMPLE_ASSETS: ReleaseAsset[] = [ }, ]; +function okDownloadResponse(body: BodyInit, init: ResponseInit = {}) { + return { + response: new Response(body, { status: 200, ...init }), + release: vi.fn().mockResolvedValue(undefined), + }; +} + +async function withTempFile(run: (filePath: string) => Promise) { + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-download-")); + try { + await run(path.join(workDir, "signal-cli.tgz")); + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +beforeEach(() => { + fetchWithSsrFGuardMock.mockReset(); +}); + describe("looksLikeArchive", () => { it("recognises .tar.gz", () => { expect(looksLikeArchive("foo.tar.gz")).toBe(true); @@ -131,6 +167,94 @@ describe("pickAsset", () => { }); }); +describe("downloadToFile", () => { + it("downloads through the SSRF guard with an explicit timeout", async () => { + const fetchResult = okDownloadResponse("archive"); + fetchWithSsrFGuardMock.mockResolvedValue(fetchResult); + + await withTempFile(async (filePath) => { + await downloadToFile("https://example.com/signal-cli.tgz", filePath); + + await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("archive"); + }); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com/signal-cli.tgz", + requireHttps: true, + timeoutMs: 5 * 60_000, + auditContext: "signal-cli-install-archive", + }), + ); + expect(fetchResult.release).toHaveBeenCalledTimes(1); + }); + + it("rejects declared archives above the download cap", async () => { + const fetchResult = okDownloadResponse("archive", { + headers: { "content-length": "12" }, + }); + fetchWithSsrFGuardMock.mockResolvedValue(fetchResult); + + await withTempFile(async (filePath) => { + await expect( + downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8), + ).rejects.toThrow("declared 12"); + + await expect(fs.access(filePath)).rejects.toThrow(); + }); + + expect(fetchResult.release).toHaveBeenCalledTimes(1); + }); + + it("aborts streamed archives above the download cap and removes partial files", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(6)); + controller.enqueue(new Uint8Array(6)); + controller.close(); + }, + }); + const fetchResult = okDownloadResponse(body); + fetchWithSsrFGuardMock.mockResolvedValue(fetchResult); + + await withTempFile(async (filePath) => { + await expect( + downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8), + ).rejects.toThrow("8-byte download cap"); + + await expect(fs.access(filePath)).rejects.toThrow(); + }); + + expect(fetchResult.release).toHaveBeenCalledTimes(1); + }); +}); + +describe("installSignalCliFromRelease", () => { + it("bounds the release metadata request with an explicit timeout", async () => { + const fetchResult = okDownloadResponse(JSON.stringify({ tag_name: "v0.14.3", assets: [] }), { + headers: { "content-type": "application/json" }, + }); + fetchWithSsrFGuardMock.mockResolvedValue(fetchResult); + + await expect( + installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv), + ).resolves.toMatchObject({ + ok: false, + error: "No compatible release asset found for this platform.", + }); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.github.com/repos/AsamK/signal-cli/releases/latest", + requireHttps: true, + timeoutMs: 30_000, + auditContext: "signal-cli-release-info", + }), + ); + expect(fetchResult.release).toHaveBeenCalledTimes(1); + }); +}); + describe("extractSignalCliArchive", () => { async function withArchiveWorkspace(run: (workDir: string) => Promise) { const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-")); diff --git a/extensions/signal/src/install-signal-cli.ts b/extensions/signal/src/install-signal-cli.ts index f15d7b5a2b1..f9e94aecc47 100644 --- a/extensions/signal/src/install-signal-cli.ts +++ b/extensions/signal/src/install-signal-cli.ts @@ -27,6 +27,8 @@ type ReleaseResponse = { }; const MAX_SIGNAL_CLI_ARCHIVE_BYTES = 256 * 1024 * 1024; +const SIGNAL_CLI_DOWNLOAD_TIMEOUT_MS = 5 * 60_000; +const SIGNAL_CLI_RELEASE_INFO_TIMEOUT_MS = 30_000; export type SignalInstallResult = { ok: boolean; @@ -111,11 +113,19 @@ export function pickAsset( return archives[0]; } -async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise { +/** @internal Exported for testing. */ +export async function downloadToFile( + url: string, + dest: string, + maxRedirects = 5, + maxBytes = MAX_SIGNAL_CLI_ARCHIVE_BYTES, +): Promise { + let completed = false; const { response, release } = await fetchWithSsrFGuard({ url, maxRedirects, requireHttps: true, + timeoutMs: SIGNAL_CLI_DOWNLOAD_TIMEOUT_MS, capture: false, auditContext: "signal-cli-install-archive", }); @@ -124,14 +134,24 @@ async function downloadToFile(url: string, dest: string, maxRedirects = 5): Prom throw new Error(`HTTP ${response.status || "?"} downloading file`); } + const rawLength = response.headers.get("content-length"); + if (rawLength !== null) { + const declaredLength = Number(rawLength); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error( + `signal-cli archive exceeds the ${maxBytes}-byte download cap (declared ${declaredLength}).`, + ); + } + } + let totalBytes = 0; const body = response.body; const readable = isNodeReadableStream(body) ? body : Readable.fromWeb(body as never); const limiter = new Transform({ transform(chunk: unknown, _encoding, callback) { totalBytes += chunkByteLength(chunk); - if (totalBytes > MAX_SIGNAL_CLI_ARCHIVE_BYTES) { - callback(new Error("signal-cli archive exceeds 256 MiB limit")); + if (totalBytes > maxBytes) { + callback(new Error(`signal-cli archive exceeded the ${maxBytes}-byte download cap.`)); return; } callback(null, chunk); @@ -140,8 +160,12 @@ async function downloadToFile(url: string, dest: string, maxRedirects = 5): Prom const out = createWriteStream(dest); await pipeline(readable, limiter, out); + completed = true; } finally { await release(); + if (!completed) { + await fs.rm(dest, { force: true }).catch(() => undefined); + } } } @@ -245,12 +269,16 @@ async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise { +/** @internal Exported for testing. */ +export async function installSignalCliFromRelease( + runtime: RuntimeEnv, +): Promise { const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; const { response, release } = await fetchWithSsrFGuard({ url: apiUrl, maxRedirects: 5, requireHttps: true, + timeoutMs: SIGNAL_CLI_RELEASE_INFO_TIMEOUT_MS, capture: false, auditContext: "signal-cli-release-info", init: {