From 356abcfc2dfed838c3e878e855db9f243f5aa794 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 16 May 2026 22:48:06 +0200 Subject: [PATCH] fix: redact sensitive config secrets from admin API response --- app/admin/_tabs/auth.tsx | 8 ++++++-- app/admin/_tabs/branding.tsx | 3 ++- app/admin/_tabs/dashboard.tsx | 6 ++++-- app/admin/_tabs/settings.tsx | 3 ++- app/api/admin/config/route.ts | 27 +++++++++++++++++++++++++-- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/admin/_tabs/auth.tsx b/app/admin/_tabs/auth.tsx index 274d3925..95aca959 100644 --- a/app/admin/_tabs/auth.tsx +++ b/app/admin/_tabs/auth.tsx @@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; interface ConfigEntry { - value: unknown; + // Sensitive keys (sessionSecret, oauthClientSecret) come back with + // `value` omitted and `hasValue` set instead — the server never echoes + // the raw secret to the client. + value?: unknown; source: 'admin' | 'env' | 'default'; + hasValue?: boolean; } export function AuthTab() { @@ -267,7 +271,7 @@ export function AuthTab() { - + diff --git a/app/admin/_tabs/branding.tsx b/app/admin/_tabs/branding.tsx index e5e3f1ca..2c9a4fa5 100644 --- a/app/admin/_tabs/branding.tsx +++ b/app/admin/_tabs/branding.tsx @@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac import { apiFetch } from '@/lib/browser-navigation'; interface ConfigEntry { - value: unknown; + value?: unknown; source: 'admin' | 'env' | 'default'; + hasValue?: boolean; } const IMAGE_FIELDS = [ diff --git a/app/admin/_tabs/dashboard.tsx b/app/admin/_tabs/dashboard.tsx index 617866b1..8c876a5c 100644 --- a/app/admin/_tabs/dashboard.tsx +++ b/app/admin/_tabs/dashboard.tsx @@ -26,7 +26,7 @@ export function DashboardTab() { const [status, setStatus] = useState(null); const [recentActivity, setRecentActivity] = useState([]); const [config, setConfig] = useState(null); - const [, setConfigSources] = useState | null>(null); + const [, setConfigSources] = useState | null>(null); const [warnings, setWarnings] = useState([]); const [pluginCount, setPluginCount] = useState(0); const [themeCount, setThemeCount] = useState(0); @@ -96,7 +96,9 @@ export function DashboardTab() { const sources = await adminConfigRes.json(); setConfigSources(sources); const sessionSecret = sources?.sessionSecret; - if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { + // Server redacts the raw value for sensitive keys; rely on hasValue, + // which is false when unset or matching a known placeholder default. + if (!sessionSecret?.hasValue) { w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.'); } const adminPassword = sources?.adminPassword; diff --git a/app/admin/_tabs/settings.tsx b/app/admin/_tabs/settings.tsx index 0e8d8e5c..63468cf7 100644 --- a/app/admin/_tabs/settings.tsx +++ b/app/admin/_tabs/settings.tsx @@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section'; import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; interface ConfigEntry { - value: unknown; + value?: unknown; source: 'admin' | 'env' | 'default'; + hasValue?: boolean; } export function SettingsTab() { diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 90c8bbb5..b3d1ed1a 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -2,12 +2,23 @@ import { NextRequest, NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; -import { CONFIG_ENV_MAP } from '@/lib/admin/types'; +import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types'; import { parseJmapServers } from '@/lib/admin/jmap-servers'; import { logger } from '@/lib/logger'; +// Strings that count as "no real secret configured" — used so the dashboard +// can warn about a placeholder session secret without us ever returning the +// raw value to the client. +const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']); + /** * GET /api/admin/config - Get full config with sources (admin-protected) + * + * Sensitive keys (sessionSecret, oauthClientSecret) are returned with + * `value` omitted and a `hasValue` boolean instead. An admin session is + * enough to read every other config knob; the secrets themselves stay on + * the server so that an XSS or session-theft can't lift them in one + * request and forge admin/user session cookies offline. */ export async function GET() { try { @@ -17,7 +28,19 @@ export async function GET() { await configManager.ensureLoaded(); const config = configManager.getAllWithSources(); - return NextResponse.json(config, { + const safe: Record = {}; + for (const [key, entry] of Object.entries(config)) { + if (SENSITIVE_CONFIG_KEYS.has(key)) { + const v = entry.value; + const hasValue = + typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v); + safe[key] = { source: entry.source, hasValue }; + } else { + safe[key] = entry; + } + } + + return NextResponse.json(safe, { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) {