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 { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
function extractUsername(value: string): string {
@@ -55,7 +56,7 @@ export default function TeamMemberEditorPage() {
setIgStatus("checking");
igTimerRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
const result = await res.json();
setIgStatus(result.valid ? "valid" : "invalid");
} catch {
@@ -106,7 +107,7 @@ export default function TeamMemberEditorPage() {
useEffect(() => {
if (isNew) return;
fetch(`/api/admin/team/${id}`)
adminFetch(`/api/admin/team/${id}`)
.then((r) => r.json())
.then((member) => {
const username = extractUsername(member.instagram || "");
@@ -139,7 +140,7 @@ export default function TeamMemberEditorPage() {
};
if (isNew) {
const res = await fetch("/api/admin/team", {
const res = await adminFetch("/api/admin/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -148,7 +149,7 @@ export default function TeamMemberEditorPage() {
router.push("/admin/team");
}
} else {
const res = await fetch(`/api/admin/team/${id}`, {
const res = await adminFetch(`/api/admin/team/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -171,7 +172,7 @@ export default function TeamMemberEditorPage() {
formData.append("folder", "team");
try {
const res = await fetch("/api/admin/upload", {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});