From 01715182e1fe6c9d3b2919bc2d52cd6b51157a70 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 19 Feb 2026 14:33:44 -0600 Subject: [PATCH] fix(tlon): address security review issues - Fix SSRF in upload.ts: use urbitFetch with SSRF protection - Fix SSRF in media.ts: use urbitFetch with SSRF protection - Add command whitelist to tlon tool to prevent command injection - Add getDefaultSsrFPolicy() helper for uploads/downloads --- extensions/tlon/index.ts | 30 ++++++++++++++++++++++++++++ extensions/tlon/src/monitor/media.ts | 20 +++++++++++++++++-- extensions/tlon/src/urbit/context.ts | 9 +++++++++ extensions/tlon/src/urbit/upload.ts | 20 +++++++++++++++++-- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index f96b40d51e7..1cbcd35bc4c 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -9,6 +9,21 @@ import { setTlonRuntime } from "./src/runtime.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +// Whitelist of allowed tlon subcommands +const ALLOWED_TLON_COMMANDS = new Set([ + "activity", + "channels", + "contacts", + "groups", + "messages", + "dms", + "posts", + "notebook", + "settings", + "help", + "version", +]); + /** * Find the tlon binary from the skill package */ @@ -141,6 +156,21 @@ const plugin = { async execute(_id: string, params: { command: string }) { try { const args = shellSplit(params.command); + + // Validate first argument is a whitelisted tlon subcommand + const subcommand = args[0]; + if (!ALLOWED_TLON_COMMANDS.has(subcommand)) { + return { + content: [ + { + type: "text" as const, + text: `Error: Unknown tlon subcommand '${subcommand}'. Allowed: ${[...ALLOWED_TLON_COMMANDS].join(", ")}`, + }, + ], + details: { error: true }, + }; + } + const output = await runTlonCommand(tlonBinary, args); return { content: [{ type: "text" as const, text: output }], diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index d6214e3ac0d..1e0b316a9c3 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,6 +5,8 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { getDefaultSsrFPolicy } from "../urbit/context.js"; +import { urbitFetch } from "../urbit/fetch.js"; // Default to OpenClaw workspace media directory const DEFAULT_MEDIA_DIR = path.join(homedir(), ".openclaw", "workspace", "media", "inbound"); @@ -52,11 +54,25 @@ export async function downloadMedia( mediaDir: string = DEFAULT_MEDIA_DIR, ): Promise { try { + // Validate URL is http/https before fetching + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + console.warn(`[tlon-media] Rejected non-http(s) URL: ${url}`); + return null; + } + // Ensure media directory exists await mkdir(mediaDir, { recursive: true }); - // Fetch the image - const response = await fetch(url); + // Fetch with SSRF protection + const { response } = await urbitFetch({ + baseUrl: url, + path: "", + init: { method: "GET" }, + ssrfPolicy: getDefaultSsrFPolicy(), + auditContext: "tlon-media-download", + }); + if (!response.ok) { console.error(`[tlon-media] Failed to fetch ${url}: ${response.status}`); return null; diff --git a/extensions/tlon/src/urbit/context.ts b/extensions/tlon/src/urbit/context.ts index 90c2721c7b8..e5c78aeee7f 100644 --- a/extensions/tlon/src/urbit/context.ts +++ b/extensions/tlon/src/urbit/context.ts @@ -45,3 +45,12 @@ export function ssrfPolicyFromAllowPrivateNetwork( ): SsrFPolicy | undefined { return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; } + +/** + * Get the default SSRF policy for image uploads. + * Uses a restrictive policy that blocks private networks by default. + */ +export function getDefaultSsrFPolicy(): SsrFPolicy | undefined { + // Default: block private networks for image uploads (safer default) + return undefined; +} diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 78338de7b03..1e3c2fe4076 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -2,6 +2,8 @@ * Upload an image from a URL to Tlon storage. */ import { uploadFile } from "@tloncorp/api"; +import { getDefaultSsrFPolicy } from "./context.js"; +import { urbitFetch } from "./fetch.js"; /** * Fetch an image from a URL and upload it to Tlon storage. @@ -11,8 +13,22 @@ import { uploadFile } from "@tloncorp/api"; */ export async function uploadImageFromUrl(imageUrl: string): Promise { try { - // Fetch the image - const response = await fetch(imageUrl); + // Validate URL is http/https before fetching + const url = new URL(imageUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + console.warn(`[tlon] Rejected non-http(s) URL: ${imageUrl}`); + return imageUrl; + } + + // Fetch the image with SSRF protection + const { response } = await urbitFetch({ + baseUrl: imageUrl, + path: "", + init: { method: "GET" }, + ssrfPolicy: getDefaultSsrFPolicy(), + auditContext: "tlon-upload-image", + }); + if (!response.ok) { console.warn(`[tlon] Failed to fetch image from ${imageUrl}: ${response.status}`); return imageUrl;