Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn- Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle Befunde lagen DANEBEN: Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto, und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht. KRITISCH /api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein Administratorpasswort genuegte dort fuer ein setup:install-Token ohne Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher; erzwungen hat sie nichts. HOCH secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22. HOCH Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung. Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu: Authentifizierung aus den letzten zehn Minuten noetig, geprueft an derselben Stelle, an der auch der Leerlauf sperrt. WEITER - Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den Hardwarekey vorzeigen muss, laesst ihn stecken. - Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen Sitzungen des Kontos, die eigene bleibt. - Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes Raten im Zwischenzustand. - Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s). - Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben, sie sagt nichts ueber ein Konto. - HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93 Inline-Handlern nicht gegen XSS - das steht so im Kommentar. - session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen, Secure-Flag aus app.url statt aus $_SERVER['HTTPS']. - rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet unter checks.webauthn_anchor, ob der Anker steht. - Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich. - config.example.php ohne echtes Passwort, verify_ssl auf true. - Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf. BELEGT 188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe "haertung". Die Blockade einer veralteten Sitzung liess sich nicht in der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert bleibt, der JSON-Weg reauth_required meldet und das Passwort allein wieder oeffnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
6.9 KiB
PHP
191 lines
6.9 KiB
PHP
<?php
|
|
|
|
/**
|
|
* GET /api/health
|
|
*
|
|
* Verfuegbarkeitspruefung fuer Monitoring und Agenten.
|
|
*
|
|
* Ohne Authentifizierung wird nur der Gesamtzustand gemeldet. Mit gueltigem
|
|
* Token, Shared Key oder angemeldeter Sitzung kommen Schema-Version,
|
|
* ausstehende Migrationen und Kennzahlen dazu.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\ApiAuth;
|
|
use Deploymentcenter\Core\Config;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Core\Logger;
|
|
use Deploymentcenter\Core\Migrator;
|
|
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
|
|
|
Http::beginJson(['GET', 'OPTIONS'], true);
|
|
|
|
$checks = [];
|
|
$healthy = true;
|
|
|
|
// --- Datenbank ---
|
|
$db = null;
|
|
try {
|
|
$started = microtime(true);
|
|
$db = Db::init();
|
|
$db->query('SELECT 1')->fetchColumn();
|
|
$checks['database'] = [
|
|
'ok' => true,
|
|
'latency_ms' => (int)round((microtime(true) - $started) * 1000),
|
|
];
|
|
} catch (Throwable $e) {
|
|
Logger::error('Health-Check: Datenbank nicht erreichbar', ['error' => $e->getMessage()]);
|
|
$checks['database'] = ['ok' => false, 'error' => 'nicht erreichbar'];
|
|
$healthy = false;
|
|
}
|
|
|
|
// --- Schreibbarkeit des Log-Verzeichnisses ---
|
|
$logDir = DC_VAR . '/log';
|
|
$checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)];
|
|
|
|
// --- Ist die eigene Adresse fest hinterlegt? ---
|
|
// Seit 2.9.3 weigert sich WebauthnServer, die Kennung aus dem Host-Header
|
|
// abzuleiten: Aus diesem Wert entstehen rpId und expectedOrigin, also die
|
|
// beiden Anker, gegen die die Anmeldezeremonie geprueft wird. Wer den Header
|
|
// setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird.
|
|
//
|
|
// Die Folge ist aber, dass eine Anlage ohne app.url den Hardwarekey nicht
|
|
// mehr annimmt. Das hier ist die Stelle, an der man das VORHER sieht statt
|
|
// beim naechsten Anmeldeversuch. Gemeldet wird nur, OB ein Wert gesetzt ist -
|
|
// nicht welcher; eine Gesundheitsauskunft ohne Anmeldung soll keine
|
|
// Konfiguration ausplaudern.
|
|
$appUrlGesetzt = trim((string)Config::get('app.url', '')) !== ''
|
|
|| trim((string)Config::get('security.webauthn_rp_id', '')) !== '';
|
|
|
|
$checks['webauthn_anchor'] = [
|
|
'ok' => $appUrlGesetzt,
|
|
'hint' => $appUrlGesetzt
|
|
? null
|
|
: 'app.url oder security.webauthn_rp_id fehlt - der Hardwarekey wird abgewiesen.',
|
|
];
|
|
|
|
// Bewusst KEIN $healthy = false: Eine Anlage ohne Hardwarekeys laeuft damit
|
|
// einwandfrei. Erst wer einen registriert, braucht den Anker - und dann sagt
|
|
// es ihm diese Zeile.
|
|
|
|
// --- Schema vorhanden? ---
|
|
// Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen
|
|
// sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten
|
|
// Migration gibt es dc_tokens nicht - und daran starb die Rechtepruefung
|
|
// weiter unten mit einer PDOException. Ausgerechnet der Endpunkt, der ueber
|
|
// kaputte Zustaende Auskunft geben soll, antwortete dann mit HTTP 500 statt
|
|
// mit healthy=false und einer Begruendung. Betroffen war jede Neuinstallation
|
|
// zwischen Deployment und erster Migration.
|
|
$schemaReady = false;
|
|
if ($db !== null) {
|
|
try {
|
|
$schemaReady = $db->query("SHOW TABLES LIKE 'dc_tokens'")->fetchColumn() !== false;
|
|
} catch (Throwable $e) {
|
|
Logger::error('Health-Check: Schemapruefung fehlgeschlagen', ['error' => $e->getMessage()]);
|
|
}
|
|
|
|
$checks['schema'] = $schemaReady
|
|
? ['ok' => true]
|
|
: [
|
|
'ok' => false,
|
|
'message' => 'Datenbankschema fehlt - die Migrationen wurden noch nicht ausgefuehrt (install_db.php aufrufen).',
|
|
];
|
|
|
|
if (!$schemaReady) {
|
|
$healthy = false;
|
|
}
|
|
}
|
|
|
|
$response = [
|
|
'healthy' => $healthy,
|
|
'app' => Config::get('app.name', 'Deploymentcenter'),
|
|
'version' => Config::get('app.version', 'unknown'),
|
|
'time_utc' => gmdate('c'),
|
|
'checks' => $checks,
|
|
];
|
|
|
|
// --- Detailinformationen nur fuer Authentifizierte ---
|
|
// Die Rechtepruefung selbst bleibt zusaetzlich abgesichert: ein halb
|
|
// migriertes Schema besteht die Pruefung oben, kann hier aber trotzdem ueber
|
|
// eine fehlende Spalte stolpern. Ein Fehlschlag wird wie "nicht
|
|
// authentifiziert" behandelt - dann kommt die Kurzfassung heraus, und die
|
|
// Antwort bleibt auswertbar.
|
|
$authenticated = false;
|
|
if ($db !== null && $schemaReady) {
|
|
try {
|
|
$authenticated = ApiAuth::resolve($db, 'bugtracker:read') !== null;
|
|
} catch (Throwable $e) {
|
|
Logger::warning('Health-Check: Rechtepruefung nicht moeglich', ['error' => $e->getMessage()]);
|
|
$response['checks']['schema'] = [
|
|
'ok' => false,
|
|
'message' => 'Datenbankschema unvollstaendig - die Rechtepruefung war nicht moeglich.',
|
|
];
|
|
$response['healthy'] = false;
|
|
}
|
|
}
|
|
|
|
if ($authenticated) {
|
|
try {
|
|
$status = Migrator::status($db);
|
|
$response['schema'] = [
|
|
'applied_count' => count($status['applied']),
|
|
'pending' => $status['pending'],
|
|
];
|
|
|
|
if ($status['pending'] !== []) {
|
|
$response['healthy'] = false;
|
|
$response['checks']['migrations'] = [
|
|
'ok' => false,
|
|
'message' => 'Ausstehende Migrationen: ' . implode(', ', $status['pending']),
|
|
];
|
|
} else {
|
|
$response['checks']['migrations'] = ['ok' => true];
|
|
}
|
|
|
|
$repo = new BugRepo($db);
|
|
$response['bugtracker'] = $repo->getStats();
|
|
|
|
$monitors = $db->query('
|
|
SELECT state, COUNT(*) AS total
|
|
FROM watchdog_monitors
|
|
GROUP BY state
|
|
')->fetchAll() ?: [];
|
|
|
|
$byState = [];
|
|
foreach ($monitors as $row) {
|
|
$byState[(string)$row['state']] = (int)$row['total'];
|
|
}
|
|
$response['watchdog'] = ['monitors_by_state' => $byState];
|
|
|
|
$lastRun = $db->query("
|
|
SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'evaluator'
|
|
")->fetchColumn();
|
|
|
|
$response['watchdog']['evaluator_last_run_utc'] = $lastRun !== false ? $lastRun : null;
|
|
|
|
// Laeuft der Evaluator nicht, sind alle Monitor-Zustaende wertlos.
|
|
if ($lastRun === false || $lastRun === null) {
|
|
$response['checks']['evaluator'] = [
|
|
'ok' => false,
|
|
'message' => 'Der Evaluator lief noch nie. Cron-Job auf /api/watchdog/v1/evaluate einrichten.',
|
|
];
|
|
} else {
|
|
$age = time() - (int)strtotime((string)$lastRun . ' UTC');
|
|
$response['checks']['evaluator'] = [
|
|
'ok' => $age < 900,
|
|
'age_seconds' => $age,
|
|
'message' => $age < 900 ? null : 'Letzter Lauf liegt zu lange zurueck.',
|
|
];
|
|
}
|
|
} catch (Throwable $e) {
|
|
Logger::warning('Health-Check: Detailabfrage fehlgeschlagen', ['error' => $e->getMessage()]);
|
|
$response['detail_error'] = 'Detailinformationen konnten nicht ermittelt werden.';
|
|
}
|
|
}
|
|
|
|
Http::ok($response, $response['healthy'] ? 200 : 503);
|