Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/lib/server/identity/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type Admission,
CLIENT_IP_HEADER,
createAuth,
hasForbiddenNameCharacter,
isNameTooLong,
isPlaceholderSecret,
MAX_NAME_LENGTH,
Expand Down Expand Up @@ -540,3 +541,156 @@ describe('the name cap', () => {
expect(await db.learner.count()).toBe(0);
});
});

describe('control and bidi characters in a name', () => {
const MESSAGE = "This name contains a character that can't be used.";
let caller = 0;
const signUp = (payload: Record<string, unknown>) => {
caller += 1;
return new Request('http://localhost:3000/api/auth/sign-up/email', {
method: 'POST',
headers: {
'content-type': 'application/json',
origin: 'http://localhost:3000',
[CLIENT_IP_HEADER]: `10.${100 + (caller >> 8)}.${caller & 255}.1`
},
body: JSON.stringify(payload)
});
};
const at = (cp: number) => `Ada${String.fromCodePoint(cp)}Lovelace`;

it.each([
[0x0000, true],
[0x001f, true],
[0x0020, false],
[0x007e, false],
[0x007f, true],
[0x009f, true],
[0x00a0, false],
[0x2029, false],
[0x202a, true],
[0x202e, true],
[0x202f, false],
[0x2065, false],
[0x2066, true],
[0x2069, true],
[0x206a, false],
// Allowed by decision: ZWJ for emoji sequences, and the zero-width space and word joiner.
[0x200b, false],
[0x200d, false],
[0x2060, false]
])('U+%s refused=%s', (cp, refused) => {
expect(hasForbiddenNameCharacter(at(cp))).toBe(refused);
});

it('refuses a tab or newline inside the name but not around it (trimmed away)', () => {
expect(hasForbiddenNameCharacter('Ada\tLovelace')).toBe(true);
expect(hasForbiddenNameCharacter('Ada\nLovelace')).toBe(true);
expect(hasForbiddenNameCharacter(' Ada Lovelace\n')).toBe(false);
});

it('allows an emoji ZWJ sequence', () => {
expect(hasForbiddenNameCharacter('\u{1F469}\u200D\u{1F4BB} Ada')).toBe(false);
});

it.each([[0x001f], [0x007f], [0x009f], [0x202a], [0x202e], [0x2066], [0x2069]])(
'refuses U+%s over HTTP with a fixed error, storing nothing',
async (cp) => {
const { auth, db } = await setup();

const res = await auth.handler(signUp({ ...body, name: at(cp) }));

expect(res.status).toBe(400);
const json = await res.json();
expect(json).toMatchObject({ code: 'NAME_INVALID_CHARACTERS', message: MESSAGE });
expect(JSON.stringify(json)).not.toContain('Ada');
expect(await db.user.count()).toBe(0);
expect(await db.account.count()).toBe(0);
expect(await db.session.count()).toBe(0);
expect(await db.learner.count()).toBe(0);
}
);

it('refuses the server-side call too, storing nothing', async () => {
const { auth, db } = await setup();

await expect(
auth.api.signUpEmail({ body: { ...body, name: at(0x202e) } })
).rejects.toMatchObject({
statusCode: 400,
body: { code: 'NAME_INVALID_CHARACTERS' }
});
expect(await db.user.count()).toBe(0);
});

it('checks the name as it would be stored: a control character around it is trimmed, not refused', async () => {
const { auth, db } = await setup();

const res = await auth.handler(signUp({ ...body, name: '\n Ada \t' }));

expect(res.status).toBe(200);
expect((await db.user.findFirstOrThrow()).name).toBe('Ada');
});

it.each([
['a ZWJ sequence', '\u{1F469}\u200D\u{1F4BB}'],
['a zero-width space', 'Ada\u200BLovelace'],
['a word joiner', 'Ada\u2060Lovelace']
])('accepts %s over HTTP and stores it', async (_label, name) => {
const { auth, db } = await setup();

const res = await auth.handler(signUp({ ...body, name }));

expect(res.status).toBe(200);
expect((await db.user.findFirstOrThrow()).name).toBe(name);
});

it('answers the same for an email that has an Account and one that has not: no probe', async () => {
const { auth, db } = await setup();
await auth.api.signUpEmail({ body });
const before = await db.user.count();
const bad = (email: string) => auth.handler(signUp({ ...body, email, name: at(0x202e) }));

const known = await bad(body.email);
const unknown = await bad('nobody@example.com');

expect(known.status).toBe(400);
expect(unknown.status).toBe(400);
expect(await known.json()).toEqual(await unknown.json());
expect(await db.user.count()).toBe(before);
});

it('still answers a closed sign-up with 403 first, whatever the name', async () => {
const { auth, db } = await setup(() => null);

expect((await auth.handler(signUp({ ...body, name: at(0x202e) }))).status).toBe(403);
expect(await db.user.count()).toBe(0);
});

it('answers a too-long name before the characters', async () => {
const { auth } = await setup();

const long = await auth.handler(signUp({ ...body, name: `${'x'.repeat(101)}\u202e` }));

expect(await long.json()).toMatchObject({ code: 'NAME_TOO_LONG' });
});

it('refuses the first admin and leaves the Instance not set up', async () => {
const db = await createFileTestDb();
const instance = await createInstance(db);
const auth = createAuth({
db,
origin: 'http://localhost:3000',
secret,
admitSignUp: createRegistration({ instance })
});

await expect(
createFirstAdmin({ instance, auth, db }, { ...body, name: at(0x202e) })
).rejects.toMatchObject({ body: { code: 'NAME_INVALID_CHARACTERS' } });

expect(instance.mode()).toBeNull();
expect(await db.user.count()).toBe(0);
expect(await db.learner.count()).toBe(0);
});
});
25 changes: 22 additions & 3 deletions src/lib/server/identity/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ export function isNameTooLong(name: string): boolean {
return [...trimmed].length > MAX_NAME_LENGTH;
}

/**
* C0 and C1 controls (U+0000-U+001F, U+007F-U+009F) and the bidi controls (U+202A-U+202E, U+2066-U+2069):
* they let a name spoof its own display on the Users page and in the nav. Everything else is allowed,
* including U+200D (emoji sequences) and the zero-width U+200B and U+2060.
*/
// \p{Cc} is exactly U+0000-U+001F and U+007F-U+009F.
const FORBIDDEN_NAME_CHARACTERS = /[\p{Cc}\u202A-\u202E\u2066-\u2069]/u;

/** Whether a name, as it would be stored (trimmed), holds a control or bidi character. */
export function hasForbiddenNameCharacter(name: string): boolean {
return FORBIDDEN_NAME_CHARACTERS.test(name.trim());
}

export const MIN_PASSWORD_LENGTH = 12;
export const MAX_PASSWORD_LENGTH = 128;

Expand Down Expand Up @@ -121,16 +134,22 @@ const signUpClosed = () => new APIError('FORBIDDEN', { message: 'Sign-up is clos
// Fixed messages and codes: the forms map the code to their own text, and the name is never echoed.
const nameTooLong = () =>
new APIError('BAD_REQUEST', { message: 'The name is too long.', code: 'NAME_TOO_LONG' });
const nameInvalidCharacters = () =>
new APIError('BAD_REQUEST', {
message: "This name contains a character that can't be used.",
code: 'NAME_INVALID_CHARACTERS'
});
const nameRequired = () =>
new APIError('BAD_REQUEST', { message: 'Enter a name.', code: 'NAME_REQUIRED' });

/**
* The refusal for a name, or null if it is fine: blank once trimmed, or over the cap. Which characters a
* name may hold is another question (a product rule) and is not asked here.
* The refusal for a name, or null if it is fine: blank once trimmed, over the cap, or holding a control or
* bidi character. The cap is checked before the characters, so a huge name is never scanned.
*/
function nameRefusal(name: string): APIError | null {
if (name.trim() === '') return nameRequired();
return isNameTooLong(name) ? nameTooLong() : null;
if (isNameTooLong(name)) return nameTooLong();
return hasForbiddenNameCharacter(name) ? nameInvalidCharacters() : null;
}

/** Better Auth over our database: email + password and the admin plugin, nothing else. */
Expand Down
13 changes: 12 additions & 1 deletion src/routes/setup/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { fail, redirect } from '@sveltejs/kit';
import { isAPIError } from 'better-auth/api';
import type { MultiUserChoice } from '$lib/server/app';
import { isNameTooLong, MAX_NAME_LENGTH } from '$lib/server/identity/auth';
import {
hasForbiddenNameCharacter,
isNameTooLong,
MAX_NAME_LENGTH
} from '$lib/server/identity/auth';
import { forwardSetCookies } from '$lib/server/identity/auth-handler';
import { InvalidFirstAdminError, MultiUserNotAvailableError } from '$lib/server/identity/setup';
import { InstanceAlreadySetUpError } from '$lib/server/instance';
Expand All @@ -22,6 +26,7 @@ const FIELD_MESSAGES = {
password: 'Use 12 to 128 characters.'
} as const;
const NAME_TOO_LONG = `Use at most ${MAX_NAME_LENGTH} characters.`;
const NAME_CHARACTERS = "This name contains a character that can't be used.";
const ALREADY_SET_UP = 'This instance has already been set up.';
const text = (data: FormData, key: string) => {
const value = data.get(key);
Expand Down Expand Up @@ -80,13 +85,19 @@ export const actions: Actions = {
if (isNameTooLong(input.name)) {
return fail(400, adminStep(typed, { field: 'name', message: NAME_TOO_LONG }));
}
if (hasForbiddenNameCharacter(input.name)) {
return fail(400, adminStep(typed, { field: 'name', message: NAME_CHARACTERS }));
}
let headers: Headers;
try {
({ headers } = await locals.setup.createFirstAdmin(input));
} catch (error) {
if (isAPIError(error) && error.body?.code === 'NAME_TOO_LONG') {
return fail(400, adminStep(typed, { field: 'name', message: NAME_TOO_LONG }));
}
if (isAPIError(error) && error.body?.code === 'NAME_INVALID_CHARACTERS') {
return fail(400, adminStep(typed, { field: 'name', message: NAME_CHARACTERS }));
}
if (isAPIError(error) && error.body?.code === 'NAME_REQUIRED') {
return fail(400, adminStep(typed, { field: 'name', message: FIELD_MESSAGES.name }));
}
Expand Down
57 changes: 57 additions & 0 deletions src/routes/setup/setup-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,63 @@ describe('step 2: create the admin', () => {
});
});

describe('control and bidi characters in the name', () => {
const MESSAGE = "This name contains a character that can't be used.";
const open = (createFirstAdmin = vi.fn(async () => ({ headers: new Headers() }))) => ({
createFirstAdmin,
locals: { setup: setupWith({ kind: 'open' }, createFirstAdmin) }
});

it.each([0x001f, 0x007f, 0x009f, 0x202a, 0x202e, 0x2066, 0x2069])(
'refuses U+%s on its field before calling createFirstAdmin, keeping what was typed',
async (cp) => {
const { createFirstAdmin, locals } = open();
const name = `Ada${String.fromCodePoint(cp)}Lovelace`;

const result = await run(actions.admin, { ...admin, name }, locals);

expect(result).toMatchObject({
status: 400,
data: { step: 'admin', field: 'name', message: MESSAGE, name, email: admin.email }
});
expect(JSON.stringify(result)).not.toContain(admin.password);
expect(createFirstAdmin).not.toHaveBeenCalled();
}
);

it.each([
['a tab inside', 'Ada\tLovelace', false],
['a newline inside', 'Ada\nLovelace', false],
['a newline around it, trimmed', ' Ada\n', true],
['U+0020', 'Ada Lovelace', true],
['U+00A0', 'Ada\u00a0Lovelace', true],
['U+202F', 'Ada\u202fLovelace', true],
['U+206A', 'Ada\u206aLovelace', true],
['U+2065', 'Ada\u2065Lovelace', true],
['a ZWJ emoji sequence', '\u{1F469}\u200D\u{1F4BB}', true],
['U+200B', 'Ada\u200bLovelace', true],
['U+2060', 'Ada\u2060Lovelace', true]
])('%s: accepted=%s', async (_label, name, accepted) => {
const { createFirstAdmin, locals } = open();

await run(actions.admin, { ...admin, name }, locals);

expect(createFirstAdmin).toHaveBeenCalledTimes(accepted ? 1 : 0);
});

it('maps Better Auth\u2019s own refusal, should one get through, to the same field error', async () => {
const refusal = new APIError('BAD_REQUEST', {
message: MESSAGE,
code: 'NAME_INVALID_CHARACTERS'
});
const { locals } = open(vi.fn(async () => Promise.reject(refusal)));

const result = await run(actions.admin, admin, locals);

expect(result).toMatchObject({ status: 400, data: { field: 'name', message: MESSAGE } });
});
});

it('refuses where Multi-user is not open, saying what to add to .env, without calling createFirstAdmin', async () => {
const createFirstAdmin = vi.fn();
const message = 'Add BETTER_AUTH_SECRET to your .env.';
Expand Down
67 changes: 67 additions & 0 deletions src/routes/signup/sign-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,73 @@ describe('what is wrong with the form', () => {
});
});

describe('control and bidi characters in the name', () => {
const MESSAGE = "This name contains a character that can't be used.";

it.each([0x001f, 0x007f, 0x009f, 0x202a, 0x202e, 0x2066, 0x2069])(
'refuses U+%s on its field before calling Better Auth, keeping what was typed',
async (cp) => {
const { post, signUpEmail } = harness();
const name = `Ada${String.fromCodePoint(cp)}Lovelace`;

const result = await post({ ...ada, name });

expect(result).toMatchObject({
status: 400,
data: { field: 'name', message: MESSAGE, name, email: ada.email }
});
expect(JSON.stringify(result)).not.toContain(ada.password);
expect(signUpEmail).not.toHaveBeenCalled();
}
);

it.each([
['a tab inside', 'Ada\tLovelace', false],
['a newline inside', 'Ada\nLovelace', false],
['a newline around it, trimmed', ' Ada\n', true],
['U+0020', 'Ada Lovelace', true],
['U+00A0', 'Ada\u00a0Lovelace', true],
['U+202F', 'Ada\u202fLovelace', true],
['U+206A', 'Ada\u206aLovelace', true],
['U+2065', 'Ada\u2065Lovelace', true],
['a ZWJ emoji sequence', '\u{1F469}\u200D\u{1F4BB}', true],
['U+200B', 'Ada\u200bLovelace', true],
['U+2060', 'Ada\u2060Lovelace', true]
])('%s: accepted=%s', async (_label, name, accepted) => {
const { post, signUpEmail } = harness();

await post({ ...ada, name });

expect(signUpEmail).toHaveBeenCalledTimes(accepted ? 1 : 0);
});

it('answers a long name before the characters', async () => {
expect(await harness().post({ ...ada, name: `${'x'.repeat(101)}\u202e` })).toMatchObject({
data: { message: 'Use at most 100 characters.' }
});
});

it('maps Better Auth\u2019s own refusal to the same field error', async () => {
const raced = harness({
auth: {
api: {
signUpEmail: async () => {
throw new APIError('BAD_REQUEST', {
message: MESSAGE,
code: 'NAME_INVALID_CHARACTERS'
});
}
}
}
});

expect(await raced.post(ada)).toMatchObject({
status: 400,
data: { field: 'name', message: MESSAGE }
});
});
});

it('accepts a password of exactly 12 and exactly 128 characters', async () => {
for (const password of ['x'.repeat(12), 'x'.repeat(128)]) {
const { post, signUpEmail } = harness();
Expand Down
Loading