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/package.json +1 -1
- package/src/auth/did-nostr.js +417 -73
- package/src/auth/nostr-keys.js +112 -0
- package/src/auth/nostr.js +37 -28
- package/src/idp/well-known-did-nostr.js +458 -0
- package/src/server.js +66 -0
- package/test/did-nostr.test.js +331 -1
- package/test/well-known-did-nostr.test.js +631 -0
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);
|
package/test/did-nostr.test.js
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
} from './helpers.js';
|
|
16
16
|
|
|
17
17
|
// Import the module under test
|
|
18
|
-
import {
|
|
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();
|