Skip to content

Commit 4a4a131

Browse files
authored
fix: add validation for bad username password input (#3075)
1 parent 26cff23 commit 4a4a131

1 file changed

Lines changed: 49 additions & 0 deletions

File tree

src/backend/stores/user/UserStore.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,41 @@ const CACHE_TTL_SECONDS = 15 * 60;
6464
// backends without splitting the cap by driver.
6565
const BULK_QUERY_CHUNK_SIZE = 200;
6666

67+
// The `email`, `clean_email`, and `username` columns are latin1_swedish_ci.
68+
// MySQL throws ER_CANT_AGGREGATE_2COLLATIONS on `=` when a utf8mb4 param
69+
// contains any character > U+00FF, since the implicit conversion to latin1
70+
// would lose data. No stored row can match such a value anyway, so we
71+
// short-circuit these lookups at the boundary instead of letting the driver
72+
// surface the collation error.
73+
const isStorableAsLatin1 = (value: string): boolean => {
74+
for (let i = 0; i < value.length; i++) {
75+
if (value.charCodeAt(i) > 0xff) return false;
76+
}
77+
return true;
78+
};
79+
80+
// Columns on the `user` table that are stored as latin1_swedish_ci. Used by
81+
// both the read path (skip the DB on un-storable lookups) and the write path
82+
// (reject inserts/updates before MySQL throws on conversion).
83+
const LATIN1_USER_COLUMNS: ReadonlySet<string> = new Set([
84+
'email',
85+
'username',
86+
'clean_email',
87+
]);
88+
89+
const assertLatin1Writable = (fields: Record<string, unknown>): void => {
90+
for (const [key, value] of Object.entries(fields)) {
91+
if (!LATIN1_USER_COLUMNS.has(key)) continue;
92+
if (typeof value !== 'string') continue;
93+
if (isStorableAsLatin1(value)) continue;
94+
const err = new Error(
95+
`User field '${key}' contains characters outside latin1`,
96+
);
97+
(err as { code?: string }).code = 'userFieldNotLatin1';
98+
throw err;
99+
}
100+
};
101+
67102
// ── UserStore ────────────────────────────────────────────────────────
68103

69104
/**
@@ -190,6 +225,7 @@ export class UserStore extends PuterStore {
190225
*/
191226
async getByCleanEmail(cleanEmailValue: string): Promise<UserRow | null> {
192227
if (!cleanEmailValue) return null;
228+
if (!isStorableAsLatin1(cleanEmailValue)) return null;
193229
const rows = (await this.clients.db.tryHardRead(
194230
'SELECT `id` FROM `user` WHERE `clean_email` = ? LIMIT 1',
195231
[cleanEmailValue],
@@ -219,6 +255,16 @@ export class UserStore extends PuterStore {
219255
if (hit) return hit;
220256
}
221257

258+
// Reject lookup values that can't exist in a latin1 column before
259+
// the driver turns them into a collation-mix error at MySQL.
260+
if (
261+
LATIN1_USER_COLUMNS.has(prop) &&
262+
typeof value === 'string' &&
263+
!isStorableAsLatin1(value)
264+
) {
265+
return null;
266+
}
267+
222268
// Replication-aware read: on `force`, go straight to the primary
223269
// (`pread`) to bypass replica lag for hot reads (e.g., immediately
224270
// after a signup). Otherwise `tryHardRead` parallels primary +
@@ -270,6 +316,7 @@ export class UserStore extends PuterStore {
270316
referrer?: string | null;
271317
last_activity_ts?: string | null;
272318
}): Promise<UserRow> {
319+
assertLatin1Writable(fields as Record<string, unknown>);
273320
const result = await this.clients.db.write(
274321
`INSERT INTO \`user\`
275322
(username,
@@ -336,6 +383,8 @@ export class UserStore extends PuterStore {
336383
const keys = Object.keys(patch);
337384
if (keys.length === 0) return;
338385

386+
assertLatin1Writable(patch);
387+
339388
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
340389
const values = keys.map((k) => patch[k]);
341390

0 commit comments

Comments
 (0)