# 🔒 Security Vulnerability Analysis — posterg-website > **Date:** 2026-02-08 > **Scope:** Full static code review of all PHP, nginx config, and deployment files. > **Analyst:** Claude Code --- ## Good Practices Already in Place - ✅ SQL injection: all queries use PDO prepared statements consistently - ✅ XSS output: `htmlspecialchars()` applied on all user-controlled output - ✅ CSRF: tokens generated with `bin2hex(random_bytes(32))` and validated with `hash_equals()` (timing-safe) on all state-changing forms - ✅ File upload: MIME type validated with `finfo` for cover images and thesis files - ✅ Input validation: year, IDs, and pagination values cast to integers - ✅ LIKE wildcard escaping implemented in public search (`Database::escapeLikeString`) --- ## 🔴 CRITICAL ### 1. Admin HTTP Basic Auth Over Plain HTTP (No TLS/HTTPS) **File:** `nginx/posterg.conf` The nginx config listens only on port 80. HTTP Basic Authentication Base64-encodes credentials but **does not encrypt them**. Anyone intercepting network traffic can decode the admin password trivially (`echo "dXNlcjpwYXNz" | base64 -d`). ```nginx listen 80 default_server; # ← No HTTPS configured at all auth_basic "Admin Access - Post-ERG"; auth_basic_user_file /etc/nginx/.htpasswd-posterg; ``` **Fix:** Add a TLS/HTTPS server block (e.g., Let's Encrypt via certbot) and redirect all port 80 traffic to HTTPS: ```nginx server { listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl; ssl_certificate /etc/letsencrypt/live/posterg.erg.be/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/posterg.erg.be/privkey.pem; # ... rest of config } ``` --- ### 2. No PHP-Level Authentication in Admin Panel **Files:** `public/admin/index.php`, `add.php`, `edit.php`, `import.php`, `thanks.php` Every admin file calls `session_start()` only to generate a CSRF token. There is **zero check that the user is authenticated in PHP**. If nginx is misconfigured, restarted, or the app is accessed directly without nginx in front (e.g., via the PHP CLI dev server), all admin functionality is fully open. ```php session_start(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } // ← No authentication check whatsoever ``` **Fix:** Implement a PHP login system with session-based authentication and add an auth guard at the top of every admin page: ```php // public/admin/auth.php session_start(); if (empty($_SESSION['admin_authenticated'])) { header('Location: /admin/login.php'); exit; } ``` --- ## 🟠 HIGH ### 3. Uploaded Files Stored Inside the Webroot **File:** `public/admin/actions/formulaire.php` (lines ~127–131) Thesis files and cover images are stored *inside* the public web directory: ```php $uploadBaseDir = __DIR__ . "/data/theses/{$annee}/{$identifier}/"; $coverDir = __DIR__ . "/data/covers/"; // Resolves to: /var/www/posterg/public/admin/actions/data/... ``` This means uploaded PDFs, ZIPs, MP4s, and images sit in a potentially web-accessible path. The nginx rule `location ^~ /data/ { deny all; }` only blocks the URL path `/data/…` from the server root — it does **not** block `/admin/actions/data/…`. **Fix:** Store uploads outside the webroot and serve through a controller: ```php $uploadBaseDir = '/var/www/posterg/storage/theses/' . $annee . '/' . $identifier . '/'; $coverDir = '/var/www/posterg/storage/covers/'; ``` --- ### 4. File Path Mismatch — Media Files Cannot Be Served to Public **Files:** `formulaire.php` (stores), `memoire.php` and `search.php` (reference) Files are stored at `public/admin/actions/data/theses/YEAR/ID/file.ext` but recorded in the database as `data/theses/YEAR/ID/file.ext`. When a browser loads `/memoire.php`, relative links resolve to `/data/theses/…`, which nginx blocks: ```php // memoire.php — tries to embed a URL that nginx denies ``` ```nginx location ^~ /data/ { deny all; # ← This kills the public file references } ``` This is both a security misconfiguration and a **functional bug** (files never display on the public site). **Fix:** Use a consistent, intentionally accessible path (e.g., `/var/www/posterg/storage/`) and serve files through a dedicated PHP endpoint that validates access rights. --- ### 5. Rate Limiter Vulnerable to IP Spoofing **File:** `src/RateLimit.php` — method `getClientIdentifier()` ```php if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $ip = $_SERVER['HTTP_X_FORWARDED_FOR']; // ← Fully attacker-controlled } elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) { $ip = $_SERVER['HTTP_CLIENT_IP']; // ← Also attacker-controlled } ``` Any attacker can set `X-Forwarded-For: 1.2.3.4` to a new IP on every request, completely bypassing the rate limiter. The `/search` endpoint has no other brute-force protection. **Fix:** Use `$_SERVER['REMOTE_ADDR']` only. If behind a trusted reverse proxy, whitelist the proxy and extract the last trusted IP from the forwarded header: ```php private function getClientIdentifier(): string { // Only trust REMOTE_ADDR unless explicitly behind a known proxy return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; } ``` --- ### 6. `.htaccess` Security Controls Silently Ignored by nginx **File:** `public/admin/.htaccess` This file contains X-Frame-Options, CSP, X-Content-Type-Options, `Options -Indexes`, and file-protection rules — all **Apache-specific directives**. The production server runs nginx, which never reads `.htaccess`. None of these controls are active. ```apache Header always set Content-Security-Policy "..." # ← Dead letter on nginx Options -Indexes # ← Never applied # ← nginx doesn't parse this Require all denied ``` **Fix:** Move all security rules into the nginx `server` block directly. --- ## 🟡 MEDIUM ### 7. LIKE Wildcard Injection in Admin Search **File:** `public/admin/index.php` (lines ~38–42) ```php $searchParam = "%$searchQuery%"; // ← % and _ characters not escaped $params[] = $searchParam; ``` While SQL injection is prevented by prepared statements, unescaped `%` and `_` trigger unintended SQL wildcard matching (e.g., `%` matches every row, `_` matches any single character). The public `Database::searchTheses()` already does this correctly — the admin search does not. **Fix:** ```php $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $searchQuery); $searchParam = "%" . $escaped . "%"; ``` --- ### 8. Session Cookies Not Hardened **Files:** All admin PHP files using `session_start()` No secure cookie parameters are configured before `session_start()`: - No `Secure` flag → cookies sent over plain HTTP - No `HttpOnly` flag → accessible to JavaScript (XSS cookie theft) - No `SameSite` → vulnerable to cross-site request forgery via cookies **Fix:** Add before every `session_start()`: ```php session_set_cookie_params([ 'lifetime' => 0, 'path' => '/admin', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict', ]); session_start(); ``` --- ### 9. Error Log in Potentially Web-Accessible Path **File:** `public/admin/actions/formulaire.php` ```php ini_set('error_log', 'error.log'); // Relative path → public/admin/actions/error.log ``` The nginx config denies `.md`, `.txt`, `.sql`, `.sh`, `.json` files — but **not `.log` files**. The `.htaccess` rule blocking `error.log` is ignored by nginx (see #6). Error logs can contain PHP stack traces, internal file paths, and database details. **Fix:** Use an absolute path outside the webroot: ```php ini_set('error_log', '/var/log/posterg/error.log'); ``` --- ### 10. External CDN Stylesheet Without Subresource Integrity (SRI) **File:** `public/admin/inc/head.php` ```html ``` If the CDN is compromised, attackers can inject malicious CSS into the admin panel. CSS-based data exfiltration (capturing form inputs via attribute selectors) is a known attack vector. **Fix:** Pin the resource with an integrity hash: ```html ``` Generate the hash: `curl -s https://cdn.jsdelivr.net/npm/water.css@2/out/water.css | openssl dgst -sha256 -binary | base64` --- ### 11. Missing Content Security Policy on Public Pages **File:** `nginx/posterg.conf` The nginx config sets `X-Frame-Options`, `X-Content-Type-Options`, etc., but defines **no `Content-Security-Policy`** header for public routes. This leaves the public site without a last line of defence if any output escaping is ever missed. **Fix:** Add to the main `server` block in nginx: ```nginx add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always; ``` --- ### 12. CSV Import Without Server-Side MIME Validation **File:** `public/admin/import.php` The cover image upload validates MIME type with `finfo`, but the CSV upload does not: ```php // Only HTML-side restriction — trivially bypassed: ``` **Fix:** ```php $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($_FILES['csv_file']['tmp_name']); if (!in_array($mime, ['text/plain', 'text/csv', 'application/csv'])) { throw new Exception("Type de fichier invalide. Seuls les fichiers CSV sont acceptés."); } ``` --- ## 🔵 LOW ### 13. Deprecated `X-XSS-Protection` Header **Files:** `nginx/posterg.conf`, `public/admin/.htaccess` `X-XSS-Protection "1; mode=block"` has been removed from Chrome and is ignored by Firefox. In some legacy browsers it can actually *introduce* reflected-XSS vulnerabilities by blocking legitimate content. **Fix:** Remove the header. Rely on a properly configured CSP instead. --- ### 14. Missing `rel="noreferrer"` on External Links **File:** `public/admin/thanks.php` ```php ``` `noopener` prevents `window.opener` hijacking, but `noreferrer` is also needed to prevent the internal admin URL from leaking in the HTTP `Referer` header to external sites. **Fix:** Use `rel="noopener noreferrer"` on all `target="_blank"` links. --- ### 15. Unescaped Integer Outputs (Cosmetic / Defence in Depth) **Files:** `public/index.php`, `public/search.php` ```php // integer from DB // integer from DB // integer from COUNT() ``` Safe in practice (integers cannot carry XSS payloads), but inconsistent with the rest of the codebase which uses `htmlspecialchars()` everywhere. **Fix:** Apply explicit integer cast for clarity: ``. --- ### 16. Redundant `DATABASE_PATH` Constant **File:** `config/bootstrap.php` ```php define('DATABASE_PATH', APP_ROOT . '/storage/test.db'); ``` This constant is never used anywhere. `Database.php` uses `getDatabasePath()` from `src/config.php`. The duplicate creates confusion about which configuration is authoritative and could lead to future bugs if someone uses the wrong one. **Fix:** Remove the `DATABASE_PATH` define from `bootstrap.php`. --- ## Summary Table | # | Issue | Severity | File(s) | |---|-------|----------|---------| | 1 | No HTTPS — admin credentials exposed in transit | 🔴 CRITICAL | nginx/posterg.conf | | 2 | No PHP-level authentication in admin | 🔴 CRITICAL | public/admin/\*.php | | 3 | Uploaded files stored inside webroot | 🟠 HIGH | admin/actions/formulaire.php | | 4 | File path mismatch — media broken & insecure | 🟠 HIGH | formulaire.php, memoire.php | | 5 | Rate limiter bypassed by IP spoofing | 🟠 HIGH | lib/RateLimit.php | | 6 | .htaccess rules ignored by nginx | 🟠 HIGH | public/admin/.htaccess | | 7 | LIKE wildcard injection in admin search | 🟡 MEDIUM | public/admin/index.php | | 8 | Session cookies not hardened | 🟡 MEDIUM | All admin PHP files | | 9 | error.log in web-accessible path | 🟡 MEDIUM | admin/actions/formulaire.php | | 10 | CDN stylesheet without SRI | 🟡 MEDIUM | admin/inc/head.php | | 11 | Missing CSP on public pages | 🟡 MEDIUM | nginx/posterg.conf | | 12 | CSV upload missing MIME validation | 🟡 MEDIUM | admin/import.php | | 13 | Deprecated X-XSS-Protection header | 🔵 LOW | nginx/posterg.conf | | 14 | Missing rel="noreferrer" on external links | 🔵 LOW | admin/thanks.php | | 15 | Unescaped integers (defence in depth) | 🔵 LOW | index.php, search.php | | 16 | Redundant DATABASE_PATH constant | 🔵 LOW | config/bootstrap.php | --- *End of report.*