Skip to content

Commit 1764300

Browse files
authored
Merge pull request #718 from dahlia/issue-700-recurse-private-address
Make recursive lookups honor `-p`/`--allow-private-address`
2 parents 31b6778 + 4d04498 commit 1764300

4 files changed

Lines changed: 401 additions & 68 deletions

File tree

CHANGES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,19 @@ To be released.
143143

144144
### @fedify/cli
145145

146+
- Made `fedify lookup --recurse` honor `-p`/`--allow-private-address`
147+
for recursively discovered object URLs, matching the policy already used
148+
by `-t`/`--traverse`. Recursive lookups still reject private or
149+
localhost targets by default unless users explicitly opt in.
150+
[[#700], [#718]]
151+
146152
- Added [FEP-044f] `quote` support to `fedify lookup --recurse`, so the CLI
147153
can follow both the new quote-post relation and the older `quoteUrl`
148154
compatibility surface. [[#452], [#679]]
149155

156+
[#700]: https://github.com/fedify-dev/fedify/issues/700
157+
[#718]: https://github.com/fedify-dev/fedify/pull/718
158+
150159
### @fedify/solidstart
151160

152161
- Added `@fedify/solidstart` package for integrating Fedify with

docs/cli.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -545,11 +545,12 @@ For short names, only Fedify property naming is accepted. For example,
545545
> `--recurse` and [`-t`/`--traverse`](#t-traverse-traverse-the-collection)
546546
> are mutually exclusive.
547547
>
548-
> Recursive fetches always disallow private/localhost addresses for safety.
549-
> URLs explicitly provided on the command line always allow private
550-
> addresses, while
548+
> Recursive fetches disallow private/localhost addresses by default for
549+
> safety. URLs explicitly provided on the command line always allow private
550+
> addresses, while recursive object fetches honor
551551
> [`-p`/`--allow-private-address`](#p-allow-private-address-allow-private-ip-addresses)
552-
> has no effect on recursive steps.
552+
> when you explicitly opt in. Recursive JSON-LD `@context` URLs still remain
553+
> blocked.
553554
554555
### `--recurse-depth`: Set recursion depth limit
555556

@@ -1015,21 +1016,19 @@ fedify lookup http://localhost:8000/users/alice
10151016
~~~~
10161017

10171018
The `-p`/`--allow-private-address` option additionally allows private
1018-
addresses for URLs discovered during traversal. It only has an effect
1019-
when used together with
1020-
[`-t`/`--traverse`](#t-traverse-traverse-the-collection), since URLs
1019+
addresses for URLs discovered during traversal or recursive object fetches.
1020+
It only affects discovered URLs used by
1021+
[`-t`/`--traverse`](#t-traverse-traverse-the-collection) and
1022+
[`--recurse`](#recurse-recurse-through-object-relationships), since URLs
10211023
embedded in remote responses are otherwise rejected to mitigate SSRF
1022-
attacks against private addresses.
1024+
attacks against private addresses. Recursive JSON-LD `@context` URLs are
1025+
still blocked even when this option is enabled.
10231026

10241027
~~~~ sh
10251028
fedify lookup --traverse --allow-private-address http://localhost:8000/users/alice/outbox
1029+
fedify lookup --recurse=replyTarget --allow-private-address http://localhost:8000/notes/1
10261030
~~~~
10271031

1028-
> [!NOTE]
1029-
> Recursive fetches enabled by
1030-
> [`--recurse`](#recurse-recurse-through-object-relationships) always
1031-
> disallow private addresses regardless of this option.
1032-
10331032
### `-s`/`--separator`: Output separator
10341033

10351034
*This option is available since Fedify 1.3.0.*

packages/cli/src/lookup.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111
import process from "node:process";
1212
import { Writable } from "node:stream";
1313
import test from "node:test";
14+
import { serve } from "srvx";
1415
import { configContext } from "./config.ts";
1516
import { getContextLoader } from "./docloader.ts";
1617
import { runCli } from "./runner.ts";
@@ -21,6 +22,7 @@ import {
2122
collectRecursiveObjects,
2223
createTimeoutSignal,
2324
getLookupFailureHint,
25+
getPrivateUrlCandidate,
2426
getRecursiveTargetId,
2527
lookupCommand,
2628
RecursiveLookupError,
@@ -768,6 +770,25 @@ test("getLookupFailureHint - suggests authorized-fetch for non-URL errors", () =
768770
);
769771
});
770772

773+
test("getPrivateUrlCandidate - detects obvious private hosts without DNS", () => {
774+
assert.equal(
775+
getPrivateUrlCandidate("http://localhost:8080/object")?.href,
776+
"http://localhost:8080/object",
777+
);
778+
assert.equal(
779+
getPrivateUrlCandidate("http://127.0.0.1:8080/object")?.href,
780+
"http://127.0.0.1:8080/object",
781+
);
782+
assert.equal(
783+
getPrivateUrlCandidate("http://[::1]:8080/object")?.href,
784+
"http://[::1]:8080/object",
785+
);
786+
assert.equal(
787+
getPrivateUrlCandidate("https://example.com/object"),
788+
null,
789+
);
790+
});
791+
771792
test("getLookupFailureHint - does not treat all UrlError values as private", () => {
772793
assert.equal(
773794
getLookupFailureHint(new UrlError("Unsupported protocol: ftp:")),
@@ -1056,12 +1077,221 @@ async function runLookupAndCaptureExitCode(
10561077
}
10571078
}
10581079

1080+
async function captureStderr<T>(
1081+
callback: () => Promise<T>,
1082+
): Promise<{ result: T; stderr: string }> {
1083+
const originalWrite = process.stderr.write;
1084+
let stderr = "";
1085+
process.stderr.write = ((
1086+
chunk: string | Uint8Array,
1087+
encodingOrCallback?: unknown,
1088+
callback?: () => void,
1089+
) => {
1090+
stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
1091+
if (typeof encodingOrCallback === "function") {
1092+
encodingOrCallback();
1093+
} else {
1094+
callback?.();
1095+
}
1096+
return true;
1097+
}) as typeof process.stderr.write;
1098+
try {
1099+
const result = await callback();
1100+
return { result, stderr };
1101+
} finally {
1102+
process.stderr.write = originalWrite;
1103+
}
1104+
}
1105+
10591106
function extractIdsFromRawOutput(content: string): string[] {
10601107
return [...content.matchAll(/"id"\s*:\s*"([^"]+)"/g)].map((match) =>
10611108
match[1]
10621109
);
10631110
}
10641111

1112+
async function withRecursiveLookupServer<T>(
1113+
options: {
1114+
replyContextPath?: string;
1115+
},
1116+
callback: (server: {
1117+
rootUrl: URL;
1118+
replyUrl: URL;
1119+
requestedPaths: string[];
1120+
}) => Promise<T>,
1121+
): Promise<T> {
1122+
const requestedPaths: string[] = [];
1123+
const server = serve({
1124+
port: 0,
1125+
hostname: "127.0.0.1",
1126+
silent: true,
1127+
fetch(request) {
1128+
const requestUrl = new URL(request.url);
1129+
const rootUrl = new URL("/notes/1", requestUrl.origin);
1130+
const replyUrl = new URL("/notes/0", requestUrl.origin);
1131+
const replyContextUrl = options.replyContextPath == null
1132+
? undefined
1133+
: new URL(options.replyContextPath, requestUrl.origin);
1134+
requestedPaths.push(requestUrl.pathname);
1135+
1136+
let body: unknown;
1137+
if (requestUrl.pathname === rootUrl.pathname) {
1138+
body = {
1139+
"@context": "https://www.w3.org/ns/activitystreams",
1140+
id: rootUrl.href,
1141+
type: "Note",
1142+
content: "root",
1143+
inReplyTo: replyUrl.href,
1144+
};
1145+
} else if (requestUrl.pathname === replyUrl.pathname) {
1146+
body = {
1147+
"@context": replyContextUrl == null
1148+
? "https://www.w3.org/ns/activitystreams"
1149+
: [
1150+
"https://www.w3.org/ns/activitystreams",
1151+
replyContextUrl.href,
1152+
],
1153+
id: replyUrl.href,
1154+
type: "Note",
1155+
content: "reply",
1156+
...(replyContextUrl == null ? {} : { fedifyTest: "value" }),
1157+
};
1158+
} else if (
1159+
replyContextUrl != null &&
1160+
requestUrl.pathname === replyContextUrl.pathname
1161+
) {
1162+
body = {
1163+
"@context": {
1164+
fedifyTest: "https://fedify.dev/ns/test#fedifyTest",
1165+
},
1166+
};
1167+
} else {
1168+
return new Response(null, { status: 404 });
1169+
}
1170+
1171+
return Response.json(body, {
1172+
headers: {
1173+
"Content-Type": "application/activity+json",
1174+
},
1175+
});
1176+
},
1177+
});
1178+
1179+
await server.ready();
1180+
assert.ok(server.url != null);
1181+
const origin = new URL(server.url).origin;
1182+
try {
1183+
return await callback({
1184+
rootUrl: new URL("/notes/1", origin),
1185+
replyUrl: new URL("/notes/0", origin),
1186+
requestedPaths,
1187+
});
1188+
} finally {
1189+
await server.close(true);
1190+
}
1191+
}
1192+
1193+
test("runLookup - rejects recursive private targets by default", async () => {
1194+
const testDir = "./test_output_runlookup_recurse_private_default";
1195+
const testFile = `${testDir}/out.jsonl`;
1196+
await mkdir(testDir, { recursive: true });
1197+
try {
1198+
await withRecursiveLookupServer(
1199+
{},
1200+
async ({ rootUrl, requestedPaths }) => {
1201+
const { result: exitCode, stderr } = await captureStderr(() =>
1202+
runLookupAndCaptureExitCode(
1203+
createLookupRunCommand({
1204+
urls: [rootUrl.href],
1205+
recurse: "replyTarget",
1206+
recurseDepth: 20,
1207+
allowPrivateAddress: false,
1208+
output: testFile,
1209+
}),
1210+
)
1211+
);
1212+
assert.equal(exitCode, 1);
1213+
assert.deepEqual(requestedPaths, ["/notes/1"]);
1214+
assert.match(
1215+
stderr,
1216+
/--allow-private-address/,
1217+
);
1218+
1219+
const content = await readFile(testFile, "utf8");
1220+
assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]);
1221+
},
1222+
);
1223+
} finally {
1224+
await rm(testDir, { recursive: true });
1225+
}
1226+
});
1227+
1228+
test("runLookup - allows recursive private targets with allowPrivateAddress", async () => {
1229+
const testDir = "./test_output_runlookup_recurse_private_allowed";
1230+
const testFile = `${testDir}/out.jsonl`;
1231+
await mkdir(testDir, { recursive: true });
1232+
try {
1233+
await withRecursiveLookupServer(
1234+
{},
1235+
async ({ rootUrl, replyUrl, requestedPaths }) => {
1236+
const exitCode = await runLookupAndCaptureExitCode(
1237+
createLookupRunCommand({
1238+
urls: [rootUrl.href],
1239+
recurse: "replyTarget",
1240+
recurseDepth: 20,
1241+
allowPrivateAddress: true,
1242+
output: testFile,
1243+
}),
1244+
);
1245+
assert.equal(exitCode, 0);
1246+
assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]);
1247+
1248+
const content = await readFile(testFile, "utf8");
1249+
assert.deepEqual(extractIdsFromRawOutput(content), [
1250+
rootUrl.href,
1251+
replyUrl.href,
1252+
]);
1253+
},
1254+
);
1255+
} finally {
1256+
await rm(testDir, { recursive: true });
1257+
}
1258+
});
1259+
1260+
test("runLookup - keeps recursive private contexts blocked", async () => {
1261+
const testDir = "./test_output_runlookup_recurse_private_context";
1262+
const testFile = `${testDir}/out.jsonl`;
1263+
await mkdir(testDir, { recursive: true });
1264+
try {
1265+
await withRecursiveLookupServer(
1266+
{ replyContextPath: "/contexts/reply" },
1267+
async ({ rootUrl, requestedPaths }) => {
1268+
const { result: exitCode, stderr } = await captureStderr(() =>
1269+
runLookupAndCaptureExitCode(
1270+
createLookupRunCommand({
1271+
urls: [rootUrl.href],
1272+
recurse: "replyTarget",
1273+
recurseDepth: 20,
1274+
allowPrivateAddress: true,
1275+
output: testFile,
1276+
}),
1277+
)
1278+
);
1279+
assert.equal(exitCode, 1);
1280+
assert.deepEqual(requestedPaths, ["/notes/1", "/notes/0"]);
1281+
assert.match(
1282+
stderr,
1283+
/Recursive JSON-LD context URL .* is always blocked/,
1284+
);
1285+
1286+
const content = await readFile(testFile, "utf8");
1287+
assert.deepEqual(extractIdsFromRawOutput(content), [rootUrl.href]);
1288+
},
1289+
);
1290+
} finally {
1291+
await rm(testDir, { recursive: true });
1292+
}
1293+
});
1294+
10651295
test("runLookup - reverses output order in default multi-input mode", async () => {
10661296
const testDir = "./test_output_runlookup_default_reverse";
10671297
const testFile = `${testDir}/out.jsonl`;

0 commit comments

Comments
 (0)