mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
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.
407 lines
13 KiB
Markdown
407 lines
13 KiB
Markdown
# 🔒 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
|
||
<embed src="<?= htmlspecialchars($file['file_path']); ?>" type="application/pdf">
|
||
```
|
||
|
||
```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:** `lib/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
|
||
<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)
|
||
|
||
```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
|
||
<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:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```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:
|
||
<input type="file" id="csv_file" name="csv_file" accept=".csv" required>
|
||
```
|
||
|
||
**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
|
||
<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`
|
||
|
||
```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`
|
||
|
||
```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.*
|