diff --git a/src/errors.ts b/src/errors.ts index fb40f43..5727d3b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2,6 +2,8 @@ import type { LemmyErrorType } from "lemmy-js-client-v0"; import { PiefedErrorResponse } from "./types"; +export type BotChallengeVendor = "anubis" | "cloudflare"; + /** * Machine-readable error codes a fediverse server may return (e.g. * "incorrect_login", "too_many_requests"), exposed on `ResponseError.code`. @@ -67,12 +69,6 @@ export class ResponseError extends FediverseError { } } -// --------------------------------------------------------------------------- -// Condition subclasses. Add a class (and its code mapping in -// CONDITION_BY_CODE) when a consumer needs to branch on a condition — the -// live error-fidelity suite verifies mappings against real instances. -// --------------------------------------------------------------------------- - /** The account has been deleted */ export class AccountDeletedError extends ResponseError { constructor(code: string, options?: ResponseErrorOptions) { @@ -81,6 +77,12 @@ export class AccountDeletedError extends ResponseError { } } +// --------------------------------------------------------------------------- +// Condition subclasses. Add a class (and its code mapping in +// CONDITION_BY_CODE) when a consumer needs to branch on a condition — the +// live error-fidelity suite verifies mappings against real instances. +// --------------------------------------------------------------------------- + /** The account is banned from the instance */ export class BannedError extends ResponseError { constructor(code: string, options?: ResponseErrorOptions) { @@ -89,6 +91,26 @@ export class BannedError extends ResponseError { } } +/** + * The request was intercepted by the instance's bot protection (e.g. a + * Cloudflare "Just a moment..." challenge) and never reached the fediverse + * software. Typically means the request's fingerprint looked automated — + * e.g. a native app sending a browser User-Agent over a non-browser TLS + * stack. Which protection intercepted it is on `.vendor`. + */ +export class BotChallengeError extends FediverseError { + vendor: BotChallengeVendor; + + constructor(vendor: BotChallengeVendor, errorOptions?: ErrorOptions) { + super( + `Blocked by the instance's bot protection challenge (${vendor})`, + errorOptions, + ); + this.name = "BotChallengeError"; + this.vendor = vendor; + } +} + /** Admins cannot be blocked */ export class CantBlockAdminError extends ResponseError { constructor(code: string, options?: ResponseErrorOptions) { @@ -192,6 +214,28 @@ export class UnsupportedSoftwareError extends UnsupportedError { } } +/** + * Which bot protection's challenge interstitial `response` is, or + * `undefined` if it doesn't look like one and presumably came from the + * fediverse software itself. + * + * Cloudflare documents `cf-mitigated: challenge`. Anubis has no documented + * detection contract, so markers are empirical (verified against live + * v1.25/v1.26 deployments; the live-smoke suite detects drift): its + * `*anubis*` cookies (readable on native and server runtimes; browsers hide + * Set-Cookie, but real browsers pass challenges anyway) and its + * `/.within.website/` vendor asset namespace in the challenge HTML (`body` — + * already-consumed text). + */ +export function detectBotChallenge( + response: Response, + body?: string, +): BotChallengeVendor | undefined { + if (response.headers.get("cf-mitigated") === "challenge") return "cloudflare"; + if (response.headers.get("set-cookie")?.includes("anubis")) return "anubis"; + if (body?.includes("/.within.website/")) return "anubis"; +} + // Lemmy codes (v0 + v1) plus PieFed's native codes as observed by the live // error-fidelity suite (PieFed puts human-ish prose in its message field; // the exact strings below were captured from piefed.social 2026-07-02 — diff --git a/src/helpers.ts b/src/helpers.ts index da39a2c..4e35174 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -14,3 +14,37 @@ export function cleanThreadiverseParams

>( export function toLowerCase(type: T): Lowercase { return type.toLowerCase() as Lowercase; } + +/** + * User-Agent and Capacitor's Android alias for it. The Android webview + * strips `User-Agent` once headers enter a `Request` object + * (https://issues.chromium.org/issues/40450316), so Capacitor apps send + * `x-cap-user-agent` instead (converted back to `User-Agent` natively). It + * only appears on native, where CORS doesn't apply. + * + * Forwarding the user agent matters: instances behind bot protection (e.g. + * piefed.social's Cloudflare) challenge requests that arrive with a browser + * User-Agent over a non-browser network stack. + */ +export const USER_AGENT_HEADERS = ["User-Agent", "x-cap-user-agent"]; + +/** + * Pick `allowed` headers (case-insensitively) from `headers`. Used where + * headers can't be forwarded wholesale — e.g. piefed's CORS policy only + * allows `Content-Type, Authorization, Accept, User-Agent`, so forwarding + * anything else (like `Cache-Control`) fails preflight in browsers. + */ +export function pickHeaders( + headers: Record | undefined, + allowed: string[], +): Record | undefined { + if (!headers) return; + + const picked = Object.fromEntries( + Object.entries(headers).filter(([key]) => + allowed.some((allow) => allow.toLowerCase() === key.toLowerCase()), + ), + ); + + if (Object.keys(picked).length) return picked; +} diff --git a/src/providers/piefed/index.ts b/src/providers/piefed/index.ts index 6aba183..a2702a6 100644 --- a/src/providers/piefed/index.ts +++ b/src/providers/piefed/index.ts @@ -6,10 +6,13 @@ import { RequestOptions, } from "../../BaseClient"; import { + BotChallengeError, createResponseError, + detectBotChallenge, InvalidPayloadError, UnsupportedError, } from "../../errors"; +import { pickHeaders, USER_AGENT_HEADERS } from "../../helpers"; import buildSafeClient from "../../SafeClient"; import { PiefedErrorResponse } from "../../schemas"; import * as types from "../../types"; @@ -23,33 +26,48 @@ import * as compat from "./compat"; import { components, paths } from "./schema"; async function validateResponse(response: Response) { - if (!response.ok) { - let code = response.statusText; - let payload: types.PiefedErrorResponse | undefined; - try { - const data: unknown = await response.json(); - const parsed = PiefedErrorResponse.safeParse(data); - if (parsed.success) { - payload = parsed.data; - code = parsed.data.message; - } else if ( - data && - typeof data === "object" && - "error" in data && - typeof data.error === "string" - ) { - code = data.error; - } - } catch { - // Non-JSON body (e.g., HTML error page from an upstream proxy). - // Fall back to statusText already set above. + if (response.ok) { + // Bot challenges (e.g. Anubis) can interject HTML with a 2xx status. + // Clone so the caller can still consume the body. + if (response.headers.get("content-type")?.includes("text/html")) { + const body = await response.clone().text(); + const vendor = detectBotChallenge(response, body); + if (vendor) throw new BotChallengeError(vendor); } - throw createResponseError(code, { - response: payload, - software: "piefed", - status: response.status, - }); + return; + } + + const headerVendor = detectBotChallenge(response); + if (headerVendor) throw new BotChallengeError(headerVendor); + + let code = response.statusText; + let payload: types.PiefedErrorResponse | undefined; + const body = await response.text(); + try { + const data: unknown = JSON.parse(body); + const parsed = PiefedErrorResponse.safeParse(data); + if (parsed.success) { + payload = parsed.data; + code = parsed.data.message; + } else if ( + data && + typeof data === "object" && + "error" in data && + typeof data.error === "string" + ) { + code = data.error; + } + } catch { + // Non-JSON body (e.g., HTML error page from an upstream proxy). + // Fall back to statusText already set above. + const vendor = detectBotChallenge(response, body); + if (vendor) throw new BotChallengeError(vendor); } + throw createResponseError(code, { + response: payload, + software: "piefed", + status: response.status, + }); } const piefedMiddleware: Middleware = { @@ -76,18 +94,19 @@ export class UnsafePiefedClient implements BaseClient { this.#customFetch = options.fetchFunction ?? globalThis.fetch; this.#url = url; - const headers = options.headers?.Authorization - ? { - Authorization: options.headers?.Authorization, - } - : undefined; + // Piefed's CORS policy only allows `Content-Type, Authorization, Accept, + // User-Agent` — forwarding anything else (e.g. `Cache-Control`) fails + // preflight in browsers. + const headers = pickHeaders(options.headers, [ + "Authorization", + ...USER_AGENT_HEADERS, + ]); this.#headers = headers; this.#client = createClient({ baseUrl: url, fetch: options.fetchFunction, - // TODO: piefed doesn't allow CORS headers other than Authorization headers, }); diff --git a/src/wellknown.ts b/src/wellknown.ts index a72f2d4..328dde4 100644 --- a/src/wellknown.ts +++ b/src/wellknown.ts @@ -1,7 +1,12 @@ import { z } from "zod/v4-mini"; import { BaseClientOptions } from "./BaseClient"; -import { UnexpectedResponseError } from "./errors"; +import { + BotChallengeError, + detectBotChallenge, + UnexpectedResponseError, +} from "./errors"; +import { pickHeaders, USER_AGENT_HEADERS } from "./helpers"; export const NodeinfoLink = z.object({ href: z.string(), @@ -38,10 +43,14 @@ export async function resolveSoftware( ): Promise { const fetch = options?.fetchFunction ?? globalThis.fetch; + // Discovery hits arbitrary instances, so only forward headers that are + // universally CORS-safe (see USER_AGENT_HEADERS for why the user agent + // must be forwarded). Notably not Authorization — no reason to send + // credentials to nodeinfo endpoints. const fetchOptions: RequestInit = { headers: { - // ...options?.headers, // TODO: Piefed doesn't allow many headers for CORS Accept: "application/json", + ...pickHeaders(options?.headers, USER_AGENT_HEADERS), }, }; @@ -49,7 +58,7 @@ export async function resolveSoftware( const data = parseDiscoveryPayload( NodeinfoLinksPayload, - await response.json(), + await parseDiscoveryJson(response, "nodeinfo links"), "nodeinfo links", ); @@ -62,13 +71,40 @@ export async function resolveSoftware( const nodeinfoData = parseDiscoveryPayload( Nodeinfo21Payload, - await nodeinfoResponse.json(), + await parseDiscoveryJson(nodeinfoResponse, "nodeinfo"), "nodeinfo", ); return nodeinfoData.software; } +/** + * Non-JSON discovery responses get a diagnosis instead of a raw + * `SyntaxError`: a bot-protection interstitial (e.g. Cloudflare's + * "Just a moment..." HTML page) throws `BotChallengeError` so consumers can + * explain what actually blocked the connection. + */ +async function parseDiscoveryJson( + response: Response, + what: string, +): Promise { + const headerVendor = detectBotChallenge(response); + if (headerVendor) throw new BotChallengeError(headerVendor); + + const body = await response.text(); + + try { + return JSON.parse(body); + } catch (error) { + const vendor = detectBotChallenge(response, body); + if (vendor) throw new BotChallengeError(vendor); + + throw new UnexpectedResponseError(`Non-JSON ${what} response`, { + cause: error, + }); + } +} + /** * Discovery is the "is this even a supported fediverse instance?" boundary, * so malformed payloads surface as the library's error taxonomy (with the diff --git a/test/ThreadiverseClient.connect.test.ts b/test/ThreadiverseClient.connect.test.ts index cdf78cc..e50f6ca 100644 --- a/test/ThreadiverseClient.connect.test.ts +++ b/test/ThreadiverseClient.connect.test.ts @@ -34,6 +34,28 @@ describe("connect() and sync introspection", () => { expect(fake.calls("GET /.well-known/nodeinfo")).toHaveLength(1); }); + it("forwards User-Agent (and not credentials) to discovery requests", async () => { + const fake = new FakeLemmyV1Instance(); + const client = new ThreadiverseClient(fake.origin, { + ...fake.clientOptions(), + headers: { + Authorization: "Bearer abc", + "User-Agent": "VoyagerApp/1.0", + }, + }); + + await client.connect(); + + for (const matcher of [ + "GET /.well-known/nodeinfo", + "GET /nodeinfo/2.1", + ] as const) { + const [call] = fake.calls(matcher); + expect(call?.headers).toMatchObject({ "user-agent": "VoyagerApp/1.0" }); + expect(call?.headers).not.toHaveProperty("authorization"); + } + }); + it("any API call connects implicitly", async () => { const fake = new FakeLemmyV1Instance(); const client = new ThreadiverseClient(fake.origin, fake.clientOptions()); diff --git a/test/bot-challenge-and-headers.test.ts b/test/bot-challenge-and-headers.test.ts new file mode 100644 index 0000000..dee4672 --- /dev/null +++ b/test/bot-challenge-and-headers.test.ts @@ -0,0 +1,197 @@ +// Instances behind bot protection (e.g. piefed.social's Cloudflare) +// challenge requests that don't carry the app's User-Agent — and Capacitor +// Android apps must send it as x-cap-user-agent to survive the webview +// (see USER_AGENT_HEADERS). These tests pin the two behaviors that keep +// that working: header forwarding on every piefed code path, and challenge +// responses surfacing as BotChallengeError instead of raw JSON parse errors. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { BaseClientOptions } from "../src/BaseClient"; +import { UnexpectedResponseError } from "../src/errors"; +import ThreadiverseClient, { clearCache } from "../src/ThreadiverseClient"; + +const BASE_URL = "https://piefed.example.com"; +const NODEINFO_HREF = `${BASE_URL}/nodeinfo/2.1`; + +const CLIENT_HEADERS = { + Authorization: "Bearer abc", + ["Cache-Control"]: "no-cache", + ["User-Agent"]: "VoyagerApp/1.0", + ["x-cap-user-agent"]: "VoyagerApp/1.0", +}; + +const CLOUDFLARE_CHALLENGE = new Response( + "Just a moment...", + { + headers: { "cf-mitigated": "challenge", "Content-Type": "text/html" }, + status: 403, + }, +); + +const ANUBIS_CHALLENGE = new Response( + '', + { headers: { "Content-Type": "text/html" }, status: 200 }, +); + +function json(payload: unknown) { + return new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); +} + +const NODEINFO_LINKS = { + links: [ + { + href: NODEINFO_HREF, + rel: "http://nodeinfo.diaspora.software/ns/schema/2.1", + }, + ], +}; + +const NODEINFO = { software: { name: "piefed", version: "1.6.27" } }; + +const SITE = { + admins: [], + site: { + actor_id: `${BASE_URL}/`, + name: "Test", + registration_mode: "Open", + user_count: 100, + }, + version: "1.6.27", +}; + +/** + * Client against a piefed instance where every route can be overridden. + * Records the headers of each request by pathname. + */ +function setup(overrides: Record Response> = {}) { + const requestHeaders: Record = {}; + + const fetchFunction = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const { pathname } = new URL(request.url); + + requestHeaders[pathname] = request.headers; + + const override = overrides[pathname]; + if (override) return override(); + + switch (pathname) { + case "/.well-known/nodeinfo": + return json(NODEINFO_LINKS); + case "/api/alpha/site": + return json(SITE); + case "/nodeinfo/2.1": + return json(NODEINFO); + default: + throw new Error(`Unexpected fetch call: ${request.url}`); + } + }, + ) as BaseClientOptions["fetchFunction"]; + + const client = new ThreadiverseClient(BASE_URL, { + fetchFunction, + headers: CLIENT_HEADERS, + }); + + return { client, requestHeaders }; +} + +beforeEach(() => { + clearCache(); +}); + +describe("piefed header forwarding", () => { + it("forwards the user agent (but not credentials) to discovery", async () => { + const { client, requestHeaders } = setup(); + + await client.connect(); + + for (const pathname of ["/.well-known/nodeinfo", "/nodeinfo/2.1"]) { + const headers = requestHeaders[pathname]!; + expect(headers.get("user-agent")).toBe("VoyagerApp/1.0"); + expect(headers.get("x-cap-user-agent")).toBe("VoyagerApp/1.0"); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("cache-control")).toBeNull(); + } + }); + + it("forwards user agent + Authorization (but nothing else) to API calls", async () => { + const { client, requestHeaders } = setup(); + + await client.getSite(); + + const headers = requestHeaders["/api/alpha/site"]!; + expect(headers.get("user-agent")).toBe("VoyagerApp/1.0"); + expect(headers.get("x-cap-user-agent")).toBe("VoyagerApp/1.0"); + expect(headers.get("authorization")).toBe("Bearer abc"); + // Not in piefed's Access-Control-Allow-Headers — would fail preflight + expect(headers.get("cache-control")).toBeNull(); + }); +}); + +describe("bot challenge detection", () => { + it("Cloudflare challenge during discovery throws BotChallengeError", async () => { + const { client } = setup({ + "/.well-known/nodeinfo": () => CLOUDFLARE_CHALLENGE.clone(), + }); + + await expect(client.connect()).rejects.toThrow( + expect.objectContaining({ + name: "BotChallengeError", + vendor: "cloudflare", + }), + ); + }); + + it("Anubis challenge (2xx HTML) during discovery throws BotChallengeError", async () => { + const { client } = setup({ + "/.well-known/nodeinfo": () => ANUBIS_CHALLENGE.clone(), + }); + + await expect(client.connect()).rejects.toThrow( + expect.objectContaining({ name: "BotChallengeError", vendor: "anubis" }), + ); + }); + + it("Cloudflare challenge on a piefed API call throws BotChallengeError", async () => { + const { client } = setup({ + "/api/alpha/site": () => CLOUDFLARE_CHALLENGE.clone(), + }); + + await expect(client.getSite()).rejects.toThrow( + expect.objectContaining({ + name: "BotChallengeError", + vendor: "cloudflare", + }), + ); + }); + + it("Anubis challenge (2xx HTML) on a piefed API call throws BotChallengeError", async () => { + const { client } = setup({ + "/api/alpha/site": () => ANUBIS_CHALLENGE.clone(), + }); + + await expect(client.getSite()).rejects.toThrow( + expect.objectContaining({ name: "BotChallengeError", vendor: "anubis" }), + ); + }); + + it("other non-JSON discovery responses throw UnexpectedResponseError", async () => { + const { client } = setup({ + "/.well-known/nodeinfo": () => + new Response("not a fediverse instance", { + headers: { "Content-Type": "text/html" }, + status: 200, + }), + }); + + await expect(client.connect()).rejects.toBeInstanceOf( + UnexpectedResponseError, + ); + }); +}); diff --git a/test/live-smoke.test.ts b/test/live-smoke.test.ts index b91503d..985cf8f 100644 --- a/test/live-smoke.test.ts +++ b/test/live-smoke.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; +import { detectBotChallenge } from "../src/errors"; import ThreadiverseClient from "../src/ThreadiverseClient"; const INSTANCES = [ @@ -68,3 +69,20 @@ describe.runIf(process.env.LIVE_SMOKE)("live smoke", () => { }); }); }); + +// Bot-challenge detection markers are empirical (Anubis documents no +// contract and has renamed its cookies before) — verify they still match a +// real deployment. Requests a page with a browser-ish User-Agent, which +// Anubis challenges by default. +describe.runIf(process.env.LIVE_SMOKE)("anubis challenge detection", () => { + it("recognizes a live anubis challenge", OPTIONS, async () => { + const response = await fetch("https://xeiaso.net/", { + headers: { + // Node lets fetch override User-Agent (unlike browsers) + ["User-Agent"]: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }, + }); + + expect(detectBotChallenge(response, await response.text())).toBe("anubis"); + }); +});