2 Commits
Author SHA1 Message Date
adminsandClaude Opus 5.5 4792df274e access-check: точное сравнение почты (без ILIKE-шаблонов), заблокированным — нет, секрет за постоянное время
По фоновой проверке безопасности: mode insensitive превращался в ILIKE,
и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь
считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем
регистре (0 из 1426 с заглавными), вход нормализуется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:06:06 +05:00
adminsandClaude Opus 5.5 83f34ea679 Внутренний метод /api/internal/access-check: есть ли у почты доступ к курсу
Для бота выдачи SB Access (payment-router): старый покупатель Second Brain
Vault оставляет почту на /tg/link, ключ входа в бота выдаётся, только если
доступ к курсу действительно есть. Тот же общий секрет, что у grant;
ответ только да/нет, почта без учёта регистра, истёкший доступ не считается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:03:24 +05:00
3 changed files with 115 additions and 0 deletions
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { accessWhere, isAuthorized, parseAccessCheck } from "@/lib/access-check";
/**
* Internal: есть ли у почты действующий доступ к курсу.
* Auth: тот же общий секрет, что у /api/internal/grant (`x-internal-secret`).
*
* Body: { email, course_slug } → { ok: true, has_access: boolean }
*/
export async function POST(request: NextRequest) {
if (!isAuthorized(request.headers.get("x-internal-secret"), process.env.INTERNAL_GRANT_SECRET)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
}
const input = parseAccessCheck(body);
if (!input) {
return NextResponse.json({ ok: false, error: "missing_fields", need: ["email", "course_slug"] }, { status: 400 });
}
const found = await prisma.courseEnrollment.findFirst({
where: accessWhere(input, new Date()),
select: { userId: true },
});
return NextResponse.json({ ok: true, has_access: Boolean(found) });
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { accessWhere, isAuthorized, parseAccessCheck } from "../access-check";
describe("access-check", () => {
it("нормализует почту и требует оба поля", () => {
expect(parseAccessCheck({ email: " A@Example.COM ", course_slug: "second-brain-vault" }))
.toEqual({ email: "a@example.com", courseSlug: "second-brain-vault" });
expect(parseAccessCheck({ email: "a@example.com" })).toBeNull();
expect(parseAccessCheck({ course_slug: "x" })).toBeNull();
expect(parseAccessCheck({ email: "без-собаки", course_slug: "x" })).toBeNull();
expect(parseAccessCheck(null)).toBeNull();
expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull();
});
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).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 } }]);
});
it("без секрета или с чужим — отказ", () => {
expect(isAuthorized("s", "s")).toBe(true);
expect(isAuthorized("s", undefined)).toBe(false);
expect(isAuthorized("s", "")).toBe(false);
expect(isAuthorized(null, "s")).toBe(false);
expect(isAuthorized("x", "s")).toBe(false);
expect(isAuthorized("ss", "s")).toBe(false);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Проверка «у этой почты есть действующий доступ к курсу» для внутренних
* вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель
* Second Brain Vault оставляет почту, бот получает ключ входа, только если
* доступ к курсу действительно есть.
*
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
*/
import { timingSafeEqual } from "crypto";
export type AccessCheckInput = { email: string; courseSlug: string };
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : "";
if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) {
return null;
}
return { email, courseSlug };
}
/**
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
* пользователя с ЭТОЙ почтой.
*
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
*/
export function accessWhere(input: AccessCheckInput, now: Date) {
return {
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 {
if (!secret || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}