Files
popertots f9e8385d5a 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)
2026-08-22 00:34:50 +01:00

319 lines
11 KiB
PHP

<?php
/**
* Save as: ./sabredav/server.php
*
* SabreDAV CardDAV and CalDAV server implementation with PostgreSQL
*/
use Sabre\DAV;
use Sabre\CalDAV;
use Sabre\CardDAV;
use Sabre\DAVACL;
require 'vendor/autoload.php';
// PostgreSQL connection
$dbHost = getenv('POSTGRES_HOST') ?: 'postgres';
$dbPort = getenv('POSTGRES_PORT') ?: '5432';
$dbName = getenv('POSTGRES_DB') ?: 'mydb';
$dbUser = getenv('POSTGRES_USER') ?: 'myuser';
$dbPass = getenv('POSTGRES_PASSWORD') ?: 'mypassword';
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $dbHost, $dbPort, $dbName);
try {
$pdo = new PDO($dsn, $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
} catch (PDOException $e) {
error_log("Database connection failed: " . $e->getMessage());
http_response_code(500);
die("Database connection failed");
}
// Create tables if they don't exist
$pdo->exec("
CREATE TABLE IF NOT EXISTS dav_principals (
id SERIAL PRIMARY KEY,
uri VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255),
displayname VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS dav_calendars (
id SERIAL PRIMARY KEY,
principaluri VARCHAR(255) NOT NULL,
displayname VARCHAR(255),
uri VARCHAR(255) NOT NULL,
description TEXT,
calendarorder INT DEFAULT 0,
calendarcolor VARCHAR(10),
timezone TEXT,
components VARCHAR(255),
transparent BOOLEAN DEFAULT FALSE,
UNIQUE(principaluri, uri)
);
CREATE TABLE IF NOT EXISTS dav_calendarobjects (
id SERIAL PRIMARY KEY,
calendardata TEXT,
uri VARCHAR(255) NOT NULL,
calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE,
lastmodified INTEGER,
etag VARCHAR(32),
size INTEGER,
componenttype VARCHAR(8),
firstoccurence INTEGER,
lastoccurence INTEGER,
uid VARCHAR(255),
UNIQUE(calendarid, uri)
);
CREATE TABLE IF NOT EXISTS dav_addressbooks (
id SERIAL PRIMARY KEY,
principaluri VARCHAR(255) NOT NULL,
displayname VARCHAR(255),
uri VARCHAR(255) NOT NULL,
description TEXT,
synctoken INTEGER DEFAULT 1,
UNIQUE(principaluri, uri)
);
CREATE TABLE IF NOT EXISTS dav_cards (
id SERIAL PRIMARY KEY,
addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE,
carddata TEXT,
uri VARCHAR(255) NOT NULL,
lastmodified INTEGER,
etag VARCHAR(32),
size INTEGER,
UNIQUE(addressbookid, uri)
);
CREATE TABLE IF NOT EXISTS dav_addressbookchanges (
id SERIAL PRIMARY KEY,
uri VARCHAR(255) NOT NULL,
synctoken INTEGER NOT NULL,
addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE,
operation SMALLINT NOT NULL
);
CREATE TABLE IF NOT EXISTS dav_calendarchanges (
id SERIAL PRIMARY KEY,
uri VARCHAR(255) NOT NULL,
synctoken INTEGER NOT NULL,
calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE,
operation SMALLINT NOT NULL
);
CREATE TABLE IF NOT EXISTS dav_users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
digesta1 VARCHAR(32) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_calendarobjects_calendarid ON dav_calendarobjects(calendarid);
CREATE INDEX IF NOT EXISTS idx_cards_addressbookid ON dav_cards(addressbookid);
");
// Create default principal if not exists
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_principals");
if ($stmt->fetchColumn() == 0) {
$pdo->exec("
INSERT INTO dav_principals (uri, email, displayname)
VALUES ('principals/default', 'admin@example.com', 'Default User')
");
}
// Create default addressbook if not exists
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_addressbooks");
if ($stmt->fetchColumn() == 0) {
$pdo->exec("
INSERT INTO dav_addressbooks (principaluri, displayname, uri, description, synctoken)
VALUES ('principals/default', 'Contacts', 'default', 'Default address book', 1)
");
}
// Create default calendar if not exists
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_calendars");
if ($stmt->fetchColumn() == 0) {
$pdo->exec("
INSERT INTO dav_calendars (principaluri, displayname, uri, description, components, transparent)
VALUES ('principals/default', 'Default Calendar', 'default', 'Default calendar', 'VEVENT,VTODO', false)
");
}
// Create default user if not exists (username: admin, password from DAV_ADMIN_PASSWORD env).
// DAV_ADMIN_PASSWORD is REQUIRED: this CardDAV/CalDAV server exposes customer
// 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');
// 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)
VALUES ('admin', '$digest')
");
}
// Custom auth backend for PostgreSQL
class PostgreSQLAuthBackend extends DAV\Auth\Backend\AbstractDigest {
protected $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
$this->realm = 'SabreDAV';
}
public function getDigestHash($realm, $username) {
$stmt = $this->pdo->prepare('SELECT digesta1 FROM dav_users WHERE username = ?');
$stmt->execute([$username]);
return $stmt->fetchColumn() ?: null;
}
}
// Custom principal backend for PostgreSQL
class PostgreSQLPrincipalBackend extends DAVACL\PrincipalBackend\AbstractBackend {
protected $pdo;
protected $tableName = 'dav_principals';
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getPrincipalsByPrefix($prefixPath) {
$stmt = $this->pdo->query('SELECT uri, email, displayname FROM ' . $this->tableName);
$principals = [];
while ($row = $stmt->fetch()) {
if (strpos($row['uri'], $prefixPath) !== 0) continue;
$principals[] = [
'uri' => $row['uri'],
'{DAV:}displayname' => $row['displayname'],
'{http://sabredav.org/ns}email-address' => $row['email'],
];
}
return $principals;
}
public function getPrincipalByPath($path) {
$stmt = $this->pdo->prepare('SELECT uri, email, displayname FROM ' . $this->tableName . ' WHERE uri = ?');
$stmt->execute([$path]);
$row = $stmt->fetch();
if (!$row) return null;
return [
'uri' => $row['uri'],
'{DAV:}displayname' => $row['displayname'],
'{http://sabredav.org/ns}email-address' => $row['email'],
];
}
public function updatePrincipal($path, $mutations) {
return 0;
}
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
return [];
}
public function getGroupMemberSet($principal) {
return [];
}
public function getGroupMembership($principal) {
return [];
}
public function setGroupMemberSet($principal, array $members) {
throw new DAV\Exception('Setting group members is not supported');
}
}
// Backends
$authBackend = new PostgreSQLAuthBackend($pdo);
$principalBackend = new PostgreSQLPrincipalBackend($pdo);
// Use SabreDAV's PostgreSQL backends for CardDAV and CalDAV
$carddavBackend = new CardDAV\Backend\PDO($pdo);
$carddavBackend->tableName = 'dav_cards';
$carddavBackend->addressBooksTableName = 'dav_addressbooks';
$carddavBackend->addressBookChangesTableName = 'dav_addressbookchanges';
$caldavBackend = new CalDAV\Backend\PDO($pdo);
$caldavBackend->tableName = 'dav_calendarobjects';
$caldavBackend->calendarTableName = 'dav_calendars';
$caldavBackend->calendarChangesTableName = 'dav_calendarchanges';
// Directory structure
$tree = [
new DAVACL\PrincipalCollection($principalBackend),
new CalDAV\CalendarRoot($principalBackend, $caldavBackend),
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
];
$server = new DAV\Server($tree);
$server->setBaseUri('/dav/');
// Plugins
$server->addPlugin(new DAV\Auth\Plugin($authBackend));
$server->addPlugin(new DAVACL\Plugin());
$server->addPlugin(new DAV\Browser\Plugin());
$server->addPlugin(new CalDAV\Plugin());
$server->addPlugin(new CardDAV\Plugin());
$server->addPlugin(new CalDAV\Schedule\Plugin());
$server->addPlugin(new DAV\Sync\Plugin());
// Run the server
$server->exec();