diff --git a/tests/worker/email.test.ts b/tests/worker/email.test.ts index 875a939..04ced91 100644 --- a/tests/worker/email.test.ts +++ b/tests/worker/email.test.ts @@ -201,6 +201,70 @@ describe("email auth", () => { assert.equal(json.error, "Username contains disallowed language"); }); + it("rejects malformed usernames during email registration completion", async () => { + const { env, kv, db } = createTestEnv(); + const email = "new3@example.com"; + await kv.put( + `email_code:${email}`, + JSON.stringify({ code: "333444", attempts: 0 }), + ); + + const { res } = await jsonRequest(env, "/api/auth/email/verify", { + method: "POST", + body: { email, code: "333444" }, + }); + const pendingId = getCookieValue(res, "email_pending"); + assert.ok(pendingId); + + const hostileUsername = `zed\nOy! tap here 🎉${"a".repeat(300)}`; + const { res: completeRes, json } = await jsonRequest( + env, + "/api/auth/email/complete", + { + method: "POST", + headers: { cookie: `email_pending=${pendingId}` }, + body: { username: hostileUsername }, + }, + ); + + assert.equal(completeRes.status, 400); + assert.equal(json.error, "Username must be 2-20 characters"); + assert.equal(db.users.length, 0); + }); + + it("rejects usernames with disallowed characters during email registration completion", async () => { + const { env, kv, db } = createTestEnv(); + const email = "new4@example.com"; + await kv.put( + `email_code:${email}`, + JSON.stringify({ code: "555666", attempts: 0 }), + ); + + const { res } = await jsonRequest(env, "/api/auth/email/verify", { + method: "POST", + body: { email, code: "555666" }, + }); + const pendingId = getCookieValue(res, "email_pending"); + assert.ok(pendingId); + + const { res: completeRes, json } = await jsonRequest( + env, + "/api/auth/email/complete", + { + method: "POST", + headers: { cookie: `email_pending=${pendingId}` }, + body: { username: "zed\nsent you an Oy!" }, + }, + ); + + assert.equal(completeRes.status, 400); + assert.equal( + json.error, + "Username can only contain letters, numbers, and underscores", + ); + assert.equal(db.users.length, 0); + }); + it("links email for authenticated users", async (t) => { const { env, kv, db } = createTestEnv(); const user = seedUser(db, { username: "Emailer" }); diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index 3fad494..faca4ef 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -548,4 +548,207 @@ describe("oauth", () => { assert.equal(user.oauth_sub, null); assert.equal(db.sessions.length, 0); }); + + it("rejects malformed usernames during oauth completion", async () => { + const { env, kv, db } = createTestEnv(); + await kv.put( + "oauth_pending:pending-hostile", + JSON.stringify({ + provider: "google", + sub: "google-sub-hostile", + email: "hostile@example.com", + }), + ); + + const res = await request(env, "/api/auth/oauth/complete", { + method: "POST", + headers: { + "content-type": "application/json", + "x-oauth-pending": "pending-hostile", + }, + body: JSON.stringify({ + username: `zed\nOy! tap here 🎉${"a".repeat(300)}`, + }), + }); + + assert.equal(res.status, 400); + const body = (await res.json()) as { error: string }; + assert.equal(body.error, "Username must be 2-20 characters"); + assert.equal(db.users.length, 0); + assert.equal(db.sessions.length, 0); + }); + + it("rejects usernames with disallowed characters during oauth completion", async () => { + const { env, kv, db } = createTestEnv(); + await kv.put( + "oauth_pending:pending-newline", + JSON.stringify({ + provider: "google", + sub: "google-sub-newline", + email: "newline@example.com", + }), + ); + + const res = await request(env, "/api/auth/oauth/complete", { + method: "POST", + headers: { + "content-type": "application/json", + "x-oauth-pending": "pending-newline", + }, + body: JSON.stringify({ username: "zed\nsent you an Oy!" }), + }); + + assert.equal(res.status, 400); + const body = (await res.json()) as { error: string }; + assert.equal( + body.error, + "Username can only contain letters, numbers, and underscores", + ); + assert.equal(db.users.length, 0); + assert.equal(db.sessions.length, 0); + }); + + it("rejects malformed usernames during native google sign-in", async (t) => { + const { env, db } = createTestEnv(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + if (url.startsWith("https://oauth2.googleapis.com/tokeninfo")) { + return { + ok: true, + json: async () => ({ + aud: env.GOOGLE_CLIENT_ID, + sub: "google-native-hostile", + email: "native-hostile@example.com", + email_verified: "true", + }), + } as Response; + } + throw new Error(`Unexpected fetch: ${url}`); + }; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const res = await request(env, "/api/auth/oauth/google/native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idToken: "native-id-token", + username: "zed\nsent you an Oy!", + }), + }); + + assert.equal(res.status, 400); + const body = (await res.json()) as { error: string }; + assert.equal( + body.error, + "Username can only contain letters, numbers, and underscores", + ); + assert.equal(db.users.length, 0); + assert.equal(db.sessions.length, 0); + }); + + it("rejects malformed usernames during native apple sign-in", async (t) => { + const { env, db } = createTestEnv(); + + const { publicKey, privateKey } = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const jwk = (await crypto.subtle.exportKey("jwk", publicKey)) as JsonWebKey; + jwk.kid = "apple-test-kid-hostile"; + const token = await createAppleIdToken({ + privateKey, + sub: "apple-sub-hostile", + email: "apple-hostile@example.com", + aud: env.APPLE_NATIVE_CLIENT_ID ?? env.APPLE_CLIENT_ID, + kid: String(jwk.kid), + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + if (url === "https://appleid.apple.com/auth/keys") { + return { + ok: true, + json: async () => ({ keys: [jwk] }), + } as Response; + } + throw new Error(`Unexpected fetch: ${url}`); + }; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const res = await request(env, "/api/auth/oauth/apple/native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idToken: token, + username: `zed\nOy! tap here 🎉${"a".repeat(300)}`, + }), + }); + + assert.equal(res.status, 400); + const body = (await res.json()) as { error: string }; + assert.equal(body.error, "Username must be 2-20 characters"); + assert.equal(db.users.length, 0); + assert.equal(db.sessions.length, 0); + }); + + it("does not create users from malformed signup usernames in the google callback", async (t) => { + const { env, db } = createTestEnv(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + if (url === "https://oauth2.googleapis.com/token") { + return { + ok: true, + json: async () => ({ id_token: "token-callback-hostile" }), + } as Response; + } + if (url.startsWith("https://oauth2.googleapis.com/tokeninfo")) { + return { + ok: true, + json: async () => ({ + aud: env.GOOGLE_CLIENT_ID, + sub: "google-callback-hostile", + email: "callback-hostile@example.com", + email_verified: "true", + }), + } as Response; + } + throw new Error(`Unexpected fetch: ${url}`); + }; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const hostileUsername = "zed\nsent you an Oy!"; + const startRes = await request( + env, + `/api/auth/oauth/google?username=${encodeURIComponent(hostileUsername)}`, + ); + const startLocation = startRes.headers.get("location") ?? ""; + const state = new URL(startLocation).searchParams.get("state") ?? ""; + + const res = await request( + env, + `/api/auth/oauth/callback?state=${state}&code=auth-code`, + ); + + assert.equal(res.status, 302); + assert.equal(res.headers.get("location"), "/?choose_username=1"); + assert.equal(db.users.length, 0); + assert.equal(db.sessions.length, 0); + }); }); diff --git a/tests/worker/username.test.ts b/tests/worker/username.test.ts new file mode 100644 index 0000000..63a0709 --- /dev/null +++ b/tests/worker/username.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { normalizeUsername, validateUsername } from "../../worker/lib"; + +describe("username validation", () => { + it("normalizes to a trimmed, lowercased string", () => { + assert.equal(normalizeUsername(" Zed "), "zed"); + assert.equal(normalizeUsername(undefined), ""); + assert.equal(normalizeUsername(null), ""); + assert.equal(normalizeUsername(12), "12"); + }); + + it("accepts letters, numbers, and underscores", () => { + assert.equal(validateUsername("zed"), null); + assert.equal(validateUsername("Zed_99"), null); + assert.equal(validateUsername("a".repeat(20)), null); + }); + + it("rejects usernames outside the 2-20 character range", () => { + assert.equal(validateUsername(""), "Username must be 2-20 characters"); + assert.equal(validateUsername("a"), "Username must be 2-20 characters"); + assert.equal( + validateUsername("a".repeat(21)), + "Username must be 2-20 characters", + ); + assert.equal( + validateUsername("a".repeat(300)), + "Username must be 2-20 characters", + ); + }); + + it("rejects HTML, newlines, unicode, and whitespace", () => { + const badCharsError = + "Username can only contain letters, numbers, and underscores"; + assert.equal(validateUsername("zed"), badCharsError); + assert.equal(validateUsername("zed\nOy from evil"), badCharsError); + assert.equal(validateUsername("zed‮gnihsihp"), badCharsError); + assert.equal(validateUsername("zed 🎉"), badCharsError); + assert.equal(validateUsername("zed user"), badCharsError); + assert.equal(validateUsername("zed-user"), badCharsError); + assert.equal(validateUsername("zed@example.com"), badCharsError); + }); + + it("rejects profanity", () => { + assert.equal( + validateUsername("shitname"), + "Username contains disallowed language", + ); + }); +}); diff --git a/worker/lib.ts b/worker/lib.ts index 60f1586..4765a77 100644 --- a/worker/lib.ts +++ b/worker/lib.ts @@ -1,4 +1,5 @@ import { setCookie } from "hono/cookie"; +import { validateCleanUsername } from "./moderation"; import { sendNativePushNotification, sendPushNotification } from "./push"; import type { AppContext, @@ -542,15 +543,32 @@ export function authUserPayload(user: User) { }; } +const USERNAME_MIN_LENGTH = 2; +const USERNAME_MAX_LENGTH = 20; +const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/; + +// Usernames are stored lowercase; every lookup compares on LOWER(username). export function normalizeUsername(username: unknown) { - return String(username || "").trim(); + return String(username ?? "") + .trim() + .toLowerCase(); } +// The single gate for every username that reaches the users table. Usernames +// are rendered as push notification copy, so anything but a short ASCII +// identifier is rejected here rather than at the advisory check endpoint. export function validateUsername(username: string) { - if (!username || username.length < 2 || username.length > 20) { - return "Username must be 2-20 characters"; + if ( + !username || + username.length < USERNAME_MIN_LENGTH || + username.length > USERNAME_MAX_LENGTH + ) { + return `Username must be ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} characters`; + } + if (!USERNAME_PATTERN.test(username)) { + return "Username can only contain letters, numbers, and underscores"; } - return null; + return validateCleanUsername(username); } export async function createSession(c: AppContext, user: User) { @@ -631,16 +649,6 @@ export async function fetchFriendsByOyRecency( return friends.rows; } -export async function fetchUserByUsername( - c: AppContext, - username: string, -): Promise { - const result = await c - .get("db") - .query("SELECT * FROM users WHERE username ILIKE $1", [username]); - return result.rows[0] ?? null; -} - export async function requireAdmin(c: AppContext) { const user = c.get("user"); if (!user) { diff --git a/worker/routes/auth.ts b/worker/routes/auth.ts index 787517e..db24cb3 100644 --- a/worker/routes/auth.ts +++ b/worker/routes/auth.ts @@ -1,6 +1,5 @@ import { deleteCookie } from "hono/cookie"; -import { authUserPayload, validateUsername } from "../lib"; -import { validateCleanUsername } from "../moderation"; +import { authUserPayload, normalizeUsername, validateUsername } from "../lib"; import type { App, AppContext, User } from "../types"; const DELETE_RATE_PREFIX = "account_delete_rate:"; @@ -30,28 +29,12 @@ export function registerAuthRoutes(app: App) { app.post("/api/auth/username/check", async (c: AppContext) => { const { username } = await c.req.json(); - const trimmed = String(username || "") - .trim() - .toLowerCase(); + const trimmed = normalizeUsername(username); const formatError = validateUsername(trimmed); if (formatError) { return c.json({ available: false, error: formatError }, 400); } - const moderationError = validateCleanUsername(trimmed); - if (moderationError) { - return c.json({ available: false, error: moderationError }, 400); - } - - if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { - return c.json( - { - available: false, - error: "Username can only contain letters, numbers, and underscores", - }, - 400, - ); - } const existing = await c .get("db") diff --git a/worker/routes/email.ts b/worker/routes/email.ts index a43e0a3..0129abc 100644 --- a/worker/routes/email.ts +++ b/worker/routes/email.ts @@ -2,10 +2,11 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { authUserPayload, createSession, + normalizeUsername, setAuthCookies, updateLastSeen, + validateUsername, } from "../lib"; -import { validateCleanUsername } from "../moderation"; import type { App, AppContext, User } from "../types"; const EMAIL_CODE_PREFIX = "email_code:"; @@ -338,16 +339,14 @@ export function registerEmailRoutes(app: App) { const { email } = JSON.parse(pendingData) as { email: string }; const body = await c.req.json(); - const trimmedUsername = String(body.username || "") - .trim() - .toLowerCase(); + const trimmedUsername = normalizeUsername(body.username); if (!trimmedUsername) { return c.json({ error: "Username is required" }, 400); } - const moderationError = validateCleanUsername(trimmedUsername); - if (moderationError) { - return c.json({ error: moderationError }, 400); + const usernameError = validateUsername(trimmedUsername); + if (usernameError) { + return c.json({ error: usernameError }, 400); } // Check if username exists diff --git a/worker/routes/oauth.ts b/worker/routes/oauth.ts index 2c2b53d..d19d63d 100644 --- a/worker/routes/oauth.ts +++ b/worker/routes/oauth.ts @@ -2,10 +2,11 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { authUserPayload, createSession, + normalizeUsername, setAuthCookies, updateLastSeen, + validateUsername, } from "../lib"; -import { validateCleanUsername } from "../moderation"; import type { App, AppContext, User } from "../types"; const OAUTH_STATE_PREFIX = "oauth_state:"; @@ -275,9 +276,8 @@ async function tryCreateOAuthUser( sub: string, email: string | undefined, ): Promise { - const trimmed = username.trim().toLowerCase(); - if (!trimmed) return null; - if (validateCleanUsername(trimmed)) return null; + const trimmed = normalizeUsername(username); + if (validateUsername(trimmed)) return null; const existing = await c .get("db") @@ -731,16 +731,14 @@ export function registerOAuthRoutes(app: App) { }; const { username } = await c.req.json(); - const trimmedUsername = String(username || "") - .trim() - .toLowerCase(); + const trimmedUsername = normalizeUsername(username); if (!trimmedUsername) { return c.json({ error: "Username is required" }, 400); } - const moderationError = validateCleanUsername(trimmedUsername); - if (moderationError) { - return c.json({ error: moderationError }, 400); + const usernameError = validateUsername(trimmedUsername); + if (usernameError) { + return c.json({ error: usernameError }, 400); } const result = await tryCreateOAuthUser( @@ -866,9 +864,9 @@ export function registerOAuthRoutes(app: App) { // New user with username provided — try to create if (username) { - const moderationError = validateCleanUsername(username); - if (moderationError) { - return c.json({ error: moderationError }, 400); + const usernameError = validateUsername(normalizeUsername(username)); + if (usernameError) { + return c.json({ error: usernameError }, 400); } const result = await tryCreateOAuthUser( c, @@ -975,9 +973,9 @@ export function registerOAuthRoutes(app: App) { // New user with username provided — try to create if (username) { - const moderationError = validateCleanUsername(username); - if (moderationError) { - return c.json({ error: moderationError }, 400); + const usernameError = validateUsername(normalizeUsername(username)); + if (usernameError) { + return c.json({ error: usernameError }, 400); } const result = await tryCreateOAuthUser( c,