nyk
fix: stop forcing dangerouslyDisableDeviceAuth + fix exec security value
4840982 unverified
Raw
History Blame Contribute Delete
15.9 kB
import { NextRequest, NextResponse } from 'next/server'
import { existsSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import crypto from 'node:crypto'
import { requireRole } from '@/lib/auth'
import { config } from '@/lib/config'
import { getDatabase } from '@/lib/db'
import { logger } from '@/lib/logger'
import { FIX_SAFETY, runSecurityScan, type FixSafety } from '@/lib/security-scan'
export interface FixResult {
id: string
name: string
fixed: boolean
detail: string
fixSafety?: FixSafety
}
function shouldMutateRuntimeEnv() {
return process.env.MISSION_CONTROL_TEST_MODE !== '1'
}
function normalizeHostname(raw: string): string {
return raw.trim().replace(/^\[|\]$/g, '').split(':')[0].replace(/\.$/, '').toLowerCase()
}
function parseForwardedHost(forwarded: string | null): string[] {
if (!forwarded) return []
const hosts: string[] = []
for (const part of forwarded.split(',')) {
const match = /(?:^|;)\s*host="?([^";]+)"?/i.exec(part)
if (match?.[1]) hosts.push(match[1])
}
return hosts
}
function getRequestHostCandidates(request: NextRequest): string[] {
const rawCandidates = [
...(request.headers.get('x-forwarded-host') || '').split(','),
...(request.headers.get('x-original-host') || '').split(','),
...(request.headers.get('x-forwarded-server') || '').split(','),
...parseForwardedHost(request.headers.get('forwarded')),
request.headers.get('host') || '',
request.nextUrl.host || '',
request.nextUrl.hostname || '',
]
return [...new Set(rawCandidates.map(normalizeHostname).filter(Boolean))]
}
function getFailingChecks() {
return Object.values(runSecurityScan().categories)
.flatMap((category) => category.checks)
.filter((check) => check.status !== 'pass')
}
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
// Optional: pass { ids: ["check_id"] } to fix only specific issues
let targetIds: Set<string> | null = null
try {
const body = await request.json()
if (Array.isArray(body?.ids) && body.ids.length > 0) {
targetIds = new Set(body.ids as string[])
}
} catch { /* no body = fix all */ }
const shouldFix = (id: string) => !targetIds || targetIds.has(id)
const results: FixResult[] = []
const envPaths = [
path.join(process.cwd(), '.env'),
path.join(process.cwd(), '.env.local'),
]
function readEnv(filePath: string): string {
try { return readFileSync(filePath, 'utf-8') } catch { return '' }
}
function setEnvVar(key: string, value: string) {
let targetPath = envPaths[0]
for (const filePath of envPaths) {
const content = readEnv(filePath)
if (new RegExp(`^${key}=.*$`, 'm').test(content)) {
targetPath = filePath
break
}
}
let content = readEnv(targetPath)
const regex = new RegExp(`^${key}=.*$`, 'm')
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`)
} else {
content = content.trimEnd() + `\n${key}=${value}\n`
}
writeFileSync(targetPath, content, 'utf-8')
if (shouldMutateRuntimeEnv()) {
process.env[key] = value
}
}
function unsetEnvVar(key: string) {
const regex = new RegExp(`^${key}=.*\n?`, 'm')
for (const filePath of envPaths) {
let content = readEnv(filePath)
if (regex.test(content)) {
content = content.replace(regex, '')
writeFileSync(filePath, content, 'utf-8')
}
}
if (shouldMutateRuntimeEnv()) {
delete process.env[key]
}
}
// 1. Fix .env file permissions
const envPath = envPaths[0]
if (shouldFix('env_permissions') && existsSync(envPath)) {
try {
const stat = statSync(envPath)
const mode = (stat.mode & 0o777).toString(8)
if (mode !== '600') {
chmodSync(envPath, 0o600)
results.push({ id: 'env_permissions', name: '.env file permissions', fixed: true, detail: `Changed from ${mode} to 600`, fixSafety: FIX_SAFETY['env_permissions'] })
} else {
results.push({ id: 'env_permissions', name: '.env file permissions', fixed: true, detail: 'Already 600', fixSafety: FIX_SAFETY['env_permissions'] })
}
} catch (e: any) {
results.push({ id: 'env_permissions', name: '.env file permissions', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['env_permissions'] })
}
}
// 2. Fix MC_ALLOWED_HOSTS if not set
const allowedHosts = (process.env.MC_ALLOWED_HOSTS || '').trim()
const allowAny = process.env.MC_ALLOW_ANY_HOST
if (shouldFix('allowed_hosts') && (!allowedHosts || allowAny === '1' || allowAny === 'true')) {
try {
if (allowAny) {
unsetEnvVar('MC_ALLOW_ANY_HOST')
}
const preservedHosts = new Set([
'localhost',
'127.0.0.1',
...allowedHosts.split(',').map((host) => normalizeHostname(host)).filter(Boolean),
...getRequestHostCandidates(request),
])
const mergedHosts = Array.from(preservedHosts)
setEnvVar('MC_ALLOWED_HOSTS', mergedHosts.join(','))
results.push({ id: 'allowed_hosts', name: 'Host allowlist', fixed: true, detail: `Set MC_ALLOWED_HOSTS=${mergedHosts.join(',')}`, fixSafety: FIX_SAFETY['allowed_hosts'] })
} catch (e: any) {
results.push({ id: 'allowed_hosts', name: 'Host allowlist', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['allowed_hosts'] })
}
}
// 3. Fix MC_ENABLE_HSTS
if (shouldFix('hsts_enabled') && process.env.MC_ENABLE_HSTS !== '1') {
try {
setEnvVar('MC_ENABLE_HSTS', '1')
results.push({ id: 'hsts_enabled', name: 'HSTS enabled', fixed: true, detail: 'Set MC_ENABLE_HSTS=1', fixSafety: FIX_SAFETY['hsts_enabled'] })
} catch (e: any) {
results.push({ id: 'hsts_enabled', name: 'HSTS', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['hsts_enabled'] })
}
}
// 4. Fix MC_COOKIE_SECURE
const cookieSecure = process.env.MC_COOKIE_SECURE
if (shouldFix('cookie_secure') && cookieSecure !== '1' && cookieSecure !== 'true') {
try {
setEnvVar('MC_COOKIE_SECURE', '1')
results.push({ id: 'cookie_secure', name: 'Secure cookies', fixed: true, detail: 'Set MC_COOKIE_SECURE=1', fixSafety: FIX_SAFETY['cookie_secure'] })
} catch (e: any) {
results.push({ id: 'cookie_secure', name: 'Secure cookies', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['cookie_secure'] })
}
}
// 4b. Re-enable runtime rate limiting
const rateLimitDisabled = process.env.MC_DISABLE_RATE_LIMIT
if (shouldFix('rate_limiting') && rateLimitDisabled) {
try {
unsetEnvVar('MC_DISABLE_RATE_LIMIT')
results.push({ id: 'rate_limiting', name: 'Rate limiting active', fixed: true, detail: 'Removed MC_DISABLE_RATE_LIMIT', fixSafety: FIX_SAFETY['rate_limiting'] })
} catch (e: any) {
results.push({ id: 'rate_limiting', name: 'Rate limiting active', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['rate_limiting'] })
}
}
// 5. Fix API_KEY if it's a known default
const apiKey = process.env.API_KEY || ''
if (shouldFix('api_key_set') && (!apiKey || apiKey === 'generate-a-random-key')) {
try {
const newKey = crypto.randomBytes(32).toString('hex')
setEnvVar('API_KEY', newKey)
results.push({ id: 'api_key_set', name: 'API key', fixed: true, detail: 'Generated new random API key', fixSafety: FIX_SAFETY['api_key_set'] })
} catch (e: any) {
results.push({ id: 'api_key_set', name: 'API key', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['api_key_set'] })
}
}
// 6. Fix OpenClaw config
const ocFixIds = ['config_permissions', 'gateway_auth', 'gateway_bind', 'elevated_disabled', 'dm_isolation', 'exec_restricted', 'control_ui_device_auth', 'control_ui_insecure_auth', 'fs_workspace_only', 'log_redaction']
const configPath = config.openclawConfigPath
if (ocFixIds.some(id => shouldFix(id)) && configPath && existsSync(configPath)) {
let ocConfig: any
try {
ocConfig = JSON.parse(readFileSync(configPath, 'utf-8'))
} catch { ocConfig = null }
if (ocConfig) {
let configChanged = false
// Fix config file permissions
if (shouldFix('config_permissions')) try {
const stat = statSync(configPath)
const mode = (stat.mode & 0o777).toString(8)
if (mode !== '600') {
chmodSync(configPath, 0o600)
results.push({ id: 'config_permissions', name: 'OpenClaw config permissions', fixed: true, detail: `Changed from ${mode} to 600`, fixSafety: FIX_SAFETY['config_permissions'] })
}
} catch (e: any) {
results.push({ id: 'config_permissions', name: 'OpenClaw config permissions', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['config_permissions'] })
}
// Fix gateway auth
if (shouldFix('gateway_auth')) {
if (!ocConfig.gateway) ocConfig.gateway = {}
if (!ocConfig.gateway.auth) ocConfig.gateway.auth = {}
if (ocConfig.gateway.auth.mode !== 'token') {
ocConfig.gateway.auth.mode = 'token'
if (!ocConfig.gateway.auth.token) {
ocConfig.gateway.auth.token = crypto.randomBytes(32).toString('hex')
}
configChanged = true
results.push({ id: 'gateway_auth', name: 'Gateway authentication', fixed: true, detail: 'Set auth.mode to "token" with generated token', fixSafety: FIX_SAFETY['gateway_auth'] })
}
}
// Fix gateway bind
if (shouldFix('gateway_bind')) {
if (!ocConfig.gateway) ocConfig.gateway = {}
if (ocConfig.gateway.bind !== 'loopback' && ocConfig.gateway.bind !== '127.0.0.1') {
ocConfig.gateway.bind = 'loopback'
configChanged = true
results.push({ id: 'gateway_bind', name: 'Gateway bind address', fixed: true, detail: 'Set bind to "loopback"', fixSafety: FIX_SAFETY['gateway_bind'] })
}
}
// Fix elevated mode
if (shouldFix('elevated_disabled')) {
if (!ocConfig.elevated) ocConfig.elevated = {}
if (ocConfig.elevated.enabled === true) {
ocConfig.elevated.enabled = false
configChanged = true
results.push({ id: 'elevated_disabled', name: 'Elevated mode', fixed: true, detail: 'Disabled elevated mode', fixSafety: FIX_SAFETY['elevated_disabled'] })
}
}
// Fix DM isolation
if (shouldFix('dm_isolation')) {
if (!ocConfig.session) ocConfig.session = {}
if (ocConfig.session.dmScope !== 'per-channel-peer') {
ocConfig.session.dmScope = 'per-channel-peer'
configChanged = true
results.push({ id: 'dm_isolation', name: 'DM session isolation', fixed: true, detail: 'Set dmScope to "per-channel-peer"', fixSafety: FIX_SAFETY['dm_isolation'] })
}
}
// Fix exec security
if (shouldFix('exec_restricted')) {
if (!ocConfig.tools) ocConfig.tools = {}
if (!ocConfig.tools.exec) ocConfig.tools.exec = {}
if (ocConfig.tools.exec.security !== 'allowlist' && ocConfig.tools.exec.security !== 'deny') {
ocConfig.tools.exec.security = 'allowlist'
configChanged = true
results.push({ id: 'exec_restricted', name: 'Exec tool restriction', fixed: true, detail: 'Set exec security to "allowlist"', fixSafety: FIX_SAFETY['exec_restricted'] })
}
}
// Fix Control UI device auth
if (shouldFix('control_ui_device_auth')) {
if (ocConfig.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
ocConfig.gateway.controlUi.dangerouslyDisableDeviceAuth = false
configChanged = true
results.push({ id: 'control_ui_device_auth', name: 'Control UI device auth', fixed: true, detail: 'Disabled dangerouslyDisableDeviceAuth', fixSafety: FIX_SAFETY['control_ui_device_auth'] })
}
}
// Fix Control UI insecure auth
if (shouldFix('control_ui_insecure_auth')) {
if (ocConfig.gateway?.controlUi?.allowInsecureAuth === true) {
ocConfig.gateway.controlUi.allowInsecureAuth = false
configChanged = true
results.push({ id: 'control_ui_insecure_auth', name: 'Control UI secure auth', fixed: true, detail: 'Disabled allowInsecureAuth', fixSafety: FIX_SAFETY['control_ui_insecure_auth'] })
}
}
// Fix filesystem workspace isolation
if (shouldFix('fs_workspace_only')) {
if (!ocConfig.tools) ocConfig.tools = {}
if (!ocConfig.tools.fs) ocConfig.tools.fs = {}
if (ocConfig.tools.fs.workspaceOnly !== true) {
ocConfig.tools.fs.workspaceOnly = true
configChanged = true
results.push({ id: 'fs_workspace_only', name: 'Filesystem workspace isolation', fixed: true, detail: 'Set tools.fs.workspaceOnly to true', fixSafety: FIX_SAFETY['fs_workspace_only'] })
}
}
// Fix log redaction
if (shouldFix('log_redaction')) {
if (!ocConfig.logging) ocConfig.logging = {}
if (!ocConfig.logging.redactSensitive) {
ocConfig.logging.redactSensitive = 'tools'
configChanged = true
results.push({ id: 'log_redaction', name: 'Log redaction', fixed: true, detail: 'Set logging.redactSensitive to "tools"', fixSafety: FIX_SAFETY['log_redaction'] })
}
}
if (configChanged) {
try {
writeFileSync(configPath, JSON.stringify(ocConfig, null, 2) + '\n', 'utf-8')
} catch (e: any) {
results.push({ id: 'config_write', name: 'Write OpenClaw config', fixed: false, detail: e.message })
}
}
}
}
// 7. Fix world-writable files (uses execFileSync with find — no user input)
if (shouldFix('world_writable')) try {
const cwd = process.cwd()
const wwOutput = execFileSync('find', [cwd, '-maxdepth', '2', '-perm', '-o+w', '-not', '-type', 'l'], {
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
if (wwOutput) {
const files = wwOutput.split('\n').filter(Boolean).slice(0, 20)
let fixedCount = 0
for (const f of files) {
try { chmodSync(f, 0o755); fixedCount++ } catch { /* skip */ }
}
if (fixedCount > 0) {
results.push({ id: 'world_writable', name: 'World-writable files', fixed: true, detail: `Fixed permissions on ${fixedCount} file(s)`, fixSafety: FIX_SAFETY['world_writable'] })
}
}
} catch { /* no world-writable files or find not available */ }
// Audit log
try {
const db = getDatabase()
db.prepare(
'INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)'
).run('security.auto_fix', auth.user.username, JSON.stringify({ fixes: results.filter(r => r.fixed).map(r => r.id) }))
} catch { /* non-critical */ }
const fixed = results.filter(r => r.fixed).length
const failed = results.filter(r => !r.fixed).length
const remainingChecks = getFailingChecks()
const remainingAutoFixable = remainingChecks.filter((check) => check.id in FIX_SAFETY).length
const remainingManual = remainingChecks.length - remainingAutoFixable
logger.info({ fixed, failed, actor: auth.user.username }, 'Security auto-fix completed')
return NextResponse.json({
attempted: results.length,
fixed,
failed,
remaining: remainingChecks.length,
remainingAutoFixable,
remainingManual,
results,
note: remainingChecks.length > 0
? 'Some issues require manual action or additional review. Environment-backed fixes may still require a server restart to fully apply.'
: 'All currently detected auto-fixable issues have been resolved. Restart the server if you changed environment-backed settings.',
})
}