mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-08 08:22:27 +02:00
The send budget was keyed by every submitted form field, so adding a field with a new value each time made a fresh budget, and one mailbox could be sent codes without limit. A claim is now built only from the claim fields (email, phone by default); anything else is dropped before it is counted and before it reaches sendCode. A budget per mailbox does nothing against a caller holding a list of addresses: each send goes to a different stranger, and the deployment's whole sending allowance goes with them. Add a second budget per requesting address, 10 codes per 24 hours by default. The window is a day rather than an hour because a caller that paces itself stays under any hourly figure a person would also fit under. The address lookup the device flow used is moved into util so both read the caller the same way.
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
|
|
import { object, string } from 'valibot';
|
|
|
|
import { CodeProvider } from '../src/provider/code.js';
|
|
import { issuer } from '../src/issuer.js';
|
|
import { MemoryStorage } from '../src/storage/memory.js';
|
|
import { createSubjects } from '../src/subject.js';
|
|
|
|
const subjects = createSubjects({ user: object({ email: string() }) });
|
|
|
|
let sent: string[] = [];
|
|
|
|
const auth = issuer({
|
|
storage: MemoryStorage(),
|
|
subjects,
|
|
allow: async () => true,
|
|
// Screens back as JSON instead of HTML, which is the whole of what it takes
|
|
// to replace the presentation layer — and is why these tests can assert on
|
|
// what the flow decided rather than on the markup it happened to produce.
|
|
renderer: { render: (screen) => Response.json(screen) },
|
|
providers: {
|
|
code: CodeProvider({
|
|
maxAttempts: 3,
|
|
maxSends: 2,
|
|
maxSendsPerAddress: 3,
|
|
sendWindow: 3600,
|
|
resendInterval: 0,
|
|
request: async (_req, _state, _form, error) => ({
|
|
kind: 'message',
|
|
tone: 'danger',
|
|
heading: error?.type ?? 'none',
|
|
body: []
|
|
}),
|
|
sendCode: async (claims, code) => {
|
|
if (!claims.email?.includes('@')) {
|
|
return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' };
|
|
}
|
|
sent.push(code);
|
|
}
|
|
})
|
|
},
|
|
success: async (ctx, value) => ctx.subject('user', { email: (value as any).claims.email })
|
|
});
|
|
|
|
const ORIGIN = 'https://auth.example.com';
|
|
|
|
function jar() {
|
|
const cookies = new Map<string, string>();
|
|
return {
|
|
absorb(response: Response) {
|
|
for (const raw of response.headers.getSetCookie()) {
|
|
const [pair] = raw.split(';');
|
|
const index = pair!.indexOf('=');
|
|
cookies.set(pair!.slice(0, index), pair!.slice(index + 1));
|
|
}
|
|
},
|
|
header() {
|
|
return [...cookies].map(([name, value]) => `${name}=${value}`).join('; ');
|
|
}
|
|
};
|
|
}
|
|
|
|
async function post(
|
|
cookies: ReturnType<typeof jar>,
|
|
body: Record<string, string>,
|
|
headers: Record<string, string> = {}
|
|
) {
|
|
const response = await auth.request(`${ORIGIN}/code/authorize`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...headers,
|
|
cookie: cookies.header(),
|
|
'content-type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams(body)
|
|
});
|
|
cookies.absorb(response);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Begin an authorization the way a client does, so success has somewhere to go.
|
|
*
|
|
* Without this there is no authorization state and a correct code produces
|
|
* tokens rather than the redirect a browser flow ends in — which would make
|
|
* "did this sign in?" a different question in the test than in the product.
|
|
*/
|
|
async function begin() {
|
|
const cookies = jar();
|
|
const url = new URL(`${ORIGIN}/authorize`);
|
|
url.searchParams.set('client_id', 'test');
|
|
url.searchParams.set('redirect_uri', 'https://client.example.com/callback');
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('provider', 'code');
|
|
cookies.absorb(await auth.request(url.toString()));
|
|
return cookies;
|
|
}
|
|
|
|
/** Start a sign-in and ask for a code, coming back with the cookies and the code. */
|
|
async function ask(email: string) {
|
|
const cookies = await begin();
|
|
await post(cookies, { action: 'request', email });
|
|
return { cookies, code: sent.at(-1)! };
|
|
}
|
|
|
|
/** What the stub UI reported, so a test can name the error rather than a status. */
|
|
async function errorOf(response: Response) {
|
|
const screen = (await response.clone().json()) as { heading: string };
|
|
return screen.heading === 'none' ? null : screen.heading;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sent = [];
|
|
});
|
|
|
|
describe('signing in with a code', () => {
|
|
test('the right code signs you in', async () => {
|
|
const { cookies, code } = await ask('right@example.com');
|
|
const response = await post(cookies, { action: 'verify', code });
|
|
expect(response.status).toBe(302);
|
|
});
|
|
|
|
test('a wrong code is refused and says so', async () => {
|
|
const { cookies, code } = await ask('wrong@example.com');
|
|
const response = await post(cookies, { action: 'verify', code: code === '000000' ? '111111' : '000000' });
|
|
expect(response.status).toBe(200);
|
|
expect(await errorOf(response)).toBe('invalid_code');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The attack a six-digit pin invites, and what stops it.
|
|
*
|
|
* The code travels in an encrypted cookie the caller holds, and the caller is
|
|
* not necessarily the person the code was mailed to — anybody can type somebody
|
|
* else's address into the first screen. So the only thing between an attacker
|
|
* and an account is how many times they may guess, and that number has to be
|
|
* kept somewhere they cannot reach.
|
|
*/
|
|
describe('guessing the code', () => {
|
|
test('runs out of guesses long before it runs out of codes', async () => {
|
|
const { cookies, code } = await ask('budget@example.com');
|
|
const wrong = code === '000000' ? '111111' : '000000';
|
|
|
|
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
|
|
'invalid_code'
|
|
);
|
|
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
|
|
'invalid_code'
|
|
);
|
|
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
|
|
'invalid_code'
|
|
);
|
|
|
|
// Out of budget. The next guess is refused whether or not it is right.
|
|
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
|
|
'rate_limit'
|
|
);
|
|
});
|
|
|
|
test('the real code stops working once the guesses are spent', async () => {
|
|
const { cookies, code } = await ask('spent@example.com');
|
|
const wrong = code === '000000' ? '111111' : '000000';
|
|
for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong });
|
|
|
|
const response = await post(cookies, { action: 'verify', code });
|
|
expect(response.status).toBe(200);
|
|
expect(await errorOf(response)).toBe('rate_limit');
|
|
});
|
|
|
|
// The counter would be worthless if it lived where the guesser does. This
|
|
// replays the cookie from before any guess was made, which is the cheapest
|
|
// way to wind back anything held in one.
|
|
test('replaying an earlier cookie does not hand back the spent guesses', async () => {
|
|
const { cookies, code } = await ask('replay@example.com');
|
|
const untouched = cookies.header();
|
|
const wrong = code === '000000' ? '111111' : '000000';
|
|
for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong });
|
|
|
|
const replayed = await auth.request(`${ORIGIN}/code/authorize`, {
|
|
method: 'POST',
|
|
headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ action: 'verify', code: wrong })
|
|
});
|
|
expect(await errorOf(replayed)).toBe('rate_limit');
|
|
});
|
|
|
|
test('a code is spent when it is used, so its guesses do not carry over', async () => {
|
|
const { cookies, code } = await ask('once@example.com');
|
|
expect((await post(cookies, { action: 'verify', code })).status).toBe(302);
|
|
|
|
const again = await post(cookies, { action: 'verify', code });
|
|
expect(again.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('asking for codes', () => {
|
|
test('a fresh code comes with a fresh budget of guesses', async () => {
|
|
const first = await ask('fresh-a@example.com');
|
|
const wrong = '000000' === first.code ? '111111' : '000000';
|
|
for (let i = 0; i < 3; i++) await post(first.cookies, { action: 'verify', code: wrong });
|
|
expect(await errorOf(await post(first.cookies, { action: 'verify', code: wrong }))).toBe(
|
|
'rate_limit'
|
|
);
|
|
|
|
// Starting over is allowed. It costs a code sent to the mailbox being
|
|
// aimed at, which is where somebody would notice.
|
|
const second = await ask('fresh-b@example.com');
|
|
expect(second.code).not.toBe(first.code);
|
|
expect((await post(second.cookies, { action: 'verify', code: second.code })).status).toBe(302);
|
|
});
|
|
|
|
test('a mailbox cannot be sent codes forever', async () => {
|
|
const email = 'flood@example.com';
|
|
const { cookies } = await ask(email);
|
|
expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe(null);
|
|
expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe('rate_limit');
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
// The budget was once held per sign-in attempt, which bounded nothing: the
|
|
// caller decides how many attempts to start, and starting one costs a
|
|
// discarded cookie. Both of these walk around a per-attempt budget and land
|
|
// on the mailbox anyway, which is why the count lives there.
|
|
test('replaying an earlier cookie does not buy more codes', async () => {
|
|
const email = 'replay-send@example.com';
|
|
const { cookies } = await ask(email);
|
|
const untouched = cookies.header();
|
|
await post(cookies, { action: 'resend', email });
|
|
expect(sent).toHaveLength(2);
|
|
|
|
const replayed = await auth.request(`${ORIGIN}/code/authorize`, {
|
|
method: 'POST',
|
|
headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ action: 'resend', email })
|
|
});
|
|
expect(await errorOf(replayed)).toBe('rate_limit');
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
test('starting over does not buy more codes either', async () => {
|
|
const email = 'restart-send@example.com';
|
|
await ask(email);
|
|
await ask(email);
|
|
expect(sent).toHaveLength(2);
|
|
|
|
const third = await begin();
|
|
expect(await errorOf(await post(third, { action: 'request', email }))).toBe('rate_limit');
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
// Each resend used to leave the code before it live, with a budget of
|
|
// guesses of its own. Five resends meant five working codes and five times
|
|
// the chances, so asking for a new code was how you bought more tries at
|
|
// the old one.
|
|
test('a resend retires the code before it', async () => {
|
|
const email = 'retire@example.com';
|
|
const { cookies, code: first } = await ask(email);
|
|
const untouched = cookies.header();
|
|
await post(cookies, { action: 'resend', email });
|
|
expect(sent.at(-1)).not.toBe(first);
|
|
|
|
const withOldCode = await auth.request(`${ORIGIN}/code/authorize`, {
|
|
method: 'POST',
|
|
headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ action: 'verify', code: first })
|
|
});
|
|
expect(withOldCode.status).toBe(200);
|
|
expect(await errorOf(withOldCode)).toBe('rate_limit');
|
|
});
|
|
|
|
// The budget is keyed by the claim. When the claim was every field in the
|
|
// form, a field with a new value each time was a new key, and so a new
|
|
// budget for the same mailbox.
|
|
test('an extra form field does not buy more codes', async () => {
|
|
const email = 'padded@example.com';
|
|
for (let i = 0; i < 4; i++) {
|
|
const cookies = await begin();
|
|
await post(cookies, { action: 'request', email, pad: String(i) });
|
|
}
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
test('an extra form field does not reach the claims', async () => {
|
|
const cookies = await begin();
|
|
await post(cookies, { action: 'request', email: 'clean@example.com', role: 'admin' });
|
|
const response = await post(cookies, { action: 'verify', code: sent.at(-1)! });
|
|
expect(response.status).toBe(302);
|
|
});
|
|
|
|
// A list of addresses walks around a budget held per mailbox. This is the
|
|
// one that bounds what a single caller can spend.
|
|
test('one caller cannot mail a list of strangers', async () => {
|
|
const from = { 'cf-connecting-ip': '198.51.100.20' };
|
|
for (let i = 0; i < 5; i++) {
|
|
const cookies = await begin();
|
|
await post(cookies, { action: 'request', email: `stranger-${i}@example.com` }, from);
|
|
}
|
|
expect(sent).toHaveLength(3);
|
|
|
|
// Somebody else is not caught by it.
|
|
const other = await begin();
|
|
await post(
|
|
other,
|
|
{ action: 'request', email: 'neighbour@example.com' },
|
|
{ 'cf-connecting-ip': '198.51.100.21' }
|
|
);
|
|
expect(sent).toHaveLength(4);
|
|
});
|
|
|
|
test('a bad address still gets told it is a bad address', async () => {
|
|
const cookies = await begin();
|
|
const response = await post(cookies, { action: 'request', email: 'not-an-address' });
|
|
expect(await errorOf(response)).toBe('invalid_claim');
|
|
expect(sent).toHaveLength(0);
|
|
});
|
|
});
|