diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 73f40bf..692a426 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -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 diff --git a/api/health.php b/api/health.php index 60a8677..6f712f5 100644 --- a/api/health.php +++ b/api/health.php @@ -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); diff --git a/config/config.php b/config/config.php index e084fed..e25b5a9 100644 --- a/config/config.php +++ b/config/config.php @@ -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 diff --git a/config/requirements.php b/config/requirements.php new file mode 100644 index 0000000..d44c6ba --- /dev/null +++ b/config/requirements.php @@ -0,0 +1,28 @@ + '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) + ], +]; diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php index 79e1dbb..3201e29 100644 --- a/middleware/AuthMiddleware.php +++ b/middleware/AuthMiddleware.php @@ -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 * diff --git a/scripts/check_requirements.php b/scripts/check_requirements.php new file mode 100644 index 0000000..3e60ffb --- /dev/null +++ b/scripts/check_requirements.php @@ -0,0 +1,44 @@ +#!/usr/bin/env php += %s); extensions: %s\n", + PHP_VERSION, + $minPhp, + implode(', ', $req['required_extensions']) +); +exit(0);