From a1a02c59520bfdae4b11b682e0a81f4d53a437cd Mon Sep 17 00:00:00 2001 From: Gauransh Mathur Date: Fri, 16 Oct 2026 14:09:16 +0000 Subject: [PATCH] feat(beyondleetcode): C33d refuse control and bidi characters in names Co-Authored-By: Claude Sonnet 5.5 --- src/lib/server/identity/auth.test.ts | 154 ++++++++++++++++++++++++++ src/lib/server/identity/auth.ts | 25 ++++- src/routes/setup/+page.server.ts | 13 ++- src/routes/setup/setup-action.test.ts | 57 ++++++++++ src/routes/signup/sign-up.test.ts | 67 +++++++++++ src/routes/signup/sign-up.ts | 7 ++ 6 files changed, 319 insertions(+), 4 deletions(-) diff --git a/src/lib/server/identity/auth.test.ts b/src/lib/server/identity/auth.test.ts index 3817898f..76182e98 100644 --- a/src/lib/server/identity/auth.test.ts +++ b/src/lib/server/identity/auth.test.ts @@ -6,6 +6,7 @@ import { type Admission, CLIENT_IP_HEADER, createAuth, + hasForbiddenNameCharacter, isNameTooLong, isPlaceholderSecret, MAX_NAME_LENGTH, @@ -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) => { + 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); + }); +}); diff --git a/src/lib/server/identity/auth.ts b/src/lib/server/identity/auth.ts index 4fc981a5..2ac898d5 100644 --- a/src/lib/server/identity/auth.ts +++ b/src/lib/server/identity/auth.ts @@ -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; @@ -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. */ diff --git a/src/routes/setup/+page.server.ts b/src/routes/setup/+page.server.ts index f04bc8da..a57a2882 100644 --- a/src/routes/setup/+page.server.ts +++ b/src/routes/setup/+page.server.ts @@ -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'; @@ -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); @@ -80,6 +85,9 @@ 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)); @@ -87,6 +95,9 @@ export const actions: Actions = { 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 })); } diff --git a/src/routes/setup/setup-action.test.ts b/src/routes/setup/setup-action.test.ts index 449ce951..ac42dab2 100644 --- a/src/routes/setup/setup-action.test.ts +++ b/src/routes/setup/setup-action.test.ts @@ -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.'; diff --git a/src/routes/signup/sign-up.test.ts b/src/routes/signup/sign-up.test.ts index 7273ba63..a38029a5 100644 --- a/src/routes/signup/sign-up.test.ts +++ b/src/routes/signup/sign-up.test.ts @@ -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(); diff --git a/src/routes/signup/sign-up.ts b/src/routes/signup/sign-up.ts index 2c34e4a7..315024dd 100644 --- a/src/routes/signup/sign-up.ts +++ b/src/routes/signup/sign-up.ts @@ -2,6 +2,7 @@ import { fail, type RequestEvent, redirect } from '@sveltejs/kit'; import { isAPIError } from 'better-auth/api'; import { z } from 'zod'; import { + hasForbiddenNameCharacter, isNameTooLong, MAX_NAME_LENGTH, MAX_PASSWORD_LENGTH, @@ -14,6 +15,7 @@ import type { Throttle } from '$lib/server/identity/throttle'; const MESSAGES = { name: 'Enter a name.', nameLong: `Use at most ${MAX_NAME_LENGTH} characters.`, + nameCharacters: "This name contains a character that can't be used.", email: 'Enter an email address like name@example.com.', passwordShort: `Use at least ${MIN_PASSWORD_LENGTH} characters.`, passwordLong: `Use at most ${MAX_PASSWORD_LENGTH} characters.`, @@ -55,6 +57,8 @@ function known(error: unknown): { status: 400 | 403; field?: Field; message: str return { status: 400, field: 'name', message: MESSAGES.name }; case 'NAME_TOO_LONG': return { status: 400, field: 'name', message: MESSAGES.nameLong }; + case 'NAME_INVALID_CHARACTERS': + return { status: 400, field: 'name', message: MESSAGES.nameCharacters }; case 'INVALID_EMAIL': return { status: 400, field: 'email', message: MESSAGES.email }; case 'PASSWORD_TOO_SHORT': @@ -122,6 +126,9 @@ export function createSignUp(throttle: Throttle) { const password = text(data, 'password'); if (name === '') return refused(400, typed, MESSAGES.name, 'name'); if (isNameTooLong(name)) return refused(400, typed, MESSAGES.nameLong, 'name'); + if (hasForbiddenNameCharacter(name)) { + return refused(400, typed, MESSAGES.nameCharacters, 'name'); + } if (!z.email().safeParse(email).success) return refused(400, typed, MESSAGES.email, 'email'); if (password.length < MIN_PASSWORD_LENGTH) { return refused(400, typed, MESSAGES.passwordShort, 'password');