[accounts] profanity filter on user/display name

This commit is contained in:
Devin Zuczek
2026-09-09 11:02:31 -04:00
parent 438475e326
commit 261ff21e0d
6 changed files with 120 additions and 6 deletions
+1
View File
@@ -15,6 +15,7 @@
"test": "run-vitest" "test": "run-vitest"
}, },
"dependencies": { "dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*", "@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
+5 -2
View File
@@ -448,7 +448,9 @@ const app = new Hono<App>()
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(SuccessResponse, 'Updated'), 200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' }, 400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
@@ -475,7 +477,8 @@ const app = new Hono<App>()
tags: ['Profile'], tags: ['Profile'],
summary: 'Change username', summary: 'Change username',
description: [ description: [
'Letters and digits only, at most 50 characters. Rejects a name taken by another', 'Letters and digits only, at most 50 characters, and free of profanity (the same',
'word list as `api`s `POST /api/sanitize/v1/isPure`). Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and', 'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`', 'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).', '(see the UsernameResult envelope).',
+27 -4
View File
@@ -9,6 +9,11 @@ import {
nameRejection, nameRejection,
} from '@repo/domain' } from '@repo/domain'
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
// than copied so a name is held to the very same word list every other player-typed
// string is.
import { nameContainsSwears } from '../../api/src/sanitize'
import type { OpenAPIV3_1 } from 'openapi-types' import type { OpenAPIV3_1 } from 'openapi-types'
/** /**
@@ -158,26 +163,44 @@ export const CreateAccountRequest = z.object({
* writes the player-facing sentence and there's no reason to write it twice. * writes the player-facing sentence and there's no reason to write it twice.
*/ */
/** Zod check that defers to the shared name rule, message and all. */ /**
* Zod check that defers to the shared name rule, message and all, and then refuses a name
* with a swear in it — the same filter, and the same word list, as `api`'s
* `POST /api/sanitize/v1/isPure`.
*
* Shape first, profanity second: a name that already broke the charset rule gets the one
* sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused.
*/
const nameCheck = (label: string, max: number) => const nameCheck = (label: string, max: number) =>
z z
.string() .string()
.trim() .trim()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max) const rejection = nameRejection(value, label, max)
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection }) if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
// Deliberately vague about WHICH word: naming it back to the player prints the
// swear in the UI, and the player knows what they typed.
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
}
}) })
export const DisplayNameRequest = z.object({ export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH) displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1) .min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'), .describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
),
}) })
export const UsernameRequest = z.object({ export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH) username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.') .min(1, 'You must enter a username.')
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'), .describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
),
}) })
export const EmailRequest = z.object({ export const EmailRequest = z.object({
@@ -223,6 +223,32 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj') expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
}) })
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
const headers = {
...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded',
}
// A name carries no spaces, so the filter has to find the swear at the seam the
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(400)
}
// And the words that merely contain one still get through — refusing these is worse
// than missing a swear, because the player can't see why.
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(200)
}
})
test('PUT /account/me/username 401s without a token', async () => { test('PUT /account/me/username 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, { const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'whoever' }), ...form({ username: 'whoever' }),
@@ -244,6 +270,30 @@ describe('auth-gated endpoints', () => {
expect(body.value).toBe('') expect(body.value).toBe('')
}) })
test('PUT /account/me/username refuses a swear without spending a change', async () => {
const headers = {
...(await bearer('894')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'ShitLord' }),
headers,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
// Vague on purpose — the message must not print the swear back at the player.
expect(body.error).toMatch(/can't contain that word/i)
expect(body.value).toBe('')
// The schema runs before the handler, so a refused name costs none of the account's
// rationed changes.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(3)
})
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => { test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
const headers = { const headers = {
...(await bearer('892')), ...(await bearer('892')),
+34
View File
@@ -60,6 +60,40 @@ export function containsSwears(value: string): boolean {
return value !== '' && filter.exists(value) return value !== '' && filter.exists(value)
} }
/**
* Where a space would be in a name if the charset allowed one: a lowercase-to-uppercase
* hop, the last capital of a run before a capitalised word, and either side of a run of
* digits. Applied in that order, so `ShitLord`, `XXFuckYou` and `Fucker123` each come
* apart at the seam a player wrote them with.
*/
const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
[/([a-z0-9])([A-Z])/g, '$1 $2'],
[/([A-Z]+)([A-Z][a-z])/g, '$1 $2'],
[/([A-Za-z])([0-9])/g, '$1 $2'],
]
/**
* Whether `value`, read as a NAME, contains a swear.
*
* A username or display name is letters and digits only (`nameRejection`), so it carries
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
* of a space, and the pieces are checked as words.
*
* That keeps the library's trade-off rather than reaching for substring matching, which
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
* `Cumberland` and `Shiitake` all contain a swear as a substring, and refusing someone's
* name without being able to say why is worse than missing `Bitchy`.
*/
export function nameContainsSwears(value: string): boolean {
const spaced = NAME_WORD_BOUNDARIES.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value
)
return containsSwears(spaced)
}
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */ /** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
export const DEFAULT_REPLACEMENT_CHAR = '*' export const DEFAULT_REPLACEMENT_CHAR = '*'
+3
View File
@@ -53,6 +53,9 @@ importers:
apps/accounts: apps/accounts:
dependencies: dependencies:
'@2toad/profanity':
specifier: 3.3.0
version: 3.3.0
'@repo/domain': '@repo/domain':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/domain version: link:../../packages/domain