fix: redact sensitive config secrets from admin API response
This commit is contained in:
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
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';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthTab() {
|
export function AuthTab() {
|
||||||
@@ -267,7 +271,7 @@ export function AuthTab() {
|
|||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved — type to replace)' : undefined} />
|
||||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_FIELDS = [
|
const IMAGE_FIELDS = [
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function DashboardTab() {
|
|||||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
@@ -96,7 +96,9 @@ export function DashboardTab() {
|
|||||||
const sources = await adminConfigRes.json();
|
const sources = await adminConfigRes.json();
|
||||||
setConfigSources(sources);
|
setConfigSources(sources);
|
||||||
const sessionSecret = sources?.sessionSecret;
|
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.');
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
}
|
}
|
||||||
const adminPassword = sources?.adminPassword;
|
const adminPassword = sources?.adminPassword;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
|
|||||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsTab() {
|
export function SettingsTab() {
|
||||||
|
|||||||
@@ -2,12 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
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 { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { logger } from '@/lib/logger';
|
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)
|
* 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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -17,7 +28,19 @@ export async function GET() {
|
|||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const config = configManager.getAllWithSources();
|
const config = configManager.getAllWithSources();
|
||||||
|
|
||||||
return NextResponse.json(config, {
|
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
|
||||||
|
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' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user