diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d92da77f1..61f3a66ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. - Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent. - Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058) +- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec. - Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec. - Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md index b55d996da4..dbd2015c4e 100644 --- a/docs/channels/tlon.md +++ b/docs/channels/tlon.md @@ -55,6 +55,22 @@ Minimal config (single account): } ``` +Private/LAN ship URLs (advanced): + +By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening). +If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`), +you must explicitly opt in: + +```json5 +{ + channels: { + tlon: { + allowPrivateNetwork: true, + }, + }, +} +``` + ## Group channels Auto-discovery is enabled by default. You can also pin channels manually: diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index e37b45ea69..b591079c7e 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -5,8 +5,7 @@ "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@urbit/aura": "^3.0.0", - "@urbit/http-api": "^3.0.0" + "@urbit/aura": "^3.0.0" }, "devDependencies": { "openclaw": "workspace:*" diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index f00b0d74bf..802af8484d 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -15,7 +15,8 @@ import { monitorTlonProvider } from "./monitor/index.js"; import { tlonOnboardingAdapter } from "./onboarding.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; -import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js"; +import { authenticate } from "./urbit/auth.js"; +import { UrbitChannelClient } from "./urbit/channel-client.js"; import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js"; const TLON_CHANNEL_ID = "tlon" as const; @@ -24,6 +25,7 @@ type TlonSetupInput = ChannelSetupInput & { ship?: string; url?: string; code?: string; + allowPrivateNetwork?: boolean; groupChannels?: string[]; dmAllowlist?: string[]; autoDiscoverChannels?: boolean; @@ -48,6 +50,9 @@ function applyTlonSetupConfig(params: { ...(input.ship ? { ship: input.ship } : {}), ...(input.url ? { url: input.url } : {}), ...(input.code ? { code: input.code } : {}), + ...(typeof input.allowPrivateNetwork === "boolean" + ? { allowPrivateNetwork: input.allowPrivateNetwork } + : {}), ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), ...(typeof input.autoDiscoverChannels === "boolean" @@ -118,12 +123,11 @@ const tlonOutbound: ChannelOutboundAdapter = { throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); } - ensureUrbitConnectPatched(); - const api = await Urbit.authenticate({ + const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; + const cookie = await authenticate(account.url, account.code, { ssrfPolicy }); + const api = new UrbitChannelClient(account.url, cookie, { ship: account.ship.replace(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, + ssrfPolicy, }); try { @@ -146,11 +150,7 @@ const tlonOutbound: ChannelOutboundAdapter = { replyToId: replyId, }); } finally { - try { - await api.delete(); - } catch { - // ignore cleanup errors - } + await api.close(); } }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { @@ -345,18 +345,17 @@ export const tlonPlugin: ChannelPlugin = { return { ok: false, error: "Not configured" }; } try { - ensureUrbitConnectPatched(); - const api = await Urbit.authenticate({ + const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; + const cookie = await authenticate(account.url, account.code, { ssrfPolicy }); + const api = new UrbitChannelClient(account.url, cookie, { ship: account.ship.replace(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, + ssrfPolicy, }); try { await api.getOurName(); return { ok: true }; } finally { - await api.delete(); + await api.close(); } } catch (error) { return { ok: false, error: (error as { message?: string })?.message ?? String(error) }; diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 338881106c..3dbc091ef6 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -19,6 +19,7 @@ export const TlonAccountSchema = z.object({ ship: ShipSchema.optional(), url: z.string().optional(), code: z.string().optional(), + allowPrivateNetwork: z.boolean().optional(), groupChannels: z.array(ChannelNestSchema).optional(), dmAllowlist: z.array(ShipSchema).optional(), autoDiscoverChannels: z.boolean().optional(), @@ -32,6 +33,7 @@ export const TlonConfigSchema = z.object({ ship: ShipSchema.optional(), url: z.string().optional(), code: z.string().optional(), + allowPrivateNetwork: z.boolean().optional(), groupChannels: z.array(ChannelNestSchema).optional(), dmAllowlist: z.array(ShipSchema).optional(), autoDiscoverChannels: z.boolean().optional(), diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 65a16a94df..2ed726e757 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -113,10 +113,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise runtime.log?.(message), error: (message) => runtime.error?.(message), diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts index e15e5e5925..9d2d6e25e0 100644 --- a/extensions/tlon/src/onboarding.ts +++ b/extensions/tlon/src/onboarding.ts @@ -9,6 +9,7 @@ import { } from "openclaw/plugin-sdk"; import type { TlonResolvedAccount } from "./types.js"; import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; +import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; const channel = "tlon" as const; @@ -24,6 +25,7 @@ function applyAccountConfig(params: { ship?: string; url?: string; code?: string; + allowPrivateNetwork?: boolean; groupChannels?: string[]; dmAllowlist?: string[]; autoDiscoverChannels?: boolean; @@ -45,6 +47,9 @@ function applyAccountConfig(params: { ...(input.ship ? { ship: input.ship } : {}), ...(input.url ? { url: input.url } : {}), ...(input.code ? { code: input.code } : {}), + ...(typeof input.allowPrivateNetwork === "boolean" + ? { allowPrivateNetwork: input.allowPrivateNetwork } + : {}), ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), ...(typeof input.autoDiscoverChannels === "boolean" @@ -73,6 +78,9 @@ function applyAccountConfig(params: { ...(input.ship ? { ship: input.ship } : {}), ...(input.url ? { url: input.url } : {}), ...(input.code ? { code: input.code } : {}), + ...(typeof input.allowPrivateNetwork === "boolean" + ? { allowPrivateNetwork: input.allowPrivateNetwork } + : {}), ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), ...(typeof input.autoDiscoverChannels === "boolean" @@ -91,6 +99,7 @@ async function noteTlonHelp(prompter: WizardPrompter): Promise { "You need your Urbit ship URL and login code.", "Example URL: https://your-ship-host", "Example ship: ~sampel-palnet", + "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, ].join("\n"), "Tlon setup", @@ -151,9 +160,32 @@ export const tlonOnboardingAdapter: ChannelOnboardingAdapter = { message: "Ship URL", placeholder: "https://your-ship-host", initialValue: resolved.url ?? undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + validate: (value) => { + const next = validateUrbitBaseUrl(String(value ?? "")); + if (!next.ok) { + return next.error; + } + return undefined; + }, }); + const validatedUrl = validateUrbitBaseUrl(String(url).trim()); + if (!validatedUrl.ok) { + throw new Error(`Invalid URL: ${validatedUrl.error}`); + } + + let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false; + if (isBlockedUrbitHostname(validatedUrl.hostname)) { + allowPrivateNetwork = await prompter.confirm({ + message: + "Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)", + initialValue: allowPrivateNetwork, + }); + if (!allowPrivateNetwork) { + throw new Error("Refusing private/internal Ship URL without explicit approval"); + } + } + const code = await prompter.text({ message: "Login code", placeholder: "lidlut-tabwed-pillex-ridrup", @@ -203,6 +235,7 @@ export const tlonOnboardingAdapter: ChannelOnboardingAdapter = { ship: String(ship).trim(), url: String(url).trim(), code: String(code).trim(), + allowPrivateNetwork, groupChannels, dmAllowlist, autoDiscoverChannels, diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index 4083154685..9447e6c9b8 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -8,6 +8,7 @@ export type TlonResolvedAccount = { ship: string | null; url: string | null; code: string | null; + allowPrivateNetwork: boolean | null; groupChannels: string[]; dmAllowlist: string[]; autoDiscoverChannels: boolean | null; @@ -25,6 +26,7 @@ export function resolveTlonAccount( ship?: string; url?: string; code?: string; + allowPrivateNetwork?: boolean; groupChannels?: string[]; dmAllowlist?: string[]; autoDiscoverChannels?: boolean; @@ -42,6 +44,7 @@ export function resolveTlonAccount( ship: null, url: null, code: null, + allowPrivateNetwork: null, groupChannels: [], dmAllowlist: [], autoDiscoverChannels: null, @@ -55,6 +58,9 @@ export function resolveTlonAccount( const ship = (account?.ship ?? base.ship ?? null) as string | null; const url = (account?.url ?? base.url ?? null) as string | null; const code = (account?.code ?? base.code ?? null) as string | null; + const allowPrivateNetwork = (account?.allowPrivateNetwork ?? base.allowPrivateNetwork ?? null) as + | boolean + | null; const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[]; const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[]; const autoDiscoverChannels = (account?.autoDiscoverChannels ?? @@ -73,6 +79,7 @@ export function resolveTlonAccount( ship, url, code, + allowPrivateNetwork, groupChannels, dmAllowlist, autoDiscoverChannels, diff --git a/extensions/tlon/src/urbit/auth.ssrf.test.ts b/extensions/tlon/src/urbit/auth.ssrf.test.ts new file mode 100644 index 0000000000..89235e922e --- /dev/null +++ b/extensions/tlon/src/urbit/auth.ssrf.test.ts @@ -0,0 +1,42 @@ +import { SsrFBlockedError } from "openclaw/plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { authenticate } from "./auth.js"; + +describe("tlon urbit auth ssrf", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("blocks private IPs by default", async () => { + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + await expect(authenticate("http://127.0.0.1:8080", "code")).rejects.toBeInstanceOf( + SsrFBlockedError, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("allows private IPs when allowPrivateNetwork is enabled", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => "ok", + headers: new Headers({ + "set-cookie": "urbauth-~zod=123; Path=/; HttpOnly", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const cookie = await authenticate("http://127.0.0.1:8080", "code", { + ssrfPolicy: { allowPrivateNetwork: true }, + lookupFn: async () => [{ address: "127.0.0.1", family: 4 }], + }); + expect(cookie).toContain("urbauth-~zod=123"); + expect(mockFetch).toHaveBeenCalled(); + }); +}); diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts index ae5fb5339a..2ae7f3aac6 100644 --- a/extensions/tlon/src/urbit/auth.ts +++ b/extensions/tlon/src/urbit/auth.ts @@ -1,18 +1,47 @@ -export async function authenticate(url: string, code: string): Promise { - const resp = await fetch(`${url}/~/login`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `password=${code}`, +import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; +import { urbitFetch } from "./fetch.js"; + +export type UrbitAuthenticateOptions = { + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + timeoutMs?: number; +}; + +export async function authenticate( + url: string, + code: string, + options: UrbitAuthenticateOptions = {}, +): Promise { + const { response, release } = await urbitFetch({ + baseUrl: url, + path: "/~/login", + init: { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password: code }).toString(), + }, + ssrfPolicy: options.ssrfPolicy, + lookupFn: options.lookupFn, + fetchImpl: options.fetchImpl, + timeoutMs: options.timeoutMs ?? 15_000, + maxRedirects: 3, + auditContext: "tlon-urbit-login", }); - if (!resp.ok) { - throw new Error(`Login failed with status ${resp.status}`); - } + try { + if (!response.ok) { + throw new Error(`Login failed with status ${response.status}`); + } - await resp.text(); - const cookie = resp.headers.get("set-cookie"); - if (!cookie) { - throw new Error("No authentication cookie received"); + // Some Urbit setups require the response body to be read before cookie headers finalize. + await response.text().catch(() => {}); + const cookie = response.headers.get("set-cookie"); + if (!cookie) { + throw new Error("No authentication cookie received"); + } + return cookie; + } finally { + await release(); } - return cookie; } diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts new file mode 100644 index 0000000000..73238dd1d5 --- /dev/null +++ b/extensions/tlon/src/urbit/base-url.ts @@ -0,0 +1,49 @@ +import { isBlockedHostname, isPrivateIpAddress } from "openclaw/plugin-sdk"; + +export type UrbitBaseUrlValidation = + | { ok: true; baseUrl: string; hostname: string } + | { ok: false; error: string }; + +function hasScheme(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value); +} + +export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) { + return { ok: false, error: "Required" }; + } + + const candidate = hasScheme(trimmed) ? trimmed : `https://${trimmed}`; + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return { ok: false, error: "Invalid URL" }; + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + return { ok: false, error: "URL must use http:// or https://" }; + } + + if (parsed.username || parsed.password) { + return { ok: false, error: "URL must not include credentials" }; + } + + const hostname = parsed.hostname.trim().toLowerCase().replace(/\.$/, ""); + if (!hostname) { + return { ok: false, error: "Invalid hostname" }; + } + + // Normalize to origin so callers can't smuggle paths/query fragments into the base URL. + return { ok: true, baseUrl: parsed.origin, hostname }; +} + +export function isBlockedUrbitHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + if (!normalized) { + return false; + } + return isBlockedHostname(normalized) || isPrivateIpAddress(normalized); +} diff --git a/extensions/tlon/src/urbit/channel-client.ts b/extensions/tlon/src/urbit/channel-client.ts new file mode 100644 index 0000000000..b857ae5477 --- /dev/null +++ b/extensions/tlon/src/urbit/channel-client.ts @@ -0,0 +1,248 @@ +import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; +import { validateUrbitBaseUrl } from "./base-url.js"; +import { urbitFetch } from "./fetch.js"; + +export type UrbitChannelClientOptions = { + ship?: string; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export class UrbitChannelClient { + readonly baseUrl: string; + readonly cookie: string; + readonly ship: string; + readonly ssrfPolicy?: SsrFPolicy; + readonly lookupFn?: LookupFn; + readonly fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + + private channelId: string | null = null; + + constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) { + const validated = validateUrbitBaseUrl(url); + if (!validated.ok) { + throw new Error(validated.error); + } + + this.baseUrl = validated.baseUrl; + this.cookie = cookie.split(";")[0]; + this.ship = ( + options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname) + ).trim(); + this.ssrfPolicy = options.ssrfPolicy; + this.lookupFn = options.lookupFn; + this.fetchImpl = options.fetchImpl; + } + + private resolveShipFromHostname(hostname: string): string { + if (hostname.includes(".")) { + return hostname.split(".")[0] ?? hostname; + } + return hostname; + } + + private get channelPath(): string { + const id = this.channelId; + if (!id) { + throw new Error("Channel not opened"); + } + return `/~/channel/${id}`; + } + + async open(): Promise { + if (this.channelId) { + return; + } + + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + + // Create the channel. + { + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: this.channelPath, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([]), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-open", + }); + + try { + if (!response.ok && response.status !== 204) { + throw new Error(`Channel creation failed: ${response.status}`); + } + } finally { + await release(); + } + } + + // Wake the channel (matches urbit/http-api behavior). + { + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: this.channelPath, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([ + { + id: Date.now(), + action: "poke", + ship: this.ship, + app: "hood", + mark: "helm-hi", + json: "Opening API channel", + }, + ]), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-wake", + }); + + try { + if (!response.ok && response.status !== 204) { + throw new Error(`Channel activation failed: ${response.status}`); + } + } finally { + await release(); + } + } + } + + async poke(params: { app: string; mark: string; json: unknown }): Promise { + await this.open(); + const pokeId = Date.now(); + const pokeData = { + id: pokeId, + action: "poke", + ship: this.ship, + app: params.app, + mark: params.mark, + json: params.json, + }; + + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: this.channelPath, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([pokeData]), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-poke", + }); + + try { + if (!response.ok && response.status !== 204) { + const errorText = await response.text().catch(() => ""); + throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`); + } + return pokeId; + } finally { + await release(); + } + } + + async scry(path: string): Promise { + const scryPath = `/~/scry${path}`; + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: scryPath, + init: { + method: "GET", + headers: { Cookie: this.cookie }, + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-scry", + }); + + try { + if (!response.ok) { + throw new Error(`Scry failed: ${response.status} for path ${path}`); + } + return await response.json(); + } finally { + await release(); + } + } + + async getOurName(): Promise { + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: "/~/name", + init: { + method: "GET", + headers: { Cookie: this.cookie }, + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-name", + }); + + try { + if (!response.ok) { + throw new Error(`Name request failed: ${response.status}`); + } + const text = await response.text(); + return text.trim(); + } finally { + await release(); + } + } + + async close(): Promise { + if (!this.channelId) { + return; + } + const channelPath = this.channelPath; + this.channelId = null; + + try { + const { response, release } = await urbitFetch({ + baseUrl: this.baseUrl, + path: channelPath, + init: { method: "DELETE", headers: { Cookie: this.cookie } }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-close", + }); + try { + void response.body?.cancel(); + } finally { + await release(); + } + } catch { + // ignore cleanup errors + } + } +} diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts new file mode 100644 index 0000000000..f941be841a --- /dev/null +++ b/extensions/tlon/src/urbit/fetch.ts @@ -0,0 +1,38 @@ +import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; +import { validateUrbitBaseUrl } from "./base-url.js"; + +export type UrbitFetchOptions = { + baseUrl: string; + path: string; + init?: RequestInit; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + timeoutMs?: number; + maxRedirects?: number; + signal?: AbortSignal; + auditContext?: string; + pinDns?: boolean; +}; + +export async function urbitFetch(params: UrbitFetchOptions) { + const validated = validateUrbitBaseUrl(params.baseUrl); + if (!validated.ok) { + throw new Error(validated.error); + } + + const url = new URL(params.path, validated.baseUrl).toString(); + return await fetchWithSsrFGuard({ + url, + fetchImpl: params.fetchImpl, + init: params.init, + timeoutMs: params.timeoutMs, + maxRedirects: params.maxRedirects, + signal: params.signal, + policy: params.ssrfPolicy, + lookupFn: params.lookupFn, + auditContext: params.auditContext, + pinDns: params.pinDns, + }); +} diff --git a/extensions/tlon/src/urbit/http-api.ts b/extensions/tlon/src/urbit/http-api.ts deleted file mode 100644 index 13edb97b80..0000000000 --- a/extensions/tlon/src/urbit/http-api.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Urbit } from "@urbit/http-api"; - -let patched = false; - -export function ensureUrbitConnectPatched() { - if (patched) { - return; - } - patched = true; - Urbit.prototype.connect = async function patchedConnect() { - const resp = await fetch(`${this.url}/~/login`, { - method: "POST", - body: `password=${this.code}`, - credentials: "include", - }); - - if (resp.status >= 400) { - throw new Error(`Login failed with status ${resp.status}`); - } - - const cookie = resp.headers.get("set-cookie"); - if (cookie) { - const match = /urbauth-~([\w-]+)/.exec(cookie); - if (match) { - if (!(this as unknown as { ship?: string | null }).ship) { - (this as unknown as { ship?: string | null }).ship = match[1]; - } - (this as unknown as { nodeId?: string }).nodeId = match[1]; - } - (this as unknown as { cookie?: string }).cookie = cookie; - } - - await (this as typeof Urbit.prototype).getShipName(); - await (this as typeof Urbit.prototype).getOurName(); - }; -} - -export { Urbit }; diff --git a/extensions/tlon/src/urbit/sse-client.test.ts b/extensions/tlon/src/urbit/sse-client.test.ts index f194aafc2f..fa0530509c 100644 --- a/extensions/tlon/src/urbit/sse-client.test.ts +++ b/extensions/tlon/src/urbit/sse-client.test.ts @@ -16,7 +16,9 @@ describe("UrbitSSEClient", () => { it("sends subscriptions added after connect", async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" }); - const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123"); + const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", { + lookupFn: async () => [{ address: "1.1.1.1", family: 4 }], + }); (client as { isConnected: boolean }).isConnected = true; await client.subscribe({ diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index 1a1d08e608..de2cf46a79 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,4 +1,7 @@ +import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; import { Readable } from "node:stream"; +import { validateUrbitBaseUrl } from "./base-url.js"; +import { urbitFetch } from "./fetch.js"; export type UrbitSseLogger = { log?: (message: string) => void; @@ -7,6 +10,9 @@ export type UrbitSseLogger = { type UrbitSseOptions = { ship?: string; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; onReconnect?: (client: UrbitSSEClient) => Promise | void; autoReconnect?: boolean; maxReconnectAttempts?: number; @@ -42,32 +48,38 @@ export class UrbitSSEClient { maxReconnectDelay: number; isConnected = false; logger: UrbitSseLogger; + ssrfPolicy?: SsrFPolicy; + lookupFn?: LookupFn; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + streamRelease: (() => Promise) | null = null; constructor(url: string, cookie: string, options: UrbitSseOptions = {}) { - this.url = url; + const validated = validateUrbitBaseUrl(url); + if (!validated.ok) { + throw new Error(validated.error); + } + + this.url = validated.baseUrl; this.cookie = cookie.split(";")[0]; - this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url); + this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname); this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; - this.channelUrl = `${url}/~/channel/${this.channelId}`; + this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString(); this.onReconnect = options.onReconnect ?? null; this.autoReconnect = options.autoReconnect !== false; this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10; this.reconnectDelay = options.reconnectDelay ?? 1000; this.maxReconnectDelay = options.maxReconnectDelay ?? 30000; this.logger = options.logger ?? {}; + this.ssrfPolicy = options.ssrfPolicy; + this.lookupFn = options.lookupFn; + this.fetchImpl = options.fetchImpl; } - private resolveShipFromUrl(url: string): string { - try { - const parsed = new URL(url); - const host = parsed.hostname; - if (host.includes(".")) { - return host.split(".")[0] ?? host; - } - return host; - } catch { - return ""; + private resolveShipFromHostname(hostname: string): string { + if (hostname.includes(".")) { + return hostname.split(".")[0] ?? hostname; } + return hostname; } async subscribe(params: { @@ -107,58 +119,100 @@ export class UrbitSSEClient { app: string; path: string; }) { - const response = await fetch(this.channelUrl, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([subscription]), }, - body: JSON.stringify([subscription]), - signal: AbortSignal.timeout(30_000), + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-subscribe", }); - if (!response.ok && response.status !== 204) { - const errorText = await response.text(); - throw new Error(`Subscribe failed: ${response.status} - ${errorText}`); + try { + if (!response.ok && response.status !== 204) { + const errorText = await response.text().catch(() => ""); + throw new Error( + `Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`, + ); + } + } finally { + await release(); } } async connect() { - const createResp = await fetch(this.channelUrl, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify(this.subscriptions), - signal: AbortSignal.timeout(30_000), - }); + { + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify(this.subscriptions), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-create", + }); - if (!createResp.ok && createResp.status !== 204) { - throw new Error(`Channel creation failed: ${createResp.status}`); + try { + if (!response.ok && response.status !== 204) { + throw new Error(`Channel creation failed: ${response.status}`); + } + } finally { + await release(); + } } - const pokeResp = await fetch(this.channelUrl, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([ - { - id: Date.now(), - action: "poke", - ship: this.ship, - app: "hood", - mark: "helm-hi", - json: "Opening API channel", + { + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([ + { + id: Date.now(), + action: "poke", + ship: this.ship, + app: "hood", + mark: "helm-hi", + json: "Opening API channel", + }, + ]), }, - ]), - signal: AbortSignal.timeout(30_000), - }); + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-wake", + }); - if (!pokeResp.ok && pokeResp.status !== 204) { - throw new Error(`Channel activation failed: ${pokeResp.status}`); + try { + if (!response.ok && response.status !== 204) { + throw new Error(`Channel activation failed: ${response.status}`); + } + } finally { + await release(); + } } await this.openStream(); @@ -172,19 +226,33 @@ export class UrbitSSEClient { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60_000); - const response = await fetch(this.channelUrl, { - method: "GET", - headers: { - Accept: "text/event-stream", - Cookie: this.cookie, + this.streamController = controller; + + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "GET", + headers: { + Accept: "text/event-stream", + Cookie: this.cookie, + }, }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, signal: controller.signal, + auditContext: "tlon-urbit-sse-stream", }); - // Clear timeout once connection established (headers received) + this.streamRelease = release; + + // Clear timeout once connection established (headers received). clearTimeout(timeoutId); if (!response.ok) { + await release(); + this.streamRelease = null; throw new Error(`Stream connection failed: ${response.status}`); } @@ -222,6 +290,12 @@ export class UrbitSSEClient { } } } finally { + if (this.streamRelease) { + const release = this.streamRelease; + this.streamRelease = null; + await release(); + } + this.streamController = null; if (!this.aborted && this.autoReconnect) { this.isConnected = false; this.logger.log?.("[SSE] Stream ended, attempting reconnection..."); @@ -285,39 +359,61 @@ export class UrbitSSEClient { json: params.json, }; - const response = await fetch(this.channelUrl, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([pokeData]), }, - body: JSON.stringify([pokeData]), - signal: AbortSignal.timeout(30_000), + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-poke", }); - if (!response.ok && response.status !== 204) { - const errorText = await response.text(); - throw new Error(`Poke failed: ${response.status} - ${errorText}`); + try { + if (!response.ok && response.status !== 204) { + const errorText = await response.text().catch(() => ""); + throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`); + } + } finally { + await release(); } return pokeId; } async scry(path: string) { - const scryUrl = `${this.url}/~/scry${path}`; - const response = await fetch(scryUrl, { - method: "GET", - headers: { - Cookie: this.cookie, + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/scry${path}`, + init: { + method: "GET", + headers: { + Cookie: this.cookie, + }, }, - signal: AbortSignal.timeout(30_000), + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-scry", }); - if (!response.ok) { - throw new Error(`Scry failed: ${response.status} for path ${path}`); + try { + if (!response.ok) { + throw new Error(`Scry failed: ${response.status} for path ${path}`); + } + return await response.json(); + } finally { + await release(); } - - return await response.json(); } async attemptReconnect() { @@ -347,7 +443,7 @@ export class UrbitSSEClient { try { this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; - this.channelUrl = `${this.url}/~/channel/${this.channelId}`; + this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString(); if (this.onReconnect) { await this.onReconnect(this); @@ -364,6 +460,7 @@ export class UrbitSSEClient { async close() { this.aborted = true; this.isConnected = false; + this.streamController?.abort(); try { const unsubscribes = this.subscriptions.map((sub) => ({ @@ -372,25 +469,61 @@ export class UrbitSSEClient { subscription: sub.id, })); - await fetch(this.channelUrl, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify(unsubscribes), - signal: AbortSignal.timeout(30_000), - }); + { + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify(unsubscribes), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-unsubscribe", + }); + try { + void response.body?.cancel(); + } finally { + await release(); + } + } - await fetch(this.channelUrl, { - method: "DELETE", - headers: { - Cookie: this.cookie, - }, - signal: AbortSignal.timeout(30_000), - }); + { + const { response, release } = await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "DELETE", + headers: { + Cookie: this.cookie, + }, + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: 30_000, + auditContext: "tlon-urbit-channel-close", + }); + try { + void response.body?.cancel(); + } finally { + await release(); + } + } } catch (error) { this.logger.error?.(`Error closing channel: ${String(error)}`); } + + if (this.streamRelease) { + const release = this.streamRelease; + this.streamRelease = null; + await release(); + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 714bfc4147..077fc20ac3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,9 +495,6 @@ importers: '@urbit/aura': specifier: ^3.0.0 version: 3.0.0 - '@urbit/http-api': - specifier: ^3.0.0 - version: 3.0.0 devDependencies: openclaw: specifier: workspace:* @@ -3118,9 +3115,6 @@ packages: resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==} engines: {node: '>=16', npm: '>=8'} - '@urbit/http-api@3.0.0': - resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==} - '@vector-im/matrix-bot-sdk@0.8.0-element.3': resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==} engines: {node: '>=22.0.0'} @@ -3416,9 +3410,6 @@ packages: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} - browser-or-node@1.3.0: - resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==} - buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3569,9 +3560,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-js@3.48.0: - resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} - core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -8647,12 +8635,6 @@ snapshots: '@urbit/aura@3.0.0': {} - '@urbit/http-api@3.0.0': - dependencies: - '@babel/runtime': 7.28.6 - browser-or-node: 1.3.0 - core-js: 3.48.0 - '@vector-im/matrix-bot-sdk@0.8.0-element.3': dependencies: '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 @@ -9034,8 +9016,6 @@ snapshots: dependencies: balanced-match: 4.0.2 - browser-or-node@1.3.0: {} - buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -9187,8 +9167,6 @@ snapshots: cookie@0.7.2: {} - core-js@3.48.0: {} - core-util-is@1.0.2: {} core-util-is@1.0.3: {} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 23d232d62d..853b0c0837 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -146,6 +146,10 @@ export { readRequestBodyWithLimit, requestBodyErrorToText, } from "../infra/http-body.js"; + +export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { SsrFBlockedError, isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js"; +export type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js"; export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js"; export { isTruthyEnvValue } from "../infra/env.js"; export { resolveToolsBySender } from "../config/group-policy.js";