@@ -2,7 +2,7 @@ import fetchMock from "fetch-mock";
22import { deepStrictEqual , ok , rejects } from "node:assert" ;
33import { test } from "node:test" ;
44import preloadedContexts from "./contexts.ts" ;
5- import { getDocumentLoader } from "./docloader.ts" ;
5+ import { getDocumentLoader , getRemoteDocument } from "./docloader.ts" ;
66import { FetchError } from "./request.ts" ;
77import { UrlError } from "./url.ts" ;
88
@@ -90,7 +90,7 @@ test("getDocumentLoader()", async (t) => {
9090 type : "Object" ,
9191 } ,
9292 headers : {
93- "Content-Type" : "text/html; charset=utf-8 " ,
93+ "Content-Type" : "application/activity+json " ,
9494 Link : '<https://example.com/object>; rel="alternate"; ' +
9595 'type="application/ld+json; profile="https://www.w3.org/ns/activitystreams""' ,
9696 } ,
@@ -247,6 +247,44 @@ test("getDocumentLoader()", async (t) => {
247247 } ) ;
248248 } ) ;
249249
250+ fetchMock . get ( "https://example.com/html-no-alternate" , {
251+ body : `<!DOCTYPE html>
252+ <html>
253+ <head>
254+ <title>Not an ActivityPub document</title>
255+ </head>
256+ <body>Not found</body>
257+ </html>` ,
258+ headers : { "Content-Type" : "text/html; charset=utf-8" } ,
259+ } ) ;
260+
261+ await t . test ( "HTML without ActivityPub alternate link" , async ( ) => {
262+ await rejects (
263+ ( ) => fetchDocumentLoader ( "https://example.com/html-no-alternate" ) ,
264+ ( error ) => {
265+ ok ( error instanceof FetchError ) ;
266+ ok (
267+ error . message . includes (
268+ "HTML document has no ActivityPub alternate link" ,
269+ ) ,
270+ ) ;
271+ ok (
272+ error . message . includes ( "Content-Type: text/html; charset=utf-8" ) ,
273+ ) ;
274+ deepStrictEqual (
275+ error . url ,
276+ new URL ( "https://example.com/html-no-alternate" ) ,
277+ ) ;
278+ ok ( error . response != null ) ;
279+ deepStrictEqual (
280+ error . response . headers . get ( "Content-Type" ) ,
281+ "text/html; charset=utf-8" ,
282+ ) ;
283+ return true ;
284+ } ,
285+ ) ;
286+ } ) ;
287+
250288 fetchMock . get ( "https://example.com/wrong-content-type" , {
251289 body : {
252290 "@context" : "https://www.w3.org/ns/activitystreams" ,
@@ -257,7 +295,7 @@ test("getDocumentLoader()", async (t) => {
257295 headers : { "Content-Type" : "text/html; charset=utf-8" } ,
258296 } ) ;
259297
260- await t . test ( "Wrong Content-Type" , async ( ) => {
298+ await t . test ( "wrong Content-Type with JSON body " , async ( ) => {
261299 deepStrictEqual (
262300 await fetchDocumentLoader ( "https://example.com/wrong-content-type" ) ,
263301 {
@@ -273,6 +311,64 @@ test("getDocumentLoader()", async (t) => {
273311 ) ;
274312 } ) ;
275313
314+ fetchMock . get ( "https://example.com/large-html" , {
315+ body : "<!DOCTYPE html>" ,
316+ headers : {
317+ "Content-Length" : String ( 1024 * 1024 + 1 ) ,
318+ "Content-Type" : "text/html; charset=utf-8" ,
319+ } ,
320+ } ) ;
321+
322+ await t . test ( "HTML Content-Length over limit" , async ( ) => {
323+ await rejects (
324+ ( ) => fetchDocumentLoader ( "https://example.com/large-html" ) ,
325+ ( error ) => {
326+ ok ( error instanceof FetchError ) ;
327+ ok (
328+ error . message . includes (
329+ "HTML document is too large to scan for an ActivityPub alternate link" ,
330+ ) ,
331+ ) ;
332+ ok ( error . response != null ) ;
333+ deepStrictEqual ( error . response . status , 200 ) ;
334+ deepStrictEqual (
335+ error . response . headers . get ( "Content-Type" ) ,
336+ "text/html; charset=utf-8" ,
337+ ) ;
338+ return true ;
339+ } ,
340+ ) ;
341+ } ) ;
342+
343+ await t . test ( "HTML Content-Length over limit cancels body" , async ( ) => {
344+ let canceled = false ;
345+ const response = new Response ( "<!DOCTYPE html>" , {
346+ headers : {
347+ "Content-Length" : String ( 1024 * 1024 + 1 ) ,
348+ "Content-Type" : "text/html; charset=utf-8" ,
349+ } ,
350+ } ) ;
351+ Object . defineProperty ( response , "body" , {
352+ value : {
353+ cancel : ( ) => {
354+ canceled = true ;
355+ } ,
356+ } ,
357+ } ) ;
358+ await rejects (
359+ ( ) =>
360+ getRemoteDocument (
361+ "https://example.com/large-html-cancel" ,
362+ response ,
363+ ( ) => {
364+ throw new Error ( "unexpected alternate fetch" ) ;
365+ } ,
366+ ) ,
367+ FetchError ,
368+ ) ;
369+ deepStrictEqual ( canceled , true ) ;
370+ } ) ;
371+
276372 fetchMock . get ( "https://example.com/404" , { status : 404 } ) ;
277373
278374 await t . test ( "not ok" , async ( ) => {
@@ -459,11 +555,11 @@ test("getDocumentLoader()", async (t) => {
459555
460556 await t . test ( "ReDoS resistance (CVE-2025-68475)" , async ( ) => {
461557 const start = performance . now ( ) ;
462- // The malicious HTML will fail JSON parsing , but the important thing is
463- // that it should complete quickly (not hang due to ReDoS)
558+ // The malicious HTML will fail alternate discovery , but the important
559+ // thing is that it should complete quickly (not hang due to ReDoS).
464560 await rejects (
465561 ( ) => fetchDocumentLoader ( "https://example.com/redos" ) ,
466- SyntaxError ,
562+ FetchError ,
467563 ) ;
468564 const elapsed = performance . now ( ) - start ;
469565
0 commit comments