From 4a4a13122606ee5c56f7fab445c81d26f16a48c3 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 11 May 2026 10:35:12 -0700 Subject: [PATCH] fix: add validation for bad username password input (#3075) --- src/backend/stores/user/UserStore.ts | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 4b6744437..11c068a65 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -64,6 +64,41 @@ const CACHE_TTL_SECONDS = 15 * 60; // backends without splitting the cap by driver. const BULK_QUERY_CHUNK_SIZE = 200; +// The `email`, `clean_email`, and `username` columns are latin1_swedish_ci. +// MySQL throws ER_CANT_AGGREGATE_2COLLATIONS on `=` when a utf8mb4 param +// contains any character > U+00FF, since the implicit conversion to latin1 +// would lose data. No stored row can match such a value anyway, so we +// short-circuit these lookups at the boundary instead of letting the driver +// surface the collation error. +const isStorableAsLatin1 = (value: string): boolean => { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0xff) return false; + } + return true; +}; + +// Columns on the `user` table that are stored as latin1_swedish_ci. Used by +// both the read path (skip the DB on un-storable lookups) and the write path +// (reject inserts/updates before MySQL throws on conversion). +const LATIN1_USER_COLUMNS: ReadonlySet = new Set([ + 'email', + 'username', + 'clean_email', +]); + +const assertLatin1Writable = (fields: Record): void => { + for (const [key, value] of Object.entries(fields)) { + if (!LATIN1_USER_COLUMNS.has(key)) continue; + if (typeof value !== 'string') continue; + if (isStorableAsLatin1(value)) continue; + const err = new Error( + `User field '${key}' contains characters outside latin1`, + ); + (err as { code?: string }).code = 'userFieldNotLatin1'; + throw err; + } +}; + // ── UserStore ──────────────────────────────────────────────────────── /** @@ -190,6 +225,7 @@ export class UserStore extends PuterStore { */ async getByCleanEmail(cleanEmailValue: string): Promise { if (!cleanEmailValue) return null; + if (!isStorableAsLatin1(cleanEmailValue)) return null; const rows = (await this.clients.db.tryHardRead( 'SELECT `id` FROM `user` WHERE `clean_email` = ? LIMIT 1', [cleanEmailValue], @@ -219,6 +255,16 @@ export class UserStore extends PuterStore { if (hit) return hit; } + // Reject lookup values that can't exist in a latin1 column before + // the driver turns them into a collation-mix error at MySQL. + if ( + LATIN1_USER_COLUMNS.has(prop) && + typeof value === 'string' && + !isStorableAsLatin1(value) + ) { + return null; + } + // Replication-aware read: on `force`, go straight to the primary // (`pread`) to bypass replica lag for hot reads (e.g., immediately // after a signup). Otherwise `tryHardRead` parallels primary + @@ -270,6 +316,7 @@ export class UserStore extends PuterStore { referrer?: string | null; last_activity_ts?: string | null; }): Promise { + assertLatin1Writable(fields as Record); const result = await this.clients.db.write( `INSERT INTO \`user\` (username, @@ -336,6 +383,8 @@ export class UserStore extends PuterStore { const keys = Object.keys(patch); if (keys.length === 0) return; + assertLatin1Writable(patch); + const setClause = keys.map((k) => `\`${k}\` = ?`).join(', '); const values = keys.map((k) => patch[k]);