Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,51 @@ function apiHostname() {
// policy is deliberately minimal — no scripts, no frames, no subresources.
const apiCsp = API_CSP

import { createRequire } from "module"
import { fileURLToPath } from "url"
import path from "path"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const require = createRequire(import.meta.url)
const webpack = require("webpack")

/**
* The three flat-file dev-auth routes are replaced with a 404 stub at
* build time when NODE_ENV === "production". This ensures they are physically
* absent from the production bundle — the runtime blockInProduction() check
* alone is insufficient because the route module (and its `fs` imports) would
* still be compiled into the bundle. Using NormalModuleReplacementPlugin
* swaps the entire module before compilation, so no flat-file code, no `fs`
* writes, and no credential-related logic ever ships to prod.
*
* The stub (src/lib/security/dev-route-stub.ts) exports a minimal 404
* handler that is Next.js App Router-compatible and has zero node:fs imports.
*/
const DEV_ONLY_ROUTES = [
/src[/\\]app[/\\]api[/\\]auth[/\\]login[/\\]route\.[jt]s$/,
/src[/\\]app[/\\]api[/\\]auth[/\\]setup[/\\]route\.[jt]s$/,
/src[/\\]app[/\\]api[/\\]upload[/\\]route\.[jt]s$/,
]

const stubPath = path.resolve(__dirname, "src/lib/security/dev-route-stub.ts")

/** @type {import('next').NextConfig} */
const nextConfig = {
webpack(config, { isServer }) {
// Only exclude on the server-side build (route handlers are server-only).
// The client build never imports these files, but we guard isServer to be
// explicit and avoid any accidental tree-shaking edge cases.
if (isServer && process.env.NODE_ENV === "production") {
DEV_ONLY_ROUTES.forEach((pattern) => {
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(pattern, stubPath)
)
})
}
return config
},

images: {
// Restrict to the specific hosts this application actually serves images
// from. The wildcard "**" that was here before is an SSRF vector — any
Expand Down
461 changes: 374 additions & 87 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"eslint-config-next": "14.2.35",
"jsdom": "^29.1.1",
"postcss": "^8",
"rolldown": "^1.2.6",
"source-map-explorer": "^2.5.2",
"tailwindcss": "^3.4.1",
"typescript": "^5",
Expand Down
42 changes: 42 additions & 0 deletions src/app/api/auth/login/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,45 @@ describe("POST /api/auth/login", () => {
expect(setCookie).toContain("moistello_session")
})
})

// ── Production guard (runtime blockInProduction defence-in-depth) ──────────
// The primary exclusion is the webpack NormalModuleReplacementPlugin in
// next.config.mjs. These tests verify the secondary runtime guard so that
// the route still 404s if the build-time replacement is ever misconfigured.

describe("POST /api/auth/login — production guard", () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})

it("returns 404 in production", async () => {
vi.stubEnv("NODE_ENV", "production")
// Fresh spies — module-level spies may have been restored by prior tests
vi.spyOn(fs, "existsSync").mockReturnValue(true)
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(users600k) as never)
vi.spyOn(fs, "writeFileSync").mockImplementation(() => {})
const res = await POST(makeRequest({ username: "alice", password: "correct-horse" }))
expect(res.status).toBe(404)
const body = await res.json()
expect(body).toEqual({ error: "Not found" })
})

it("does not write any files in production", async () => {
vi.stubEnv("NODE_ENV", "production")
vi.spyOn(fs, "existsSync").mockReturnValue(true)
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(users600k) as never)
const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {})
await POST(makeRequest({ username: "alice", password: "correct-horse" }))
expect(writeSpy).not.toHaveBeenCalled()
})

it("does not read users.json in production", async () => {
vi.stubEnv("NODE_ENV", "production")
vi.spyOn(fs, "existsSync").mockReturnValue(true)
const readSpy = vi.spyOn(fs, "readFileSync").mockReturnValue("[]" as never)
vi.spyOn(fs, "writeFileSync").mockImplementation(() => {})
await POST(makeRequest({ username: "alice", password: "correct-horse" }))
expect(readSpy).not.toHaveBeenCalled()
})
})
Loading