feat: add CSRF protection for admin API routes

Double-submit cookie pattern: login sets bh-csrf-token cookie,
proxy.ts validates X-CSRF-Token header on POST/PUT/DELETE to /api/admin/*.
New adminFetch() helper in src/lib/csrf.ts auto-includes the header.
All admin pages migrated from fetch() to adminFetch().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 17:53:02 +03:00
parent 3ac6a4d840
commit 6cbdba2197
12 changed files with 161 additions and 53 deletions

View File

@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
@@ -335,7 +336,7 @@ function ImageUploadField({
formData.append("file", file);
formData.append("folder", "master-classes");
try {
const res = await fetch("/api/admin/upload", {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
@@ -506,7 +507,7 @@ function RegistrationRow({
instagram: `@${ig.trim()}`,
telegram: tg.trim() ? `@${tg.trim()}` : undefined,
};
const res = await fetch("/api/admin/mc-registrations", {
const res = await adminFetch("/api/admin/mc-registrations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -647,7 +648,7 @@ function RegistrationsList({ title }: { title: string }) {
useEffect(() => {
if (!title) return;
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setCount(data.length);
@@ -659,7 +660,7 @@ function RegistrationsList({ title }: { title: string }) {
function toggle() {
if (!open && regs.length === 0 && count !== 0) {
setLoading(true);
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setRegs(data);
@@ -680,7 +681,7 @@ function RegistrationsList({ title }: { title: string }) {
instagram: `@${newIg.trim()}`,
telegram: newTg.trim() ? `@${newTg.trim()}` : undefined,
};
const res = await fetch("/api/admin/mc-registrations", {
const res = await adminFetch("/api/admin/mc-registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -705,7 +706,7 @@ function RegistrationsList({ title }: { title: string }) {
}
async function handleDelete(id: number) {
await fetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
setRegs((prev) => prev.filter((r) => r.id !== id));
setCount((prev) => (prev !== null ? prev - 1 : null));
}
@@ -823,7 +824,7 @@ export default function MasterClassesEditorPage() {
useEffect(() => {
// Fetch trainers from team
fetch("/api/admin/team")
adminFetch("/api/admin/team")
.then((r) => r.json())
.then((members: { name: string }[]) => {
setTrainers(members.map((m) => m.name));
@@ -831,7 +832,7 @@ export default function MasterClassesEditorPage() {
.catch(() => {});
// Fetch styles from classes section
fetch("/api/admin/sections/classes")
adminFetch("/api/admin/sections/classes")
.then((r) => r.json())
.then((data: { items: { name: string }[] }) => {
setStyles(data.items.map((c) => c.name));
@@ -839,7 +840,7 @@ export default function MasterClassesEditorPage() {
.catch(() => {});
// Fetch locations from schedule section
fetch("/api/admin/sections/schedule")
adminFetch("/api/admin/sections/schedule")
.then((r) => r.json())
.then((data: { locations: { name: string; address: string }[] }) => {
setLocations(data.locations);