- 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)
319 lines
11 KiB
PHP
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(); |