fix: harden CMS security controls

This commit is contained in:
Andy Dixon
2026-06-08 17:12:42 +01:00
parent 42fe5b9304
commit a636ecd991
297 changed files with 174942 additions and 7485 deletions
+162 -32
View File
@@ -2,33 +2,70 @@
namespace NeoCMS;
/**
* Manages session authentication, role capabilities, and CSRF tokens.
*
* Credentials remain configuration-backed to preserve NeoCMS's database-free design. Only hashes
* created by password_hash() are accepted; plaintext credentials are rejected.
*/
class Authentication
{
/** Valid bcrypt hash used solely to equalise unknown-user password verification work. */
private const DUMMY_PASSWORD_HASH = '$2y$10$NaiFDtsxcQzcxAiQrTmEPuDde8sDsdpjAlSgoiYwAvdnF5Cwytute';
/** Map of usernames to password hashes. */
private array $credentials;
public function __construct(array $credentials)
/** Map of usernames to editor, publisher, or administrator roles. */
private array $roles;
/** Maximum permitted inactivity for an authenticated session. */
private int $idleTimeout;
/** Maximum total lifetime of an authenticated session. */
private int $absoluteTimeout;
/** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */
private ?bool $cookieSecure;
/** Initialise secure session handling and retain the configured account maps. */
public function __construct(array $credentials, array $roles = [], array $sessionOptions = [])
{
$this->startSession();
$this->credentials = $credentials;
$this->roles = $roles;
$this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800));
$this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200));
$this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null;
$this->startSession();
$this->enforceSessionLifetime();
}
/**
* Start the PHP session with conservative cookie settings.
*
* HTTPS cookies are marked Secure when the request is encrypted. SameSite=Lax protects common
* cross-site request scenarios without making ordinary login redirects needlessly dramatic.
*/
private function startSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443);
$isHttps = $this->cookieSecure ?? (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
);
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_samesite', 'Lax');
session_name('NEOCMSSESSID');
session_set_cookie_params([
'httponly' => true,
'path' => '/',
'path' => '/cms',
'samesite' => 'Lax',
'secure' => $isHttps,
]);
@@ -37,43 +74,84 @@ class Authentication
}
/**
* Logs a user into the CMS
* @param $username
* @param $password
* @return bool
* Authenticate a user and establish a fresh privileged session.
*
* @param mixed $username Submitted account name.
* @param mixed $password Submitted plaintext password.
*/
public function login($username, $password)
public function login($username, $password): bool
{
if ($this->credentialsAreValid((string) $username, (string) $password)) {
$username = (string) $username;
if ($this->credentialsAreValid($username, (string) $password)) {
// Rotate the identifier at the privilege boundary to prevent session fixation.
session_regenerate_id(true);
$_SESSION['loggedIn'] = true;
$_SESSION['loggedInUser'] = $username;
$configuredRole = $this->roles[$username] ?? 'editor';
$_SESSION['role'] = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
? $configuredRole
: 'editor';
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
$_SESSION['authenticatedAt'] = time();
$_SESSION['lastActivityAt'] = time();
$_SESSION['lastRegeneratedAt'] = time();
return true;
}
$_SESSION['loggedIn'] = false;
unset(
$_SESSION['loggedIn'],
$_SESSION['loggedInUser'],
$_SESSION['role'],
$_SESSION['credentialFingerprint'],
$_SESSION['authenticatedAt'],
$_SESSION['lastActivityAt'],
$_SESSION['lastRegeneratedAt']
);
return false;
}
/**
* Checks if a user has logged in
* @return bool
*/
/** Return whether the current session belongs to an authenticated user. */
public function isLoggedIn(): bool
{
return $_SESSION['loggedIn'] ?? false;
}
/**
* Returns the user logged in
* @return string
*/
/** Return the current username, or a safe label when no user is authenticated. */
public function getLoggedInUser(): string
{
return $_SESSION['loggedInUser'] ?? 'Not logged in';
}
/** Return the session role, defaulting unauthorised sessions to the least powerful role. */
public function getRole(): string
{
$username = $this->getLoggedInUser();
$configuredRole = $this->roles[$username] ?? 'editor';
$role = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
? $configuredRole
: 'editor';
$_SESSION['role'] = $role;
return $role;
}
/**
* Check whether the current role includes a named capability.
*
* Capability checks live here so controllers and upload handlers share one policy table.
*/
public function can(string $capability): bool
{
$permissions = [
'editor' => ['draft', 'upload'],
'publisher' => ['draft', 'upload', 'publish', 'schedule'],
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
];
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
}
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
public function getCsrfToken(): string
{
if (empty($_SESSION['csrfToken'])) {
@@ -83,6 +161,7 @@ class Authentication
return $_SESSION['csrfToken'];
}
/** Compare a submitted CSRF token in constant time to avoid leaking useful timing information. */
public function isValidCsrfToken(?string $token): bool
{
if (!is_string($token) || $token === '') {
@@ -92,34 +171,85 @@ class Authentication
return hash_equals($this->getCsrfToken(), $token);
}
/**
* Destroy the session
* @return void
*/
public function logout()
/** Clear session data, expire its cookie, and destroy the server-side session. */
public function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => 'Lax',
]);
}
session_destroy();
}
/**
* Validate submitted credentials against a password_hash() value.
*/
private function credentialsAreValid(string $username, string $password): bool
{
$storedCredential = $this->credentials[$username] ?? null;
if (!is_string($storedCredential) || $storedCredential === '') {
return false;
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
$configuredHashIsValid = is_string($storedCredential)
&& $storedCredential !== ''
&& ($passwordInfo['algoName'] ?? 'unknown') !== 'unknown';
$verificationHash = $configuredHashIsValid ? $storedCredential : self::DUMMY_PASSWORD_HASH;
$verified = password_verify($password, $verificationHash);
return $configuredHashIsValid && $verified;
}
/** Expire stale sessions and periodically rotate active session identifiers. */
private function enforceSessionLifetime(): void
{
if (empty($_SESSION['loggedIn'])) {
return;
}
$passwordInfo = password_get_info($storedCredential);
if (($passwordInfo['algo'] ?? null) !== null) {
return password_verify($password, $storedCredential);
$now = time();
$username = is_string($_SESSION['loggedInUser'] ?? null) ? $_SESSION['loggedInUser'] : '';
$storedCredential = $this->credentials[$username] ?? null;
$passwordInfo = is_string($storedCredential) ? password_get_info($storedCredential) : [];
if (!is_string($storedCredential) || ($passwordInfo['algoName'] ?? 'unknown') === 'unknown') {
$this->invalidateSession();
return;
}
$fingerprint = hash('sha256', $storedCredential);
if (isset($_SESSION['credentialFingerprint']) && !hash_equals((string) $_SESSION['credentialFingerprint'], $fingerprint)) {
$this->invalidateSession();
return;
}
$_SESSION['credentialFingerprint'] = $fingerprint;
$_SESSION['authenticatedAt'] = (int) ($_SESSION['authenticatedAt'] ?? $now);
$_SESSION['lastActivityAt'] = (int) ($_SESSION['lastActivityAt'] ?? $now);
$_SESSION['lastRegeneratedAt'] = (int) ($_SESSION['lastRegeneratedAt'] ?? $now);
$idleExpired = $now - $_SESSION['lastActivityAt'] > $this->idleTimeout;
$absoluteExpired = $now - $_SESSION['authenticatedAt'] > $this->absoluteTimeout;
if ($idleExpired || $absoluteExpired) {
$this->invalidateSession();
return;
}
return hash_equals($storedCredential, $password);
if ($now - $_SESSION['lastRegeneratedAt'] > 900) {
session_regenerate_id(true);
$_SESSION['lastRegeneratedAt'] = $now;
}
$_SESSION['lastActivityAt'] = $now;
}
/** Clear privileged session state and rotate the identifier after expiry or account revocation. */
private function invalidateSession(): void
{
$_SESSION = [];
session_regenerate_id(true);
}
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace NeoCMS;
/**
* Persists NeoCMS metadata as human-readable JSON and provides managed subdirectories.
*
* Public pages remain ordinary HTML files. This store contains only the supporting cast: drafts,
* revisions, schedules, menus, media metadata, and activity. No database server need feel left out.
*/
final class FileStore
{
/** Absolute data-directory path, always ending with the platform separator. */
private string $root;
/**
* Prepare the configured data directory.
*
* @param string $root Absolute or project-relative storage path.
*/
public function __construct(string $root)
{
$this->root = rtrim($root, '/\\') . DIRECTORY_SEPARATOR;
$this->ensureDirectory($this->root);
}
/**
* Read a named JSON document, returning the supplied default when it does not yet exist.
* Invalid JSON is treated as absent data so callers always receive the promised array shape.
*
* @param string $name Logical document name without the .json suffix.
* @param array $default Value returned for a missing or unreadable document.
* @return array Decoded document contents.
*/
public function read(string $name, array $default = []): array
{
$path = $this->jsonPath($name);
if (!is_file($path)) {
return $default;
}
$value = json_decode((string) file_get_contents($path), true);
return is_array($value) ? $value : $default;
}
/**
* Atomically replace a named JSON document.
*
* Data is written to a temporary neighbour and renamed only after the write succeeds. Readers
* therefore see either the old document or the complete new one, never half a JSON sandwich.
*
* @param string $name Logical document name without the .json suffix.
* @param array $value Serializable document contents.
*/
public function write(string $name, array $value): void
{
$path = $this->jsonPath($name);
$temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
$json = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false || file_put_contents($temporary, $json . "\n", LOCK_EX) === false) {
throw new \RuntimeException('Unable to write CMS data');
}
@chmod($temporary, 0600);
if (!rename($temporary, $path)) {
@unlink($temporary);
throw new \RuntimeException('Unable to replace CMS data');
}
@chmod($path, 0600);
}
/** Atomically write a private content file beneath a managed subdirectory. */
public function writePrivateFile(string $directory, string $filename, string $content): string
{
$target = $this->directory($directory) . basename($filename);
$temporary = $target . '.' . bin2hex(random_bytes(6)) . '.tmp';
if (file_put_contents($temporary, $content, LOCK_EX) === false) {
throw new \RuntimeException('Unable to write private CMS file');
}
@chmod($temporary, 0600);
if (!rename($temporary, $target)) {
@unlink($temporary);
throw new \RuntimeException('Unable to replace private CMS file');
}
@chmod($target, 0600);
return $target;
}
/**
* Return a managed subdirectory, creating it when first required.
*
* @param string $name Relative directory name such as drafts or revisions.
*/
public function directory(string $name): string
{
$path = $this->root . trim($name, '/\\') . DIRECTORY_SEPARATOR;
$this->ensureDirectory($path);
return $path;
}
/** Convert a logical document name into a safe JSON path beneath the data root. */
private function jsonPath(string $name): string
{
return $this->root . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.json';
}
/** Create a directory recursively or fail with a useful application-level exception. */
private function ensureDirectory(string $path): void
{
if (!is_dir($path) && !mkdir($path, 0700, true) && !is_dir($path)) {
throw new \RuntimeException('Unable to create CMS data directory');
}
@chmod($path, 0700);
}
}
+76 -8
View File
@@ -1,31 +1,57 @@
<?php
namespace NeoCMS;
/** Writes simple, daily, append-only audit logs for operational traceability. */
class Logger
{
/** Whether audit writes are enabled by configuration. */
private $auditEnabled;
/** Absolute directory containing the dated audit files. */
private $logsDir;
public function __construct($auditEnabled)
/** Maximum size of one active daily log before it is rotated. */
private $maxFileBytes;
/** Number of days for which audit logs are retained. */
private $retentionDays;
/**
* Configure logging and ensure its destination exists.
*
* @param mixed $auditEnabled Truthy values enable audit output.
* @param array<string, mixed> $security Security limits shared with the rest of the CMS.
*/
public function __construct($auditEnabled, array $security = [])
{
$this->auditEnabled = $auditEnabled;
// Conservative defaults prevent a forgotten audit directory from becoming its own archive service.
$this->maxFileBytes = max(1024, (int) ($security['auditMaxFileBytes'] ?? 10 * 1024 * 1024));
$this->retentionDays = max(1, (int) ($security['auditRetentionDays'] ?? 90));
$this->logsDir = __DIR__ . '/../../logs';
$this->logsDir = rtrim($this->logsDir, '/\\') . DIRECTORY_SEPARATOR;
// Ensure the logs directory exists
// Create the directory lazily so a fresh installation needs no ceremonial folder setup.
if (!is_dir($this->logsDir)) {
if (!mkdir($this->logsDir, 0755, true)) {
if (!mkdir($this->logsDir, 0700, true)) {
throw new \Exception('Failed to create logs directory: ' . $this->logsDir);
}
}
@chmod($this->logsDir, 0700);
if ($this->auditEnabled) {
$this->pruneExpiredLogs();
}
}
/**
* Write an entry to the log
* @param $message
* @param $user
* @return void
* Append a timestamped user action to today's audit file.
*
* @param mixed $message Human-readable action description.
* @param mixed $user Username responsible for the action.
*/
public function write($message, $user): void
{
@@ -33,7 +59,9 @@ class Logger
return;
}
// One file per day keeps inspection and rotation pleasantly unsurprising.
$logFile = $this->logsDir . date('Y-m-d') . '-audit.txt';
$this->rotateFullLog($logFile);
$entry = sprintf(
"[%s]\tUser: %s\t%s\n",
date('Y-m-d H:i:s'),
@@ -41,9 +69,49 @@ class Logger
$this->normaliseLogField($message)
);
file_put_contents($logFile, $entry, FILE_APPEND);
if (file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX) === false) {
throw new \RuntimeException('Unable to write audit log');
}
@chmod($logFile, 0600);
}
/**
* Move a full daily log aside before appending another entry.
*
* @param string $logFile Absolute path to today's active log.
*/
private function rotateFullLog(string $logFile): void
{
clearstatcache(true, $logFile);
if (!is_file($logFile) || (int) filesize($logFile) < $this->maxFileBytes) {
return;
}
$rotated = $this->logsDir . date('Y-m-d-His') . '-' . bin2hex(random_bytes(3)) . '-audit.txt';
if (!rename($logFile, $rotated)) {
throw new \RuntimeException('Unable to rotate audit log');
}
@chmod($rotated, 0600);
}
/** Remove audit files older than the configured retention period. */
private function pruneExpiredLogs(): void
{
$cutoff = time() - ($this->retentionDays * 86400);
$logFiles = glob($this->logsDir . '*-audit.txt') ?: [];
foreach ($logFiles as $logFile) {
if (is_file($logFile) && (int) filemtime($logFile) < $cutoff && !unlink($logFile)) {
throw new \RuntimeException('Unable to remove expired audit log');
}
}
}
/**
* Remove control characters that could forge extra audit entries or columns.
*
* @param mixed $value Value destined for one tab-separated log field.
*/
private function normaliseLogField($value): string
{
$value = trim((string) $value);
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace NeoCMS;
/**
* Provides filesystem-backed throttling for failed login attempts.
*
* Limits are applied to both the source address and the source-address/username pair. This slows
* password guessing without allowing one remote address to lock an account for everybody else.
*/
final class LoginRateLimiter
{
private string $path;
private int $windowSeconds;
private int $lockoutSeconds;
private int $maxIdentityAttempts;
private int $maxAddressAttempts;
/** Configure the protected state file and throttling thresholds. */
public function __construct(string $dataDirectory, array $options = [])
{
$directory = rtrim($dataDirectory, '/\\') . DIRECTORY_SEPARATOR;
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new \RuntimeException('Unable to create login security directory');
}
@chmod($directory, 0700);
$this->path = $directory . 'login-attempts.json';
$this->windowSeconds = max(60, (int) ($options['loginWindowSeconds'] ?? 900));
$this->lockoutSeconds = max(60, (int) ($options['loginLockoutSeconds'] ?? 900));
$this->maxIdentityAttempts = max(1, (int) ($options['loginMaxAttempts'] ?? 5));
$this->maxAddressAttempts = max($this->maxIdentityAttempts, (int) ($options['loginMaxAddressAttempts'] ?? 20));
}
/** Return the remaining lockout in seconds, or zero when another attempt is permitted. */
public function retryAfter(string $address, string $username): int
{
return $this->withState(function (array &$state) use ($address, $username): int {
$now = time();
$this->prune($state, $now);
$retryAt = max(
(int) ($state[$this->addressKey($address)]['locked_until'] ?? 0),
(int) ($state[$this->identityKey($address, $username)]['locked_until'] ?? 0)
);
return max(0, $retryAt - $now);
});
}
/** Record one denied login and lock a bucket once its configured threshold is reached. */
public function recordFailure(string $address, string $username): void
{
$this->withState(function (array &$state) use ($address, $username): void {
$now = time();
$this->prune($state, $now);
$this->increment($state, $this->addressKey($address), $this->maxAddressAttempts, $now);
$this->increment($state, $this->identityKey($address, $username), $this->maxIdentityAttempts, $now);
});
}
/** Clear successful login buckets so typographical mistakes do not linger unnecessarily. */
public function clear(string $address, string $username): void
{
$this->withState(function (array &$state) use ($address, $username): void {
unset($state[$this->addressKey($address)], $state[$this->identityKey($address, $username)]);
$this->prune($state, time());
});
}
/** Update one attempt bucket and begin a fixed lockout when its threshold is reached. */
private function increment(array &$state, string $key, int $limit, int $now): void
{
$record = $state[$key] ?? ['attempts' => [], 'locked_until' => 0];
$record['attempts'][] = $now;
if (count($record['attempts']) >= $limit) {
$record['locked_until'] = $now + $this->lockoutSeconds;
$record['attempts'] = [];
}
$state[$key] = $record;
}
/** Remove expired attempts and stale unlocked buckets to keep the state file bounded. */
private function prune(array &$state, int $now): void
{
$cutoff = $now - $this->windowSeconds;
foreach ($state as $key => &$record) {
$record['attempts'] = array_values(array_filter(
is_array($record['attempts'] ?? null) ? $record['attempts'] : [],
static fn($timestamp): bool => is_int($timestamp) && $timestamp >= $cutoff
));
$record['locked_until'] = (int) ($record['locked_until'] ?? 0);
if ($record['attempts'] === [] && $record['locked_until'] <= $now) {
unset($state[$key]);
}
}
unset($record);
}
/** Serialise state updates beneath an exclusive lock to avoid concurrent lost updates. */
private function withState(callable $callback)
{
$handle = fopen($this->path, 'c+');
if ($handle === false || !flock($handle, LOCK_EX)) {
if (is_resource($handle)) {
fclose($handle);
}
throw new \RuntimeException('Unable to access login security state');
}
@chmod($this->path, 0600);
try {
rewind($handle);
$decoded = json_decode((string) stream_get_contents($handle), true);
$state = is_array($decoded) ? $decoded : [];
$result = $callback($state);
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new \RuntimeException('Unable to encode login security state');
}
rewind($handle);
if (!ftruncate($handle, 0) || fwrite($handle, $json . "\n") === false || !fflush($handle)) {
throw new \RuntimeException('Unable to write login security state');
}
@chmod($this->path, 0600);
return $result;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
/** Hash address data so the throttling file does not become a convenient IP-address register. */
private function addressKey(string $address): string
{
return 'address:' . hash('sha256', $address);
}
/** Hash the combined address and case-normalised username for the tighter identity bucket. */
private function identityKey(string $address, string $username): string
{
return 'identity:' . hash('sha256', $address . "\0" . strtolower($username));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace NeoCMS;
/** Applies consistent browser security and cache-control headers to CMS responses. */
final class SecurityHeaders
{
/** Protect an HTML administration page, optionally allowing the pinned UI CDNs. */
public static function html(bool $allowUiCdns = false, ?bool $secureTransport = null): void
{
self::common($secureTransport);
header('Cache-Control: no-store, private');
header('Pragma: no-cache');
$scriptSources = $allowUiCdns
? "'self' https://code.jquery.com"
: "'self'";
$styleSources = $allowUiCdns
? "'self' 'unsafe-inline' https://code.jquery.com"
: "'self'";
header("Content-Security-Policy: default-src 'self'; script-src {$scriptSources}; style-src {$styleSources}; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
}
/** Protect JSON API responses and prevent authenticated data from being cached. */
public static function json(?bool $secureTransport = null): void
{
self::common($secureTransport);
header('Cache-Control: no-store, private');
header('Pragma: no-cache');
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
header('Content-Type: application/json');
}
/** Emit headers shared by both document and API responses. */
private static function common(?bool $secureTransport): void
{
header_remove('X-Powered-By');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()');
header('Cross-Origin-Opener-Policy: same-origin');
header('Cross-Origin-Resource-Policy: same-origin');
$isHttps = $secureTransport ?? (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
);
if ($isHttps) {
header('Strict-Transport-Security: max-age=31536000');
}
}
}