diff --git a/extensions/tlon/src/urbit/auth.ssrf.test.ts b/extensions/tlon/src/urbit/auth.ssrf.test.ts index 11b3b96bdbaa..980e3b9144da 100644 --- a/extensions/tlon/src/urbit/auth.ssrf.test.ts +++ b/extensions/tlon/src/urbit/auth.ssrf.test.ts @@ -24,14 +24,11 @@ describe("tlon urbit auth ssrf", () => { }); it("allows private IPs when allowPrivateNetwork is enabled", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, + const response = new Response("ok", { status: 200, - text: async () => "ok", - headers: new Headers({ - "set-cookie": "urbauth-~zod=123; Path=/; HttpOnly", - }), + headers: { "set-cookie": "urbauth-~zod=123; Path=/; HttpOnly" }, }); + const mockFetch = vi.fn().mockResolvedValue(response); vi.stubGlobal("fetch", mockFetch); const lookupFn = (async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn; @@ -41,6 +38,37 @@ describe("tlon urbit auth ssrf", () => { fetchImpl: mockFetch as typeof fetch, }); expect(cookie).toContain("urbauth-~zod=123"); + expect(response.bodyUsed).toBe(true); expect(mockFetch).toHaveBeenCalled(); }); + + it("cancels an open body stream as soon as the drain cap is reached", async () => { + const cookieValue = "urbauth-~zod=789; Path=/; HttpOnly"; + const cappedBody = new Uint8Array(64 * 1024).fill(0x78); + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(cappedBody); + }, + cancel() { + canceled = true; + }, + }); + const response = new Response(stream, { + status: 200, + headers: { "set-cookie": cookieValue }, + }); + + const mockFetch = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", mockFetch); + const lookupFn = (async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn; + + const cookie = await authenticate("http://127.0.0.1:8080", "code", { + ssrfPolicy: { allowPrivateNetwork: true }, + lookupFn, + fetchImpl: mockFetch as typeof fetch, + }); + expect(cookie).toContain("urbauth-~zod=789"); + expect(canceled).toBe(true); + }); }); diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts index f90ed811d1b6..c17c0cd26dd7 100644 --- a/extensions/tlon/src/urbit/auth.ts +++ b/extensions/tlon/src/urbit/auth.ts @@ -1,8 +1,11 @@ // Tlon plugin module implements auth behavior. +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { UrbitAuthError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; +const MAX_AUTH_BODY_DRAIN_BYTES = 64 * 1024; + type UrbitAuthenticateOptions = { ssrfPolicy?: SsrFPolicy; lookupFn?: LookupFn; @@ -36,8 +39,8 @@ export async function authenticate( throw new UrbitAuthError("auth_failed", `Login failed with status ${response.status}`); } - // Some Urbit setups require the response body to be read before cookie headers finalize. - await response.text().catch(() => {}); + // Finish normal login responses for connection reuse, but cancel as soon as the cap is reached. + await readResponseTextLimited(response, MAX_AUTH_BODY_DRAIN_BYTES).catch(() => undefined); const cookie = response.headers.get("set-cookie"); if (!cookie) { throw new UrbitAuthError("missing_cookie", "No authentication cookie received");