diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..43fa3cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint --if-present + + - name: Build + run: npm run build --if-present + + - name: Test + run: npm test --if-present + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Dependency audit (high severity) + run: npm audit --audit-level=high || true + + - name: Secret scan (gitleaks) + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \ + | tar -xz gitleaks + ./gitleaks detect --source . --no-banner --redact --verbose diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 00a0b18..c850054 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { SESSION_COOKIE, sessionToken } from "@/lib/auth"; +import { SESSION_COOKIE, sessionToken, timingSafeStringEqual } from "@/lib/auth"; export const runtime = "nodejs"; @@ -14,7 +14,7 @@ export async function POST(req: NextRequest) { const next = safeNext(form.get("next")); const expected = process.env.SITE_PASSWORD ?? ""; - if (!expected || password !== expected) { + if (!expected || !timingSafeStringEqual(password, expected)) { const url = new URL("/login", req.url); url.searchParams.set("error", "1"); if (next !== "/") url.searchParams.set("next", next); diff --git a/src/app/api/entries/[id]/photos/route.ts b/src/app/api/entries/[id]/photos/route.ts index 79b7bc3..a7adb44 100644 --- a/src/app/api/entries/[id]/photos/route.ts +++ b/src/app/api/entries/[id]/photos/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { addEntryPhoto, deleteEntryPhoto, normalizeImageExt } from "@/lib/entries"; +import { requireAuth } from "@/lib/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -9,6 +10,9 @@ const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB // Add a photo to an existing memory (multipart form, field name "photo"). export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const unauthorized = await requireAuth(req); + if (unauthorized) return unauthorized; + const { id } = await ctx.params; let file: File | null = null; @@ -50,6 +54,9 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string // Remove a photo from a memory (JSON body: { path }). export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const unauthorized = await requireAuth(req); + if (unauthorized) return unauthorized; + const { id } = await ctx.params; let body: { path?: unknown }; diff --git a/src/app/api/entries/[id]/route.ts b/src/app/api/entries/[id]/route.ts index b954475..5bb59b9 100644 --- a/src/app/api/entries/[id]/route.ts +++ b/src/app/api/entries/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { updateEntryDescription } from "@/lib/entries"; +import { requireAuth } from "@/lib/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -7,6 +8,9 @@ export const dynamic = "force-dynamic"; // Edit an existing memory. Only the free-text description is editable here; // photos are managed via the /photos sub-route. export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const unauthorized = await requireAuth(req); + if (unauthorized) return unauthorized; + const { id } = await ctx.params; let body: { description?: unknown }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 610c1cb..0c1ee37 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,3 +1,6 @@ +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; + export const SESSION_COOKIE = "lj_session"; /** @@ -12,3 +15,41 @@ export async function sessionToken(password: string): Promise { .map((b) => b.toString(16).padStart(2, "0")) .join(""); } + +/** + * Constant-time string comparison. Guards against length mismatch first + * (timingSafeEqual throws on unequal-length Buffers), then defers to + * crypto.timingSafeEqual so the comparison does not short-circuit and leak + * timing information about the password/token. + */ +export function timingSafeStringEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} + +/** + * Authorize a mutating request. WRITE endpoints (create/edit/delete) must + * always require a valid owner session, independent of whether SITE_PASSWORD + * gates public reads. When SITE_PASSWORD is unset there is no owner session to + * validate against, so writes fail closed (401) rather than falling open. + * + * Returns a 401 NextResponse when the caller is unauthenticated/invalid, or + * `null` when the request is authorized and the handler may proceed. + */ +export async function requireAuth(req: NextRequest): Promise { + const unauthorized = () => + NextResponse.json({ ok: false, error: "Unauthorized." }, { status: 401 }); + + const password = process.env.SITE_PASSWORD ?? ""; + if (!password) return unauthorized(); + + const token = req.cookies.get(SESSION_COOKIE)?.value ?? ""; + if (!token) return unauthorized(); + + const expected = await sessionToken(password); + if (!timingSafeStringEqual(token, expected)) return unauthorized(); + + return null; +} diff --git a/src/proxy.ts b/src/proxy.ts index 27ef125..003a077 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { SESSION_COOKIE, sessionToken } from "@/lib/auth"; +import { SESSION_COOKIE, sessionToken, timingSafeStringEqual } from "@/lib/auth"; /** * Optional site-wide password gate (Next.js 16 "proxy"). @@ -17,7 +17,7 @@ export async function proxy(req: NextRequest) { } const token = req.cookies.get(SESSION_COOKIE)?.value; - if (token && token === (await sessionToken(password))) { + if (token && timingSafeStringEqual(token, await sessionToken(password))) { return NextResponse.next(); }