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:
+44
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user