Wire discovery and probing for LNURL mints, add rate-limited POST /api/index for user submissions, and optionally announce confirmed state to relays. Co-authored-by: Cursor <cursoragent@cursor.com>
475 lines
19 KiB
TypeScript
475 lines
19 KiB
TypeScript
/**
|
|
* 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<void>): Promise<void> {
|
|
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<string, Response>): { fetch: typeof fetch; seen: string[] } {
|
|
const seen: string[] = [];
|
|
const impl = (async (input: unknown): Promise<Response> => {
|
|
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<string, Response> = {
|
|
'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`);
|