VYPR
High severity8.3NVD Advisory· Published Mar 5, 2026· Updated Apr 21, 2026

CVE-2026-28476

CVE-2026-28476

Description

OpenClaw versions prior to 2026.2.14 contain a server-side request forgery vulnerability in the optional Tlon Urbit extension that accepts user-provided base URLs for authentication without proper validation. Attackers who can influence the configured Urbit URL can induce the gateway to make HTTP requests to arbitrary hosts including internal addresses.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.142026.2.14

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.2.14

Patches

1
bfa7d21e997b

fix(security): harden tlon Urbit requests against SSRF

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
18 files changed · +735 191
  • CHANGELOG.md+1 0 modified
    @@ -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.
    
  • docs/channels/tlon.md+16 0 modified
    @@ -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:
    
  • extensions/tlon/package.json+1 2 modified
    @@ -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:*"
    
  • extensions/tlon/src/channel.ts+16 17 modified
    @@ -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) };
    
  • extensions/tlon/src/config-schema.ts+2 0 modified
    @@ -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(),
    
  • extensions/tlon/src/monitor/index.ts+3 1 modified
    @@ -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),
    
  • extensions/tlon/src/onboarding.ts+34 1 modified
    @@ -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,
    
  • extensions/tlon/src/types.ts+7 0 modified
    @@ -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,
    
  • extensions/tlon/src/urbit/auth.ssrf.test.ts+42 0 added
    @@ -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();
    +  });
    +});
    
  • extensions/tlon/src/urbit/auth.ts+42 13 modified
    @@ -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;
     }
    
  • extensions/tlon/src/urbit/base-url.ts+49 0 added
    @@ -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);
    +}
    
  • extensions/tlon/src/urbit/channel-client.ts+248 0 added
    @@ -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
    +    }
    +  }
    +}
    
  • extensions/tlon/src/urbit/fetch.ts+38 0 added
    @@ -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,
    +  });
    +}
    
  • extensions/tlon/src/urbit/http-api.ts+0 38 removed
    @@ -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 };
    
  • extensions/tlon/src/urbit/sse-client.test.ts+3 1 modified
    @@ -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({
    
  • extensions/tlon/src/urbit/sse-client.ts+229 96 modified
    @@ -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();
    +    }
       }
     }
    
  • pnpm-lock.yaml+0 22 modified
    @@ -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: {}
    
  • src/plugin-sdk/index.ts+4 0 modified
    @@ -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";
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.