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:
@@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||
|
||||
interface InputFieldProps {
|
||||
@@ -379,7 +380,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
formData.append("file", file);
|
||||
formData.append("folder", "team");
|
||||
try {
|
||||
const res = await fetch("/api/admin/upload", { method: "POST", body: formData });
|
||||
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Loader2, Check, AlertCircle } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
|
||||
interface SectionEditorProps<T> {
|
||||
sectionKey: string;
|
||||
@@ -24,7 +25,7 @@ export function SectionEditor<T>({
|
||||
const initialLoadRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/admin/sections/${sectionKey}`)
|
||||
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Failed to load");
|
||||
return r.json();
|
||||
@@ -39,7 +40,7 @@ export function SectionEditor<T>({
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
|
||||
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(dataToSave),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { NewsItem } from "@/types/content";
|
||||
|
||||
interface NewsData {
|
||||
@@ -30,7 +31,7 @@ function ImageUploadField({
|
||||
formData.append("file", file);
|
||||
formData.append("folder", "news");
|
||||
try {
|
||||
const res = await fetch("/api/admin/upload", {
|
||||
const res = await adminFetch("/api/admin/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
||||
import { Plus, X, Trash2 } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||
|
||||
interface ScheduleData {
|
||||
@@ -1113,21 +1114,21 @@ export default function ScheduleEditorPage() {
|
||||
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/team")
|
||||
adminFetch("/api/admin/team")
|
||||
.then((r) => r.json())
|
||||
.then((members: { name: string }[]) => {
|
||||
setTrainers(members.map((m) => m.name));
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/admin/sections/contact")
|
||||
adminFetch("/api/admin/sections/contact")
|
||||
.then((r) => r.json())
|
||||
.then((contact: { addresses?: string[] }) => {
|
||||
setAddresses(contact.addresses ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/admin/sections/classes")
|
||||
adminFetch("/api/admin/sections/classes")
|
||||
.then((r) => r.json())
|
||||
.then((classes: { items?: { name: string }[] }) => {
|
||||
setClassTypes((classes.items ?? []).map((c) => c.name));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyPassword, signToken, COOKIE_NAME } from "@/lib/auth";
|
||||
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json() as { password?: string };
|
||||
@@ -9,6 +9,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const token = signToken();
|
||||
const csrfToken = generateCsrfToken();
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
response.cookies.set(COOKIE_NAME, token, {
|
||||
@@ -16,7 +17,15 @@ export async function POST(request: NextRequest) {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
maxAge: 60 * 60 * 24,
|
||||
});
|
||||
|
||||
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // JS must read this to send as header
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
@@ -63,4 +63,10 @@ function verifyTokenNode(token: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||
|
||||
export function generateCsrfToken(): string {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export { COOKIE_NAME };
|
||||
|
||||
17
src/lib/csrf.ts
Normal file
17
src/lib/csrf.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||
|
||||
function getCsrfToken(): string {
|
||||
const match = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`));
|
||||
return match ? match.split("=")[1] : "";
|
||||
}
|
||||
|
||||
/** Wrapper around fetch that auto-includes the CSRF token header for admin API calls */
|
||||
export function adminFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("x-csrf-token")) {
|
||||
headers.set("x-csrf-token", getCsrfToken());
|
||||
}
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
14
src/proxy.ts
14
src/proxy.ts
@@ -1,6 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
|
||||
|
||||
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
@@ -20,6 +24,16 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
|
||||
// CSRF check on state-changing API requests
|
||||
if (pathname.startsWith("/api/admin/") && STATE_CHANGING_METHODS.has(request.method)) {
|
||||
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value;
|
||||
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
||||
|
||||
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
|
||||
return NextResponse.json({ error: "CSRF token mismatch" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user