@learncard/network-plugin 2.13.15 → 2.13.17

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/dist/plugin.d.ts CHANGED
@@ -1,19 +1,39 @@
1
1
  import { LearnCard } from '@learncard/core';
2
2
  import { VerifyExtension } from '@learncard/vc-plugin';
3
3
  import { LearnCardNetworkPluginDependentMethods, LearnCardNetworkPlugin, VerifyBoostPlugin } from './types';
4
+ /**
5
+ * Configuration for federation URL validation.
6
+ */
7
+ export interface FederationConfig {
8
+ /**
9
+ * Explicit list of trusted hosts. If not provided, federation is open (any HTTPS host allowed).
10
+ * This is safe for client-side code where SSRF doesn't apply.
11
+ * Server-side code should provide an explicit allowlist.
12
+ */
13
+ trustedFederationHosts?: string[];
14
+ /**
15
+ * Allow localhost/127.0.0.1 for development.
16
+ * Auto-detected: defaults to true when serviceUrl is localhost, false otherwise.
17
+ */
18
+ allowLocalhostFederation?: boolean;
19
+ /**
20
+ * The origin of this plugin's service URL. When set, endpoints matching this origin
21
+ * skip validation (they're self-generated, not from external DID documents).
22
+ */
23
+ serviceOrigin?: string;
24
+ }
4
25
  export * from './types';
5
26
  export type GuardianApprovalGetter = () => string | undefined | Promise<string | undefined>;
6
27
  /**
7
28
  * @group Plugins
8
29
  */
9
- export declare function getLearnCardNetworkPlugin(learnCard: LearnCard<any, 'id', LearnCardNetworkPluginDependentMethods>, url: string, apiTokenOrOptions?: {
10
- guardianApprovalGetter?: GuardianApprovalGetter;
11
- extraHeaders?: Record<string, string>;
12
- }): Promise<LearnCardNetworkPlugin>;
13
- export declare function getLearnCardNetworkPlugin(learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>, url: string, apiToken: string, options?: {
30
+ /** Options for the LearnCard Network Plugin */
31
+ export interface LearnCardNetworkPluginOptions extends FederationConfig {
14
32
  guardianApprovalGetter?: GuardianApprovalGetter;
15
33
  extraHeaders?: Record<string, string>;
16
- }): Promise<LearnCardNetworkPlugin>;
34
+ }
35
+ export declare function getLearnCardNetworkPlugin(learnCard: LearnCard<any, 'id', LearnCardNetworkPluginDependentMethods>, url: string, apiTokenOrOptions?: LearnCardNetworkPluginOptions): Promise<LearnCardNetworkPlugin>;
36
+ export declare function getLearnCardNetworkPlugin(learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>, url: string, apiToken: string, options?: LearnCardNetworkPluginOptions): Promise<LearnCardNetworkPlugin>;
17
37
  /**
18
38
  * @group Plugins
19
39
  */
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAQvD,OAAO,EACH,sCAAsC,EACtC,sBAAsB,EACtB,iBAAiB,EAEpB,MAAM,SAAS,CAAC;AAwRjB,cAAc,SAAS,CAAC;AAExB,MAAM,MAAM,sBAAsB,GAAG,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAE5F;;GAEG;AACH,wBAAsB,yBAAyB,CAC3C,SAAS,EAAE,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,sCAAsC,CAAC,EACvE,GAAG,EAAE,MAAM,EACX,iBAAiB,CAAC,EAAE;IAChB,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC,GACF,OAAO,CAAC,sBAAsB,CAAC,CAAC;AACnC,wBAAsB,yBAAyB,CAC3C,SAAS,EAAE,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,sCAAsC,CAAC,EACtE,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACN,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC,GACF,OAAO,CAAC,sBAAsB,CAAC,CAAC;AAgxEnC;;GAEG;AACH,eAAO,MAAM,oBAAoB,cAClB,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,eAAe,CAAC,4BACrB,MAAM,KACjC,OAAO,CAAC,iBAAiB,CA+G3B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAQvD,OAAO,EACH,sCAAsC,EACtC,sBAAsB,EACtB,iBAAiB,EAEpB,MAAM,SAAS,CAAC;AAUjB;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC7B;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,EAAE,CAAC;IAClC;;;OAGG;IACH,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CAC1B;AA2XD,cAAc,SAAS,CAAC;AAExB,MAAM,MAAM,sBAAsB,GAAG,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAE5F;;GAEG;AACH,+CAA+C;AAC/C,MAAM,WAAW,6BAA8B,SAAQ,gBAAgB;IACnE,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,wBAAsB,yBAAyB,CAC3C,SAAS,EAAE,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,sCAAsC,CAAC,EACvE,GAAG,EAAE,MAAM,EACX,iBAAiB,CAAC,EAAE,6BAA6B,GAClD,OAAO,CAAC,sBAAsB,CAAC,CAAC;AACnC,wBAAsB,yBAAyB,CAC3C,SAAS,EAAE,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,sCAAsC,CAAC,EACtE,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,6BAA6B,GACxC,OAAO,CAAC,sBAAsB,CAAC,CAAC;AA6xEnC;;GAEG;AACH,eAAO,MAAM,oBAAoB,GAC7B,WAAW,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,eAAe,CAAC,EAC/C,0BAA0B,MAAM,KACjC,OAAO,CAAC,iBAAiB,CA8G3B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/network-plugin",
3
- "version": "2.13.15",
3
+ "version": "2.13.17",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -39,10 +39,10 @@
39
39
  "url": "https://github.com/learningeconomy/LearnCard/issues"
40
40
  },
41
41
  "devDependencies": {
42
- "@learncard/didkey-plugin": "^1.1.31",
43
- "@learncard/didkit-plugin": "^1.9.11",
44
- "@learncard/types": "5.18.2",
45
- "@learncard/vc-plugin": "^1.5.11",
42
+ "@learncard/didkey-plugin": "^1.1.33",
43
+ "@learncard/didkit-plugin": "^1.9.13",
44
+ "@learncard/types": "5.18.3",
45
+ "@learncard/vc-plugin": "^1.5.13",
46
46
  "@types/node": "^17.0.31",
47
47
  "aqu": "0.4.3",
48
48
  "esbuild": "^0.27.1",
@@ -52,9 +52,9 @@
52
52
  },
53
53
  "types": "./dist/index.d.ts",
54
54
  "dependencies": {
55
- "@learncard/core": "9.4.31",
56
- "@learncard/helpers": "1.3.13",
57
- "@learncard/network-brain-client": "2.5.52",
55
+ "@learncard/core": "9.4.33",
56
+ "@learncard/helpers": "1.4.1",
57
+ "@learncard/network-brain-client": "2.5.54",
58
58
  "mustache": "^4.2.0"
59
59
  },
60
60
  "sideEffects": false
package/src/plugin.ts CHANGED
@@ -34,6 +34,135 @@ import {
34
34
  TrustedBoostRegistryEntry,
35
35
  } from './types';
36
36
 
37
+ /**
38
+ * Trusted LearnCard domain suffixes for federation.
39
+ * Only dotted suffixes - the isLearnCardDomain check handles apex domains via
40
+ * `host === suffix.replace(/^\./, '')`. Bare entries would allow bypass
41
+ * (e.g., 'evillearncard.com'.endsWith('learncard.com') === true).
42
+ */
43
+ const LEARNCARD_DOMAIN_SUFFIXES = ['.learncard.com', '.learncard.app', '.learncard.ai'];
44
+
45
+ /**
46
+ * Configuration for federation URL validation.
47
+ */
48
+ export interface FederationConfig {
49
+ /**
50
+ * Explicit list of trusted hosts. If not provided, federation is open (any HTTPS host allowed).
51
+ * This is safe for client-side code where SSRF doesn't apply.
52
+ * Server-side code should provide an explicit allowlist.
53
+ */
54
+ trustedFederationHosts?: string[];
55
+ /**
56
+ * Allow localhost/127.0.0.1 for development.
57
+ * Auto-detected: defaults to true when serviceUrl is localhost, false otherwise.
58
+ */
59
+ allowLocalhostFederation?: boolean;
60
+ /**
61
+ * The origin of this plugin's service URL. When set, endpoints matching this origin
62
+ * skip validation (they're self-generated, not from external DID documents).
63
+ */
64
+ serviceOrigin?: string;
65
+ }
66
+
67
+ /**
68
+ * Checks if a hostname matches any LearnCard domain suffix.
69
+ */
70
+ const isLearnCardDomain = (host: string): boolean => {
71
+ return LEARNCARD_DOMAIN_SUFFIXES.some(
72
+ suffix => host === suffix.replace(/^\./, '') || host.endsWith(suffix)
73
+ );
74
+ };
75
+
76
+ /**
77
+ * Checks if a hostname is localhost or loopback.
78
+ */
79
+ const isLocalhostHost = (host: string): boolean => {
80
+ const lower = host.toLowerCase();
81
+ return lower === 'localhost' || lower === '127.0.0.1';
82
+ };
83
+
84
+ /**
85
+ * Validates and normalizes a federation URL.
86
+ *
87
+ * This runs in the CLIENT plugin where SSRF risk is lower (the browser's same-origin
88
+ * policy provides protection). By default, federation is open to any HTTPS host.
89
+ * Server-side code should provide an explicit trustedFederationHosts list.
90
+ *
91
+ * Note: For local-service recipients, getInboxEndpointForDid returns a fixed
92
+ * `/api/inbox/receive` path. Only external DID doc serviceEndpoints preserve
93
+ * their original path.
94
+ *
95
+ * @param userProvidedUrl - The inbox endpoint URL (from DID doc or local service)
96
+ * @param config - Federation configuration with trusted hosts
97
+ * @returns The validated URL
98
+ * @throws Error if the URL is invalid or host is not trusted
99
+ */
100
+ const validateFederationUrl = (userProvidedUrl: string, config: FederationConfig): string => {
101
+ let parsed: URL;
102
+ try {
103
+ parsed = new URL(userProvidedUrl);
104
+ } catch {
105
+ throw new Error(`Invalid federation endpoint URL: ${userProvidedUrl}`);
106
+ }
107
+
108
+ // If this endpoint matches our own service origin, skip validation entirely.
109
+ // These URLs are self-generated (e.g., from getInboxEndpointForDid when the recipient
110
+ // resolves to our local service), not from external DID documents.
111
+ if (config.serviceOrigin) {
112
+ const endpointOrigin = parsed.origin;
113
+ if (endpointOrigin === config.serviceOrigin) {
114
+ // Self-referential URL - return parsed.href so CodeQL sees URL sanitization
115
+ return parsed.href;
116
+ }
117
+ }
118
+
119
+ const host = parsed.hostname.toLowerCase();
120
+ const isLocalhost = isLocalhostHost(host);
121
+
122
+ // Handle localhost: check allowLocalhostFederation and convert https->http
123
+ if (isLocalhost) {
124
+ if (!config.allowLocalhostFederation) {
125
+ throw new Error(
126
+ `Localhost federation is disabled. Set allowLocalhostFederation: true for development.`
127
+ );
128
+ }
129
+ // Local dev typically doesn't have TLS - convert https to http
130
+ if (parsed.protocol === 'https:') {
131
+ parsed.protocol = 'http:';
132
+ }
133
+ // Return the validated/normalized URL
134
+ return parsed.href;
135
+ }
136
+
137
+ // Validate protocol - require HTTPS for non-localhost
138
+ if (parsed.protocol !== 'https:') {
139
+ throw new Error(`Federation requires HTTPS for non-localhost hosts: ${userProvidedUrl}`);
140
+ }
141
+
142
+ // If trustedFederationHosts is provided, use explicit allowlist mode
143
+ if (config.trustedFederationHosts !== undefined) {
144
+ const trustedHosts = new Set(config.trustedFederationHosts);
145
+ const allowAnyHost = trustedHosts.has('*');
146
+
147
+ // Check explicit list, LearnCard domains, or wildcard.
148
+ // Note: LearnCard domains are always trusted even with an explicit allowlist -
149
+ // this is intentional to ensure core federation always works.
150
+ if (!allowAnyHost && !isLearnCardDomain(host) && !trustedHosts.has(host)) {
151
+ throw new Error(
152
+ `Federation host '${host}' is not trusted. ` +
153
+ `Add it to trustedFederationHosts or use '*' to allow any host.`
154
+ );
155
+ }
156
+ }
157
+ // If trustedFederationHosts is not provided, allow any HTTPS host (client-side default)
158
+ // This enables third-party did:web federation without requiring explicit opt-in
159
+
160
+ // Return the validated URL - CodeQL: at this point the URL has been validated
161
+ // as either matching our service origin, being localhost with permission, or
162
+ // being HTTPS with a trusted/allowed host
163
+ return parsed.href;
164
+ };
165
+
37
166
  const uint8ArrayToBase64Url = (bytes: Uint8Array): string => {
38
167
  let binary = '';
39
168
  for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]!);
@@ -319,46 +448,52 @@ export type GuardianApprovalGetter = () => string | undefined | Promise<string |
319
448
  /**
320
449
  * @group Plugins
321
450
  */
451
+ /** Options for the LearnCard Network Plugin */
452
+ export interface LearnCardNetworkPluginOptions extends FederationConfig {
453
+ guardianApprovalGetter?: GuardianApprovalGetter;
454
+ extraHeaders?: Record<string, string>;
455
+ }
456
+
322
457
  export async function getLearnCardNetworkPlugin(
323
458
  learnCard: LearnCard<any, 'id', LearnCardNetworkPluginDependentMethods>,
324
459
  url: string,
325
- apiTokenOrOptions?: {
326
- guardianApprovalGetter?: GuardianApprovalGetter;
327
- extraHeaders?: Record<string, string>;
328
- }
460
+ apiTokenOrOptions?: LearnCardNetworkPluginOptions
329
461
  ): Promise<LearnCardNetworkPlugin>;
330
462
  export async function getLearnCardNetworkPlugin(
331
463
  learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>,
332
464
  url: string,
333
465
  apiToken: string,
334
- options?: {
335
- guardianApprovalGetter?: GuardianApprovalGetter;
336
- extraHeaders?: Record<string, string>;
337
- }
466
+ options?: LearnCardNetworkPluginOptions
338
467
  ): Promise<LearnCardNetworkPlugin>;
339
468
  export async function getLearnCardNetworkPlugin(
340
469
  learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>,
341
470
  url: string,
342
- apiTokenOrOptions?:
343
- | string
344
- | {
345
- guardianApprovalGetter?: GuardianApprovalGetter;
346
- extraHeaders?: Record<string, string>;
347
- },
348
- options?: {
349
- guardianApprovalGetter?: GuardianApprovalGetter;
350
- extraHeaders?: Record<string, string>;
351
- }
471
+ apiTokenOrOptions?: string | LearnCardNetworkPluginOptions,
472
+ options?: LearnCardNetworkPluginOptions
352
473
  ): Promise<LearnCardNetworkPlugin> {
353
474
  const apiToken = typeof apiTokenOrOptions === 'string' ? apiTokenOrOptions : undefined;
354
- const guardianApprovalGetter =
355
- (typeof apiTokenOrOptions === 'object'
356
- ? apiTokenOrOptions?.guardianApprovalGetter
357
- : undefined) ?? options?.guardianApprovalGetter;
358
-
359
- const extraHeaders =
360
- (typeof apiTokenOrOptions === 'object' ? apiTokenOrOptions?.extraHeaders : undefined) ??
361
- options?.extraHeaders;
475
+ const resolvedOptions: LearnCardNetworkPluginOptions =
476
+ (typeof apiTokenOrOptions === 'object' ? apiTokenOrOptions : options) ?? {};
477
+
478
+ const {
479
+ guardianApprovalGetter,
480
+ extraHeaders,
481
+ trustedFederationHosts,
482
+ allowLocalhostFederation,
483
+ } = resolvedOptions;
484
+
485
+ // Parse service URL to determine origin and auto-detect localhost
486
+ const serviceUrl = new URL(url);
487
+ const serviceIsLocalhost = isLocalhostHost(serviceUrl.hostname);
488
+
489
+ // Federation config for URL validation
490
+ // - serviceOrigin: skip validation for self-referential URLs
491
+ // - allowLocalhostFederation: auto-detect from service URL if not explicitly set
492
+ const federationConfig: FederationConfig = {
493
+ trustedFederationHosts,
494
+ allowLocalhostFederation: allowLocalhostFederation ?? serviceIsLocalhost,
495
+ serviceOrigin: serviceUrl.origin,
496
+ };
362
497
  // Initialize DID safely: in API-key mode there may be no local ID plane provider
363
498
  let did = '';
364
499
  try {
@@ -713,7 +848,11 @@ export async function getLearnCardNetworkPlugin(
713
848
  return client.profileManager.updateProfileManager.mutate(manager);
714
849
  },
715
850
  deleteProfile: async () => {
716
- if (!userData) throw new Error('Account does not exist!');
851
+ // Settle initialization before deletion so it cannot repopulate userData later.
852
+ await initialQuery;
853
+ // API tokens may grant profiles:delete without profiles:read. The server
854
+ // validates the profile and scope; a local read must not gate deletion.
855
+ if (!apiToken) await ensureUser();
717
856
 
718
857
  const result = await client.profile.deleteProfile.mutate();
719
858
 
@@ -910,13 +1049,14 @@ export async function getLearnCardNetworkPlugin(
910
1049
  challenge: `inbox-federation-${crypto.randomUUID()}`,
911
1050
  });
912
1051
 
913
- let receiveUrl = inboxEndpoint;
914
-
915
- if (receiveUrl.includes('localhost')) {
916
- receiveUrl = receiveUrl.replace('https://', 'http://');
917
- }
1052
+ // Validate the federation URL. For external DIDs, inboxEndpoint comes
1053
+ // from the DID doc's serviceEndpoint; for local-service DIDs, it's
1054
+ // a fixed /api/inbox/receive path from getInboxEndpointForDid.
1055
+ const receiveUrl = validateFederationUrl(inboxEndpoint, federationConfig);
918
1056
 
919
1057
  const response = await fetch(receiveUrl, {
1058
+ // A redirect must not bypass endpoint validation or forward credentials.
1059
+ redirect: 'error',
920
1060
  method: 'POST',
921
1061
  headers: {
922
1062
  'Content-Type': 'application/json',
@@ -2645,7 +2785,9 @@ export async function getLearnCardNetworkPlugin(
2645
2785
 
2646
2786
  isServiceTrusted: async (_learnCard, serviceDid) => {
2647
2787
  const trustedServices = await client.federation.getTrustedServices.query({});
2648
- return trustedServices.some(s => s.did === serviceDid);
2788
+ return trustedServices.some(
2789
+ (service: { did: string }) => service.did === serviceDid
2790
+ );
2649
2791
  },
2650
2792
 
2651
2793
  getTrustedServices: async () => {
@@ -2713,9 +2855,8 @@ export const getVerifyBoostPlugin = async (
2713
2855
  const boostCredential = credential?.boostCredential;
2714
2856
  try {
2715
2857
  if (boostCredential) {
2716
- const verifyBoostCredential = await learnCard.invoke.verifyCredential(
2717
- boostCredential
2718
- );
2858
+ const verifyBoostCredential =
2859
+ await learnCard.invoke.verifyCredential(boostCredential);
2719
2860
  const boostCredentialErrors = verifyBoostCredential.errors ?? [];
2720
2861
  if (verifyBoostCredential.status?.length) {
2721
2862
  verificationCheck.status = [