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,
});

View File

@@ -11,6 +11,7 @@ import {
GripVertical,
Check,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { TeamMember } from "@/types/content";
type Member = TeamMember & { id: number };
@@ -29,7 +30,7 @@ export default function TeamEditorPage() {
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
fetch("/api/admin/team")
adminFetch("/api/admin/team")
.then((r) => r.json())
.then(setMembers)
.finally(() => setLoading(false));
@@ -38,7 +39,7 @@ export default function TeamEditorPage() {
const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated);
setSaving(true);
await fetch("/api/admin/team/reorder", {
await adminFetch("/api/admin/team/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
@@ -159,7 +160,7 @@ export default function TeamEditorPage() {
async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return;
await fetch(`/api/admin/team/${id}`, { method: "DELETE" });
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
setMembers((prev) => prev.filter((m) => m.id !== id));
}