fix(security): harden tlon Urbit requests against SSRF

This commit is contained in:
Peter Steinberger
2026-02-14 18:41:23 +01:00
parent 5a313c83b7
commit bfa7d21e99
18 changed files with 735 additions and 191 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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:*"

View File

@@ -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) };

View File

@@ -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(),

View File

@@ -113,10 +113,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
let api: UrbitSSEClient | null = null;
try {
const ssrfPolicy = account.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
const cookie = await authenticate(account.url, account.code);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
api = new UrbitSSEClient(account.url, cookie, {
ship: botShipName,
ssrfPolicy,
logger: {
log: (message) => runtime.log?.(message),
error: (message) => runtime.error?.(message),

View File

@@ -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<void> {
"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,

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -1,18 +1,47 @@
export async function authenticate(url: string, code: string): Promise<string> {
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<Response>;
timeoutMs?: number;
};
export async function authenticate(
url: string,
code: string,
options: UrbitAuthenticateOptions = {},
): Promise<string> {
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;
}

View File

@@ -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);
}

View File

@@ -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<Response>;
};
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<Response>;
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<void> {
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<number> {
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<unknown> {
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<string> {
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<void> {
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
}
}
}

View File

@@ -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<Response>;
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,
});
}

View File

@@ -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 };

View File

@@ -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({

View File

@@ -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<Response>;
onReconnect?: (client: UrbitSSEClient) => Promise<void> | 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<Response>;
streamRelease: (() => Promise<void>) | 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();
}
}
}

22
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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";