From 6a10ddb6e5d93a328af11e6660c57312d88378ee Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 12:47:42 +0800 Subject: [PATCH] feat: validate production runtime config --- CHANGELOG.md | 1 + app/admin/page.tsx | 3 +++ app/api/admin/config/route.ts | 4 ++++ lib/config.ts | 10 ++++++++++ lib/db.ts | 2 ++ tests/config.test.ts | 10 ++++++++++ 6 files changed, 30 insertions(+) create mode 100644 app/api/admin/config/route.ts create mode 100644 lib/config.ts create mode 100644 tests/config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7211ed..cfd48b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection. +- Added production configuration validation for secrets, encryption keys, and the public HTTPS URL, plus an administrator-facing status check. ### Fixed diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 1c03108..e700442 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import { db } from "@/lib/db"; +import { checkRuntimeConfig } from "@/lib/config"; export const dynamic = "force-dynamic"; @@ -12,6 +13,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis const errors = db.prepare("SELECT scope,message,created_at FROM error_events ORDER BY id DESC LIMIT 30").all() as any[]; const audits = db.prepare("SELECT a.action,a.target_type,a.target_id,a.metadata_json,a.created_at,u.username FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id ORDER BY a.id DESC LIMIT 50").all() as any[]; const sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.is_enabled,count(sm.user_id) AS member_count FROM sources s LEFT JOIN source_members sm ON sm.source_id=s.id GROUP BY s.id ORDER BY s.id DESC").all() as any[]; + const config = checkRuntimeConfig(); return <>

管理員

{query.updated &&

管理操作已完成。

}{query.error &&

管理操作未完成。

}

待審核檢舉

{reports.length ? :

沒有待審核檢舉。

}

使用者

@@ -19,5 +21,6 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis

最近系統錯誤

{errors.length ? :

尚無記錄。

}

稽核紀錄

{audits.length ? :

尚無稽核紀錄。

}

所有來源

+

正式環境設定

{config.ok ?

設定檢查通過。

: }
; } diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts new file mode 100644 index 0000000..0a77e93 --- /dev/null +++ b/app/api/admin/config/route.ts @@ -0,0 +1,4 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { checkRuntimeConfig } from "@/lib/config"; +export async function GET() { try { const user = await requireUser(); if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json(checkRuntimeConfig()); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } } diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..d5e1551 --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,10 @@ +export type ConfigStatus = { ok: boolean; errors: string[] }; +export function checkRuntimeConfig(env: NodeJS.ProcessEnv = process.env): ConfigStatus { + if (env.HUB_BUILD === "1" || env.NODE_ENV !== "production") return { ok: true, errors: [] }; + const errors: string[] = []; const session = env.SESSION_SECRET || ""; const encryption = env.TOKEN_ENCRYPTION_KEY || ""; const publicUrl = env.NEXT_PUBLIC_APP_URL || ""; + if (session.length < 32 || session === "development-only-change-me" || session.includes("replace-with")) errors.push("SESSION_SECRET must be a non-default value of at least 32 characters"); + if (!/^[0-9a-f]{64}$/i.test(encryption)) errors.push("TOKEN_ENCRYPTION_KEY must be 64 hexadecimal characters"); + try { if (new URL(publicUrl).protocol !== "https:") throw new Error(); } catch { errors.push("NEXT_PUBLIC_APP_URL must be an HTTPS URL in production"); } + return { ok: errors.length === 0, errors }; +} +export function requireRuntimeConfig() { const status = checkRuntimeConfig(); if (!status.ok) throw new Error(`Invalid production configuration: ${status.errors.join("; ")}`); } diff --git a/lib/db.ts b/lib/db.ts index 5537ce6..0c75698 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,7 +1,9 @@ import Database from "better-sqlite3"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; +import { requireRuntimeConfig } from "@/lib/config"; +requireRuntimeConfig(); const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db"); if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); export const db = new Database(path); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..552ee2c --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { checkRuntimeConfig } from "../lib/config"; + +test("requires secure production secrets and a public HTTPS origin", () => { + assert.equal(checkRuntimeConfig({ NODE_ENV: "production", SESSION_SECRET: "x".repeat(32), TOKEN_ENCRYPTION_KEY: "a".repeat(64), NEXT_PUBLIC_APP_URL: "https://hub.example.test" }).ok, true); + const invalid = checkRuntimeConfig({ NODE_ENV: "production", SESSION_SECRET: "development-only-change-me", TOKEN_ENCRYPTION_KEY: "wrong", NEXT_PUBLIC_APP_URL: "http://localhost:8088" }); + assert.equal(invalid.ok, false); assert.equal(invalid.errors.length, 3); + assert.equal(checkRuntimeConfig({ NODE_ENV: "test" }).ok, true); +});