@fedify/vocab 2.4.0-dev.2056 → 2.4.0-dev.2065

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lookup.ts CHANGED
@@ -6,7 +6,6 @@ import {
6
6
  canonicalizePortableUri,
7
7
  type DocumentLoader,
8
8
  formatIri,
9
- fromCompatibleEf61Id,
10
9
  getDocumentLoader,
11
10
  haveSameFe34Origin,
12
11
  haveSameIriOrigin,
@@ -14,9 +13,12 @@ import {
14
13
  type RemoteDocument,
15
14
  } from "@fedify/vocab-runtime";
16
15
  import {
16
+ createSnapshotContextLoader,
17
17
  dereferencePortableIri,
18
18
  getPortableGatewayCandidates,
19
+ getPortableResponseClaim,
19
20
  isPortableIri,
21
+ parseCompatibleEf61Reference,
20
22
  PortableObjectRejectedError,
21
23
  } from "@fedify/vocab-runtime/internal/portable-dereference";
22
24
  import { lookupWebFinger } from "@fedify/webfinger";
@@ -193,8 +195,11 @@ export interface LookupObjectOptions {
193
195
  * whether they are looked up directly or found in the `self` links of
194
196
  * a WebFinger response, are fetched through FEP-ef61 gateways. A fetched
195
197
  * portable object is returned only if its `@id` identifies the requested
196
- * portable object and this function accepts it. Note that
197
- * `crossOrigin: "trust"` does not skip these checks.
198
+ * portable object and this function accepts it. A document fetched from
199
+ * an ordinary HTTP(S) URL is checked the same way if its final URL or its
200
+ * `@id` is a portable or compatible identifier, instead of being trusted
201
+ * because of its origin. Note that `crossOrigin: "trust"` does not skip
202
+ * these checks.
198
203
  *
199
204
  * Without it, portable identifiers are not looked up, and compatible
200
205
  * identifiers are fetched as ordinary HTTP(S) URLs, whose objects with
@@ -434,52 +439,82 @@ async function lookupObjectInternal(
434
439
  }
435
440
  }
436
441
  if (remoteDoc == null) return null;
437
- let object: Object;
438
- let documentUrl: URL;
442
+ // With verifyPortableObject, the document may turn out to be a portable
443
+ // object, which has to be verified with the same context documents:
444
+ const snapshot = options.verifyPortableObject == null
445
+ ? null
446
+ : createSnapshotContextLoader(
447
+ options.contextLoader ??
448
+ getDocumentLoader({ userAgent: options.userAgent }),
449
+ );
439
450
  try {
440
- documentUrl = parseIri(remoteDoc.documentUrl);
441
- object = await Object.fromJsonLd(remoteDoc.document, {
442
- documentLoader,
443
- contextLoader: options.contextLoader,
444
- tracerProvider: options.tracerProvider,
445
- baseUrl: documentUrl,
446
- });
447
- } catch (error) {
448
- if (error instanceof TypeError) {
449
- logger.debug(
450
- "Failed to parse JSON-LD document: {error}\n{document}",
451
- { ...remoteDoc, error },
452
- );
453
- return null;
451
+ let object: Object;
452
+ let documentUrl: URL;
453
+ try {
454
+ documentUrl = parseIri(remoteDoc.documentUrl);
455
+ object = await Object.fromJsonLd(remoteDoc.document, {
456
+ documentLoader,
457
+ contextLoader: snapshot?.loader ?? options.contextLoader,
458
+ tracerProvider: options.tracerProvider,
459
+ baseUrl: documentUrl,
460
+ });
461
+ } catch (error) {
462
+ if (error instanceof TypeError) {
463
+ logger.debug(
464
+ "Failed to parse JSON-LD document: {error}\n{document}",
465
+ { ...remoteDoc, error },
466
+ );
467
+ return null;
468
+ }
469
+ throw error;
454
470
  }
455
- throw error;
456
- }
457
- if (
458
- object.id != null &&
459
- // A portable object belongs to its DID, not to the server that serves
460
- // it, so crossOrigin: "trust" does not let a server vouch for it:
461
- (options.crossOrigin !== "trust" || isPortableIri(object.id)) &&
462
- !haveSameIriOrigin(object.id, documentUrl) &&
463
- !haveSameFe34Origin(object.id, documentUrl)
464
- ) {
465
- if (options.crossOrigin === "throw") {
466
- throw new Error(
467
- `The object's @id (${object.id.href}) has a different origin than ` +
468
- `the document URL (${remoteDoc.documentUrl}); refusing to return ` +
469
- `the object. If you want to bypass this check and are aware of ` +
470
- `the security implications, set the crossOrigin option to "trust".`,
471
+ if (snapshot != null) {
472
+ // A document whose final URL or @id stands for a portable object is
473
+ // verified as one instead of being trusted because of its origin:
474
+ const claim = getPortableResponseClaim(remoteDoc.documentUrl, object.id);
475
+ if (claim === null) {
476
+ logger.debug(
477
+ "Refusing the document {documentUrl}, as it claims to be " +
478
+ "a portable object with a malformed compatible identifier.",
479
+ { documentUrl: remoteDoc.documentUrl },
480
+ );
481
+ return null;
482
+ } else if (claim != null) {
483
+ return await dereference(portable, claim.id, claim.inferredGateways, {
484
+ response: remoteDoc,
485
+ contextLoader: snapshot.loader,
486
+ });
487
+ }
488
+ }
489
+ if (
490
+ object.id != null &&
491
+ // A portable object belongs to its DID, not to the server that serves
492
+ // it, so crossOrigin: "trust" does not let a server vouch for it:
493
+ (options.crossOrigin !== "trust" || isPortableIri(object.id)) &&
494
+ !haveSameIriOrigin(object.id, documentUrl) &&
495
+ !haveSameFe34Origin(object.id, documentUrl)
496
+ ) {
497
+ if (options.crossOrigin === "throw") {
498
+ throw new Error(
499
+ `The object's @id (${object.id.href}) has a different origin than ` +
500
+ `the document URL (${remoteDoc.documentUrl}); refusing to return ` +
501
+ `the object. If you want to bypass this check and are aware of ` +
502
+ `the security implications, set the crossOrigin option to "trust".`,
503
+ );
504
+ }
505
+ logger.warn(
506
+ "The object's @id ({objectId}) has a different origin than the " +
507
+ "document URL ({documentUrl}); refusing to return the object. If " +
508
+ "you want to bypass this check and are aware of the security " +
509
+ 'implications, set the crossOrigin option to "trust".',
510
+ { ...remoteDoc, objectId: object.id.href },
471
511
  );
512
+ return null;
472
513
  }
473
- logger.warn(
474
- "The object's @id ({objectId}) has a different origin than the document " +
475
- "URL ({documentUrl}); refusing to return the object. If you want to " +
476
- "bypass this check and are aware of the security implications, " +
477
- 'set the crossOrigin option to "trust".',
478
- { ...remoteDoc, objectId: object.id.href },
479
- );
480
- return null;
514
+ return object;
515
+ } finally {
516
+ snapshot?.release();
481
517
  }
482
- return object;
483
518
  }
484
519
 
485
520
  const PORTABLE_IRI_PATTERN = /^ap(?:\+ef61)?:/i;
@@ -534,25 +569,9 @@ function parsePortableCandidate(iri: string): URL | null {
534
569
  function getCompatibleCandidate(
535
570
  href: string,
536
571
  options: LookupObjectOptions,
537
- ): { id: URL; gateway: URL } | null | undefined {
538
- if (options.verifyPortableObject == null || !URL.canParse(href)) {
539
- return undefined;
540
- }
541
- const url = new URL(href);
542
- try {
543
- const id = fromCompatibleEf61Id(url);
544
- if (id == null) return undefined;
545
- return { id, gateway: new URL(url.origin) };
546
- } catch (error) {
547
- if (error instanceof TypeError) {
548
- logger.debug(
549
- "Invalid FEP-ef61 compatible identifier {href}: {error}",
550
- { href, error },
551
- );
552
- return null;
553
- }
554
- throw error;
555
- }
572
+ ): { readonly id: URL; readonly gateway: URL } | null | undefined {
573
+ if (options.verifyPortableObject == null) return undefined;
574
+ return parseCompatibleEf61Reference(href);
556
575
  }
557
576
 
558
577
  /**
@@ -629,15 +648,20 @@ async function dereference(
629
648
  { options, documentLoader }: PortableLookup,
630
649
  id: URL,
631
650
  gateways: readonly URL[],
651
+ extra: { response?: RemoteDocument; contextLoader?: DocumentLoader } = {},
632
652
  ): Promise<Object | null> {
633
653
  const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
634
654
  try {
635
655
  return await dereferencePortableIri(id, {
636
656
  documentLoader,
637
- contextLoader: options.contextLoader ??
657
+ contextLoader: extra.contextLoader ?? options.contextLoader ??
638
658
  getDocumentLoader({ userAgent: options.userAgent }),
639
659
  tracerProvider,
640
- gateways,
660
+ // Gateways are inferred from the identifier, WebFinger, or location
661
+ // hints, rather than given by the caller, so they are reported to the
662
+ // verifier as hints:
663
+ inferredGateways: gateways,
664
+ response: extra.response,
641
665
  verifyPortableObject: options.verifyPortableObject,
642
666
  crossOrigin: options.crossOrigin === "throw" ? "throw" : "ignore",
643
667
  signal: options.signal,
@@ -687,6 +711,31 @@ export interface TraverseCollectionOptions {
687
711
  * @default `{ seconds: 0 }`
688
712
  */
689
713
  interval?: Temporal.Duration | Temporal.DurationLike;
714
+ /**
715
+ * Whether to trust objects whose origin differs from the collection or page
716
+ * that refers to them. See the `crossOrigin` option of property accessors
717
+ * such as `Collection.getItems()`.
718
+ * @since 2.4.0
719
+ */
720
+ crossOrigin?: "ignore" | "throw" | "trust";
721
+
722
+ /**
723
+ * The [FEP-ef61] gateways to fetch portable (`ap:`/`ap+ef61:`) pages and
724
+ * items through, in order. See the `gateways` option of property
725
+ * accessors.
726
+ *
727
+ * [FEP-ef61]: https://w3id.org/fep/ef61
728
+ * @since 2.4.0
729
+ */
730
+ gateways?: readonly (string | URL)[];
731
+
732
+ /**
733
+ * The policy to apply to portable pages and items fetched through
734
+ * gateways, typically `verifyPortableObject()` from `@fedify/fedify`.
735
+ * Portable pages and items are not fetched without it.
736
+ * @since 2.4.0
737
+ */
738
+ verifyPortableObject?: PortableObjectVerifier;
690
739
  }
691
740
 
692
741
  /**
@@ -4,6 +4,7 @@ import {
4
4
  FetchError,
5
5
  parseIri,
6
6
  type PortableObjectVerifier,
7
+ type PortableObjectVerifierOptions,
7
8
  type RemoteDocument,
8
9
  } from "@fedify/vocab-runtime";
9
10
  import fetchMock from "fetch-mock";
@@ -455,3 +456,135 @@ test("getActorHandle() with FEP-ef61 portable actors", {
455
456
  fetchMock.hardReset();
456
457
  }
457
458
  });
459
+
460
+ test("lookupObject() reports inferred gateways as hints", async () => {
461
+ const calls: PortableObjectVerifierOptions[] = [];
462
+ // deno-lint-ignore require-await
463
+ const verifyPortableObject: PortableObjectVerifier = async (_, options) => {
464
+ calls.push(options);
465
+ return { verified: true };
466
+ };
467
+ const actor = await lookupObject(compatibleId, {
468
+ documentLoader: createLoader({ [compatibleId]: person() }),
469
+ contextLoader: mockDocumentLoader,
470
+ verifyPortableObject,
471
+ });
472
+ assertInstanceOf(actor, Person);
473
+ deepStrictEqual(calls.length, 1);
474
+ deepStrictEqual(calls[0].gateways, undefined);
475
+ deepStrictEqual(calls[0].gatewayHints, [new URL("https://example.com")]);
476
+ });
477
+
478
+ test("lookupObject() verifies fetched documents that stand for portable objects", async (t) => {
479
+ const plainUrl = "https://example.com/users/alice";
480
+ const redirectingLoader = (
481
+ documentUrl: string,
482
+ document: Record<string, unknown>,
483
+ ): DocumentLoader & { readonly fetched: string[] } => {
484
+ const fetched: string[] = [];
485
+ // deno-lint-ignore require-await
486
+ const loader = async (url: string): Promise<RemoteDocument> => {
487
+ fetched.push(url);
488
+ if (url !== plainUrl) {
489
+ throw new FetchError(
490
+ url,
491
+ "HTTP 404",
492
+ new globalThis.Response(null, { status: 404 }),
493
+ );
494
+ }
495
+ return { contextUrl: null, documentUrl, document };
496
+ };
497
+ return Object.assign(loader, { fetched });
498
+ };
499
+
500
+ await t.step("a redirect to a compatible identifier", async () => {
501
+ const calls: PortableObjectVerifierOptions[] = [];
502
+ const documentLoader = redirectingLoader(compatibleId, person());
503
+ const actor = await lookupObject(plainUrl, {
504
+ documentLoader,
505
+ contextLoader: mockDocumentLoader,
506
+ // deno-lint-ignore require-await
507
+ verifyPortableObject: async (_, options) => {
508
+ calls.push(options);
509
+ return { verified: true };
510
+ },
511
+ });
512
+ assertInstanceOf(actor, Person);
513
+ deepStrictEqual(actor.id, parseIri(actorId));
514
+ // Verified in place, without another request:
515
+ deepStrictEqual(documentLoader.fetched, [plainUrl]);
516
+ deepStrictEqual(calls.length, 1);
517
+ deepStrictEqual(calls[0].documentUrl, new URL(compatibleId));
518
+ deepStrictEqual(calls[0].gatewayHints, [new URL("https://example.com")]);
519
+ equal(
520
+ await lookupObject(plainUrl, {
521
+ documentLoader,
522
+ contextLoader: mockDocumentLoader,
523
+ verifyPortableObject: createVerifier(false),
524
+ }),
525
+ null,
526
+ );
527
+ // A document that claims another object:
528
+ equal(
529
+ await lookupObject(plainUrl, {
530
+ documentLoader: redirectingLoader(
531
+ compatibleId,
532
+ person(`ap://${did}/other`),
533
+ ),
534
+ contextLoader: mockDocumentLoader,
535
+ verifyPortableObject: createVerifier(),
536
+ }),
537
+ null,
538
+ );
539
+ });
540
+
541
+ await t.step("a compatible @id", async () => {
542
+ const documentLoader = redirectingLoader(plainUrl, person(compatibleId));
543
+ const verifyPortableObject = createVerifier();
544
+ equal(
545
+ await lookupObject(plainUrl, {
546
+ documentLoader,
547
+ contextLoader: mockDocumentLoader,
548
+ verifyPortableObject,
549
+ }),
550
+ null,
551
+ );
552
+ deepStrictEqual(verifyPortableObject.documents, []);
553
+ // Without verifyPortableObject, it is fetched as before:
554
+ const actor = await lookupObject(plainUrl, {
555
+ documentLoader,
556
+ contextLoader: mockDocumentLoader,
557
+ });
558
+ assertInstanceOf(actor, Person);
559
+ deepStrictEqual(actor.id, new URL(compatibleId));
560
+ });
561
+
562
+ await t.step("a portable @id", async () => {
563
+ const documentLoader = redirectingLoader(plainUrl, person());
564
+ const actor = await lookupObject(plainUrl, {
565
+ documentLoader,
566
+ contextLoader: mockDocumentLoader,
567
+ verifyPortableObject: createVerifier(),
568
+ });
569
+ assertInstanceOf(actor, Person);
570
+ deepStrictEqual(actor.id, parseIri(actorId));
571
+ equal(
572
+ await lookupObject(plainUrl, {
573
+ documentLoader,
574
+ contextLoader: mockDocumentLoader,
575
+ verifyPortableObject: createVerifier(false),
576
+ }),
577
+ null,
578
+ );
579
+ await rejects(
580
+ () =>
581
+ lookupObject(plainUrl, {
582
+ documentLoader,
583
+ contextLoader: mockDocumentLoader,
584
+ verifyPortableObject: createVerifier(false),
585
+ crossOrigin: "throw",
586
+ }),
587
+ /No gateway returned a valid portable object/,
588
+ );
589
+ });
590
+ });