From 4792df274e6872e662e5901df3826fc72b72dc41 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 13 Oct 2026 15:06:06 +0500 Subject: [PATCH] =?UTF-8?q?access-check:=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=87=D1=82=D1=8B=20(=D0=B1=D0=B5=D0=B7=20ILIKE?= =?UTF-8?q?-=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2),=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC=20=E2=80=94=20=D0=BD=D0=B5=D1=82,=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=20=D0=B7=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=B2?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По фоновой проверке безопасности: mode insensitive превращался в ILIKE, и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем регистре (0 из 1426 с заглавными), вход нормализуется. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/__tests__/access-check.test.ts | 7 +++++-- src/lib/access-check.ts | 24 +++++++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/access-check.test.ts b/src/lib/__tests__/access-check.test.ts index 8e601f9..0c0e074 100644 --- a/src/lib/__tests__/access-check.test.ts +++ b/src/lib/__tests__/access-check.test.ts @@ -12,10 +12,12 @@ describe("access-check", () => { expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull(); }); - it("условие: почта без учёта регистра, курс по slug, доступ не истёк", () => { + it("условие: точная почта (без ILIKE-шаблонов), не заблокирован, курс по slug, доступ не истёк", () => { const now = new Date("2026-10-13T00:00:00Z"); const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now); - expect(w.user.email).toEqual({ equals: "a@example.com", mode: "insensitive" }); + expect(w.user.email).toBe("a@example.com"); + expect(JSON.stringify(w)).not.toContain("insensitive"); + expect(w.user.NOT).toEqual({ banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] }); expect(w.course).toEqual({ slug: "second-brain-vault" }); expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]); }); @@ -26,5 +28,6 @@ describe("access-check", () => { expect(isAuthorized("s", "")).toBe(false); expect(isAuthorized(null, "s")).toBe(false); expect(isAuthorized("x", "s")).toBe(false); + expect(isAuthorized("ss", "s")).toBe(false); }); }); diff --git a/src/lib/access-check.ts b/src/lib/access-check.ts index 3e12b97..7b1bddf 100644 --- a/src/lib/access-check.ts +++ b/src/lib/access-check.ts @@ -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); }