fix: auth/2FA security — stdout-log code delivery is dev/test-only, production fails closed until email/SMS; verification-code hashing, lockout recovery, sabredav fail-closed

- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak
- verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle
- sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD
- delete-account re-auth (current_password + fresh 2FA code when enforced)
- prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check
- startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals
- env-docs parity updated (flag removed, 38 vars)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 1429eddd34
commit f9e8385d5a
19 changed files with 1047 additions and 472 deletions
+44 -3
View File
@@ -149,14 +149,55 @@ if ($stmt->fetchColumn() == 0) {
// PII (vCards), so a default or publicly-known admin credential is never
// acceptable — fail fast instead of starting with one.
$davPassword = getenv('DAV_ADMIN_PASSWORD');
$weakDavPasswords = ['admin', 'password', 'changeme', 'change-me', 'changethis', 'secret', 'sabredav', 'test'];
if ($davPassword === false || $davPassword === '' || in_array(strtolower(trim($davPassword)), $weakDavPasswords, true)) {
error_log("FATAL: DAV_ADMIN_PASSWORD is not set or is a known weak/default value. Refusing to start: set a strong random DAV_ADMIN_PASSWORD (e.g. `openssl rand -hex 32`) in the environment and restart.");
// Known weak/default values — INCLUDING compound placeholders like the one
// shipped in .env.example (changeme-admin-password). The old list only had the
// bare words, so `cp .env.example .env` booted CardDAV with a
// publicly-documented admin credential exposing every customer vCard
// (name/email/phone/DOB). Exact-match entries catch the documented
// placeholders; the entropy/length gate below catches everything else a
// copy-paste deployment could ship.
$weakDavPasswords = [
'admin', 'password', 'changeme', 'change-me', 'changethis', 'secret',
'sabredav', 'test',
// Compound placeholders (documented in .env.example / READMEs / attack tooling).
'changeme-admin-password', 'changeme-admin', 'change-me-admin',
'change-me-admin-password', 'changethis-admin-password', 'admin-password',
'sabredav-admin', 'sabredav-password', 'test-admin', 'test-password',
'password123', 'admin123', 'adminadmin', 'passwordpassword',
];
// davPasswordIsWeak reports whether a DAV_ADMIN_PASSWORD is a known
// placeholder, shorter than the minimum safe length, or lacks sufficient
// entropy. Mirrors isWeakJWTSecret in backend/main.go: length alone is not
// enough ("aaaaaaaaaaaaaaaa" passes a length check but has trivial key space).
// The bars here (>= 16 chars, >= 8 distinct characters) are deliberate: the
// goal is to refuse copy-paste defaults, not to gate a genuinely random secret
// (even pure hex, which can use at most 16 distinct chars, clears the 8-char bar).
function davPasswordIsWeak($password) {
$trimmed = strtolower(trim($password));
if (strlen($trimmed) < 16) {
return true;
}
global $weakDavPasswords;
if (in_array($trimmed, $weakDavPasswords, true)) {
return true;
}
$distinct = count_chars($trimmed, 3); // returns only chars present
return strlen($distinct) < 8;
}
if ($davPassword === false || $davPassword === '' || davPasswordIsWeak($davPassword)) {
error_log("FATAL: DAV_ADMIN_PASSWORD is not set or is a known weak/default value (it must be at least 16 characters, use at least 8 distinct characters, and not be a documented placeholder). Refusing to start: set a strong random DAV_ADMIN_PASSWORD (e.g. `openssl rand -hex 32`) in the environment and restart.");
http_response_code(500);
die("DAV_ADMIN_PASSWORD is not configured");
}
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_users");
if ($stmt->fetchColumn() == 0) {
// digesta1 = md5(username:realm:password). SabreDAV 4.7.0's HTTP digest
// auth supports ONLY the RFC 2617 MD5 A1 form (see vendor sabre/http
// Auth/Digest.php validateA1) — a SHA-256 upgrade would require patching
// the vendored library, so it is intentionally NOT attempted here. The MD5
// residual is bounded by the strong-password gate above: the stored value
// is a salted-by-realm MD5 of a high-entropy admin password, so a DB leak
// cannot yield the credential via a dictionary attack.
$digest = md5('admin:SabreDAV:' . $davPassword);
$pdo->exec("
INSERT INTO dav_users (username, digesta1)