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:
@@ -12,10 +12,12 @@ describe("access-check", () => {
|
|||||||
expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull();
|
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 now = new Date("2026-10-13T00:00:00Z");
|
||||||
const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now);
|
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.course).toEqual({ slug: "second-brain-vault" });
|
||||||
expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]);
|
expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]);
|
||||||
});
|
});
|
||||||
@@ -26,5 +28,6 @@ describe("access-check", () => {
|
|||||||
expect(isAuthorized("s", "")).toBe(false);
|
expect(isAuthorized("s", "")).toBe(false);
|
||||||
expect(isAuthorized(null, "s")).toBe(false);
|
expect(isAuthorized(null, "s")).toBe(false);
|
||||||
expect(isAuthorized("x", "s")).toBe(false);
|
expect(isAuthorized("x", "s")).toBe(false);
|
||||||
|
expect(isAuthorized("ss", "s")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,8 @@
|
|||||||
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
|
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
export type AccessCheckInput = { email: string; courseSlug: string };
|
export type AccessCheckInput = { email: string; courseSlug: string };
|
||||||
|
|
||||||
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
|
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
|
||||||
@@ -20,15 +22,31 @@ export function parseAccessCheck(body: unknown): AccessCheckInput | null {
|
|||||||
return { email, courseSlug };
|
return { email, courseSlug };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Условие Prisma: запись о доступе к курсу у пользователя с этой почтой, не истёкшая. */
|
/**
|
||||||
|
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
|
||||||
|
* пользователя с ЭТОЙ почтой.
|
||||||
|
*
|
||||||
|
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
|
||||||
|
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
|
||||||
|
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
|
||||||
|
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
|
||||||
|
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
|
||||||
|
*/
|
||||||
export function accessWhere(input: AccessCheckInput, now: Date) {
|
export function accessWhere(input: AccessCheckInput, now: Date) {
|
||||||
return {
|
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 },
|
course: { slug: input.courseSlug },
|
||||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
|
||||||
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user