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) {