From b03f80d2d6b449860c2984774fe2abce3abf17f0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 00:28:13 +0900 Subject: [PATCH 01/10] Honor private addresses in recurse lookups Make `fedify lookup --recurse` honor the same explicit `-p`/`--allow-private-address` opt-in that `--traverse` already uses for discovered URLs. Update the recursive lookup failure hints to use Optique option markup, refresh the CLI docs and changelog, and add regression tests covering both the default blocked behavior and the opt-in path using `srvx`. Fixes https://github.com/fedify-dev/fedify/issues/700 Assisted-by: Codex:gpt-5.4 --- CHANGES.md | 9 ++ docs/cli.md | 21 ++--- packages/cli/src/lookup.test.ts | 144 ++++++++++++++++++++++++++++++++ packages/cli/src/lookup.ts | 122 ++++++++++++++------------- 4 files changed, 225 insertions(+), 71 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 7307dd7c9..52bd641e0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -117,10 +117,19 @@ To be released. ### @fedify/cli + - Made `fedify lookup --recurse` honor `-p`/`--allow-private-address` + for recursively discovered object URLs, matching the policy already used + by `-t`/`--traverse`. Recursive lookups still reject private or + localhost targets by default unless users explicitly opt in. + [[#700], [#718]] + - Added [FEP-044f] `quote` support to `fedify lookup --recurse`, so the CLI can follow both the new quote-post relation and the older `quoteUrl` compatibility surface. [[#452], [#679]] +[#700]: https://github.com/fedify-dev/fedify/issues/700 +[#718]: https://github.com/fedify-dev/fedify/pull/718 + ### @fedify/solidstart - Added `@fedify/solidstart` package for integrating Fedify with diff --git a/docs/cli.md b/docs/cli.md index 67ee8d0c0..7917016b9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -545,11 +545,11 @@ For short names, only Fedify property naming is accepted. For example, > `--recurse` and [`-t`/`--traverse`](#t-traverse-traverse-the-collection) > are mutually exclusive. > -> Recursive fetches always disallow private/localhost addresses for safety. -> URLs explicitly provided on the command line always allow private -> addresses, while +> Recursive fetches disallow private/localhost addresses by default for +> safety. URLs explicitly provided on the command line always allow private +> addresses, while recursive steps honor > [`-p`/`--allow-private-address`](#p-allow-private-address-allow-private-ip-addresses) -> has no effect on recursive steps. +> when you explicitly opt in. ### `--recurse-depth`: Set recursion depth limit @@ -1015,21 +1015,18 @@ fedify lookup http://localhost:8000/users/alice ~~~~ The `-p`/`--allow-private-address` option additionally allows private -addresses for URLs discovered during traversal. It only has an effect -when used together with -[`-t`/`--traverse`](#t-traverse-traverse-the-collection), since URLs +addresses for URLs discovered during traversal or recursion. It only +affects discovered URLs used by +[`-t`/`--traverse`](#t-traverse-traverse-the-collection) and +[`--recurse`](#recurse-recurse-through-object-relationships), since URLs embedded in remote responses are otherwise rejected to mitigate SSRF attacks against private addresses. ~~~~ sh fedify lookup --traverse --allow-private-address http://localhost:8000/users/alice/outbox +fedify lookup --recurse=replyTarget --allow-private-address http://localhost:8000/notes/1 ~~~~ -> [!NOTE] -> Recursive fetches enabled by -> [`--recurse`](#recurse-recurse-through-object-relationships) always -> disallow private addresses regardless of this option. - ### `-s`/`--separator`: Output separator *This option is available since Fedify 1.3.0.* diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 7f6bdf7f8..01e9c8407 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import process from "node:process"; import { Writable } from "node:stream"; import test from "node:test"; +import { serve } from "srvx"; import { configContext } from "./config.ts"; import { getContextLoader } from "./docloader.ts"; import { runCli } from "./runner.ts"; @@ -1062,6 +1063,149 @@ function extractIdsFromRawOutput(content: string): string[] { ); } +async function withRecursiveLookupServer( + callback: (server: { + rootUrl: URL; + replyUrl: URL; + requestedPaths: string[]; + }) => Promise, +): Promise { + const requestedPaths: string[] = []; + const server = serve({ + port: 0, + hostname: "127.0.0.1", + silent: true, + fetch(request) { + const requestUrl = new URL(request.url); + const rootUrl = new URL("/notes/1", requestUrl.origin); + const replyUrl = new URL("/notes/0", requestUrl.origin); + requestedPaths.push(requestUrl.pathname); + + let body: unknown; + if (requestUrl.pathname === rootUrl.pathname) { + body = { + "@context": "https://www.w3.org/ns/activitystreams", + id: rootUrl.href, + type: "Note", + content: "root", + inReplyTo: replyUrl.href, + }; + } else if (requestUrl.pathname === replyUrl.pathname) { + body = { + "@context": "https://www.w3.org/ns/activitystreams", + id: replyUrl.href, + type: "Note", + content: "reply", + }; + } else { + return new Response(null, { status: 404 }); + } + + return Response.json(body, { + headers: { + "Content-Type": "application/activity+json", + }, + }); + }, + }); + + await server.ready(); + assert.ok(server.url != null); + const origin = new URL(server.url).origin; + try { + return await callback({ + rootUrl: new URL("/notes/1", origin), + replyUrl: new URL("/notes/0", origin), + requestedPaths, + }); + } finally { + await server.close(true); + } +} + +test("runLookup - rejects recursive private targets by default", async () => { + const testDir = "./test_output_runlookup_recurse_private_default"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + await withRecursiveLookupServer(async ({ rootUrl, requestedPaths }) => { + const originalWrite = process.stderr.write; + let stderr = ""; + process.stderr.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: unknown, + callback?: () => void, + ) => { + stderr += typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString(); + if (typeof encodingOrCallback === "function") { + encodingOrCallback(); + } else { + callback?.(); + } + return true; + }) as typeof process.stderr.write; + let exitCode: number | null; + try { + exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [rootUrl.href], + recurse: "replyTarget", + recurseDepth: 20, + allowPrivateAddress: false, + output: testFile, + }), + ); + } finally { + process.stderr.write = originalWrite; + } + assert.equal(exitCode, 1); + assert.deepEqual(requestedPaths, ["/notes/1"]); + assert.match( + stderr, + /Try with `-p`\/`--allow-private-address`/, + ); + + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]); + }); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - allows recursive private targets with allowPrivateAddress", async () => { + const testDir = "./test_output_runlookup_recurse_private_allowed"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + await withRecursiveLookupServer( + async ({ rootUrl, replyUrl, requestedPaths }) => { + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [rootUrl.href], + recurse: "replyTarget", + recurseDepth: 20, + allowPrivateAddress: true, + output: testFile, + }), + ); + assert.equal(exitCode, 0); + assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]); + + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + rootUrl.href, + replyUrl.href, + ]); + }, + ); + } finally { + await rm(testDir, { recursive: true }); + } +}); + test("runLookup - reverses output order in default multi-input mode", async () => { const testDir = "./test_output_runlookup_default_reverse"; const testFile = `${testDir}/out.jsonl`; diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 069700162..e743c9ab6 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -12,7 +12,11 @@ import { Object as APObject, traverseCollection, } from "@fedify/vocab"; -import { type DocumentLoader, UrlError } from "@fedify/vocab-runtime"; +import { + type DocumentLoader, + UrlError, + validatePublicUrl, +} from "@fedify/vocab-runtime"; import type { ResourceDescriptor } from "@fedify/webfinger"; import { getLogger } from "@logtape/logtape"; import { bindConfig } from "@optique/config"; @@ -87,11 +91,8 @@ const suppressErrorsOption = bindConfig( const allowPrivateAddressOption = bindConfig( flag("-p", "--allow-private-address", { description: message`Allow private IP addresses for URLs discovered \ -during traversal. This option only has an effect when used together \ -with ${optionNames(["-t", "--traverse"])}, since URLs explicitly \ -provided on the command line always allow private addresses and \ -recursive fetches via ${optionNames(["--recurse"])} always disallow \ -them.`, +during traversal or recursion. URLs explicitly provided on the \ +command line always allow private addresses.`, }), { context: configContext, @@ -527,7 +528,9 @@ function handleTimeoutError( const urlText = url ? ` for: ${colors.red(url)}` : ""; spinner.fail(`Request timed out after ${timeoutSeconds} seconds${urlText}.`); printError( - message`Try increasing the timeout with -T/--timeout option or check network connectivity.`, + message`Try increasing the timeout with ${ + optionNames(["-T", "--timeout"]) + } option or check network connectivity.`, ); } @@ -548,6 +551,15 @@ function isPrivateAddressError(error: unknown): boolean { ); } +async function isPrivateAddressTarget(target: string): Promise { + try { + await validatePublicUrl(target); + } catch (error) { + return isPrivateAddressError(error); + } + return false; +} + export function getLookupFailureHint( error: unknown, options: { recursive?: boolean } = {}, @@ -582,17 +594,25 @@ function printLookupFailureHint( switch (hint) { case "private-address": printError( - message`The URL appears to be private or localhost. Try with -p/--allow-private-address.`, + message`The URL appears to be private or localhost. Try with ${ + optionNames(["-p", "--allow-private-address"]) + }.`, ); return; case "recursive-private-address": printError( - message`Recursive fetches do not allow private/localhost URLs. Use -S/--suppress-errors to skip blocked steps, or fetch those targets explicitly without --recurse.`, + message`The recursive target appears to be private or localhost. Try with ${ + optionNames(["-p", "--allow-private-address"]) + }, or use ${ + optionNames(["-S", "--suppress-errors"]) + } to skip blocked steps.`, ); return; case "authorized-fetch": printError( - message`It may be a private object. Try with -a/--authorized-fetch.`, + message`It may be a private object. Try with ${ + optionNames(["-a", "--authorized-fetch"]) + }.`, ); return; } @@ -730,10 +750,8 @@ export async function runLookup( let server: TemporaryServer | undefined = undefined; // URLs explicitly provided by the user always allow private addresses, // so that local servers can be looked up without -p/--allow-private-address. - // URLs discovered during traversal follow the option to mitigate SSRF - // against private addresses, while recursive fetches always disallow - // private addresses regardless of the option (see the --recurse branch - // below, which hardcodes `allowPrivateAddress: false`). + // URLs discovered during traversal or recursion follow the option to + // mitigate SSRF against private addresses. const initialBaseDocumentLoader = await getDocumentLoader({ userAgent: command.userAgent, allowPrivateAddress: true, @@ -894,46 +912,11 @@ export async function runLookup( }...`; if (command.recurse != null) { - const recursiveBaseDocumentLoader = await getDocumentLoader({ - userAgent: command.userAgent, - allowPrivateAddress: false, - }); - const recursiveDocumentLoader = wrapDocumentLoaderWithTimeout( - recursiveBaseDocumentLoader, - command.timeout, - ); - const recursiveBaseContextLoader = await getContextLoader({ - userAgent: command.userAgent, - allowPrivateAddress: false, - }); - const recursiveContextLoader = wrapDocumentLoaderWithTimeout( - recursiveBaseContextLoader, - command.timeout, - ); - const recursiveAuthLoader = command.authorizedFetch && - authIdentity != null - ? wrapDocumentLoaderWithTimeout( - getAuthenticatedDocumentLoader( - authIdentity, - { - allowPrivateAddress: false, - userAgent: command.userAgent, - specDeterminer: { - determineSpec() { - return command.firstKnock; - }, - rememberSpec() { - }, - }, - }, - ), - command.timeout, - ) - : undefined; const initialLookupDocumentLoader: DocumentLoader = initialAuthLoader ?? initialDocumentLoader; - const recursiveLookupDocumentLoader: DocumentLoader = recursiveAuthLoader ?? - recursiveDocumentLoader; + const recursiveLookupDocumentLoader: DocumentLoader = authLoader ?? + documentLoader; + const recursiveContextLoader = contextLoader; let totalObjects = 0; const recurseDepth = command.recurseDepth!; @@ -966,7 +949,9 @@ export async function runLookup( spinner.fail(`Failed to fetch object: ${colors.red(url)}.`); if (authLoader == null) { printError( - message`It may be a private object. Try with -a/--authorized-fetch.`, + message`It may be a private object. Try with ${ + optionNames(["-a", "--authorized-fetch"]) + }.`, ); } await finalizeAndExit(1); @@ -1048,9 +1033,20 @@ export async function runLookup( spinner.fail( `Failed to recursively fetch object: ${colors.red(error.target)}.`, ); - if (authLoader == null) { + if ( + !command.allowPrivateAddress && + await isPrivateAddressTarget(error.target) + ) { + printLookupFailureHint( + authLoader, + new UrlError("Invalid or private address"), + { recursive: true }, + ); + } else if (authLoader == null) { printError( - message`It may be a private object. Try with -a/--authorized-fetch.`, + message`It may be a private object. Try with ${ + optionNames(["-a", "--authorized-fetch"]) + }.`, ); } } else { @@ -1058,7 +1054,9 @@ export async function runLookup( const hint = getLookupFailureHint(error, { recursive: true }); if (shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint)) { printError( - message`Use the -S/--suppress-errors option to suppress partial errors.`, + message`Use the ${ + optionNames(["-S", "--suppress-errors"]) + } option to suppress partial errors.`, ); } else { printLookupFailureHint(authLoader, error, { recursive: true }); @@ -1172,7 +1170,9 @@ export async function runLookup( spinner.fail(`Failed to fetch object: ${colors.red(url)}.`); if (authLoader == null) { printError( - message`It may be a private object. Try with -a/--authorized-fetch.`, + message`It may be a private object. Try with ${ + optionNames(["-a", "--authorized-fetch"]) + }.`, ); } await finalizeAndExit(1); @@ -1272,7 +1272,9 @@ export async function runLookup( const hint = getLookupFailureHint(error); if (shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint)) { printError( - message`Use the -S/--suppress-errors option to suppress partial errors.`, + message`Use the ${ + optionNames(["-S", "--suppress-errors"]) + } option to suppress partial errors.`, ); } else { printLookupFailureHint(authLoader, error); @@ -1323,7 +1325,9 @@ export async function runLookup( spinner.fail(`Failed to fetch ${colors.red(url)}`); if (authLoader == null) { printError( - message`It may be a private object. Try with -a/--authorized-fetch.`, + message`It may be a private object. Try with ${ + optionNames(["-a", "--authorized-fetch"]) + }.`, ); } success = false; From 620cd708d0ccc143268ee6dfb2466595838e0be6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 00:53:15 +0900 Subject: [PATCH 02/10] Keep recursive contexts on strict loader Limit the `-p`/`--allow-private-address` opt-in to recursive object fetches and keep indirect JSON-LD `@context` loads on the strict loader. Add a regression test that proves recursive private contexts stay blocked even when recursive object fetches are explicitly allowed. Addresses https://github.com/fedify-dev/fedify/pull/718#pullrequestreview-4163814601 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.test.ts | 134 ++++++++++++++++++++++---------- packages/cli/src/lookup.ts | 9 ++- 2 files changed, 102 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 01e9c8407..1c1c6db65 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1064,6 +1064,9 @@ function extractIdsFromRawOutput(content: string): string[] { } async function withRecursiveLookupServer( + options: { + replyContextPath?: string; + }, callback: (server: { rootUrl: URL; replyUrl: URL; @@ -1079,6 +1082,9 @@ async function withRecursiveLookupServer( const requestUrl = new URL(request.url); const rootUrl = new URL("/notes/1", requestUrl.origin); const replyUrl = new URL("/notes/0", requestUrl.origin); + const replyContextUrl = options.replyContextPath == null + ? undefined + : new URL(options.replyContextPath, requestUrl.origin); requestedPaths.push(requestUrl.pathname); let body: unknown; @@ -1092,10 +1098,25 @@ async function withRecursiveLookupServer( }; } else if (requestUrl.pathname === replyUrl.pathname) { body = { - "@context": "https://www.w3.org/ns/activitystreams", + "@context": replyContextUrl == null + ? "https://www.w3.org/ns/activitystreams" + : [ + "https://www.w3.org/ns/activitystreams", + replyContextUrl.href, + ], id: replyUrl.href, type: "Note", content: "reply", + ...(replyContextUrl == null ? {} : { fedifyTest: "value" }), + }; + } else if ( + replyContextUrl != null && + requestUrl.pathname === replyContextUrl.pathname + ) { + body = { + "@context": { + fedifyTest: "https://fedify.dev/ns/test#fedifyTest", + }, }; } else { return new Response(null, { status: 404 }); @@ -1128,48 +1149,51 @@ test("runLookup - rejects recursive private targets by default", async () => { const testFile = `${testDir}/out.jsonl`; await mkdir(testDir, { recursive: true }); try { - await withRecursiveLookupServer(async ({ rootUrl, requestedPaths }) => { - const originalWrite = process.stderr.write; - let stderr = ""; - process.stderr.write = (( - chunk: string | Uint8Array, - encodingOrCallback?: unknown, - callback?: () => void, - ) => { - stderr += typeof chunk === "string" - ? chunk - : Buffer.from(chunk).toString(); - if (typeof encodingOrCallback === "function") { - encodingOrCallback(); - } else { - callback?.(); + await withRecursiveLookupServer( + {}, + async ({ rootUrl, requestedPaths }) => { + const originalWrite = process.stderr.write; + let stderr = ""; + process.stderr.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: unknown, + callback?: () => void, + ) => { + stderr += typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString(); + if (typeof encodingOrCallback === "function") { + encodingOrCallback(); + } else { + callback?.(); + } + return true; + }) as typeof process.stderr.write; + let exitCode: number | null; + try { + exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [rootUrl.href], + recurse: "replyTarget", + recurseDepth: 20, + allowPrivateAddress: false, + output: testFile, + }), + ); + } finally { + process.stderr.write = originalWrite; } - return true; - }) as typeof process.stderr.write; - let exitCode: number | null; - try { - exitCode = await runLookupAndCaptureExitCode( - createLookupRunCommand({ - urls: [rootUrl.href], - recurse: "replyTarget", - recurseDepth: 20, - allowPrivateAddress: false, - output: testFile, - }), + assert.equal(exitCode, 1); + assert.deepEqual(requestedPaths, ["/notes/1"]); + assert.match( + stderr, + /Try with `-p`\/`--allow-private-address`/, ); - } finally { - process.stderr.write = originalWrite; - } - assert.equal(exitCode, 1); - assert.deepEqual(requestedPaths, ["/notes/1"]); - assert.match( - stderr, - /Try with `-p`\/`--allow-private-address`/, - ); - const content = await readFile(testFile, "utf8"); - assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]); - }); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]); + }, + ); } finally { await rm(testDir, { recursive: true }); } @@ -1181,6 +1205,7 @@ test("runLookup - allows recursive private targets with allowPrivateAddress", as await mkdir(testDir, { recursive: true }); try { await withRecursiveLookupServer( + {}, async ({ rootUrl, replyUrl, requestedPaths }) => { const exitCode = await runLookupAndCaptureExitCode( createLookupRunCommand({ @@ -1206,6 +1231,35 @@ test("runLookup - allows recursive private targets with allowPrivateAddress", as } }); +test("runLookup - keeps recursive private contexts blocked", async () => { + const testDir = "./test_output_runlookup_recurse_private_context"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + await withRecursiveLookupServer( + { replyContextPath: "/contexts/reply" }, + async ({ rootUrl, requestedPaths }) => { + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [rootUrl.href], + recurse: "replyTarget", + recurseDepth: 20, + allowPrivateAddress: true, + output: testFile, + }), + ); + assert.equal(exitCode, 1); + assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]); + + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]); + }, + ); + } finally { + await rm(testDir, { recursive: true }); + } +}); + test("runLookup - reverses output order in default multi-input mode", async () => { const testDir = "./test_output_runlookup_default_reverse"; const testFile = `${testDir}/out.jsonl`; diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index e743c9ab6..85d128894 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -916,7 +916,14 @@ export async function runLookup( initialDocumentLoader; const recursiveLookupDocumentLoader: DocumentLoader = authLoader ?? documentLoader; - const recursiveContextLoader = contextLoader; + const recursiveBaseContextLoader = await getContextLoader({ + userAgent: command.userAgent, + allowPrivateAddress: false, + }); + const recursiveContextLoader = wrapDocumentLoaderWithTimeout( + recursiveBaseContextLoader, + command.timeout, + ); let totalObjects = 0; const recurseDepth = command.recurseDepth!; From a0ffab3256f24c35ef09446755504ace0724af65 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 01:10:12 +0900 Subject: [PATCH 03/10] Clarify recursive private-context hints Recognize recursive failures caused by private JSON-LD context URLs and print a dedicated hint instead of suggesting authorized fetch. Also pin that stderr output in the recursive private-context regression test so future UX changes stay visible. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132117005 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132117013 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132140325 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.test.ts | 44 ++++++++++++++++++++++++++------- packages/cli/src/lookup.ts | 37 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 1c1c6db65..e77600239 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1239,17 +1239,43 @@ test("runLookup - keeps recursive private contexts blocked", async () => { await withRecursiveLookupServer( { replyContextPath: "/contexts/reply" }, async ({ rootUrl, requestedPaths }) => { - const exitCode = await runLookupAndCaptureExitCode( - createLookupRunCommand({ - urls: [rootUrl.href], - recurse: "replyTarget", - recurseDepth: 20, - allowPrivateAddress: true, - output: testFile, - }), - ); + const originalWrite = process.stderr.write; + let stderr = ""; + process.stderr.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: unknown, + callback?: () => void, + ) => { + stderr += typeof chunk === "string" + ? chunk + : Buffer.from(chunk).toString(); + if (typeof encodingOrCallback === "function") { + encodingOrCallback(); + } else { + callback?.(); + } + return true; + }) as typeof process.stderr.write; + let exitCode: number | null; + try { + exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [rootUrl.href], + recurse: "replyTarget", + recurseDepth: 20, + allowPrivateAddress: true, + output: testFile, + }), + ); + } finally { + process.stderr.write = originalWrite; + } assert.equal(exitCode, 1); assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]); + assert.match( + stderr, + /Recursive JSON-LD context URLs are always blocked/, + ); const content = await readFile(testFile, "utf8"); assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 85d128894..3682a9e0c 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -560,6 +560,31 @@ async function isPrivateAddressTarget(target: string): Promise { return false; } +async function getPrivateContextUrl(error: unknown): Promise { + const errorMessage = error instanceof Error ? error.message : String(error); + if ( + !(error instanceof Error) || + (error.name !== "jsonld.InvalidUrl" && + !errorMessage.includes("valid JSON-LD object")) + ) { + return null; + } + const match = errorMessage.match(/URL:\s*"([^"]+)"/); + if (match == null) return null; + + try { + const url = new URL(match[1]); + try { + await validatePublicUrl(url.href); + return null; + } catch (validationError) { + return isPrivateAddressError(validationError) ? url : null; + } + } catch { + return null; + } +} + export function getLookupFailureHint( error: unknown, options: { recursive?: boolean } = {}, @@ -1058,6 +1083,18 @@ export async function runLookup( } } else { spinner.fail("Failed to recursively fetch object."); + const privateContextUrl = await getPrivateContextUrl(error); + if (privateContextUrl != null) { + printError( + message`Recursive JSON-LD context URLs are always blocked, even with ${ + optionNames(["-p", "--allow-private-address"]) + }. Use ${ + optionNames(["-S", "--suppress-errors"]) + } to skip blocked steps.`, + ); + await finalizeAndExit(1); + return; + } const hint = getLookupFailureHint(error, { recursive: true }); if (shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint)) { printError( From 1113332675a9cad81c2e8474e4b3861208693e43 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 01:29:32 +0900 Subject: [PATCH 04/10] Clarify private-context recursion limits Clarify that `-p`/`--allow-private-address` only applies to recursive object fetches, while recursive JSON-LD `@context` loads remain blocked. Also document the jsonld error-shape dependency in `getPrivateContextUrl()` so future library upgrades know which fields and message format this detection relies on. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132210889 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132262967 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132263016 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132263039 Assisted-by: Codex:gpt-5.4 --- docs/cli.md | 12 +++++++----- packages/cli/src/lookup.ts | 7 ++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7917016b9..5074d0152 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -547,9 +547,10 @@ For short names, only Fedify property naming is accepted. For example, > > Recursive fetches disallow private/localhost addresses by default for > safety. URLs explicitly provided on the command line always allow private -> addresses, while recursive steps honor +> addresses, while recursive object fetches honor > [`-p`/`--allow-private-address`](#p-allow-private-address-allow-private-ip-addresses) -> when you explicitly opt in. +> when you explicitly opt in. Recursive JSON-LD `@context` URLs still remain +> blocked. ### `--recurse-depth`: Set recursion depth limit @@ -1015,12 +1016,13 @@ fedify lookup http://localhost:8000/users/alice ~~~~ The `-p`/`--allow-private-address` option additionally allows private -addresses for URLs discovered during traversal or recursion. It only -affects discovered URLs used by +addresses for URLs discovered during traversal or recursive object fetches. +It only affects discovered URLs used by [`-t`/`--traverse`](#t-traverse-traverse-the-collection) and [`--recurse`](#recurse-recurse-through-object-relationships), since URLs embedded in remote responses are otherwise rejected to mitigate SSRF -attacks against private addresses. +attacks against private addresses. Recursive JSON-LD `@context` URLs are +still blocked even when this option is enabled. ~~~~ sh fedify lookup --traverse --allow-private-address http://localhost:8000/users/alice/outbox diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 3682a9e0c..25f611d56 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -91,7 +91,8 @@ const suppressErrorsOption = bindConfig( const allowPrivateAddressOption = bindConfig( flag("-p", "--allow-private-address", { description: message`Allow private IP addresses for URLs discovered \ -during traversal or recursion. URLs explicitly provided on the \ +during traversal or recursive object fetches. Recursive JSON-LD \ +context URLs always remain blocked. URLs explicitly provided on the \ command line always allow private addresses.`, }), { @@ -561,6 +562,10 @@ async function isPrivateAddressTarget(target: string): Promise { } async function getPrivateContextUrl(error: unknown): Promise { + // This detection intentionally depends on jsonld's current error shape: + // name === "jsonld.InvalidUrl", the "valid JSON-LD object" substring, and + // a trailing `URL: "..."` segment. If jsonld changes those details, this + // helper and the related lookup tests need to be updated together. const errorMessage = error instanceof Error ? error.message : String(error); if ( !(error instanceof Error) || From 996df9eac9463249943b78399babb26883367c63 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 01:29:45 +0900 Subject: [PATCH 05/10] Factor stderr capture in lookup tests Extract the repeated stderr-capture setup into a small helper so recursive lookup tests share one implementation and always restore `process.stderr.write` in a single place. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132210885 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.test.ts | 82 ++++++++++++++------------------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index e77600239..a90d16dae 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1057,6 +1057,32 @@ async function runLookupAndCaptureExitCode( } } +async function captureStderr( + callback: () => Promise, +): Promise<{ result: T; stderr: string }> { + const originalWrite = process.stderr.write; + let stderr = ""; + process.stderr.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: unknown, + callback?: () => void, + ) => { + stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); + if (typeof encodingOrCallback === "function") { + encodingOrCallback(); + } else { + callback?.(); + } + return true; + }) as typeof process.stderr.write; + try { + const result = await callback(); + return { result, stderr }; + } finally { + process.stderr.write = originalWrite; + } +} + function extractIdsFromRawOutput(content: string): string[] { return [...content.matchAll(/"id"\s*:\s*"([^"]+)"/g)].map((match) => match[1] @@ -1152,26 +1178,8 @@ test("runLookup - rejects recursive private targets by default", async () => { await withRecursiveLookupServer( {}, async ({ rootUrl, requestedPaths }) => { - const originalWrite = process.stderr.write; - let stderr = ""; - process.stderr.write = (( - chunk: string | Uint8Array, - encodingOrCallback?: unknown, - callback?: () => void, - ) => { - stderr += typeof chunk === "string" - ? chunk - : Buffer.from(chunk).toString(); - if (typeof encodingOrCallback === "function") { - encodingOrCallback(); - } else { - callback?.(); - } - return true; - }) as typeof process.stderr.write; - let exitCode: number | null; - try { - exitCode = await runLookupAndCaptureExitCode( + const { result: exitCode, stderr } = await captureStderr(() => + runLookupAndCaptureExitCode( createLookupRunCommand({ urls: [rootUrl.href], recurse: "replyTarget", @@ -1179,10 +1187,8 @@ test("runLookup - rejects recursive private targets by default", async () => { allowPrivateAddress: false, output: testFile, }), - ); - } finally { - process.stderr.write = originalWrite; - } + ) + ); assert.equal(exitCode, 1); assert.deepEqual(requestedPaths, ["/notes/1"]); assert.match( @@ -1239,26 +1245,8 @@ test("runLookup - keeps recursive private contexts blocked", async () => { await withRecursiveLookupServer( { replyContextPath: "/contexts/reply" }, async ({ rootUrl, requestedPaths }) => { - const originalWrite = process.stderr.write; - let stderr = ""; - process.stderr.write = (( - chunk: string | Uint8Array, - encodingOrCallback?: unknown, - callback?: () => void, - ) => { - stderr += typeof chunk === "string" - ? chunk - : Buffer.from(chunk).toString(); - if (typeof encodingOrCallback === "function") { - encodingOrCallback(); - } else { - callback?.(); - } - return true; - }) as typeof process.stderr.write; - let exitCode: number | null; - try { - exitCode = await runLookupAndCaptureExitCode( + const { result: exitCode, stderr } = await captureStderr(() => + runLookupAndCaptureExitCode( createLookupRunCommand({ urls: [rootUrl.href], recurse: "replyTarget", @@ -1266,10 +1254,8 @@ test("runLookup - keeps recursive private contexts blocked", async () => { allowPrivateAddress: true, output: testFile, }), - ); - } finally { - process.stderr.write = originalWrite; - } + ) + ); assert.equal(exitCode, 1); assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]); assert.match( From 1e0a2eae1c1209ff46bd7b0116e82eadde3d2b8a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 01:44:02 +0900 Subject: [PATCH 06/10] Tighten private-context error matching Require the expected jsonld error name and message shape together before treating a recursive failure as a blocked private-context case, and align the inline comment with that predicate. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132320334 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132332804 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 25f611d56..5a97c0bfb 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -564,13 +564,14 @@ async function isPrivateAddressTarget(target: string): Promise { async function getPrivateContextUrl(error: unknown): Promise { // This detection intentionally depends on jsonld's current error shape: // name === "jsonld.InvalidUrl", the "valid JSON-LD object" substring, and - // a trailing `URL: "..."` segment. If jsonld changes those details, this - // helper and the related lookup tests need to be updated together. + // a trailing `URL: "..."` segment all at once. If jsonld changes those + // details, this helper and the related lookup tests need to be updated + // together. const errorMessage = error instanceof Error ? error.message : String(error); if ( !(error instanceof Error) || - (error.name !== "jsonld.InvalidUrl" && - !errorMessage.includes("valid JSON-LD object")) + error.name !== "jsonld.InvalidUrl" || + !errorMessage.includes("valid JSON-LD object") ) { return null; } From 8886089af9da67596c50543754e32e6e89de12c6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 01:56:11 +0900 Subject: [PATCH 07/10] Decouple recursive private-url hints Stop routing recursive private-address hints through a synthetic UrlError, surface the blocked recursive context URL directly, and make private-context detection prefer structured URL fields before falling back to the jsonld error message. Also relax the recursive private-target stderr assertion so it does not depend on Optique's exact presentation formatting. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132361867 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132361873 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132365950 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132366018 Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132366066 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.test.ts | 4 +- packages/cli/src/lookup.ts | 85 +++++++++++++++++++-------------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index a90d16dae..5d33310b2 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1193,7 +1193,7 @@ test("runLookup - rejects recursive private targets by default", async () => { assert.deepEqual(requestedPaths, ["/notes/1"]); assert.match( stderr, - /Try with `-p`\/`--allow-private-address`/, + /--allow-private-address/, ); const content = await readFile(testFile, "utf8"); @@ -1260,7 +1260,7 @@ test("runLookup - keeps recursive private contexts blocked", async () => { assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]); assert.match( stderr, - /Recursive JSON-LD context URLs are always blocked/, + /Recursive JSON-LD context URL .* is always blocked/, ); const content = await readFile(testFile, "utf8"); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 5a97c0bfb..59f0f8512 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -41,6 +41,7 @@ import { string, withDefault, } from "@optique/core"; +import { url as messageUrl } from "@optique/core/message"; import { path, printError } from "@optique/run"; import { createWriteStream, type WriteStream } from "node:fs"; import process from "node:process"; @@ -552,13 +553,24 @@ function isPrivateAddressError(error: unknown): boolean { ); } -async function isPrivateAddressTarget(target: string): Promise { +async function getPrivateUrlCandidate(candidate: unknown): Promise { + if (typeof candidate !== "string" && !(candidate instanceof URL)) return null; + try { - await validatePublicUrl(target); - } catch (error) { - return isPrivateAddressError(error); + const url = new URL(candidate); + try { + await validatePublicUrl(url.href); + return null; + } catch (validationError) { + return isPrivateAddressError(validationError) ? url : null; + } + } catch { + return null; } - return false; +} + +async function isPrivateAddressTarget(target: string): Promise { + return await getPrivateUrlCandidate(target) != null; } async function getPrivateContextUrl(error: unknown): Promise { @@ -575,20 +587,39 @@ async function getPrivateContextUrl(error: unknown): Promise { ) { return null; } + + const structuredError = error as { + details?: { url?: unknown }; + url?: unknown; + }; + const structuredUrl = + await getPrivateUrlCandidate(structuredError.details?.url) ?? + await getPrivateUrlCandidate(structuredError.url); + if (structuredUrl != null) return structuredUrl; + const match = errorMessage.match(/URL:\s*"([^"]+)"/); if (match == null) return null; + return await getPrivateUrlCandidate(match[1]); +} - try { - const url = new URL(match[1]); - try { - await validatePublicUrl(url.href); - return null; - } catch (validationError) { - return isPrivateAddressError(validationError) ? url : null; - } - } catch { - return null; - } +function printRecursivePrivateAddressHint(): void { + printError( + message`The recursive target appears to be private or localhost. Try with ${ + optionNames(["-p", "--allow-private-address"]) + }, or use ${ + optionNames(["-S", "--suppress-errors"]) + } to skip blocked steps.`, + ); +} + +function printRecursivePrivateContextHint(privateContextUrl: URL): void { + printError( + message`Recursive JSON-LD context URL ${ + messageUrl(privateContextUrl) + } is always blocked, even with ${ + optionNames(["-p", "--allow-private-address"]) + }. Use ${optionNames(["-S", "--suppress-errors"])} to skip blocked steps.`, + ); } export function getLookupFailureHint( @@ -631,13 +662,7 @@ function printLookupFailureHint( ); return; case "recursive-private-address": - printError( - message`The recursive target appears to be private or localhost. Try with ${ - optionNames(["-p", "--allow-private-address"]) - }, or use ${ - optionNames(["-S", "--suppress-errors"]) - } to skip blocked steps.`, - ); + printRecursivePrivateAddressHint(); return; case "authorized-fetch": printError( @@ -1075,11 +1100,7 @@ export async function runLookup( !command.allowPrivateAddress && await isPrivateAddressTarget(error.target) ) { - printLookupFailureHint( - authLoader, - new UrlError("Invalid or private address"), - { recursive: true }, - ); + printRecursivePrivateAddressHint(); } else if (authLoader == null) { printError( message`It may be a private object. Try with ${ @@ -1091,13 +1112,7 @@ export async function runLookup( spinner.fail("Failed to recursively fetch object."); const privateContextUrl = await getPrivateContextUrl(error); if (privateContextUrl != null) { - printError( - message`Recursive JSON-LD context URLs are always blocked, even with ${ - optionNames(["-p", "--allow-private-address"]) - }. Use ${ - optionNames(["-S", "--suppress-errors"]) - } to skip blocked steps.`, - ); + printRecursivePrivateContextHint(privateContextUrl); await finalizeAndExit(1); return; } From 936faf6b008d2918fc38e45593d9a7a902818562 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 10:32:13 +0900 Subject: [PATCH 08/10] Avoid DNS in recursive private hints Keep recursive private-address hinting on a cheap hostname-only path so the failure branch does not need a second DNS-based public- URL validation pass. Also cover the obvious private-host detection with a dedicated unit test. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132455586 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.test.ts | 20 +++++++++++++++ packages/cli/src/lookup.ts | 44 +++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 5d33310b2..ae9541916 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -22,6 +22,7 @@ import { collectRecursiveObjects, createTimeoutSignal, getLookupFailureHint, + getPrivateUrlCandidate, getRecursiveTargetId, lookupCommand, RecursiveLookupError, @@ -769,6 +770,25 @@ test("getLookupFailureHint - suggests authorized-fetch for non-URL errors", () = ); }); +test("getPrivateUrlCandidate - detects obvious private hosts without DNS", () => { + assert.equal( + getPrivateUrlCandidate("http://localhost:8080/object")?.href, + "http://localhost:8080/object", + ); + assert.equal( + getPrivateUrlCandidate("http://127.0.0.1:8080/object")?.href, + "http://127.0.0.1:8080/object", + ); + assert.equal( + getPrivateUrlCandidate("http://[::1]:8080/object")?.href, + "http://[::1]:8080/object", + ); + assert.equal( + getPrivateUrlCandidate("https://example.com/object"), + null, + ); +}); + test("getLookupFailureHint - does not treat all UrlError values as private", () => { assert.equal( getLookupFailureHint(new UrlError("Unsupported protocol: ftp:")), diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 59f0f8512..1e3603555 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -14,8 +14,10 @@ import { } from "@fedify/vocab"; import { type DocumentLoader, + expandIPv6Address, + isValidPublicIPv4Address, + isValidPublicIPv6Address, UrlError, - validatePublicUrl, } from "@fedify/vocab-runtime"; import type { ResourceDescriptor } from "@fedify/webfinger"; import { getLogger } from "@logtape/logtape"; @@ -553,27 +555,38 @@ function isPrivateAddressError(error: unknown): boolean { ); } -async function getPrivateUrlCandidate(candidate: unknown): Promise { +export function getPrivateUrlCandidate( + candidate: unknown, +): URL | null { if (typeof candidate !== "string" && !(candidate instanceof URL)) return null; try { const url = new URL(candidate); - try { - await validatePublicUrl(url.href); - return null; - } catch (validationError) { - return isPrivateAddressError(validationError) ? url : null; + const hostname = url.hostname; + if (hostname === "localhost") return url; + + if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { + return isValidPublicIPv4Address(hostname) ? null : url; } + + const normalized = hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + if (normalized.includes(":")) { + const expanded = expandIPv6Address(normalized); + return isValidPublicIPv6Address(expanded) ? null : url; + } + return null; } catch { return null; } } -async function isPrivateAddressTarget(target: string): Promise { - return await getPrivateUrlCandidate(target) != null; +function isPrivateAddressTarget(target: string): boolean { + return getPrivateUrlCandidate(target) != null; } -async function getPrivateContextUrl(error: unknown): Promise { +function getPrivateContextUrl(error: unknown): URL | null { // This detection intentionally depends on jsonld's current error shape: // name === "jsonld.InvalidUrl", the "valid JSON-LD object" substring, and // a trailing `URL: "..."` segment all at once. If jsonld changes those @@ -592,14 +605,13 @@ async function getPrivateContextUrl(error: unknown): Promise { details?: { url?: unknown }; url?: unknown; }; - const structuredUrl = - await getPrivateUrlCandidate(structuredError.details?.url) ?? - await getPrivateUrlCandidate(structuredError.url); + const structuredUrl = getPrivateUrlCandidate(structuredError.details?.url) ?? + getPrivateUrlCandidate(structuredError.url); if (structuredUrl != null) return structuredUrl; const match = errorMessage.match(/URL:\s*"([^"]+)"/); if (match == null) return null; - return await getPrivateUrlCandidate(match[1]); + return getPrivateUrlCandidate(match[1]); } function printRecursivePrivateAddressHint(): void { @@ -1098,7 +1110,7 @@ export async function runLookup( ); if ( !command.allowPrivateAddress && - await isPrivateAddressTarget(error.target) + isPrivateAddressTarget(error.target) ) { printRecursivePrivateAddressHint(); } else if (authLoader == null) { @@ -1110,7 +1122,7 @@ export async function runLookup( } } else { spinner.fail("Failed to recursively fetch object."); - const privateContextUrl = await getPrivateContextUrl(error); + const privateContextUrl = getPrivateContextUrl(error); if (privateContextUrl != null) { printRecursivePrivateContextHint(privateContextUrl); await finalizeAndExit(1); From 79689b31b6c0035135d0661bcaad1ccca899b15e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 10:48:00 +0900 Subject: [PATCH 09/10] Avoid DNS in recursive private hints Keep recursive private-address hinting on a cheap hostname-only path so the failure branch does not need a second DNS-based public- URL validation pass. Also cover the obvious private-host detection with a dedicated unit test. Addresses https://github.com/fedify-dev/fedify/pull/718#discussion_r3132455586 Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 1e3603555..9edcf31b3 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -46,6 +46,7 @@ import { import { url as messageUrl } from "@optique/core/message"; import { path, printError } from "@optique/run"; import { createWriteStream, type WriteStream } from "node:fs"; +import { isIP } from "node:net"; import process from "node:process"; import ora from "ora"; import { configContext } from "./config.ts"; @@ -565,14 +566,14 @@ export function getPrivateUrlCandidate( const hostname = url.hostname; if (hostname === "localhost") return url; - if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { - return isValidPublicIPv4Address(hostname) ? null : url; - } - const normalized = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; - if (normalized.includes(":")) { + const ipVersion = isIP(normalized); + if (ipVersion === 4) { + return isValidPublicIPv4Address(normalized) ? null : url; + } + if (ipVersion === 6) { const expanded = expandIPv6Address(normalized); return isValidPublicIPv6Address(expanded) ? null : url; } From 4d04498520698d5291944b3515d2a3d094fd34ee Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 24 Apr 2026 11:00:28 +0900 Subject: [PATCH 10/10] Document recursive lookup helper intent Add targeted comments explaining why recursive object fetches and recursive JSON-LD context fetches use different loader policies, and why the private-address helper functions only exist for post-failure hint classification. Assisted-by: Codex:gpt-5.4 --- packages/cli/src/lookup.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 9edcf31b3..0eed6bb7c 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -559,6 +559,9 @@ function isPrivateAddressError(error: unknown): boolean { export function getPrivateUrlCandidate( candidate: unknown, ): URL | null { + // This helper is only for post-failure hinting. It intentionally does a + // cheap hostname/IP check so we can recognize obvious private targets + // without re-running the full document-loader validation path. if (typeof candidate !== "string" && !(candidate instanceof URL)) return null; try { @@ -588,6 +591,12 @@ function isPrivateAddressTarget(target: string): boolean { } function getPrivateContextUrl(error: unknown): URL | null { + // Recursive object fetches and recursive JSON-LD context fetches use + // different loader policies. When the strict context loader rejects a + // private @context URL, the underlying UrlError is often surfaced as a + // jsonld parsing error instead of the original loader error. This helper + // reconstructs the blocked private context URL so the CLI can show a + // recurse-specific hint instead of the generic authorized-fetch hint. // This detection intentionally depends on jsonld's current error shape: // name === "jsonld.InvalidUrl", the "valid JSON-LD object" substring, and // a trailing `URL: "..."` segment all at once. If jsonld changes those @@ -985,6 +994,10 @@ export async function runLookup( initialDocumentLoader; const recursiveLookupDocumentLoader: DocumentLoader = authLoader ?? documentLoader; + // `-p/--allow-private-address` only changes the follow-up object fetches + // that recurse explicitly performs. JSON-LD context loads stay on the + // strict loader so a remote object cannot implicitly expand the trust + // boundary via private @context URLs. const recursiveBaseContextLoader = await getContextLoader({ userAgent: command.userAgent, allowPrivateAddress: false,