From 3b371cc663692a2f9e7eddb91193543d291a6fbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 18 May 2026 20:18:28 +0000 Subject: [PATCH] fix: security hardening, mobile UX improvements, and code quality Security: - Add CSRF protection (rejectCrossSiteMutation) to login and bootstrap routes - Prevent user enumeration via timing-safe dummy password hash on failed lookups - Add request body size limit (512KB) guard on mutation API routes - Minimize health endpoint info leakage (no longer exposes table-level details) - Move card DELETE id from query params to request body for CSRF consistency - Harden CSP nonce generation using crypto.getRandomValues (16 bytes) Mobile UX: - Add viewport meta tag with device-width, maximumScale=1, viewportFit=cover - Add touch-action: manipulation to prevent 300ms tap delay - Remove -webkit-tap-highlight-color flash on mobile - Set min font-size 16px on inputs to prevent iOS Safari auto-zoom - Make card-form dialog full-screen on mobile viewports Performance: - Add loading.tsx skeleton screens for dashboard, collection, and player routes Code quality: - Extract shared Combobox component from card-form and admin-cards-section - Add payloadTooLarge translation key (fr/en) --- messages/en.json | 3 +- messages/fr.json | 3 +- src/app/(app)/collection/loading.tsx | 19 +++ src/app/(app)/loading.tsx | 19 +++ src/app/(app)/player/loading.tsx | 18 +++ src/app/api/auth/bootstrap/route.ts | 4 + src/app/api/auth/login/route.ts | 11 +- src/app/api/cards/route.ts | 20 ++- src/app/api/health/route.ts | 5 +- src/app/api/references/route.ts | 7 +- src/app/globals.css | 8 ++ src/app/layout.tsx | 10 +- src/components/admin/admin-cards-section.tsx | 143 +++---------------- src/components/card-form.tsx | 133 ++--------------- src/components/ui/combobox.tsx | 123 ++++++++++++++++ src/lib/csp.ts | 4 +- src/lib/data.ts | 5 +- src/lib/db.ts | 43 +----- src/lib/password.ts | 10 ++ src/lib/request-guard.ts | 15 ++ 20 files changed, 302 insertions(+), 301 deletions(-) create mode 100644 src/app/(app)/collection/loading.tsx create mode 100644 src/app/(app)/loading.tsx create mode 100644 src/app/(app)/player/loading.tsx create mode 100644 src/components/ui/combobox.tsx diff --git a/messages/en.json b/messages/en.json index a35ecc9..6bc67ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -343,6 +343,7 @@ "noValidPlayers": "No valid players.", "noValidClubs": "No valid teams.", "noValidYears": "No valid years.", - "updateFailed": "Update failed." + "updateFailed": "Update failed.", + "payloadTooLarge": "Request body too large." } } diff --git a/messages/fr.json b/messages/fr.json index ceb82d3..2154c24 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -343,6 +343,7 @@ "noValidPlayers": "Aucun joueur valide.", "noValidClubs": "Aucun club valide.", "noValidYears": "Aucune année valide.", - "updateFailed": "Échec de la mise à jour." + "updateFailed": "Échec de la mise à jour.", + "payloadTooLarge": "Corps de requête trop volumineux." } } diff --git a/src/app/(app)/collection/loading.tsx b/src/app/(app)/collection/loading.tsx new file mode 100644 index 0000000..f8693f5 --- /dev/null +++ b/src/app/(app)/collection/loading.tsx @@ -0,0 +1,19 @@ +export default function CollectionLoading() { + return ( +
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/src/app/(app)/loading.tsx b/src/app/(app)/loading.tsx new file mode 100644 index 0000000..5744200 --- /dev/null +++ b/src/app/(app)/loading.tsx @@ -0,0 +1,19 @@ +export default function AppLoading() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/src/app/(app)/player/loading.tsx b/src/app/(app)/player/loading.tsx new file mode 100644 index 0000000..1eb192c --- /dev/null +++ b/src/app/(app)/player/loading.tsx @@ -0,0 +1,18 @@ +export default function PlayersLoading() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/src/app/api/auth/bootstrap/route.ts b/src/app/api/auth/bootstrap/route.ts index aab6226..91bba05 100644 --- a/src/app/api/auth/bootstrap/route.ts +++ b/src/app/api/auth/bootstrap/route.ts @@ -12,8 +12,12 @@ import { hashPassword } from "@/lib/password"; import { bootstrapFirstUser, type UserRecord } from "@/lib/users-store"; import { authMisconfiguredResponse } from "@/lib/auth-config"; import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; +import { rejectCrossSiteMutation } from "@/lib/request-guard"; export async function POST(request: NextRequest) { + const crossSite = rejectCrossSiteMutation(request); + if (crossSite) return crossSite; + const misconfigured = authMisconfiguredResponse(request); if (misconfigured) return misconfigured; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 10fcf4b..cab33eb 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -7,7 +7,7 @@ import { SESSION_MAX_AGE_SEC, } from "@/lib/auth-session"; import { loginSchema } from "@/lib/auth-validation"; -import { verifyPassword } from "@/lib/password"; +import { dummyVerify, verifyPassword } from "@/lib/password"; import { checkRateLimit, getClientIp, @@ -16,8 +16,12 @@ import { } from "@/lib/rate-limit"; import { findUserByUsername } from "@/lib/users-store"; import { authMisconfiguredResponse } from "@/lib/auth-config"; +import { rejectCrossSiteMutation } from "@/lib/request-guard"; export async function POST(request: NextRequest) { + const crossSite = rejectCrossSiteMutation(request); + if (crossSite) return crossSite; + const misconfigured = authMisconfiguredResponse(request); if (misconfigured) return misconfigured; @@ -66,7 +70,10 @@ export async function POST(request: NextRequest) { } const user = findUserByUsername(username); - if (!user || !verifyPassword(password, user.passwordHash)) { + const passwordValid = user + ? verifyPassword(password, user.passwordHash) + : (dummyVerify(password), false); + if (!user || !passwordValid) { const failed = checkRateLimit(failureKey, { limit: 5, windowMs: 15 * 60 * 1000, diff --git a/src/app/api/cards/route.ts b/src/app/api/cards/route.ts index fab8831..4c5b1ec 100644 --- a/src/app/api/cards/route.ts +++ b/src/app/api/cards/route.ts @@ -10,7 +10,10 @@ import { formatZodError, } from "@/lib/card-schema"; import { getRequestTranslator } from "@/i18n/request"; -import { rejectCrossSiteMutation } from "@/lib/request-guard"; +import { + rejectCrossSiteMutation, + rejectOversizedBody, +} from "@/lib/request-guard"; export const dynamic = "force-dynamic"; @@ -26,6 +29,8 @@ export async function POST(request: NextRequest) { if (gate instanceof NextResponse) return gate; const crossSite = rejectCrossSiteMutation(request); if (crossSite) return crossSite; + const oversized = rejectOversizedBody(request); + if (oversized) return oversized; const t = getRequestTranslator(request); let body: unknown; @@ -70,6 +75,8 @@ export async function PUT(request: NextRequest) { if (gate instanceof NextResponse) return gate; const crossSite = rejectCrossSiteMutation(request); if (crossSite) return crossSite; + const oversized = rejectOversizedBody(request); + if (oversized) return oversized; const t = getRequestTranslator(request); let body: unknown; @@ -105,8 +112,15 @@ export async function DELETE(request: NextRequest) { const crossSite = rejectCrossSiteMutation(request); if (crossSite) return crossSite; const t = getRequestTranslator(request); - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); + + let id: string | null = null; + try { + const body = (await request.json()) as { id?: unknown }; + id = typeof body.id === "string" ? body.id.trim() : null; + } catch { + const { searchParams } = new URL(request.url); + id = searchParams.get("id"); + } if (!id) { return NextResponse.json( diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 88898c5..36cc4f1 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -3,5 +3,8 @@ import { getDataHealth } from "@/lib/data"; export async function GET() { const health = getDataHealth(); - return NextResponse.json({ ok: health.ok }, { status: health.ok ? 200 : 503 }); + return NextResponse.json( + { status: health.ok ? "healthy" : "degraded" }, + { status: health.ok ? 200 : 503 } + ); } diff --git a/src/app/api/references/route.ts b/src/app/api/references/route.ts index bbb0222..0cbfba5 100644 --- a/src/app/api/references/route.ts +++ b/src/app/api/references/route.ts @@ -20,7 +20,10 @@ import { } from "@/lib/reference-schema"; import { getRequestTranslator } from "@/i18n/request"; import type { Translator } from "@/i18n/translator"; -import { rejectCrossSiteMutation } from "@/lib/request-guard"; +import { + rejectCrossSiteMutation, + rejectOversizedBody, +} from "@/lib/request-guard"; export async function GET(request: NextRequest) { const gate = requireAuth(request); @@ -143,6 +146,8 @@ export async function PATCH(request: NextRequest) { if (gate instanceof NextResponse) return gate; const crossSite = rejectCrossSiteMutation(request); if (crossSite) return crossSite; + const oversized = rejectOversizedBody(request); + if (oversized) return oversized; const t = getRequestTranslator(request); let body: unknown; diff --git a/src/app/globals.css b/src/app/globals.css index 77a1cbf..5b8d221 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -124,9 +124,17 @@ } body { @apply bg-background text-foreground; + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: 100%; } html { @apply font-sans scroll-smooth; + touch-action: manipulation; + } + input, + select, + textarea { + font-size: 16px; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e0c1112..dd63c13 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,17 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { connection } from "next/server"; import { LocaleProvider } from "@/i18n/client"; import { getTranslations } from "@/i18n/server"; import "./globals.css"; +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + viewportFit: "cover", + themeColor: "#0a0a0a", +}; + export async function generateMetadata(): Promise { const { t } = await getTranslations(); return { diff --git a/src/components/admin/admin-cards-section.tsx b/src/components/admin/admin-cards-section.tsx index 8d5d86e..284dd80 100644 --- a/src/components/admin/admin-cards-section.tsx +++ b/src/components/admin/admin-cards-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useDeferredValue, useId, useMemo, useState } from "react"; +import { useDeferredValue, useMemo, useState } from "react"; import { Card, References } from "@/lib/types"; import { CardForm } from "@/components/card-form"; import { CardBadges } from "@/components/card-badges"; @@ -22,6 +22,7 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { AdminFeedback } from "@/components/admin/admin-feedback"; +import { Combobox } from "@/components/ui/combobox"; import { Plus, Pencil, Trash2, Search } from "lucide-react"; import { useTranslations } from "@/i18n/client"; @@ -38,114 +39,6 @@ function uniqueSorted(values: string[]): string[] { ).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); } -interface ColumnFilterComboboxProps { - value: string; - onChange: (value: string) => void; - placeholder: string; - suggestions: string[]; - className?: string; -} - -function ColumnFilterCombobox({ - value, - onChange, - placeholder, - suggestions, - className, -}: ColumnFilterComboboxProps) { - const inputId = useId(); - const listboxId = `${inputId}-listbox`; - const [open, setOpen] = useState(false); - const [activeIndex, setActiveIndex] = useState(0); - const query = value.trim().toLowerCase(); - const visibleSuggestions = useMemo( - () => - suggestions.filter((suggestion) => - suggestion.toLowerCase().includes(query) - ), - [query, suggestions] - ); - - function selectSuggestion(nextValue: string): void { - onChange(nextValue); - setOpen(false); - setActiveIndex(0); - } - - return ( -
- { - onChange(event.target.value); - setOpen(true); - setActiveIndex(0); - }} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={(event) => { - if (!open && ["ArrowDown", "ArrowUp"].includes(event.key)) { - setOpen(true); - return; - } - if (event.key === "Escape") { - setOpen(false); - return; - } - if (visibleSuggestions.length === 0) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveIndex((index) => (index + 1) % visibleSuggestions.length); - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveIndex( - (index) => - (index - 1 + visibleSuggestions.length) % - visibleSuggestions.length - ); - } - if (event.key === "Enter" && open) { - event.preventDefault(); - selectSuggestion(visibleSuggestions[activeIndex]); - } - }} - placeholder={placeholder} - role="combobox" - aria-expanded={open && visibleSuggestions.length > 0} - aria-controls={listboxId} - aria-autocomplete="list" - className={className} - /> - {open && visibleSuggestions.length > 0 && ( -
- {visibleSuggestions.map((suggestion, index) => ( - - ))} -
- )} -
- ); -} - export function AdminCardsSection({ cards, references, @@ -356,9 +249,11 @@ export function AdminCardsSection({ async function handleDelete() { if (!deleteTarget) return; setSuccess(null); - const res = await fetch(`/api/cards?id=${deleteTarget.id}`, { + const res = await fetch("/api/cards", { method: "DELETE", + headers: { "Content-Type": "application/json" }, credentials: "include", + body: JSON.stringify({ id: deleteTarget.id }), }); if (res.ok) { onCardsChange(cards.filter((c) => c.id !== deleteTarget.id)); @@ -415,7 +310,7 @@ export function AdminCardsSection({
- { setPlayerColumnFilter(nextValue); @@ -425,7 +320,7 @@ export function AdminCardsSection({ suggestions={columnSuggestions.players} className="h-9 text-xs" /> - { setTeamColumnFilter(nextValue); @@ -436,7 +331,7 @@ export function AdminCardsSection({ className="h-9 text-xs" />
- { setYearColumnFilter(nextValue); @@ -446,7 +341,7 @@ export function AdminCardsSection({ suggestions={columnSuggestions.years} className="h-9 text-xs" /> - { setTagsColumnFilter(nextValue); @@ -457,7 +352,7 @@ export function AdminCardsSection({ className="h-9 text-xs" />
- { setBrandColumnFilter(nextValue); @@ -467,7 +362,7 @@ export function AdminCardsSection({ suggestions={columnSuggestions.brands} className="h-9 text-xs" /> - { setSetColumnFilter(nextValue); @@ -477,7 +372,7 @@ export function AdminCardsSection({ suggestions={columnSuggestions.sets} className="h-9 text-xs" /> - { setVariationColumnFilter(nextValue); @@ -569,7 +464,7 @@ export function AdminCardsSection({ - { setPlayerColumnFilter(nextValue); @@ -581,7 +476,7 @@ export function AdminCardsSection({ /> - { setTeamColumnFilter(nextValue); @@ -593,7 +488,7 @@ export function AdminCardsSection({ /> - { setYearColumnFilter(nextValue); @@ -605,7 +500,7 @@ export function AdminCardsSection({ /> - { setBrandColumnFilter(nextValue); @@ -617,7 +512,7 @@ export function AdminCardsSection({ /> - { setSetColumnFilter(nextValue); @@ -629,7 +524,7 @@ export function AdminCardsSection({ /> - { setVariationColumnFilter(nextValue); @@ -641,7 +536,7 @@ export function AdminCardsSection({ /> - { setTagsColumnFilter(nextValue); diff --git a/src/components/card-form.tsx b/src/components/card-form.tsx index 8729905..084c02e 100644 --- a/src/components/card-form.tsx +++ b/src/components/card-form.tsx @@ -19,6 +19,7 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { AdminFeedback } from "@/components/admin/admin-feedback"; +import { Combobox } from "@/components/ui/combobox"; import { useTranslations } from "@/i18n/client"; import { patchReferences } from "@/lib/references-client"; @@ -35,15 +36,6 @@ interface CardFormProps { saveError?: string | null; } -interface CardFormComboboxProps { - id?: string; - value: string; - onChange: (value: string) => void; - suggestions: string[]; - disabled?: boolean; - required?: boolean; -} - function uniqueSorted(values: string[]): string[] { return Array.from( new Set(values.map((value) => value.trim()).filter(Boolean)) @@ -84,111 +76,6 @@ function variationsLinkedToSet(references: References, setName: string): string[ ); } -function CardFormCombobox({ - id, - value, - onChange, - suggestions, - disabled, - required, -}: CardFormComboboxProps) { - const inputId = useId(); - const resolvedInputId = id ?? inputId; - const listboxId = `${resolvedInputId}-listbox`; - const [open, setOpen] = useState(false); - const [activeIndex, setActiveIndex] = useState(0); - const query = value.trim().toLowerCase(); - const visibleSuggestions = useMemo( - () => { - if (!open) return []; - return suggestions.filter((suggestion) => - suggestion.toLowerCase().includes(query) - ); - }, - [open, query, suggestions] - ); - - function selectSuggestion(nextValue: string): void { - onChange(nextValue); - setOpen(false); - setActiveIndex(0); - } - - return ( -
- { - onChange(event.target.value); - setOpen(true); - setActiveIndex(0); - }} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={(event) => { - if (!open && ["ArrowDown", "ArrowUp"].includes(event.key)) { - setOpen(true); - return; - } - if (event.key === "Escape") { - setOpen(false); - return; - } - if (visibleSuggestions.length === 0) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveIndex((index) => (index + 1) % visibleSuggestions.length); - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveIndex( - (index) => - (index - 1 + visibleSuggestions.length) % - visibleSuggestions.length - ); - } - if (event.key === "Enter" && open) { - event.preventDefault(); - selectSuggestion(visibleSuggestions[activeIndex]); - } - }} - disabled={disabled} - required={required} - role="combobox" - aria-expanded={open && visibleSuggestions.length > 0} - aria-controls={listboxId} - aria-autocomplete="list" - className="w-full" - /> - {open && visibleSuggestions.length > 0 && !disabled && ( -
- {visibleSuggestions.map((suggestion, index) => ( - - ))} -
- )} -
- ); -} - const emptyCard: Partial = { player: "", team: "", @@ -227,7 +114,7 @@ export function CardForm({ return ( !v && onClose()}> - + {open ? (
- update("player", value)} @@ -428,7 +315,7 @@ function CardFormFields({
- update("team", value)} @@ -438,7 +325,7 @@ function CardFormFields({
- update("year", value)} @@ -448,7 +335,7 @@ function CardFormFields({
- )} - )} - update("variation", value)} suggestions={variationsForSet} @@ -615,7 +502,7 @@ function CardFormFields({
- update("protection", value)} suggestions={references.protections} @@ -624,7 +511,7 @@ function CardFormFields({
- update("storage", value)} suggestions={references.storages} diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx new file mode 100644 index 0000000..4ad3713 --- /dev/null +++ b/src/components/ui/combobox.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState, useMemo, useId } from "react"; +import { Input } from "@/components/ui/input"; + +interface ComboboxProps { + id?: string; + value: string; + onChange: (value: string) => void; + suggestions: string[]; + disabled?: boolean; + required?: boolean; + placeholder?: string; + className?: string; +} + +export function Combobox({ + id, + value, + onChange, + suggestions, + disabled, + required, + placeholder, + className, +}: ComboboxProps) { + const inputId = useId(); + const resolvedInputId = id ?? inputId; + const listboxId = `${resolvedInputId}-listbox`; + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const query = value.trim().toLowerCase(); + const visibleSuggestions = useMemo( + () => { + if (!open) return []; + return suggestions.filter((suggestion) => + suggestion.toLowerCase().includes(query) + ); + }, + [open, query, suggestions] + ); + + function selectSuggestion(nextValue: string): void { + onChange(nextValue); + setOpen(false); + setActiveIndex(0); + } + + return ( +
+ { + onChange(event.target.value); + setOpen(true); + setActiveIndex(0); + }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={(event) => { + if (!open && ["ArrowDown", "ArrowUp"].includes(event.key)) { + setOpen(true); + return; + } + if (event.key === "Escape") { + setOpen(false); + return; + } + if (visibleSuggestions.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => (index + 1) % visibleSuggestions.length); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex( + (index) => + (index - 1 + visibleSuggestions.length) % + visibleSuggestions.length + ); + } + if (event.key === "Enter" && open) { + event.preventDefault(); + selectSuggestion(visibleSuggestions[activeIndex]); + } + }} + disabled={disabled} + required={required} + placeholder={placeholder} + role="combobox" + aria-expanded={open && visibleSuggestions.length > 0} + aria-controls={listboxId} + aria-autocomplete="list" + className={className} + /> + {open && visibleSuggestions.length > 0 && !disabled && ( +
+ {visibleSuggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/lib/csp.ts b/src/lib/csp.ts index 6fc9372..39ec9a7 100644 --- a/src/lib/csp.ts +++ b/src/lib/csp.ts @@ -2,7 +2,9 @@ const isDev = process.env.NODE_ENV === "development"; const isProduction = process.env.NODE_ENV === "production"; export function createNonce(): string { - return Buffer.from(crypto.randomUUID()).toString("base64"); + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("base64"); } export function buildContentSecurityPolicy(nonce: string): string { diff --git a/src/lib/data.ts b/src/lib/data.ts index b6e869a..7f769c8 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -38,9 +38,6 @@ export async function saveCollection(cards: Card[]): Promise { replaceAllCards(cards); } -export function getDataHealth(): { - ok: boolean; - data: Record; -} { +export function getDataHealth(): { ok: boolean } { return getDatabaseHealth(); } diff --git a/src/lib/db.ts b/src/lib/db.ts index f9f1878..c99fdb7 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -310,47 +310,12 @@ export function writeReferencesState(refs: References): void { importReferences(getDb(), refs); } -export function getDatabaseHealth(): { - ok: boolean; - data: Record; -} { +export function getDatabaseHealth(): { ok: boolean } { try { const db = getDb(); - const cards = ( - db.prepare("SELECT COUNT(*) as count FROM cards").get() as { count: number } - ).count; - const references = ( - db.prepare("SELECT COUNT(*) as count FROM references_state").get() as { - count: number; - } - ).count; - const users = ( - db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number } - ).count; - const sessions = ( - db.prepare("SELECT COUNT(*) as count FROM sessions").get() as { - count: number; - } - ).count; - - return { - ok: true, - data: { - cards: cards > 0, - references: references > 0, - users: users > 0, - sessions: sessions > 0, - }, - }; + db.prepare("SELECT 1").get(); + return { ok: true }; } catch { - return { - ok: false, - data: { - cards: false, - references: false, - users: false, - sessions: false, - }, - }; + return { ok: false }; } } diff --git a/src/lib/password.ts b/src/lib/password.ts index 9ec6014..5b12da3 100644 --- a/src/lib/password.ts +++ b/src/lib/password.ts @@ -20,6 +20,8 @@ export function hashPassword(plain: string): string { return `${salt}:${hash.toString("hex")}`; } +const DUMMY_HASH = hashPassword("hobbyhoops-timing-safe-dummy"); + export function verifyPassword(plain: string, stored: string): boolean { const parts = stored.split(":"); if (parts.length !== 2) return false; @@ -33,3 +35,11 @@ export function verifyPassword(plain: string, stored: string): boolean { return false; } } + +/** + * Run a dummy scrypt derivation so callers can keep constant response time + * regardless of whether the target user exists. + */ +export function dummyVerify(plain: string): void { + verifyPassword(plain, DUMMY_HASH); +} diff --git a/src/lib/request-guard.ts b/src/lib/request-guard.ts index 6bb8faa..3a34924 100644 --- a/src/lib/request-guard.ts +++ b/src/lib/request-guard.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getRequestTranslator } from "@/i18n/request"; +const MAX_JSON_BODY_BYTES = 512 * 1024; const SAFE_FETCH_SITES = new Set(["same-origin", "none"]); function trustProxyHeaders(): boolean { @@ -57,3 +58,17 @@ export function rejectCrossSiteMutation( return null; } + +export function rejectOversizedBody( + request: NextRequest +): NextResponse | null { + const length = request.headers.get("content-length"); + if (length && Number.parseInt(length, 10) > MAX_JSON_BODY_BYTES) { + const t = getRequestTranslator(request); + return NextResponse.json( + { error: t("errors.payloadTooLarge") }, + { status: 413 } + ); + } + return null; +}