access-check: точное сравнение почты (без ILIKE-шаблонов), заблокированным — нет, секрет за постоянное время

По фоновой проверке безопасности: mode insensitive превращался в ILIKE,
и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь
считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем
регистре (0 из 1426 с заглавными), вход нормализуется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-13 15:06:06 +05:00
co-authored by Claude Opus 5.5
parent 83f34ea679
commit 4792df274e
2 changed files with 26 additions and 5 deletions
+21 -3
View File
@@ -7,6 +7,8 @@
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
*/
import { timingSafeEqual } from "crypto";
export type AccessCheckInput = { email: string; courseSlug: string };
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
@@ -20,15 +22,31 @@ export function parseAccessCheck(body: unknown): AccessCheckInput | null {
return { email, courseSlug };
}
/** Условие Prisma: запись о доступе к курсу у пользователя с этой почтой, не истёкшая. */
/**
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
* пользователя с ЭТОЙ почтой.
*
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
*/
export function accessWhere(input: AccessCheckInput, now: Date) {
return {
user: { email: { equals: input.email, mode: "insensitive" as const } },
user: {
email: input.email,
NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] },
},
course: { slug: input.courseSlug },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
return Boolean(secret) && header === secret;
if (!secret || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}