@@ -11,6 +11,7 @@ import { join } from "node:path";
1111import process from "node:process" ;
1212import { Writable } from "node:stream" ;
1313import test from "node:test" ;
14+ import { serve } from "srvx" ;
1415import { configContext } from "./config.ts" ;
1516import { getContextLoader } from "./docloader.ts" ;
1617import { 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+
771792test ( "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+
10591106function extractIdsFromRawOutput ( content : string ) : string [ ] {
10601107 return [ ...content . matchAll ( / " i d " \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+ / - - a l l o w - p r i v a t e - a d d r e s s / ,
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+ / R e c u r s i v e J S O N - L D c o n t e x t U R L .* i s a l w a y s b l o c k e d / ,
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+
10651295test ( "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