/** * pnpm --filter ./api test:index * * The checks for on-demand indexing (`POST /api/index`). Same shape as `check.ts`: no * framework, throws on the first failure, prints a count. * * Three things are being defended here, and they are in descending order of how bad it * would be to get them wrong: * * 1. **SSRF.** This is the one endpoint that fetches an address a stranger chose, so * every refusal it makes is asserted against a resolver and a fetch that this file * controls. Nothing here touches the network: a rule that can only be exercised by * pointing the test at a real host is a rule that stops being exercised the first * time CI runs offline. * 2. **Slug collapse.** Two spellings of one mint must never become two rows with half * its reviews on each. That is the bug the normalizer was written for, and this * endpoint is a new way to reintroduce it — a reader can now type the spelling * discovery never saw. * 3. **Invite decoding**, which is what makes a Fedimint submission possible at all. */ import assert from 'node:assert/strict'; import { checkIndexInput, federationIdFromInviteCode, isNut06Info, isPrivateIpAddress, lnurlKey, normalizeMintUrl, typeForPath, } from '@cashumints/shared'; import { checkDestination, safeFetchText, type FetchDeps } from './safe-fetch.ts'; import { RATE_LIMIT, resetRateLimits, takeToken } from './rate-limit.ts'; /** A real code off the relay pool, the same one `check.ts` parses an announcement from. */ const REAL_INVITE = 'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' + 'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg'; let checks = 0; function check(name: string, fn: () => void): void { try { fn(); checks++; } catch (err) { console.error(`FAIL: ${name}`); throw err; } } async function checkAsync(name: string, fn: () => Promise): Promise { try { await fn(); checks++; } catch (err) { console.error(`FAIL: ${name}`); throw err; } } /* ---------- address rules ---------- */ check('every private, loopback and link-local range is refused', () => { for (const address of [ '127.0.0.1', '127.1.2.3', '10.0.0.1', '10.255.255.255', '172.16.0.1', '172.20.10.5', '172.31.255.255', '192.168.0.1', '192.168.1.5', '169.254.169.254', // the cloud metadata endpoint, the reason this exists '0.0.0.0', '100.64.0.1', // this-network and carrier-grade NAT '224.0.0.1', '255.255.255.255', '::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1', ]) { assert.equal(isPrivateIpAddress(address), true, `${address} must be refused`); } }); check('ordinary public addresses are not', () => { for (const address of ['1.1.1.1', '8.8.8.8', '157.245.26.63', '172.15.0.1', '172.32.0.1', '2606:4700::1111']) { assert.equal(isPrivateIpAddress(address), false, `${address} must be allowed`); } }); await checkAsync('a URL whose hostname is a private literal never reaches DNS', async () => { // If any of these consulted the resolver, this one would throw rather than answer. const explode: FetchDeps = { resolve: () => { throw new Error('a literal address must not be resolved'); }, }; for (const url of [ 'https://127.0.0.1/v1/info', 'https://10.0.0.1/v1/info', 'https://172.16.4.4/v1/info', 'https://192.168.1.5/v1/info', 'https://169.254.169.254/latest/meta-data/', 'https://[::1]/v1/info', 'https://localhost/v1/info', 'https://mint.local/v1/info', 'https://abcdefghij234567.onion/v1/info', ]) { const verdict = await checkDestination(new URL(url), explode); assert.equal(verdict?.kind, 'blocked', `${url} must be refused`); } }); await checkAsync('a public-looking name that resolves privately is refused', async () => { const deps: FetchDeps = { resolve: async () => ['10.0.0.5'] }; const verdict = await checkDestination(new URL('https://internal.example.com'), deps); assert.equal(verdict?.kind, 'blocked'); // One private answer among several is enough: the socket would pick one of them. const mixed: FetchDeps = { resolve: async () => ['93.184.216.34', '127.0.0.1'] }; assert.equal((await checkDestination(new URL('https://mixed.example.com'), mixed))?.kind, 'blocked'); const public_: FetchDeps = { resolve: async () => ['93.184.216.34'] }; assert.equal(await checkDestination(new URL('https://mint.example.com'), public_), null); }); await checkAsync('a name that does not resolve is unresolved, not blocked', async () => { // The distinction the rugged-mint case turns on: a mint whose operator let the domain // lapse must reach the Nostr lookup, not be rejected as an inadmissible address. const gone: FetchDeps = { resolve: async () => null }; const verdict = await checkDestination(new URL('https://gone.example.com'), gone); assert.equal(verdict?.kind, 'unresolved'); const outcome = await safeFetchText( 'https://gone.example.com/v1/info', { accept: 'application/json' }, { ...gone, fetchImpl: (() => { throw new Error('must not connect'); }) as unknown as typeof fetch }, ); assert.equal(outcome.state, 'unreachable'); }); await checkAsync('http is refused outright: this endpoint is https only', async () => { assert.equal((await checkDestination(new URL('http://mint.example.com')))?.kind, 'blocked'); assert.equal((await checkDestination(new URL('ftp://mint.example.com')))?.kind, 'blocked'); }); /* ---------- redirects ---------- */ /** A fetch that answers from a table, and records every URL it was asked for. */ function scriptedFetch(routes: Record): { fetch: typeof fetch; seen: string[] } { const seen: string[] = []; const impl = (async (input: unknown): Promise => { const url = String(input); seen.push(url); const res = routes[url]; if (!res) throw new Error(`unexpected fetch of ${url}`); return res; }) as typeof fetch; return { fetch: impl, seen }; } await checkAsync('a redirect to a private address is refused before it is fetched', async () => { const { fetch: impl, seen } = scriptedFetch({ 'https://mint.example.com/v1/info': new Response(null, { status: 302, headers: { location: 'https://169.254.169.254/latest/meta-data/' }, }), }); const outcome = await safeFetchText( 'https://mint.example.com/v1/info', { accept: 'application/json' }, { fetchImpl: impl, resolve: async () => ['93.184.216.34'] }, ); assert.equal(outcome.state, 'blocked'); assert.match(outcome.state === 'blocked' ? outcome.reason : '', /redirected/); // The crux: the metadata endpoint was never connected to, only reasoned about. assert.deepEqual(seen, ['https://mint.example.com/v1/info']); }); await checkAsync('a redirect to a name that resolves privately is refused too', async () => { const { fetch: impl, seen } = scriptedFetch({ 'https://mint.example.com/v1/info': new Response(null, { status: 301, headers: { location: 'https://internal.example.com/v1/info' }, }), }); const outcome = await safeFetchText( 'https://mint.example.com/v1/info', { accept: 'application/json' }, { fetchImpl: impl, resolve: async (host) => (host === 'mint.example.com' ? ['93.184.216.34'] : ['10.1.2.3']), }, ); assert.equal(outcome.state, 'blocked'); assert.equal(seen.length, 1, 'the private hop must never be fetched'); }); await checkAsync('two redirects are followed, a third is not', async () => { const ok = { 'content-type': 'application/json' }; const routes: Record = { 'https://a.example.com/v1/info': new Response(null, { status: 302, headers: { location: 'https://b.example.com/v1/info' }, }), 'https://b.example.com/v1/info': new Response(null, { status: 302, headers: { location: 'https://c.example.com/v1/info' }, }), 'https://c.example.com/v1/info': new Response('{"name":"ok"}', { headers: ok }), }; const deps = { resolve: async () => ['93.184.216.34'] }; const two = await safeFetchText( 'https://a.example.com/v1/info', { accept: 'application/json' }, { ...deps, fetchImpl: scriptedFetch(routes).fetch }, ); assert.equal(two.state, 'ok', 'two hops are within the cap'); const deeper = { ...routes, 'https://c.example.com/v1/info': new Response(null, { status: 302, headers: { location: 'https://d.example.com/v1/info' }, }), }; const three = scriptedFetch(deeper); const over = await safeFetchText( 'https://a.example.com/v1/info', { accept: 'application/json' }, { ...deps, fetchImpl: three.fetch }, ); assert.equal(over.state, 'unreachable'); assert.equal(three.seen.length, 3, 'the fourth address is never fetched'); }); await checkAsync('a body over the cap and a body of the wrong type are both refused', async () => { const deps = { resolve: async () => ['93.184.216.34'] }; const big = scriptedFetch({ 'https://mint.example.com/v1/info': new Response('x'.repeat(2048), { headers: { 'content-type': 'application/json' }, }), }); const capped = await safeFetchText( 'https://mint.example.com/v1/info', { accept: 'application/json', maxBytes: 512 }, { ...deps, fetchImpl: big.fetch }, ); assert.equal(capped.state, 'unreachable'); const image = scriptedFetch({ 'https://mint.example.com/v1/info': new Response('\x89PNG', { headers: { 'content-type': 'image/png' }, }), }); const wrongType = await safeFetchText( 'https://mint.example.com/v1/info', { accept: 'application/json' }, { ...deps, fetchImpl: image.fetch }, ); assert.equal(wrongType.state, 'unreachable'); assert.match(wrongType.state === 'unreachable' ? wrongType.reason : '', /content type/); }); /* ---------- slug collapse ---------- */ check('every spelling of one mint collapses to one row key', () => { const spellings = [ 'https://mint.600.wtf', 'mint.600.wtf', 'mint.600.wtf/', 'https://mint.600.wtf/', 'https://MINT.600.WTF', 'http://mint.600.wtf', 'https://mint.600.wtf:443/', ' https://mint.600.wtf/ ', ].map((raw) => normalizeMintUrl(raw)); const urls = new Set(spellings.map((s) => s?.url)); const hosts = new Set(spellings.map((s) => s?.host)); assert.equal(urls.size, 1, `one canonical URL, got ${[...urls].join(', ')}`); assert.equal(hosts.size, 1, `one routing slug, got ${[...hosts].join(', ')}`); assert.equal([...urls][0], 'https://mint.600.wtf'); assert.equal([...hosts][0], 'mint.600.wtf'); // And the LNURL row key derived from it is one value too, since that is what the // endpoint actually looks the row up by. assert.equal(new Set(spellings.map((s) => lnurlKey(s!.url))).size, 1); }); check('a path is still part of a mint identity, and still collapses per path', () => { const withPath = ['https://mint.example.com/Bitcoin', 'mint.example.com/Bitcoin/'].map((raw) => normalizeMintUrl(raw), ); assert.equal(new Set(withPath.map((s) => s?.url)).size, 1); assert.notEqual(withPath[0]?.url, normalizeMintUrl('https://mint.example.com')?.url); }); /* ---------- what the browser checks before it asks ---------- */ check('the client-side pre-check accepts what the normalizer accepts', () => { assert.deepEqual(checkIndexInput('lnurl', 'mint.600.wtf'), { ok: true, value: 'https://mint.600.wtf', }); assert.deepEqual(checkIndexInput('cashu', ' https://21mint.me/ '), { ok: true, value: 'https://21mint.me', }); assert.deepEqual(checkIndexInput('cashu', ''), { ok: false, reason: 'empty' }); assert.deepEqual(checkIndexInput('cashu', 'not a url at all'), { ok: false, reason: 'bad_url' }); // A private address fails the pre-check for the same reason the server refuses it. assert.deepEqual(checkIndexInput('cashu', 'http://127.0.0.1:3338'), { ok: false, reason: 'bad_url', }); }); check('an invite code pasted into a URL field is named as an invite code', () => { const code = REAL_INVITE; assert.deepEqual(checkIndexInput('cashu', code), { ok: false, reason: 'bad_invite' }); assert.deepEqual(checkIndexInput('lnurl', code), { ok: false, reason: 'bad_invite' }); assert.deepEqual(checkIndexInput('fedimint', code), { ok: true, value: code }); assert.deepEqual(checkIndexInput('fedimint', 'https://mint.example.com'), { ok: false, reason: 'bad_invite', }); }); /* ---------- invite codes ---------- */ check('a real invite code yields the federation id its announcement carries', () => { // The `d` tag of the announcement this code came in: decoding must agree with it, or // a federation submitted by code would get a second row beside the announced one. assert.equal( federationIdFromInviteCode(REAL_INVITE), 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21', ); assert.equal(federationIdFromInviteCode(REAL_INVITE.toUpperCase()), federationIdFromInviteCode(REAL_INVITE)); }); check('anything that is not an invite code decodes to nothing', () => { for (const junk of [ '', 'fed1', 'fed11', 'https://mint.example.com', `${REAL_INVITE}x`, // checksum fails REAL_INVITE.slice(0, -1), // truncated REAL_INVITE.replace('fed11q', 'fed11p'), // one flipped character 'lnbc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', // valid-ish bech32, wrong hrp ]) { assert.equal(federationIdFromInviteCode(junk), null, `${junk.slice(0, 24)} must not decode`); } }); /* ---------- what counts as a mint ---------- */ check('a NUT-06 document is told apart from an arbitrary JSON endpoint', () => { assert.ok(isNut06Info({ nuts: { '4': { methods: [] } } })); assert.ok(isNut06Info({ pubkey: '03c21ef6'.padEnd(66, 'a'), name: 'x' })); assert.ok(isNut06Info({ name: 'Some mint', version: 'cdk-mintd/0.17.5' })); // The shapes a host that is not a mint actually answers with. assert.ok(!isNut06Info({ status: 'ok' })); assert.ok(!isNut06Info({ detail: 'Not Found' })); assert.ok(!isNut06Info({ tag: 'withdrawRequest', minWithdrawable: 1 })); assert.ok(!isNut06Info([])); assert.ok(!isNut06Info('hello')); assert.ok(!isNut06Info(null)); assert.ok(!isNut06Info({ nuts: {} }), 'an empty nuts object proves nothing'); }); /* ---------- rate limiting ---------- */ check('the eleventh submission in an hour is refused, and refusals do not extend it', () => { resetRateLimits(); const now = 1_800_000_000_000; for (let i = 0; i < RATE_LIMIT; i++) { assert.equal(takeToken('1.2.3.4', now + i).ok, true, `submission ${i + 1} must be allowed`); } const refused = takeToken('1.2.3.4', now + RATE_LIMIT); assert.equal(refused.ok, false); assert.ok(refused.retryAfter > 0 && refused.retryAfter <= 3600); // Pressing the button again must not push the window out. const again = takeToken('1.2.3.4', now + RATE_LIMIT + 1000); assert.ok(again.retryAfter <= refused.retryAfter, 'a refusal must not extend the wait'); // A different address has its own budget. assert.equal(takeToken('5.6.7.8', now + RATE_LIMIT).ok, true); // An hour later the window has slid past the first submission. assert.equal(takeToken('1.2.3.4', now + 3_600_001).ok, true); resetRateLimits(); }); /* ---------- the deep-link routes ---------- */ check('every deep link shape maps to the ecosystem that owns it', () => { assert.deepEqual(typeForPath('/mint/mint.600.wtf'), { type: 'cashu', host: 'mint.600.wtf' }); assert.deepEqual(typeForPath('/lnurl-mint/mint.600.wtf'), { type: 'lnurl', host: 'mint.600.wtf' }); assert.deepEqual(typeForPath('/fedimint/fed-aeca6cc80ffc530b'), { type: 'fedimint', host: 'fed-aeca6cc80ffc530b', }); // A trailing slash is the same page; anything else is not a mint page at all. assert.deepEqual(typeForPath('/mint/x.example.com/'), { type: 'cashu', host: 'x.example.com' }); assert.equal(typeForPath('/mints'), null); assert.equal(typeForPath('/'), null); assert.equal(typeForPath('/mint/'), null); }); /* ---------- one submission, one row ---------- */ /* * The dedup and the write path, against a throwaway in-memory database and with no * network involved at all: a Fedimint submission is decoded rather than fetched, so it * exercises `indexSubmission` end to end — the in-flight map, the insert, and the * payload read back — without touching a relay or a mint. * * The import is deferred until after `DATABASE_URL` is set, because the config resolves * its target on first use and this must not open the real database. */ process.env['DATABASE_URL'] = ':memory:'; const { indexSubmission, inFlightCount } = await import('./index-mint.ts'); const { closeDb } = await import('./db.ts'); await checkAsync('two simultaneous submissions of one address share one probe', async () => { assert.equal(inFlightCount(), 0); const first = indexSubmission('fedimint', REAL_INVITE); assert.equal(inFlightCount(), 1, 'the first submission claims the key immediately'); // Deliberately a different spelling of the same code: the key is the decoded // federation id, so the two collapse before anything is written. const second = indexSubmission('fedimint', REAL_INVITE.toUpperCase()); assert.equal(inFlightCount(), 1, 'the second submission joins the first, it does not start'); const [a, b] = await Promise.all([first, second]); assert.equal(a, b, 'both callers get the same answer object'); assert.equal(a.status, 201); assert.equal(inFlightCount(), 0, 'the entry is released when the work finishes'); const created = a.body as { host?: string; status?: string; invite_codes?: string[] }; assert.equal(created.host, 'fed-aeca6cc80ffc530b'); assert.equal(created.status, 'announced', 'nothing checks a federation, so nothing claims it is up'); assert.deepEqual(created.invite_codes, [REAL_INVITE.toLowerCase()]); // And once it is a row, submitting it again is a lookup rather than a write. const again = await indexSubmission('fedimint', REAL_INVITE); assert.equal(again.status, 200); assert.equal((again.body as { existing?: boolean }).existing, true); }); await checkAsync('a submission of the wrong shape never reaches the work at all', async () => { const junk = await indexSubmission('fedimint', 'fed11not-a-real-code'); assert.equal(junk.status, 422); assert.equal((junk.body as { error?: string }).error, 'invalid_invite'); assert.equal(inFlightCount(), 0); const wrongType = await indexSubmission('cashu-ish', 'https://mint.example.com'); assert.equal((wrongType.body as { error?: string }).error, 'bad_type'); // A private address is refused by the normalizer, before DNS and before the map. const private_ = await indexSubmission('cashu', 'https://192.168.1.5'); assert.equal((private_.body as { error?: string }).error, 'blocked_host'); assert.equal(inFlightCount(), 0); }); await closeDb(); console.log(`ok, ${checks} index checks passed`);