Add trusted-proxy auth hardening + PHP requirements checks
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 52s
Security / PHP Security (semgrep) (push) Successful in 2m6s
Lint / Deploy (push) Successful in 13s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 52s
Security / PHP Security (semgrep) (push) Successful in 2m6s
Lint / Deploy (push) Successful in 13s
Lint / Notify on failure (push) Has been skipped
Trusted-proxy hardening (defense-in-depth for Authelia forward-auth): - AuthMiddleware now only honors Remote-* identity headers when REMOTE_ADDR is in a configured TRUSTED_PROXIES allowlist; otherwise it refuses with 403 and logs an 'untrusted_proxy' security event. Previously anything that could reach PHP directly could spoof Remote-User/Remote-Groups and log in as admin. - New config TRUSTED_PROXIES (comma-separated, from .env). Empty = enforcement off, so this is backward compatible until the allowlist is set on a host. Requirements checks (so a PHP upgrade dropping an extension can't silently break features like avatars again): - config/requirements.php: single source of truth for min PHP version and required extensions (ldap, mysqli, curl, mbstring, fileinfo, json). - scripts/check_requirements.php: CI script that fails the build if the environment doesn't satisfy them. - New 'requirements' CI job installs those extensions and runs the check; deploy now depends on it. - api/health.php: adds php_extensions + php_version checks so production monitoring surfaces the drift (returns 503 if a required extension is gone). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -35,10 +35,27 @@ jobs:
|
||||
- name: Run ESLint
|
||||
run: npx eslint assets/js/
|
||||
|
||||
requirements:
|
||||
name: PHP requirements (version + extensions)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install PHP with required extensions
|
||||
run: |
|
||||
apt-get update -qq
|
||||
# Install the extensions declared in config/requirements.php so the
|
||||
# check verifies they are actually installable + loadable, and so this
|
||||
# build fails if a required extension can't be provided.
|
||||
apt-get install -y -qq php-cli php-ldap php-mysql php-curl php-mbstring
|
||||
|
||||
- name: Verify runtime requirements
|
||||
run: php scripts/check_requirements.php
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php-lint, js-lint]
|
||||
needs: [php-lint, js-lint, requirements]
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -77,7 +94,7 @@ jobs:
|
||||
notify-failure:
|
||||
name: Notify on failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php-lint, js-lint]
|
||||
needs: [php-lint, js-lint, requirements]
|
||||
if: failure() && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Send Matrix alert
|
||||
|
||||
@@ -95,6 +95,40 @@ if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
||||
];
|
||||
}
|
||||
|
||||
// Check 5: Required PHP extensions (catches e.g. a PHP upgrade silently
|
||||
// dropping php-ldap, which breaks avatars with no other visible error).
|
||||
$requirements = require dirname(__DIR__) . '/config/requirements.php';
|
||||
$missingExt = array_values(array_filter(
|
||||
$requirements['required_extensions'],
|
||||
fn($ext) => !extension_loaded($ext)
|
||||
));
|
||||
if (empty($missingExt)) {
|
||||
$checks['php_extensions'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'All required extensions loaded'
|
||||
];
|
||||
} else {
|
||||
$checks['php_extensions'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Missing extensions: ' . implode(', ', $missingExt)
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 6: PHP version meets the declared minimum
|
||||
if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
|
||||
$checks['php_version'] = [
|
||||
'status' => 'ok',
|
||||
'message' => PHP_VERSION
|
||||
];
|
||||
} else {
|
||||
$checks['php_version'] = [
|
||||
'status' => 'error',
|
||||
'message' => sprintf('PHP %s < required %s', PHP_VERSION, $requirements['min_php_version'])
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
|
||||
@@ -60,6 +60,16 @@ $GLOBALS['config'] = [
|
||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||
|
||||
// Trusted reverse proxies. Authelia forward-auth (Remote-* headers) is only
|
||||
// honored when REMOTE_ADDR is in this allowlist, so the spoofable identity
|
||||
// headers can't be set by anything that reaches PHP directly. Comma-separated
|
||||
// IPs in .env (e.g. TRUSTED_PROXIES=10.10.10.27). Empty = enforcement OFF
|
||||
// (backward compatible — relies solely on network topology).
|
||||
'TRUSTED_PROXIES' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string)($envVars['TRUSTED_PROXIES'] ?? ''))
|
||||
), fn($ip) => $ip !== '')),
|
||||
|
||||
// URL settings
|
||||
'BASE_URL' => '', // Empty since we're serving from document root
|
||||
'ASSETS_URL' => '/assets', // Assets URL
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Runtime requirements — single source of truth.
|
||||
*
|
||||
* Consumed by:
|
||||
* - scripts/check_requirements.php (CI: fails the build if unmet)
|
||||
* - api/health.php (production: surfaces drift to monitoring)
|
||||
*
|
||||
* This exists because a PHP upgrade once silently dropped the ldap extension,
|
||||
* which broke avatars with no visible error. Keep this list in sync with the
|
||||
* extensions the code actually relies on.
|
||||
*/
|
||||
|
||||
return [
|
||||
// Minimum supported PHP version (production runs 8.4).
|
||||
'min_php_version' => '8.2',
|
||||
|
||||
// Extensions the application requires to function.
|
||||
'required_extensions' => [
|
||||
'ldap', // api/user_avatar.php — lldap avatar lookups
|
||||
'mysqli', // helpers/Database.php — all data access
|
||||
'curl', // helpers/NotificationHelper.php, SynapseHelper.php — Matrix
|
||||
'mbstring', // multibyte string handling
|
||||
'fileinfo', // api/upload_attachment.php — MIME validation
|
||||
'json', // request/response encoding (bundled, but assert anyway)
|
||||
],
|
||||
];
|
||||
@@ -96,6 +96,12 @@ class AuthMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
// Only honor Authelia forward-auth headers from a trusted reverse proxy.
|
||||
// Without this, anything that can reach PHP directly could spoof
|
||||
// Remote-User / Remote-Groups and log in (as admin). No valid session
|
||||
// exists at this point, so we are about to trust request headers.
|
||||
$this->enforceTrustedProxy();
|
||||
|
||||
// Read Authelia forward auth headers
|
||||
$username = $this->getHeader('HTTP_REMOTE_USER');
|
||||
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
||||
@@ -136,6 +142,33 @@ class AuthMiddleware
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject forward-auth headers that did not arrive via a trusted proxy.
|
||||
*
|
||||
* If TRUSTED_PROXIES is configured and the connecting REMOTE_ADDR is not in
|
||||
* the allowlist, the Remote-* headers cannot be trusted, so we refuse rather
|
||||
* than honor a potentially spoofed identity. Empty allowlist = disabled.
|
||||
*/
|
||||
private function enforceTrustedProxy(): void
|
||||
{
|
||||
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
||||
if (empty($trusted)) {
|
||||
return; // Enforcement disabled (no allowlist configured)
|
||||
}
|
||||
|
||||
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if (!in_array($remoteAddr, $trusted, true)) {
|
||||
$this->logSecurityEvent('untrusted_proxy', [
|
||||
'reason' => 'Remote-* auth headers from non-allowlisted source',
|
||||
'remote_addr' => $remoteAddr ?: 'unknown'
|
||||
]);
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Forbidden: authentication headers must arrive via a trusted proxy.';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get header value from server variables
|
||||
*
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Verify the running PHP environment meets the declared runtime requirements.
|
||||
*
|
||||
* Reads config/requirements.php and checks the PHP version and that every
|
||||
* required extension is loaded. Exits non-zero (failing CI) on any miss.
|
||||
*
|
||||
* Usage: php scripts/check_requirements.php
|
||||
*/
|
||||
|
||||
$req = require __DIR__ . '/../config/requirements.php';
|
||||
|
||||
$errors = [];
|
||||
|
||||
// PHP version
|
||||
$minPhp = $req['min_php_version'];
|
||||
if (version_compare(PHP_VERSION, $minPhp, '<')) {
|
||||
$errors[] = sprintf('PHP %s is below the required minimum %s', PHP_VERSION, $minPhp);
|
||||
}
|
||||
|
||||
// Required extensions
|
||||
foreach ($req['required_extensions'] as $ext) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$errors[] = sprintf('Missing required PHP extension: %s', $ext);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
fwrite(STDERR, "Requirement check FAILED:\n");
|
||||
foreach ($errors as $err) {
|
||||
fwrite(STDERR, ' - ' . $err . "\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
printf(
|
||||
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
||||
PHP_VERSION,
|
||||
$minPhp,
|
||||
implode(', ', $req['required_extensions'])
|
||||
);
|
||||
exit(0);
|
||||
Reference in New Issue
Block a user