From f490133345aca0f116cf225d0e4b6d252511cfc0 Mon Sep 17 00:00:00 2001 From: MOH Date: Tue, 17 Mar 2026 22:05:50 +0100 Subject: [PATCH] Harden security, fix rate-limiting, and rename proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move admin login lockout from client cookie to AppConfig (DB), keyed by hashed client IP — clearing browser cookies no longer bypasses it - Replace rate-limit $transaction (TOCTOU) with atomic SQL INSERT...ON CONFLICT...RETURNING; add stale-entry cleanup on each submission to prevent table bloat - Add 5 s module-level cache for middleware runtime state fetch, reducing per-request DB roundtrips - Rename middleware.ts → proxy.ts to resolve Next.js 16 deprecation warning; update test import accordingly - Require ADMIN_PASSWORD, ADMIN_AUTH_SECRET, ADMIN_BASIC_AUTH_USER, and ADMIN_BASIC_AUTH_PASS in docker-compose.yml (:? syntax) — startup fails loudly instead of using placeholder defaults - Add set -e and informative echo lines to Dockerfile CMD for clearer startup failure attribution - Export requireAdminAuth() from lib/admin-auth for centralised use in admin pages - Add CLAUDE.md with architecture notes and working rules Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 125 ++++++++++++++++++++++++++++++++++ Dockerfile | 2 +- docker-compose.yml | 8 +-- lib/admin-auth.ts | 138 ++++++++++++++++++++++++++++---------- lib/contact-guard.ts | 41 +++++------ messages/ar.json | 2 +- messages/de.json | 2 +- messages/en.json | 2 +- middleware.ts => proxy.ts | 15 ++++- tests/middleware.test.ts | 8 +-- 10 files changed, 274 insertions(+), 69 deletions(-) create mode 100644 CLAUDE.md rename middleware.ts => proxy.ts (93%) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..25ba89a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development +npm run dev # Start Next.js dev server +npm run build # Build for production (--webpack flag applied in package.json) +npm run lint # Run ESLint +npm run test # Run all tests with Vitest +npx vitest run tests/some-file.test.ts # Run a single test file + +# Database +npm run prisma:generate # Regenerate Prisma client after schema changes +npm run db:migrate # Apply migrations (production) +npm run db:migrate:dev # Create and apply dev migration +npm run db:seed # Seed the database +``` + +### Docker (production/staging) + +```bash +make start # Build and start all containers +make stop # Stop containers +make deploy # Pull + rebuild + restart +make logs # Follow container logs +make db-init # Generate client, apply migrations, and seed (first run) +make db-shell # Open psql shell +make app-shell # Open shell in app container +make health # Hit /api/health via public URL +``` + +## Architecture + +This is a multilingual Next.js (App Router) portfolio site with an admin workspace. Stack: TypeScript, next-intl, Prisma + PostgreSQL, Tailwind CSS, Radix UI, framer-motion, nodemailer. + +### Routing overview + +There are two applications sharing one Next.js instance: + +**Public site** — `app/[locale]/(site)/` +Localized routes for `de`, `en`, `ar`. Default locale is dynamic (stored in `AppConfig`), read at request time via `/api/site/default-locale`. Maintenance mode redirects visitors to `/coming-soon`. + +**Admin workspace** — `app/_admin/` (canonical source) +Accessed via a dedicated subdomain (`root.mohfarawati.de`) in production, or via the `/root` path prefix in development. The middleware rewrites both to `app/admin-internal/`. The `app/root/` and `app/admin-internal/` directories mirror `app/_admin/` — treat `app/_admin/` as the source of truth. + +The full routing rewrite logic lives in `lib/admin-routing.ts` and `middleware.ts`. + +### i18n + +- Locales: `de`, `en`, `ar` — defined in `i18n/routing.ts` +- Default locale is configurable at runtime via `AppConfig` (key: `default_locale`) +- `localePrefix: "as-needed"` — default locale has no prefix in URLs +- No locale cookie or browser detection; locale is set explicitly by user +- Translation messages live in `messages/{locale}.json` + +### Persistence + +Prisma client is in `lib/prisma.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access Prisma. + +`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, contact protection, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/contact-protection.ts`, `lib/marquee-settings.ts`. + +### Module boundaries + +- `lib/*` — server-side application logic (queries, services, config) +- `components/ui/` — shared Radix UI primitives (design system base) +- `components/layout/`, `components/site/` — public site UI +- `components/admin/`, `components/dashboard/` — admin UI +- Server actions (`actions.ts` files in page directories) are the entry points for form submissions; they call `lib/*` modules +- Business logic must not live inside UI components + +### Key canonical files + +| Concern | File | +|---|---| +| i18n routing | `i18n/routing.ts` | +| Admin routing logic | `lib/admin-routing.ts` | +| Middleware (routing + auth) | `middleware.ts` | +| Prisma client | `lib/prisma.ts` | +| AppConfig aggregate | `lib/app-config.ts` | +| Portfolio queries | `lib/portfolio.ts` | +| Media handling | `lib/media.ts` | +| Contact flow | `lib/contact-guard.ts`, `lib/mail.ts` | + +### Documentation to read by task scope + +- **Small UI/copy/style fixes**: read only the relevant files +- **Feature changes**: read `specs/.md` + `docs/ARCHITECTURE.md` if structure is affected +- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file + +Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior. + +## Environment variables + +Key variables (see `.env.example` for full list): + +``` +DATABASE_URL PostgreSQL connection string +NEXT_PUBLIC_SITE_URL Public site URL +NEXT_PUBLIC_ADMIN_URL Admin subdomain URL +ADMIN_HOST Admin hostname (used by middleware for host-based routing) +ADMIN_PASSWORD In-app admin session password +ADMIN_AUTH_SECRET JWT/cookie secret for admin session +ADMIN_BASIC_AUTH_USER HTTP Basic Auth user (optional, adds middleware-level protection) +ADMIN_BASIC_AUTH_PASS HTTP Basic Auth password +SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state (defaults to http://127.0.0.1:3000 in production) +``` + +## Working rules + +- Before making any change, first explain the plan briefly and list the files that will be touched. +- Make the smallest safe change that solves the task. +- Do not modify unrelated files. +- Preserve existing architecture, naming, and folder conventions. +- Prefer server-side logic in `lib/*` and keep business logic out of UI components. +- Never access Prisma from client components. +- For admin-related changes, treat `app/_admin/` as the canonical source of truth unless explicitly told otherwise. +- Do not add new dependencies unless absolutely necessary and explicitly justified. +- After code changes, run only the minimum relevant checks (for example: targeted test, lint on changed files, or build if necessary). +- If a task may affect routing, auth, i18n, or runtime config, inspect `middleware.ts`, `lib/admin-routing.ts`, `i18n/routing.ts`, and the relevant `lib/app-config.ts` modules first. +- For schema or database changes, inspect Prisma schema, migration flow, and seed impact before editing. +- Ask before performing large refactors, file moves, destructive changes, or broad formatting changes. +- When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c9988d0..655c1cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ COPY --from=builder /app/next.config.mjs ./next.config.mjs EXPOSE 3000 -CMD ["/bin/sh", "-c", "mkdir -p /app/public/uploads/media && npm run db:migrate && npm run start -- --hostname 0.0.0.0 --port 3000"] +CMD ["/bin/sh", "-c", "set -e; mkdir -p /app/public/uploads/media && echo '[startup] Running database migrations...' && npm run db:migrate && echo '[startup] Migrations complete. Starting server...' && npm run start -- --hostname 0.0.0.0 --port 3000"] diff --git a/docker-compose.yml b/docker-compose.yml index a53a2c1..b503686 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,10 @@ services: NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de} SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000} DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me} - ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:-change-me-long-secret} - ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:-root} - ADMIN_BASIC_AUTH_PASS: ${ADMIN_BASIC_AUTH_PASS:-change-me-root} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env} + ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)} + ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env} + ADMIN_BASIC_AUTH_PASS: ${ADMIN_BASIC_AUTH_PASS:?ADMIN_BASIC_AUTH_PASS must be set in .env} depends_on: db: condition: service_healthy diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts index 0ba92e1..cd13ed1 100644 --- a/lib/admin-auth.ts +++ b/lib/admin-auth.ts @@ -1,11 +1,15 @@ -import { createHmac, timingSafeEqual } from "crypto"; -import { cookies } from "next/headers"; +import { createHash, createHmac, timingSafeEqual } from "crypto"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { prisma } from "./prisma"; +import { getAdminAppPath } from "./admin-routing"; export const ADMIN_SESSION_COOKIE = "moh_admin_session"; -const ADMIN_FAIL_COOKIE = "moh_admin_fail"; const ADMIN_SESSION_VALUE = "superadmin"; const MAX_FAILED_ATTEMPTS = 5; const LOCKOUT_SECONDS = 15 * 60; +const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout"; function getSecret(): string { return process.env.ADMIN_AUTH_SECRET ?? ""; @@ -114,6 +118,35 @@ function verifyToken(token: string): boolean { return timingSafeEqual(left, right); } +async function getClientIp(): Promise { + const requestHeaders = await headers(); + const forwardedFor = requestHeaders.get("x-forwarded-for"); + + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() ?? "unknown"; + } + + return requestHeaders.get("x-real-ip")?.trim() ?? "unknown"; +} + +function getLockoutKey(ip: string): string { + const hash = createHash("sha256").update(ip).digest("hex").slice(0, 16); + return `${ADMIN_LOCKOUT_KEY_PREFIX}:${hash}`; +} + +async function cleanupExpiredLockouts(): Promise { + try { + const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000); + await prisma.$executeRaw` + DELETE FROM "AppConfig" + WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`} + AND "updatedAt" < ${cutoff} + `; + } catch { + // Non-critical — ignore cleanup errors. + } +} + export function isAdminAuthConfigured(): boolean { return getPassword().length > 0 && getSecret().length > 0; } @@ -180,51 +213,73 @@ function parseFailState(rawValue: string | undefined): FailState { } export async function getAdminLockState(): Promise<{ locked: boolean; remainingSeconds: number }> { - const store = await cookies(); - const state = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value); - const now = Date.now(); + try { + const ip = await getClientIp(); + const key = getLockoutKey(ip); + const config = await prisma.appConfig.findUnique({ + where: { key }, + select: { value: true }, + }); + const state = parseFailState(config?.value); + const now = Date.now(); - if (state.lockUntil > now) { - return { - locked: true, - remainingSeconds: Math.ceil((state.lockUntil - now) / 1000), - }; + if (state.lockUntil > now) { + return { + locked: true, + remainingSeconds: Math.ceil((state.lockUntil - now) / 1000), + }; + } + } catch { + // DB unavailable — fail open to avoid blocking the login page. } return { locked: false, remainingSeconds: 0 }; } export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; remainingSeconds: number }> { - const store = await cookies(); - const now = Date.now(); - const current = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value); - const attempts = current.lockUntil > now ? current.attempts : current.attempts + 1; - const locked = attempts >= MAX_FAILED_ATTEMPTS; - const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0; + try { + const ip = await getClientIp(); + const key = getLockoutKey(ip); + const now = Date.now(); - store.set(ADMIN_FAIL_COOKIE, JSON.stringify({ attempts, lockUntil }), { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: LOCKOUT_SECONDS, - }); + await cleanupExpiredLockouts(); - return { - locked, - remainingSeconds: locked ? LOCKOUT_SECONDS : 0, - }; + const config = await prisma.appConfig.findUnique({ + where: { key }, + select: { value: true }, + }); + + const current = parseFailState(config?.value); + // If a previous lockout has expired, reset the counter. + const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts; + const attempts = baseAttempts + 1; + const locked = attempts >= MAX_FAILED_ATTEMPTS; + const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0; + + await prisma.appConfig.upsert({ + where: { key }, + update: { value: JSON.stringify({ attempts, lockUntil }) }, + create: { key, value: JSON.stringify({ attempts, lockUntil }) }, + }); + + return { + locked, + remainingSeconds: locked ? LOCKOUT_SECONDS : 0, + }; + } catch { + // DB error — don't lock out so admin can still log in. + return { locked: false, remainingSeconds: 0 }; + } } export async function resetAdminFailedAttempts(): Promise { - const store = await cookies(); - store.set(ADMIN_FAIL_COOKIE, "", { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: 0, - }); + try { + const ip = await getClientIp(); + const key = getLockoutKey(ip); + await prisma.appConfig.deleteMany({ where: { key } }); + } catch { + // Non-critical — ignore. + } } export async function isAdminAuthenticated(): Promise { @@ -241,3 +296,14 @@ export async function isAdminAuthenticated(): Promise { return verifyToken(token); } + +/** + * Call at the top of every authenticated admin page or server action. + * Clears the session cookie and redirects to the login page if not authenticated. + */ +export async function requireAdminAuth(): Promise { + if (!(await isAdminAuthenticated())) { + await clearAdminSessionCookie(); + redirect(getAdminAppPath("/")); + } +} diff --git a/lib/contact-guard.ts b/lib/contact-guard.ts index f0cdfc1..1c25c17 100644 --- a/lib/contact-guard.ts +++ b/lib/contact-guard.ts @@ -44,28 +44,29 @@ export async function enforceContactRateLimit(settings: ContactProtectionSetting const ip = await getClientIpFromHeaders(); const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes); - await prisma.$transaction(async (tx) => { - const current = await tx.appConfig.findUnique({ - where: { key }, - select: { value: true }, - }); - const nextCount = parseCount(current?.value) + 1; + // Clean up stale rate limit entries (older than 2x the window) to prevent table bloat. + const cutoffDate = new Date(Date.now() - settings.rateLimit.windowMinutes * 2 * 60 * 1000); + await prisma.$executeRaw` + DELETE FROM "AppConfig" + WHERE key LIKE ${`${CONTACT_RATE_LIMIT_KEY_PREFIX}:%`} + AND "updatedAt" < ${cutoffDate} + `; - if (nextCount > settings.rateLimit.maxRequests) { - throw new Error("Too many contact requests. Please try again later."); - } + // Atomically insert or increment the counter for this IP + window. + const result = await prisma.$queryRaw>` + INSERT INTO "AppConfig" (id, key, value, "createdAt", "updatedAt") + VALUES (gen_random_uuid()::text, ${key}, '1', NOW(), NOW()) + ON CONFLICT (key) DO UPDATE + SET value = (CAST("AppConfig".value AS INTEGER) + 1)::text, + "updatedAt" = NOW() + RETURNING CAST(value AS INTEGER) AS count + `; - await tx.appConfig.upsert({ - where: { key }, - update: { - value: String(nextCount), - }, - create: { - key, - value: "1", - }, - }); - }); + const count = result[0]?.count ?? 0; + + if (count > settings.rateLimit.maxRequests) { + throw new Error("Too many contact requests. Please try again later."); + } } export async function verifyTurnstileToken( diff --git a/messages/ar.json b/messages/ar.json index 7e0993b..c19d6e5 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -145,7 +145,7 @@ }, "projects": { "eyebrow": "مشاريع مختارة", - "title": "ثلاثة أمثلة على نوع الشغل الذي أحب أن أطلقه.", + "title": "مشاريع مميزة", "description": "الأعمال المختارة تجمع بين جودة واجهة قوية وبنية تقنية تبقى ثابتة بعد الإطلاق.", "stackLabel": "Stack", "cta": "فتح المشروع", diff --git a/messages/de.json b/messages/de.json index 6e5c964..c634acf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -145,7 +145,7 @@ }, "projects": { "eyebrow": "Ausgewaehlte Projekte", - "title": "Drei Beispiele fuer die Art von Arbeit, die ich gerne ausliefere.", + "title": "Hervorgehobene Projekte", "description": "Ausgewaehlte Projekte verbinden starke Interface-Qualitaet mit technischer Struktur, die auch nach dem Launch stabil bleibt.", "stackLabel": "Stack", "cta": "Projekt oeffnen", diff --git a/messages/en.json b/messages/en.json index 31dac91..27b43d4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -145,7 +145,7 @@ }, "projects": { "eyebrow": "Selected projects", - "title": "Three examples of the kind of work I like to ship.", + "title": "Highlighted Projects", "description": "Featured work combines strong interface quality with technical structure that stays stable after launch.", "stackLabel": "Stack", "cta": "Open project", diff --git a/middleware.ts b/proxy.ts similarity index 93% rename from middleware.ts rename to proxy.ts index 199968e..4b36252 100644 --- a/middleware.ts +++ b/proxy.ts @@ -28,6 +28,9 @@ type SiteRuntimeState = { maintenanceEnabled: boolean; }; +let runtimeStateCache: { value: SiteRuntimeState; expiresAt: number } | null = null; +const RUNTIME_STATE_CACHE_TTL_MS = 5_000; + function getSiteRuntimeStateOrigin(request: NextRequest): string { const configuredOrigin = process.env.SITE_RUNTIME_ORIGIN?.trim(); @@ -53,6 +56,12 @@ function isComingSoonPath(pathname: string) { } async function getSiteRuntimeState(request: NextRequest): Promise { + const now = Date.now(); + + if (runtimeStateCache !== null && runtimeStateCache.expiresAt > now) { + return runtimeStateCache.value; + } + try { const runtimeStateUrl = new URL("/api/site/default-locale", getSiteRuntimeStateOrigin(request)); const response = await fetch(runtimeStateUrl, { @@ -74,10 +83,14 @@ async function getSiteRuntimeState(request: NextRequest): Promise { })), ); - const { default: middleware } = await import("../middleware"); + const { default: middleware } = await import("../proxy"); const request = createMockRequest("https://example.com/"); await middleware(request as never); @@ -89,7 +89,7 @@ describe("middleware locale runtime config", () => { }), ); - const { default: middleware } = await import("../middleware"); + const { default: middleware } = await import("../proxy"); const request = createMockRequest("https://example.com/"); await middleware(request as never); @@ -113,7 +113,7 @@ describe("middleware locale runtime config", () => { })), ); - const { default: middleware } = await import("../middleware"); + const { default: middleware } = await import("../proxy"); const request = createMockRequest("https://example.com/"); const response = await middleware(request as never); @@ -136,7 +136,7 @@ describe("middleware locale runtime config", () => { })), ); - const { default: middleware } = await import("../middleware"); + const { default: middleware } = await import("../proxy"); const request = createMockRequest("https://example.com/"); await middleware(request as never);