Files
tinker_tickets/api/health.php
T
jaredandClaude Sonnet 5 c78d24154a Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94)
TRUSTED_PROXIES ships empty in .env.example, which disables
AuthMiddleware's reverse-proxy allowlist entirely — a fresh deployment
that doesn't explicitly set it has zero verification that
Remote-User/Remote-Groups headers actually came from the trusted
Authelia proxy. Anything that can reach the app directly (a
misconfigured firewall rule, an exposed container port, SSRF from
another internal service) can set Remote-User: admin and fully
impersonate any user with zero authentication. The enforcement logic
itself was already correct; this was purely a dangerous, easy-to-miss
default.

Added a boxed, unmissable warning around TRUSTED_PROXIES in
.env.example (previously just an inline comment easy to skim past),
added the same warning to README's setup instructions (which didn't
mention this variable at all), and added a Check 8 to api/health.php
that reports a 'warning' status when TRUSTED_PROXIES is empty, so a
deployment that forgets it doesn't go unnoticed after the fact.
Verified against real MariaDB via a running server: the health
endpoint correctly reports 'warning' when empty and 'ok' once set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:29 -04:00

202 lines
6.4 KiB
PHP

<?php
/**
* Health Check Endpoint
*
* Returns system health status for monitoring tools.
* Does not require authentication - suitable for load balancer health checks.
*
* Returns:
* - 200 OK: System is healthy
* - 503 Service Unavailable: System has issues
*/
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
$startTime = microtime(true);
$checks = [];
$healthy = true;
// Check 1: Database connectivity
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
// Quick query to verify connection is actually working
$result = $conn->query('SELECT 1');
if ($result && $result->fetch_row()) {
$checks['database'] = [
'status' => 'ok',
'message' => 'Connected'
];
} else {
$checks['database'] = [
'status' => 'error',
'message' => 'Query failed'
];
$healthy = false;
}
} catch (Exception $e) {
$checks['database'] = [
'status' => 'error',
'message' => 'Connection failed'
];
$healthy = false;
}
// Check 2: File system (uploads directory writable)
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
if (is_dir($uploadDir) && is_writable($uploadDir)) {
$checks['filesystem'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['filesystem'] = [
'status' => 'warning',
'message' => 'Upload directory not writable'
];
// Don't mark as unhealthy - this might be intentional
}
// Check 3: Session storage
$sessionPath = session_save_path() ?: sys_get_temp_dir();
if (is_dir($sessionPath) && is_writable($sessionPath)) {
$checks['sessions'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['sessions'] = [
'status' => 'error',
'message' => 'Session storage not writable'
];
$healthy = false;
}
// Check 4: Rate limit storage
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir($rateLimitDir)) {
@mkdir($rateLimitDir, 0755, true);
}
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
$checks['rate_limit'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['rate_limit'] = [
'status' => 'warning',
'message' => 'Rate limit storage not writable'
];
}
// Check 5: Required PHP extensions (catches e.g. a PHP upgrade silently
// dropping php-ldap, which breaks avatars with no other visible error).
$requirements = require dirname(__DIR__) . '/config/requirements.php';
$missingExt = array_values(array_filter(
$requirements['required_extensions'],
fn($ext) => !extension_loaded($ext)
));
if (empty($missingExt)) {
$checks['php_extensions'] = [
'status' => 'ok',
'message' => 'All required extensions loaded'
];
} else {
$checks['php_extensions'] = [
'status' => 'error',
'message' => 'Missing extensions: ' . implode(', ', $missingExt)
];
$healthy = false;
}
// Check 6: PHP version meets the declared minimum
if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
$checks['php_version'] = [
'status' => 'ok',
'message' => PHP_VERSION
];
} else {
$checks['php_version'] = [
'status' => 'error',
'message' => sprintf('PHP %s < required %s', PHP_VERSION, $requirements['min_php_version'])
];
$healthy = false;
}
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
// low default doesn't fail requests until something large actually runs, so
// surface it here rather than waiting for a mysterious failure under load).
$memLimitIni = ini_get('memory_limit');
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
$memLimitBytes = $memLimitIni === '-1'
? -1
: (int)$memLimitIni * match ($memLimitUnit) {
'g' => 1024 * 1024 * 1024,
'm' => 1024 * 1024,
'k' => 1024,
default => 1,
};
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
} else {
$checks['memory_limit'] = [
'status' => 'warning',
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
];
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
} else {
$checks['max_execution_time'] = [
'status' => 'warning',
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
];
}
// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s
// allowlist entirely, meaning anything that can reach this app directly can
// spoof the Authelia forward-auth Remote-* headers and impersonate any user,
// including an admin. Not fatal (a fresh/dev install may not sit behind a
// proxy yet), but should never go unnoticed on a real deployment.
if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) {
$checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured'];
} else {
$checks['trusted_proxies'] = [
'status' => 'warning',
'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; '
. 'anything that can reach this app directly can impersonate any user'
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// Set status code
http_response_code($healthy ? 200 : 503);
// This endpoint is unauthenticated, so expose only a coarse per-component status
// and never the diagnostic messages (they leak PHP_VERSION, exact missing
// extension names, and filesystem paths to anonymous callers).
$publicChecks = [];
foreach ($checks as $name => $check) {
$publicChecks[$name] = ['status' => $check['status']];
}
// Return response
echo json_encode([
'status' => $healthy ? 'healthy' : 'unhealthy',
'timestamp' => date('c'),
'response_time_ms' => $responseTime,
'checks' => $publicChecks,
'version' => '1.0.0'
], JSON_PRETTY_PRINT);