fix(security): harden imessage remote scp/ssh handling

This commit is contained in:
Peter Steinberger
2026-02-19 11:07:56 +01:00
parent cdb00fe242
commit 49d0def6d1
12 changed files with 150 additions and 12 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Gateway/WebChat: block `sessions.patch` and `sessions.delete` for WebChat clients so session-store mutations stay restricted to non-WebChat operator flows. Thanks @allsmog for reporting.
- Security/Skills: for the next npm release, reject symlinks during skill packaging to prevent external file inclusion in distributed `.skill` archives. Thanks @aether-ai-agent for reporting.
- Security/iMessage: harden remote attachment SSH/SCP handling by requiring strict host-key verification, validating `channels.imessage.remoteHost` as `host`/`user@host`, and rejecting unsafe host tokens from config or auto-detection. Thanks @allsmog for reporting.
- Security/Feishu: prevent path traversal in Feishu inbound media temp-file writes by replacing key-derived temp filenames with UUID-based names. Thanks @allsmog for reporting.
- LINE/Security: harden inbound media temp-file naming by using UUID-based temp paths for downloaded media instead of external message IDs. (#20792) Thanks @mbelinky.
- Security/Refactor: centralize hardened temp-file path generation for Feishu and LINE media downloads via shared `buildRandomTempFilePath` helper to reduce drift risk. (#20810) Thanks @mbelinky.

View File

@@ -103,6 +103,8 @@ exec ssh -T gateway-host imsg "$@"
```
If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script.
`remoteHost` must be `host` or `user@host` (no spaces or SSH options).
OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`.
</Tab>
</Tabs>
@@ -224,6 +226,7 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
```
Use SSH keys so both SSH and SCP are non-interactive.
Ensure the host key is trusted first (for example `ssh bot@mac-mini.tailnet-1234.ts.net`) so `known_hosts` is populated.
</Accordion>
@@ -241,6 +244,7 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
<Accordion title="Attachments and media">
- inbound attachment ingestion is optional: `channels.imessage.includeAttachments`
- remote attachment paths can be fetched via SCP when `remoteHost` is set
- SCP uses strict host-key checking (`StrictHostKeyChecking=yes`)
- outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB)
</Accordion>
@@ -326,6 +330,7 @@ openclaw channels status --probe
- `channels.imessage.remoteHost`
- SSH/SCP key auth from the gateway host
- host key exists in `~/.ssh/known_hosts` on the gateway host
- remote path readability on the Mac running Messages
</Accordion>

View File

@@ -404,7 +404,8 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
- Requires Full Disk Access to the Messages DB.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
- `cliPath` can point to an SSH wrapper; set `remoteHost` for SCP attachment fetching.
- `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
<Accordion title="iMessage SSH wrapper example">

View File

@@ -56,6 +56,7 @@ Remote mode supports two transports:
## Security notes
- Prefer loopback binds on the remote host and connect via SSH or Tailscale.
- SSH tunneling uses strict host-key checking; trust the host key first so it exists in `~/.ssh/known_hosts`.
- If you bind the Gateway to a non-loopback interface, require token/password auth.
- See [Security](/gateway/security) and [Tailscale](/gateway/tailscale).

View File

@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { assertSandboxPath } from "../../agents/sandbox-paths.js";
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
import type { OpenClawConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
import { getMediaDir } from "../../media/store.js";
import { CONFIG_DIR } from "../../utils.js";
import type { MsgContext, TemplateContext } from "../templating.js";
export async function stageSandboxMedia(params: {
ctx: MsgContext;
@@ -165,6 +166,10 @@ export async function stageSandboxMedia(params: {
}
async function scpFile(remoteHost: string, remotePath: string, localPath: string): Promise<void> {
const safeRemoteHost = normalizeScpRemoteHost(remoteHost);
if (!safeRemoteHost) {
throw new Error("invalid remote host for SCP");
}
return new Promise((resolve, reject) => {
const child = spawn(
"/usr/bin/scp",
@@ -172,8 +177,9 @@ async function scpFile(remoteHost: string, remotePath: string, localPath: string
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
`${remoteHost}:${remotePath}`,
"StrictHostKeyChecking=yes",
"--",
`${safeRemoteHost}:${remotePath}`,
localPath,
],
{ stdio: ["ignore", "ignore", "pipe"] },

View File

@@ -36,4 +36,31 @@ describe("config schema regressions", () => {
expect(res.ok).toBe(true);
});
it("accepts safe iMessage remoteHost", () => {
const res = validateConfigObject({
channels: {
imessage: {
remoteHost: "bot@gateway-host",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects unsafe iMessage remoteHost", () => {
const res = validateConfigObject({
channels: {
imessage: {
remoteHost: "bot@gateway-host -oProxyCommand=whoami",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.imessage.remoteHost");
}
});
});

View File

@@ -23,7 +23,7 @@ export type IMessageAccountConfig = {
cliPath?: string;
/** Optional Messages db path override. */
dbPath?: string;
/** Remote host for SCP when attachments live on a different machine (e.g., openclaw@192.168.64.3). */
/** Remote SSH host token for SCP attachment fetches (`host` or `user@host`). */
remoteHost?: string;
/** Optional default send service (imessage|sms|auto). */
service?: "imessage" | "sms" | "auto";

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { isSafeScpRemoteHost } from "../infra/scp-host.js";
import {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
@@ -804,7 +805,10 @@ export const IMessageAccountSchemaBase = z
configWrites: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(),
remoteHost: z.string().optional(),
remoteHost: z
.string()
.refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)")
.optional(),
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
@@ -18,6 +19,7 @@ import { recordInboundSession } from "../../channels/session.js";
import { loadConfig } from "../../config/config.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
import { waitForTransportReady } from "../../infra/transport-ready.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
@@ -39,7 +41,6 @@ import {
} from "./inbound-processing.js";
import { parseIMessageNotification } from "./parse-notification.js";
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
/**
* Try to detect remote host from an SSH wrapper script like:
@@ -146,10 +147,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script
let remoteHost = imessageCfg.remoteHost;
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script.
// Accept only a safe host token to avoid option/argument injection into SCP.
const configuredRemoteHost = normalizeScpRemoteHost(imessageCfg.remoteHost);
if (imessageCfg.remoteHost && !configuredRemoteHost) {
logVerbose("imessage: ignoring unsafe channels.imessage.remoteHost value");
}
let remoteHost = configuredRemoteHost;
if (!remoteHost && cliPath && cliPath !== "imsg") {
remoteHost = await detectRemoteHostFromCliPath(cliPath);
const detected = await detectRemoteHostFromCliPath(cliPath);
const normalizedDetected = normalizeScpRemoteHost(detected);
if (detected && !normalizedDetected) {
logVerbose("imessage: ignoring unsafe auto-detected remoteHost from cliPath");
}
remoteHost = normalizedDetected;
if (remoteHost) {
logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`);
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isSafeScpRemoteHost, normalizeScpRemoteHost } from "./scp-host.js";
describe("scp remote host", () => {
it("accepts host and user@host forms", () => {
expect(normalizeScpRemoteHost("gateway-host")).toBe("gateway-host");
expect(normalizeScpRemoteHost("bot@gateway-host")).toBe("bot@gateway-host");
expect(normalizeScpRemoteHost("bot@192.168.64.3")).toBe("bot@192.168.64.3");
expect(normalizeScpRemoteHost("bot@[fe80::1]")).toBe("bot@[fe80::1]");
});
it("rejects unsafe host tokens", () => {
expect(isSafeScpRemoteHost("-oProxyCommand=whoami")).toBe(false);
expect(isSafeScpRemoteHost("bot@gateway-host -oStrictHostKeyChecking=no")).toBe(false);
expect(isSafeScpRemoteHost("bot@host:22")).toBe(false);
expect(isSafeScpRemoteHost("bot@/tmp/host")).toBe(false);
expect(isSafeScpRemoteHost("bot@@host")).toBe(false);
});
});

62
src/infra/scp-host.ts Normal file
View File

@@ -0,0 +1,62 @@
const SSH_TOKEN = /^[A-Za-z0-9._-]+$/;
const BRACKETED_IPV6 = /^\[[0-9A-Fa-f:.%]+\]$/;
const WHITESPACE = /\s/;
function hasControlOrWhitespace(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if (code <= 0x1f || code === 0x7f || WHITESPACE.test(char)) {
return true;
}
}
return false;
}
export function normalizeScpRemoteHost(value: string | null | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (hasControlOrWhitespace(trimmed)) {
return undefined;
}
if (trimmed.startsWith("-") || trimmed.includes("/") || trimmed.includes("\\")) {
return undefined;
}
const firstAt = trimmed.indexOf("@");
const lastAt = trimmed.lastIndexOf("@");
let user: string | undefined;
let host = trimmed;
if (firstAt !== -1) {
if (firstAt !== lastAt || firstAt === 0 || firstAt === trimmed.length - 1) {
return undefined;
}
user = trimmed.slice(0, firstAt);
host = trimmed.slice(firstAt + 1);
if (!SSH_TOKEN.test(user)) {
return undefined;
}
}
if (!host || host.startsWith("-") || host.includes("@")) {
return undefined;
}
if (host.includes(":") && !BRACKETED_IPV6.test(host)) {
return undefined;
}
if (!SSH_TOKEN.test(host) && !BRACKETED_IPV6.test(host)) {
return undefined;
}
return user ? `${user}@${host}` : host;
}
export function isSafeScpRemoteHost(value: string | null | undefined): boolean {
return normalizeScpRemoteHost(value) !== undefined;
}

View File

@@ -135,7 +135,7 @@ export async function startSshPortForward(opts: {
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=yes",
"-o",