Item 13 — Remove deprecated X-XSS-Protection header
- nginx/posterg.conf: header removed (was '1; mode=block')
- nginx/SECURITY_HEADERS.md: new file documenting header decisions
and explaining why X-XSS-Protection is counterproductive
Item 14 — Add rel="noreferrer" to external target="_blank" link
- public/admin/thanks.php: rel="noopener" → rel="noopener noreferrer"
Item 15 — Explicit (int) casts on all integer HTML outputs
- public/index.php: (int) on item id, page numbers
- public/search.php: (int) on totalItems, year options, item id, pagination
Item 16 — Remove unused DATABASE_PATH constant
- config/bootstrap.php: define('DATABASE_PATH', ...) removed
docs/TODO.SECURITY.md updated: items 13-16 marked resolved and
moved to the ✅ Resolved section.
13 KiB
🔒 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 withhash_equals()(timing-safe) on all state-changing forms - ✅ File upload: MIME type validated with
finfofor 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).
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:
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.
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:
// 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:
$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:
$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:
// memoire.php — tries to embed a URL that nginx denies
<embed src="<?= htmlspecialchars($file['file_path']); ?>" type="application/pdf">
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: lib/RateLimit.php — method getClientIdentifier()
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:
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.
Header always set Content-Security-Policy "..." # ← Dead letter on nginx
Options -Indexes # ← Never applied
<FilesMatch "(error\.log)$"> # ← 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)
$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:
$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
Secureflag → cookies sent over plain HTTP - No
HttpOnlyflag → accessible to JavaScript (XSS cookie theft) - No
SameSite→ vulnerable to cross-site request forgery via cookies
Fix: Add before every session_start():
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
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:
ini_set('error_log', '/var/log/posterg/error.log');
10. External CDN Stylesheet Without Subresource Integrity (SRI)
File: public/admin/inc/head.php
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
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:
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css"
integrity="sha256-<computed-hash>"
crossorigin="anonymous">
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:
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:
// Only HTML-side restriction — trivially bypassed:
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
Fix:
$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
<a href="..." target="_blank" rel="noopener">
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
<?= $item["id"] ?> // integer from DB
<?= $year; ?> // integer from DB
<?= $totalItems; ?> // 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: <?= (int) $item['id'] ?>.
16. Redundant DATABASE_PATH Constant
File: config/bootstrap.php
define('DATABASE_PATH', APP_ROOT . '/database/test.db');
This constant is never used anywhere. Database.php uses getDatabasePath() from
lib/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.