diff --git a/CLAUDE.md b/CLAUDE.md index 37f7186..6bbcbaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,7 @@ This is a combined RMS (Resource Management System) backend + marketing site for - **tRPC caller constraint:** When calling procedures from server components, always verify the Zod input schema's runtime constraints (min, max, regex, enum). TypeScript only checks the base type (`number`, `string`), not Zod refinements. Open the router file and read the `.input()` block before writing the call. - **Audit logging:** mutations use `withAudit` middleware — do not bypass - **Public pages:** require 4 additions: proxy.ts PUBLIC_PATHS, [slug]/page.tsx RESERVED, sitemap.ts, footer link in site-footer.tsx +- **Middleware is default-deny:** any page or endpoint a logged-out client must reach — auth flows, webhooks, cron — needs a `PUBLIC_PATHS` entry, or it silently redirects to `/login`. Verify with a logged-out `curl -I`. - **All public pages:** use `export const dynamic = "force-dynamic"` and fetch company name with try/catch fallback - **Settings:** stored in DB as key/value (Setting model), fetched via `db.setting.findUnique` - **CMS blocks:** JSON array of { type, content, level, src, alt, ctaText, ctaHref } objects @@ -168,8 +169,11 @@ This is a combined RMS (Resource Management System) backend + marketing site for - **Admin password:** seed now generates a random 24-char password on first run, writes to `.first-admin-credentials` (0600). No more hardcoded `changeme123`. Founder rotated production password manually — confirm by reading the file or by attempting login with the old default (should fail) - **No CSRF on public API routes:** `/api/tickets/reply` and `/api/files/upload` use POST but no CSRF token — relies on SameSite cookies which is sufficient for browser clients but not for API-style access - **Rate limiting is in-memory:** `server/rate-limit.ts` uses a Map — resets on server restart, doesn't work across multiple instances. Acceptable for single-instance VPS deployment. -- **No email queue retry backoff:** `app/api/cron/process-emails/route.ts` retries failed emails up to 3 times with no delay between attempts — should add exponential backoff if email failures become common - **Portal token is permanent:** Client portal tokens (cuid) never expire and can't be rotated without DB manual intervention — add rotation mechanism before handling sensitive client data +- **Cron jobs have never run:** `proxy.ts` redirects `/api/cron/*` to `/login` before the handlers' `CRON_SECRET` check, `CRON_SECRET` is never set (not in `.env.example` or `deploy.sh`), and no scheduler is installed. So appointment reminders and overdue-invoice flagging (`/api/cron/overdue` is the only code that sets `OVERDUE`) never happen. `EmailQueue` has no producer, so `process-emails` has nothing to do. Fix: generate `CRON_SECRET` in `deploy.sh`, let `/api/cron` through `proxy.ts` but `deny` it in nginx (a systemd timer calls `127.0.0.1:3000` directly), and compare the secret with `timingSafeEqual`. +- **Booking slots for visitors east of America/New_York:** `scheduling.availableSlots` receives the browser's local-midnight instant, takes the weekday from the UTC server's `getDay()`, and places the window on that instant's ET date. Visitors east of UTC see the previous day's slots; visitors between ET and UTC (Atlantic Canada, Puerto Rico/USVI in winter, Brazil, UK in winter) are offered slots that `book` then rejects. Every US state is unaffected. Fix: send the picked day as a `YYYY-MM-DD` string. +- **Uploads aren't backed up:** `db-backup.yml` dumps Postgres only. The `uploads_data` volume (writable only since the Dockerfile `chown` fix) has no backup, so restoring a dump leaves `File` rows pointing at missing files. +- **Root-level CMS pages sit behind login:** `proxy.ts` only exempts listed prefixes, so a published `CmsPage` served by `app/[slug]` redirects anonymous visitors to `/login`. Latent — no CMS pages are published. - **Prod DB password is the public dev default:** `docker-compose.yml` hardcodes `simplerms_dev` for dev *and* prod, and this repo is public. Mitigated by loopback-only port binding plus the cloud security group (only 22/80/443 reachable). Rotate it (move `DATABASE_URL` into the server's `.env.local`) if this box outlives the Holt migration. - **nodemailer forced via `overrides`:** `"nodemailer": "$nodemailer"` forces the whole tree to our direct nodemailer (now 10). next-auth's `@auth/core` declares an **optional** `nodemailer@^7` peer used only by its Email provider, which we don't use (Credentials-only in `server/auth/index.ts`) — runtime-safe, no `--legacy-peer-deps`. Side effect: `npm audit fix` and `npm update` crash on the `$` reference — raise the override floors in `package.json` by hand instead. diff --git a/Dockerfile b/Dockerfile index 33881df..8f52e02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,11 @@ COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public +# Code stays root-owned (read-only to the app); only the dirs it writes are +# node's. An empty named volume takes this dir's owner whenever a container +# is created with it. +RUN mkdir -p uploads .next/cache && chown node:node uploads .next/cache + # Run as non-root for defense in depth. USER node EXPOSE 3000 diff --git a/__tests__/content-disposition.test.ts b/__tests__/content-disposition.test.ts new file mode 100644 index 0000000..992160a --- /dev/null +++ b/__tests__/content-disposition.test.ts @@ -0,0 +1,36 @@ +// __tests__/content-disposition.test.ts +import { describe, it, expect } from "vitest"; +import { contentDisposition } from "@/lib/content-disposition"; + +// The real failure: undici rejects any header value that isn't a ByteString. +const isByteString = (v: string) => [...v].every((c) => c.charCodeAt(0) <= 0xff); + +describe("contentDisposition", () => { + it("passes plain ASCII names through", () => { + expect(contentDisposition("inline", "report.pdf")).toBe( + `inline; filename="report.pdf"; filename*=UTF-8''report.pdf` + ); + }); + + it("encodes a macOS screenshot name (U+202F before AM)", () => { + const name = "Screenshot 2026-09-30 at 10.52.11\u202fAM.png"; + const header = contentDisposition("inline", name); + expect(isByteString(header)).toBe(true); + expect(header).toContain(`filename="Screenshot 2026-09-30 at 10.52.11_AM.png"`); + expect(header).toContain("filename*=UTF-8''Screenshot%202026-09-30%20at%2010.52.11%E2%80%AFAM.png"); + expect(() => new Response("x", { headers: { "Content-Disposition": header } })).not.toThrow(); + }); + + it("encodes CJK and emoji names", () => { + const header = contentDisposition("attachment", "請求書 🧾.pdf"); + expect(isByteString(header)).toBe(true); + expect(decodeURIComponent(header.split("UTF-8''")[1])).toBe("請求書 🧾.pdf"); + }); + + it("cannot be broken out of by quotes, backslashes, CR/LF, or RFC 5987 delimiters", () => { + const header = contentDisposition("inline", `a"b\\c\r\nSet-Cookie: x=1'(*).txt`); + expect(header).not.toMatch(/[\r\n]/); + expect(header).toContain(`filename="a_b_c__Set-Cookie: x=1'(*).txt"`); + expect(header.split("UTF-8''")[1]).not.toMatch(/['()*"\\]/); + }); +}); diff --git a/__tests__/safe-redirect.test.ts b/__tests__/safe-redirect.test.ts new file mode 100644 index 0000000..422278d --- /dev/null +++ b/__tests__/safe-redirect.test.ts @@ -0,0 +1,42 @@ +// __tests__/safe-redirect.test.ts +import { describe, it, expect } from "vitest"; +import { safeRedirectPath } from "@/lib/safe-redirect"; + +const ORIGIN = "https://akritos.com"; + +describe("safeRedirectPath", () => { + it("keeps same-origin paths, including query and hash", () => { + expect(safeRedirectPath("/dashboard/invoices?status=open#top", ORIGIN)).toBe( + "/dashboard/invoices?status=open#top" + ); + }); + + it("reduces a same-origin absolute URL to its path", () => { + expect(safeRedirectPath("https://akritos.com/dashboard/settings", ORIGIN)).toBe("/dashboard/settings"); + }); + + it.each([ + ["protocol-relative", "//evil.example"], + ["backslash host", "/\\evil.example"], + ["double backslash", "/\\\\evil.example"], + ["mixed slashes", "\\/evil.example"], + ["tab-split slashes", "/\t/evil.example"], + ["newline-split slashes", "/\n/evil.example"], + ["absolute foreign URL", "https://evil.example/dashboard"], + ["lookalike host", "https://akritos.com.evil.example/"], + ["other scheme on our host", "http://akritos.com/dashboard"], + ["javascript URL", "javascript:alert(1)"], + ["data URL", "data:text/html,"], + ])("rejects %s", (_label, raw) => { + expect(safeRedirectPath(raw, ORIGIN)).toBe("/dashboard"); + }); + + it("falls back when missing or empty", () => { + expect(safeRedirectPath(null, ORIGIN)).toBe("/dashboard"); + expect(safeRedirectPath("", ORIGIN)).toBe("/dashboard"); + }); + + it("uses the caller's fallback", () => { + expect(safeRedirectPath("//evil.example", ORIGIN, "/")).toBe("/"); + }); +}); diff --git a/app/api/files/[id]/route.ts b/app/api/files/[id]/route.ts index b09e093..277e4dc 100644 --- a/app/api/files/[id]/route.ts +++ b/app/api/files/[id]/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/server/auth"; import { db } from "@/server/db"; import { getFile } from "@/server/storage/local"; +import { contentDisposition } from "@/lib/content-disposition"; export async function GET( _request: NextRequest, @@ -16,21 +17,24 @@ export async function GET( const file = await db.file.findUnique({ where: { id } }); if (!file) return NextResponse.json({ error: "Not found" }, { status: 404 }); + let buffer: Buffer; try { - const buffer = await getFile(file.storagePath); - // Strip quotes/CR/LF from the client-supplied name so it can't break the - // header, and send nosniff so a browser won't MIME-sniff a spoofed - // Content-Type into something executable (the upload MIME is client-set). - const safeName = file.originalName.replace(/[\r\n"\\]/g, "_"); - return new NextResponse(new Uint8Array(buffer), { - headers: { - "Content-Type": file.mimeType, - "Content-Disposition": `inline; filename="${safeName}"`, - "Content-Length": String(file.sizeBytes), - "X-Content-Type-Options": "nosniff", - }, - }); - } catch { - return NextResponse.json({ error: "File not found on disk" }, { status: 404 }); + buffer = await getFile(file.storagePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return NextResponse.json({ error: "File not found on disk" }, { status: 404 }); + } + throw err; } + + return new NextResponse(new Uint8Array(buffer), { + headers: { + "Content-Type": file.mimeType, + "Content-Disposition": contentDisposition("inline", file.originalName), + "Content-Length": String(file.sizeBytes), + // The upload MIME type is client-set; nosniff stops a browser from + // sniffing a spoofed type into something executable. + "X-Content-Type-Options": "nosniff", + }, + }); } diff --git a/app/api/files/upload/route.ts b/app/api/files/upload/route.ts index e578a76..6be46e8 100644 --- a/app/api/files/upload/route.ts +++ b/app/api/files/upload/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/server/auth"; import { db } from "@/server/db"; import { saveFile } from "@/server/storage/local"; +import { rateLimit } from "@/server/rate-limit"; const MAX_SIZE = 10 * 1024 * 1024; // 10 MB @@ -21,6 +22,13 @@ const ALLOWED_TYPES = new Set([ export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // Matches staffProcedure: READONLY accounts can't mutate anything. + if (session.user.role === "READONLY") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + // Checked before the body is parsed; bounds how fast one account can fill + // the disk that Postgres shares. + const { allowed } = rateLimit(`upload:${session.user.id}`, 30, 60 * 60 * 1000); + if (!allowed) return NextResponse.json({ error: "Too many uploads. Try again later." }, { status: 429 }); const formData = await request.formData(); const file = formData.get("file") as File | null; diff --git a/app/blog/[slug]/opengraph-image.tsx b/app/blog/[slug]/opengraph-image.tsx index d67c83d..960d630 100644 --- a/app/blog/[slug]/opengraph-image.tsx +++ b/app/blog/[slug]/opengraph-image.tsx @@ -7,9 +7,10 @@ export const alt = "Akritos blog post"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; -export default async function OG({ params }: { params: { slug: string } }) { +export default async function OG({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; const post = await db.cmsPost.findUnique({ - where: { slug: params.slug, status: "PUBLISHED" }, + where: { slug, status: "PUBLISHED" }, select: { title: true, excerpt: true }, }); diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 6739a42..b73aa13 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -9,7 +9,11 @@ import { db } from "@/server/db"; import { formatDate } from "@/lib/utils"; import { ArrowRight } from "lucide-react"; -export const metadata = { title: "Blog", description: "Insights and updates from our team." }; +export const metadata = { + title: "Blog", + description: "Insights and updates from our team.", + alternates: { canonical: "https://akritos.com/blog" }, +}; async function getData() { const [setting, posts] = await Promise.all([ diff --git a/app/layout.tsx b/app/layout.tsx index 62089f9..3ff5ac2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { Geist } from "next/font/google"; import { TRPCProvider } from "@/lib/trpc/provider"; import { Analytics } from "@/components/site/analytics"; +import { PublicOnly } from "@/components/site/public-only"; import { db } from "@/server/db"; import "./globals.css"; @@ -62,7 +63,9 @@ export default function RootLayout({