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
|
- name: Run ESLint
|
||||||
run: npx eslint assets/js/
|
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:
|
deploy:
|
||||||
name: Deploy
|
name: Deploy
|
||||||
runs-on: ubuntu-latest
|
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')
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -77,7 +94,7 @@ jobs:
|
|||||||
notify-failure:
|
notify-failure:
|
||||||
name: Notify on failure
|
name: Notify on failure
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [php-lint, js-lint]
|
needs: [php-lint, js-lint, requirements]
|
||||||
if: failure() && github.event_name == 'push'
|
if: failure() && github.event_name == 'push'
|
||||||
steps:
|
steps:
|
||||||
- name: Send Matrix alert
|
- 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
|
// Calculate response time
|
||||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ $GLOBALS['config'] = [
|
|||||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
'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
|
// URL settings
|
||||||
'BASE_URL' => '', // Empty since we're serving from document root
|
'BASE_URL' => '', // Empty since we're serving from document root
|
||||||
'ASSETS_URL' => '/assets', // Assets URL
|
'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
|
// Read Authelia forward auth headers
|
||||||
$username = $this->getHeader('HTTP_REMOTE_USER');
|
$username = $this->getHeader('HTTP_REMOTE_USER');
|
||||||
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
||||||
@@ -136,6 +142,33 @@ class AuthMiddleware
|
|||||||
return $user;
|
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
|
* 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