javascript-solid-server 0.0.179 → 0.0.180

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/server.js CHANGED
@@ -11,6 +11,10 @@ import { authorize, handleUnauthorized } from './auth/middleware.js';
11
11
  import { notificationsPlugin } from './notifications/index.js';
12
12
  import { startFileWatcher } from './notifications/events.js';
13
13
  import { idpPlugin } from './idp/index.js';
14
+ // well-known-did-nostr is loaded lazily inside the idpEnabled branch
15
+ // below so non-IdP deployments don't pull in the IdP accounts module
16
+ // (bcryptjs etc.) just to register Fastify routes. The same lazy-load
17
+ // pattern is used in src/auth/nostr.js for the NIP-98 verifier.
14
18
  import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
15
19
  import { handleCorsProxy, isCorsProxyRequest, setProxyCorsHeaders } from './handlers/cors-proxy.js';
16
20
  import { AccessMode } from './wac/parser.js';
@@ -651,6 +655,68 @@ export function createServer(options = {}) {
651
655
  }
652
656
  };
653
657
 
658
+ // /.well-known/did/nostr/<pubkey>(.json|.jsonld)? — did:nostr HTTP
659
+ // resolution for accounts on this pod (#407). Registered before the
660
+ // LDP wildcard so it actually matches; without this the
661
+ // dynamic-segment + .json suffix gets swallowed by the wildcard
662
+ // GET /* handler below and never reaches our route.
663
+ // The 405 method blocks for /.well-known/did/nostr/* must be
664
+ // registered REGARDLESS of idpEnabled. The global auth preHandler
665
+ // unconditionally skips WAC for any /.well-known/* request (that's
666
+ // the spec-mandated public namespace), so without these blocks the
667
+ // wildcard write handlers (PUT/POST/PATCH/DELETE /*) would still
668
+ // accept unauthenticated writes under this namespace on non-IdP
669
+ // deployments — anyone could PUT a file at
670
+ // /.well-known/did/nostr/whatever.json. The GET/HEAD generation
671
+ // (which actually serves DID docs) stays IdP-only since it reads
672
+ // the IdP accounts index.
673
+ const methodNotAllowed = async (request, reply) => reply.code(405)
674
+ .header('Allow', 'GET, HEAD, OPTIONS')
675
+ .send({ error: 'Method Not Allowed' });
676
+ // OPTIONS must report the SAME `Allow` set as the 405s. Without
677
+ // an explicit handler the request falls through to the wildcard
678
+ // `OPTIONS /*` which advertises GET, HEAD, PUT, DELETE, PATCH,
679
+ // POST — wrong for this namespace and confusing to CORS
680
+ // preflights. We also set the full CORS header set (origin,
681
+ // allowed-methods restricted to read-only, allowed-headers,
682
+ // credentials, max-age) so browser preflights to this endpoint
683
+ // succeed; bare 204 with only `Allow` would fail CORS.
684
+ const optionsForReadOnlyNamespace = async (request, reply) => {
685
+ const cors = getCorsHeaders(request.headers.origin);
686
+ cors['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS';
687
+ return reply.code(204)
688
+ .header('Allow', 'GET, HEAD, OPTIONS')
689
+ .headers(cors)
690
+ .send();
691
+ };
692
+ for (const pat of [
693
+ '/.well-known/did/nostr',
694
+ '/.well-known/did/nostr/',
695
+ '/.well-known/did/nostr/:pubkeyAndExt',
696
+ '/.well-known/did/nostr/*',
697
+ ]) {
698
+ fastify.put(pat, methodNotAllowed);
699
+ fastify.post(pat, methodNotAllowed);
700
+ fastify.patch(pat, methodNotAllowed);
701
+ fastify.delete(pat, methodNotAllowed);
702
+ fastify.options(pat, optionsForReadOnlyNamespace);
703
+ }
704
+ if (idpEnabled) {
705
+ // Async plugin registration so the dynamic import lives in here,
706
+ // not at module top level. Non-IdP deployments never enter this
707
+ // branch and never pull in the IdP accounts module.
708
+ fastify.register(async (instance) => {
709
+ const { buildWellKnownDidNostrHandler } = await import('./idp/well-known-did-nostr.js');
710
+ const wellKnownDidNostr = buildWellKnownDidNostrHandler();
711
+ instance.get('/.well-known/did/nostr/:pubkeyAndExt', wellKnownDidNostr);
712
+ // HEAD shares the GET implementation so headers (Content-Type,
713
+ // Cache-Control, Last-Modified, etc.) match. Without this the
714
+ // request falls through to the wildcard HEAD /* below and the
715
+ // LDP layer returns 404 because there's no on-disk file.
716
+ instance.head('/.well-known/did/nostr/:pubkeyAndExt', wellKnownDidNostr);
717
+ });
718
+ }
719
+
654
720
  // LDP routes - using wildcard routing
655
721
  // Read operations - no rate limit (handled by bodyLimit)
656
722
  fastify.get('/*', handleGet);
@@ -15,7 +15,12 @@ import {
15
15
  } from './helpers.js';
16
16
 
17
17
  // Import the module under test
18
- import { resolveDidNostrToWebId, clearCache } from '../src/auth/did-nostr.js';
18
+ import {
19
+ resolveDidNostrToWebId,
20
+ clearCache,
21
+ _cacheSizeForTests,
22
+ _CACHE_MAX_FOR_TESTS,
23
+ } from '../src/auth/did-nostr.js';
19
24
 
20
25
  describe('DID:nostr Resolution', () => {
21
26
  describe('Unit Tests', () => {
@@ -141,6 +146,331 @@ describe('DID:nostr Resolution', () => {
141
146
  });
142
147
  });
143
148
 
149
+ describe('Same-origin shortcut removed — backlink always verified', () => {
150
+ // The previous same-origin shortcut returned a WebID without
151
+ // checking that the WebID profile actually claimed the pubkey.
152
+ // On a multi-tenant origin where one user controls
153
+ // /.well-known/did/nostr/<theirPubkey>.json and another user
154
+ // controls /<other>/profile/card, the attacker could publish
155
+ // a DID doc with `alsoKnownAs` pointing at the OTHER user's
156
+ // WebID and the resolver would accept it.
157
+ //
158
+ // We can't drive the live resolver here because
159
+ // validateExternalUrl unconditionally blocks loopback (the
160
+ // only thing a unit test can bind to), so we exercise the
161
+ // CID-VM backlink check directly via the exposed test seam
162
+ // with in-memory profiles. The full multi-tenant flow is
163
+ // observable in production once a public host is involved.
164
+ let attackPubkey;
165
+ let legitPubkey;
166
+ let legitX;
167
+ let legitY;
168
+
169
+ before(async () => {
170
+ const { secp256k1 } = await import('@noble/curves/secp256k1');
171
+ // Use real on-curve keys; we need a valid point to match the
172
+ // verifier's BIP-340 even-y derivation.
173
+ legitPubkey = getPublicKey(generateSecretKey());
174
+ attackPubkey = getPublicKey(generateSecretKey());
175
+ const point = secp256k1.ProjectivePoint.fromHex('02' + legitPubkey);
176
+ const yHex = point.toAffine().y.toString(16).padStart(64, '0');
177
+ const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
178
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
179
+ legitX = b64u(legitPubkey);
180
+ legitY = b64u(yHex);
181
+ clearCache();
182
+ });
183
+
184
+ it('checkCidVmBacklink: accepts a profile with matching VM in authentication', async () => {
185
+ const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
186
+ const subj = `http://example.test/profile/card#me`;
187
+ const profile = {
188
+ '@id': subj,
189
+ verificationMethod: [{
190
+ id: `${subj.replace('#me','')}#k`,
191
+ type: 'JsonWebKey',
192
+ controller: subj,
193
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
194
+ }],
195
+ authentication: [`${subj.replace('#me','')}#k`],
196
+ };
197
+ assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), true);
198
+ });
199
+
200
+ it('checkCidVmBacklink: rejects a profile without the VM', async () => {
201
+ const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
202
+ const profile = {
203
+ '@id': 'http://example.test/profile/card#me',
204
+ verificationMethod: [],
205
+ authentication: [],
206
+ };
207
+ // attackPubkey (all-a) isn't in the profile — multi-tenant
208
+ // attack scenario.
209
+ assert.strictEqual(_checkCidVmBacklinkForTests(profile, attackPubkey), false);
210
+ });
211
+
212
+ it('checkCidVmBacklink: rejects a VM in verificationMethod but NOT in authentication', async () => {
213
+ const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
214
+ const subj = `http://example.test/profile/card#me`;
215
+ const profile = {
216
+ '@id': subj,
217
+ verificationMethod: [{
218
+ id: `${subj.replace('#me','')}#k`,
219
+ type: 'JsonWebKey',
220
+ controller: subj,
221
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
222
+ }],
223
+ authentication: [], // <-- not declared for auth
224
+ };
225
+ assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false);
226
+ });
227
+
228
+ it('checkCidVmBacklink: handles relative @id when docUrl is supplied', async () => {
229
+ const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
230
+ const docUrl = 'http://example.test/profile/card.jsonld';
231
+ // Relative subject AND absolute VM IDs (a common mixed shape).
232
+ // Without the docUrl fallback, base would be empty and the
233
+ // authentication-membership check would silently fail.
234
+ const profile = {
235
+ '@id': '#me',
236
+ verificationMethod: [{
237
+ id: `${docUrl}#k`,
238
+ type: 'JsonWebKey',
239
+ controller: `${docUrl}#me`,
240
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
241
+ }],
242
+ authentication: [`${docUrl}#k`],
243
+ };
244
+ assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey, docUrl), true);
245
+ });
246
+
247
+ it('checkCidVmBacklink: rejects when VM controller is not in expected set', async () => {
248
+ const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
249
+ const subj = 'http://example.test/profile/card#me';
250
+ // VM controller points at a totally different origin — would
251
+ // be a planted-key attack. Resource-side verifier rejects this;
252
+ // CID-VM backlink must agree.
253
+ const profile = {
254
+ '@id': subj,
255
+ // No top-level controller — defaults to subject.
256
+ verificationMethod: [{
257
+ id: `${subj.replace('#me','')}#k`,
258
+ type: 'JsonWebKey',
259
+ controller: 'http://attacker.example/me',
260
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
261
+ }],
262
+ authentication: [`${subj.replace('#me','')}#k`],
263
+ };
264
+ assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false);
265
+ });
266
+
267
+ it('checkCidVmBacklink: rejects a VM with NO explicit controller (matches resource-side strictness)', async () => {
268
+ // Earlier passes had a permissive branch that accepted a
269
+ // controller-less VM if the VM ID and the profile subject
270
+ // shared an origin. That made backlink looser than the
271
+ // resource-side verifier, opening a binding-rule mismatch
272
+ // (DID resolution would say "yes" for keys the LWS10-CID
273
+ // verifier would later reject). Both layers now require
274
+ // an explicit controller.
275
+ const { _checkCidVmBacklinkForTests } = await import('../src/auth/did-nostr.js');
276
+ const subj = 'http://example.test/profile/card#me';
277
+ const profile = {
278
+ '@id': subj,
279
+ verificationMethod: [{
280
+ id: `${subj.replace('#me','')}#k`,
281
+ type: 'JsonWebKey',
282
+ // controller intentionally absent
283
+ publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: legitX, y: legitY },
284
+ }],
285
+ authentication: [`${subj.replace('#me','')}#k`],
286
+ };
287
+ assert.strictEqual(_checkCidVmBacklinkForTests(profile, legitPubkey), false);
288
+ });
289
+ });
290
+
291
+ describe('Pubkey input validation', () => {
292
+ // Pubkey is attacker-controlled (NIP-98 event) and is
293
+ // interpolated into the resolver URL path and cache key.
294
+ // Length-only validation lets a malicious pubkey containing
295
+ // `/` or other chars rewrite the URL path on the resolver
296
+ // origin and pollute the cache.
297
+ it('rejects non-hex pubkeys without making any request', async () => {
298
+ // 64-character pubkey containing a path separator —
299
+ // length-only validation would let this through and
300
+ // cause an arbitrary-path fetch on the resolver origin.
301
+ const evil = 'a'.repeat(31) + '/' + 'b'.repeat(32);
302
+ assert.strictEqual(evil.length, 64);
303
+ const out = await resolveDidNostrToWebId(evil, 'http://nonexistent.invalid:1');
304
+ assert.strictEqual(out, null);
305
+ });
306
+
307
+ it('rejects too-short and non-string pubkeys', async () => {
308
+ assert.strictEqual(await resolveDidNostrToWebId('abc'), null);
309
+ assert.strictEqual(await resolveDidNostrToWebId(null), null);
310
+ assert.strictEqual(await resolveDidNostrToWebId(42), null);
311
+ assert.strictEqual(await resolveDidNostrToWebId('a'.repeat(63)), null);
312
+ assert.strictEqual(await resolveDidNostrToWebId('a'.repeat(65)), null);
313
+ });
314
+ });
315
+
316
+ describe('Cache key includes resolverUrl', () => {
317
+ // Cross-resolver leakage: different resolvers can legitimately
318
+ // disagree about the same pubkey (one might have a DID doc,
319
+ // another not). Keying the cache only on pubkey would let a
320
+ // hit from one resolver suppress a real lookup against another.
321
+ before(() => clearCache());
322
+
323
+ it('does NOT share cache entries across resolvers', async () => {
324
+ // Both calls hit unresolvable hosts → both cache as
325
+ // failureTtl. Crucially, they cache under DIFFERENT keys, so
326
+ // the cache size grows by 2 (not 1).
327
+ const pk = 'a'.repeat(64);
328
+ const sizeBefore = _cacheSizeForTests();
329
+ await resolveDidNostrToWebId(pk, 'http://nonexistent-a.invalid:1');
330
+ await resolveDidNostrToWebId(pk, 'http://nonexistent-b.invalid:1');
331
+ const sizeAfter = _cacheSizeForTests();
332
+ assert.strictEqual(sizeAfter - sizeBefore, 2,
333
+ 'each resolver+pubkey pair should cache independently');
334
+ });
335
+ });
336
+
337
+ describe('Cache bounding', () => {
338
+ // The cache is keyed by attacker-controlled NIP-98 pubkeys.
339
+ // Without an LRU cap a stream of unique pubkeys would grow
340
+ // memory without limit. Drive the cap directly via the
341
+ // SSRF / unknown-resolver path (every lookup gets cached as
342
+ // a transient failure) and assert the size never exceeds
343
+ // CACHE_MAX_ENTRIES.
344
+ before(() => clearCache());
345
+
346
+ it('cache stays at-or-under CACHE_MAX_ENTRIES after a burst of misses', async () => {
347
+ // Use an unreachable resolver so every lookup fast-fails
348
+ // and gets cached as `failureTtl: true`. The LRU code is
349
+ // mechanical (set + while size > cap → delete oldest),
350
+ // so a small N is enough to assert the invariant
351
+ // `size <= cap`.
352
+ assert.ok(_CACHE_MAX_FOR_TESTS >= 1, 'cap must be positive');
353
+ const N = 5;
354
+ const before = _cacheSizeForTests();
355
+ for (let i = 0; i < N; i++) {
356
+ const pk = i.toString(16).padStart(64, '0');
357
+ // Force a network failure → cached as failureTtl
358
+ await resolveDidNostrToWebId(pk, 'http://nonexistent.invalid:1');
359
+ }
360
+ const after = _cacheSizeForTests();
361
+ assert.ok(after - before <= N, 'cache shouldn\'t grow more than N');
362
+ assert.ok(after <= _CACHE_MAX_FOR_TESTS,
363
+ `cache size ${after} > cap ${_CACHE_MAX_FOR_TESTS}`);
364
+ });
365
+ });
366
+
367
+ describe('fetchWithRedirectGuard SSRF / redirect hardening', () => {
368
+ // The production resolver wraps fetchWithRedirectGuard with
369
+ // validateExternalUrl as a hard SSRF gate, which by design
370
+ // rejects loopback (`127.0.0.1`) — the only thing a unit test
371
+ // can bind to. So testing the resolver end-to-end against a
372
+ // local server makes the redirect/cap logic invisible: every
373
+ // request fails on the SSRF guard before fetch is even called.
374
+ //
375
+ // Solution: import fetchWithRedirectGuard directly and inject
376
+ // a permissive `_validateUrl` stub. That isolates the redirect
377
+ // hop counter, cross-origin check, and size cap from the SSRF
378
+ // gate so we can actually observe each one.
379
+ let http;
380
+ let server;
381
+ let port;
382
+ let hopMode = 'cross-origin';
383
+ let fetchWithRedirectGuard;
384
+ const allowAll = async () => ({ valid: true });
385
+
386
+ before(async () => {
387
+ http = await import('node:http');
388
+ ({ fetchWithRedirectGuard } = await import('../src/auth/did-nostr.js'));
389
+ server = http.createServer((req, res) => {
390
+ if (hopMode === 'cross-origin') {
391
+ res.writeHead(302, { Location: 'http://other.example:1/foo.json' });
392
+ res.end();
393
+ return;
394
+ }
395
+ if (hopMode === 'loop') {
396
+ // Each hop appends `/r` to the path; the cap fires before
397
+ // we ever return a non-3xx.
398
+ res.writeHead(302, { Location: req.url + '/r' });
399
+ res.end();
400
+ return;
401
+ }
402
+ if (hopMode === 'oversize') {
403
+ res.writeHead(200, { 'Content-Type': 'application/json' });
404
+ // Stream a body larger than the 1 KB cap we'll pass.
405
+ res.end('"' + 'x'.repeat(2000) + '"');
406
+ return;
407
+ }
408
+ if (hopMode === 'ok') {
409
+ res.writeHead(200, { 'Content-Type': 'application/json' });
410
+ res.end('{"ok":true}');
411
+ return;
412
+ }
413
+ res.writeHead(404).end();
414
+ });
415
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
416
+ port = server.address().port;
417
+ });
418
+
419
+ after(async () => {
420
+ await new Promise((resolve) => server.close(resolve));
421
+ });
422
+
423
+ it('refuses cross-origin redirects', async () => {
424
+ hopMode = 'cross-origin';
425
+ await assert.rejects(
426
+ () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/foo.json`, { _validateUrl: allowAll }),
427
+ /cross-origin redirect refused/,
428
+ );
429
+ });
430
+
431
+ it('refuses redirect chains exceeding the hop cap', async () => {
432
+ hopMode = 'loop';
433
+ await assert.rejects(
434
+ () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/start`, { _validateUrl: allowAll }),
435
+ /too many redirects/,
436
+ );
437
+ });
438
+
439
+ it('refuses oversized response bodies', async () => {
440
+ hopMode = 'oversize';
441
+ await assert.rejects(
442
+ () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/big`, {
443
+ _validateUrl: allowAll,
444
+ maxBytes: 1000,
445
+ }),
446
+ /response too large/,
447
+ );
448
+ });
449
+
450
+ it('re-runs SSRF validation on every hop', async () => {
451
+ hopMode = 'loop';
452
+ let calls = 0;
453
+ const counting = async (url) => {
454
+ calls++;
455
+ return { valid: true };
456
+ };
457
+ await assert.rejects(
458
+ () => fetchWithRedirectGuard(`http://127.0.0.1:${port}/start`, { _validateUrl: counting }),
459
+ /too many redirects/,
460
+ );
461
+ // 1 initial + MAX_REDIRECTS (5) hops = 6 calls if we re-validate
462
+ // on every hop. < 6 means the per-hop check is missing.
463
+ assert.ok(calls >= 6, `expected ≥6 validator calls, got ${calls}`);
464
+ });
465
+
466
+ it('returns the response body on success', async () => {
467
+ hopMode = 'ok';
468
+ const r = await fetchWithRedirectGuard(`http://127.0.0.1:${port}/ok`, { _validateUrl: allowAll });
469
+ assert.strictEqual(r.status, 200);
470
+ assert.strictEqual(r.body, '{"ok":true}');
471
+ });
472
+ });
473
+
144
474
  describe('Real DID Document Fetch', () => {
145
475
  before(() => {
146
476
  clearCache();