fix(tlon): restore SSRF protection in probeAccount

- Restore channel-client.ts for UrbitChannelClient
- Use UrbitChannelClient with ssrfPolicy in probeAccount
- Ensures account probe respects allowPrivateNetwork setting
This commit is contained in:
Hunter Miller
2026-02-19 12:01:01 -06:00
committed by Josh Lehman
parent 3876597621
commit a7f3113d63
2 changed files with 165 additions and 8 deletions

View File

@@ -17,6 +17,7 @@ import { tlonOnboardingAdapter } from "./onboarding.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
import { authenticate } from "./urbit/auth.js";
import { UrbitChannelClient } from "./urbit/channel-client.js";
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
import {
@@ -443,21 +444,20 @@ export const tlonPlugin: ChannelPlugin = {
return { ok: false, error: "Not configured" };
}
try {
ensureUrbitConnectPatched();
const api = await Urbit.authenticate({
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
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: any) {
return { ok: false, error: error?.message ?? String(error) };
} catch (error) {
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
}
},
buildAccountSnapshot: ({ account, runtime, probe }) => {

View File

@@ -0,0 +1,157 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.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 ctx = getUrbitContext(url, options.ship);
this.baseUrl = ctx.baseUrl;
this.cookie = normalizeUrbitCookie(cookie);
this.ship = ctx.ship;
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
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;
}
const channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
this.channelId = channelId;
try {
await ensureUrbitChannelOpen(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ship: this.ship,
channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{
createBody: [],
createAuditContext: "tlon-urbit-channel-open",
},
);
} catch (error) {
this.channelId = null;
throw error;
}
}
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
await this.open();
const channelId = this.channelId;
if (!channelId) {
throw new Error("Channel not opened");
}
return await pokeUrbitChannel(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ship: this.ship,
channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ ...params, auditContext: "tlon-urbit-poke" },
);
}
async scry(path: string): Promise<unknown> {
return await scryUrbitPath(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ path, auditContext: "tlon-urbit-scry" },
);
}
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
}
}
}