security: fix all LOW priority items from TODO.SECURITY.md

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.
This commit is contained in:
Théophile Gervreau-Mercier
2026-02-08 11:58:51 +01:00
parent 94d110438f
commit f5d3281c43
8 changed files with 490 additions and 221 deletions

View File

@@ -6,9 +6,6 @@
// Define application root // Define application root
define('APP_ROOT', dirname(__DIR__)); define('APP_ROOT', dirname(__DIR__));
// Database path
define('DATABASE_PATH', APP_ROOT . '/database/test.db');
// Error reporting // Error reporting
if (php_sapi_name() === 'cli-server') { if (php_sapi_name() === 'cli-server') {
// Development mode // Development mode

View File

@@ -1,277 +1,406 @@
# Security Analysis - Search Feature # 🔒 Security Vulnerability Analysis — posterg-website
## Current Security Status > **Date:** 2026-02-08
> **Scope:** Full static code review of all PHP, nginx config, and deployment files.
### ✅ Protections in Place > **Analyst:** Claude Code
1. **SQL Injection Prevention**
- ✅ Uses PDO prepared statements
- ✅ All parameters bound with `bindValue()`
- ✅ No direct concatenation of user input into SQL
- ✅ Dynamic WHERE clause built from hardcoded strings only
2. **XSS (Cross-Site Scripting) Prevention**
- ✅ All output uses `htmlspecialchars()`
- ✅ Form values escaped when displayed
- ✅ Search results escaped before rendering
3. **Access Control**
- ✅ Only published theses searchable (`is_published = 1`)
- ✅ Uses read-only view (`v_theses_public`)
4. **Type Safety**
- ✅ Year parameter uses `intval()`
- ✅ Boolean values properly cast
--- ---
## ⚠️ Security Vulnerabilities ## Good Practices Already in Place
### 1. LIKE Wildcard Injection (Low Severity) - ✅ 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`)
**Issue:** Users can inject SQL LIKE wildcards (`%`, `_`) to match unintended patterns. ---
**Example Attack:** ## 🔴 CRITICAL
```
Search query: "%"
Result: Matches ALL theses (bypasses search intent)
Search query: "a%b%c%d%e%f%g%h%i%j%k%l%m%n%o%p%q%r%s%t%u%v%w%x%y%z" ### 1. Admin HTTP Basic Auth Over Plain HTTP (No TLS/HTTPS)
Result: Forces inefficient pattern matching, potential DoS
**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;
``` ```
**Current Code:** **Fix:** Add a TLS/HTTPS server block (e.g., Let's Encrypt via certbot) and redirect
```php all port 80 traffic to HTTPS:
$bindings[':query'] = '%' . $params['query'] . '%';
```
**Impact:** ```nginx
- Not SQL injection (still uses prepared statements) server {
- Allows overly broad searches listen 80;
- Performance degradation with complex patterns return 301 https://$host$request_uri;
- Information disclosure through pattern matching
**Fix:** Escape wildcards before using in LIKE:
```php
private function escapeLikeString($string) {
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $string);
} }
server {
// In query: listen 443 ssl;
$bindings[':query'] = '%' . $this->escapeLikeString($params['query']) . '%'; ssl_certificate /etc/letsencrypt/live/posterg.erg.be/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/posterg.erg.be/privkey.pem;
// In SQL: # ... rest of config
"title LIKE :query ESCAPE '\\'"
```
---
### 2. No Input Length Validation (Medium Severity)
**Issue:** No limits on search string length.
**Example Attack:**
```php
// 10MB query string
$query = str_repeat('a', 10 * 1024 * 1024);
```
**Impact:**
- Memory exhaustion
- Database query slowdown
- Denial of Service (DoS)
**Fix:** Validate input length:
```php
if (strlen($params['query']) > 200) {
throw new InvalidArgumentException("Search query too long");
} }
``` ```
--- ---
### 3. No Rate Limiting (Medium Severity) ### 2. No PHP-Level Authentication in Admin Panel
**Issue:** Unlimited search requests allowed. **Files:** `public/admin/index.php`, `add.php`, `edit.php`, `import.php`, `thanks.php`
**Example Attack:** Every admin file calls `session_start()` only to generate a CSRF token. There is
```bash **zero check that the user is authenticated in PHP**. If nginx is misconfigured,
# Spam 10,000 requests restarted, or the app is accessed directly without nginx in front (e.g., via the
for i in {1..10000}; do PHP CLI dev server), all admin functionality is fully open.
curl "http://site.com/search.php?query=test&page=$i" &
done
```
**Impact:**
- Database overload
- Server resource exhaustion
- Denial of Service for legitimate users
**Fix:** Implement rate limiting (see solution below)
---
### 4. No Pagination Limits (Low Severity)
**Issue:** Users can request excessive offset values.
**Example:**
```
search.php?page=999999999
```
**Impact:**
- Database scans large result sets
- Wasted resources on impossible pages
**Fix:** Validate pagination:
```php ```php
$limit = max(1, min(100, intval($limit))); // Max 100 per page session_start();
$offset = max(0, intval($offset)); if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// ← No authentication check whatsoever
```
// Optionally limit max offset **Fix:** Implement a PHP login system with session-based authentication and add an
if ($offset > 10000) { auth guard at the top of every admin page:
throw new InvalidArgumentException("Page too high");
```php
// public/admin/auth.php
session_start();
if (empty($_SESSION['admin_authenticated'])) {
header('Location: /admin/login.php');
exit;
} }
``` ```
--- ---
## 🔒 Recommended Security Improvements ## 🟠 HIGH
### Priority 1: Apply Input Validation (HIGH) ### 3. Uploaded Files Stored Inside the Webroot
Use the enhanced `Database_secure.php` class which includes: **File:** `public/admin/actions/formulaire.php` (lines ~127131)
- Wildcard escaping
- Length validation
- Range validation
- ESCAPE clause in LIKE queries
### Priority 2: Implement Rate Limiting (MEDIUM) Thesis files and cover images are stored *inside* the public web directory:
Example using simple file-based rate limiting:
```php ```php
<?php $uploadBaseDir = __DIR__ . "/data/theses/{$annee}/{$identifier}/";
// rate_limit.php - Simple rate limiter $coverDir = __DIR__ . "/data/covers/";
// Resolves to: /var/www/posterg/public/admin/actions/data/...
```
function checkRateLimit($identifier, $maxRequests = 10, $timeWindow = 60) { This means uploaded PDFs, ZIPs, MP4s, and images sit in a potentially web-accessible
$cacheDir = __DIR__ . '/cache/rate_limit'; path. The nginx rule `location ^~ /data/ { deny all; }` only blocks the URL path
if (!is_dir($cacheDir)) { `/data/…` from the server root — it does **not** block `/admin/actions/data/…`.
mkdir($cacheDir, 0755, true);
}
$file = $cacheDir . '/' . md5($identifier) . '.json'; **Fix:** Store uploads outside the webroot and serve through a controller:
$data = file_exists($file) ? json_decode(file_get_contents($file), true) : []; ```php
$uploadBaseDir = '/var/www/posterg/storage/theses/' . $annee . '/' . $identifier . '/';
$coverDir = '/var/www/posterg/storage/covers/';
```
// Clean old entries ---
$now = time();
$data = array_filter($data, function($timestamp) use ($now, $timeWindow) {
return ($now - $timestamp) < $timeWindow;
});
// Check if limit exceeded ### 4. File Path Mismatch — Media Files Cannot Be Served to Public
if (count($data) >= $maxRequests) {
return false;
}
// Add new request **Files:** `formulaire.php` (stores), `memoire.php` and `search.php` (reference)
$data[] = $now;
file_put_contents($file, json_encode($data));
return true; 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:
// In search.php: ```php
$userIP = $_SERVER['REMOTE_ADDR']; // memoire.php — tries to embed a URL that nginx denies
if (!checkRateLimit($userIP, 20, 60)) { // 20 requests per minute <embed src="<?= htmlspecialchars($file['file_path']); ?>" type="application/pdf">
http_response_code(429); ```
die('Too many requests. Please try again later.');
```nginx
location ^~ /data/ {
deny all; # ← This kills the public file references
} }
``` ```
### Priority 3: Add Content Security Policy (LOW) 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()`
Add to header:
```php ```php
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net;"); if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
header("X-Content-Type-Options: nosniff"); $ip = $_SERVER['HTTP_X_FORWARDED_FOR']; // ← Fully attacker-controlled
header("X-Frame-Options: DENY"); } elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
header("X-XSS-Protection: 1; mode=block"); $ip = $_SERVER['HTTP_CLIENT_IP']; // ← Also attacker-controlled
}
``` ```
### Priority 4: Add Query Logging (LOW) 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:
Log suspicious search patterns:
```php ```php
// Detect potential attacks private function getClientIdentifier(): string {
if (preg_match('/[%_]{10,}/', $params['query'])) { // Only trust REMOTE_ADDR unless explicitly behind a known proxy
error_log("Suspicious search pattern from {$_SERVER['REMOTE_ADDR']}: {$params['query']}"); return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
} }
``` ```
--- ---
## Security Best Practices Checklist ### 6. `.htaccess` Security Controls Silently Ignored by nginx
- [x] Use prepared statements (SQL injection) **File:** `public/admin/.htaccess`
- [x] Escape output with htmlspecialchars() (XSS)
- [ ] Escape LIKE wildcards (wildcard injection) This file contains X-Frame-Options, CSP, X-Content-Type-Options, `Options -Indexes`,
- [ ] Validate input lengths (DoS) and file-protection rules — all **Apache-specific directives**. The production server
- [ ] Implement rate limiting (DoS) runs nginx, which never reads `.htaccess`. None of these controls are active.
- [ ] Validate pagination limits (resource waste)
- [x] Restrict to published data only (access control) ```apache
- [ ] Add security headers (defense in depth) Header always set Content-Security-Policy "..." # ← Dead letter on nginx
- [ ] Log suspicious activity (monitoring) Options -Indexes # ← Never applied
- [ ] Use HTTPS in production (encryption) <FilesMatch "(error\.log)$"> # ← nginx doesn't parse this
Require all denied
```
**Fix:** Move all security rules into the nginx `server` block directly.
--- ---
## Testing Security ## 🟡 MEDIUM
### Test 1: SQL Injection ### 7. LIKE Wildcard Injection in Admin Search
```bash
# These should NOT cause errors or expose data
curl "search.php?query=' OR 1=1--"
curl "search.php?query='; DROP TABLE theses;--"
curl "search.php?year=' OR '1'='1"
```
**Expected:** Treated as literal search strings, no SQL execution
### Test 2: XSS **File:** `public/admin/index.php` (lines ~3842)
```bash
curl "search.php?query=<script>alert('XSS')</script>"
```
**Expected:** Script tags displayed as text, not executed
### Test 3: Wildcard Injection ```php
```bash $searchParam = "%$searchQuery%"; // ← % and _ characters not escaped
curl "search.php?query=%" $params[] = $searchParam;
``` ```
**Current:** Returns all results ❌
**After fix:** Searches for literal "%" character ✅
### Test 4: DoS via Long Input While SQL injection is prevented by prepared statements, unescaped `%` and `_`
```bash trigger unintended SQL wildcard matching (e.g., `%` matches every row, `_` matches
curl "search.php?query=$(python3 -c 'print("a"*100000)')" any single character). The public `Database::searchTheses()` already does this
correctly — the admin search does not.
**Fix:**
```php
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $searchQuery);
$searchParam = "%" . $escaped . "%";
``` ```
**Current:** Processes full string ❌
**After fix:** Rejects with error ✅
--- ---
## Conclusion ### 8. Session Cookies Not Hardened
**Current Status:** The search system has **good baseline security** against SQL injection and XSS, but needs hardening for production use. **Files:** All admin PHP files using `session_start()`
**Recommended Actions:** No secure cookie parameters are configured before `session_start()`:
1. Apply wildcard escaping (use `Database_secure.php`)
2. Add input length validation
3. Implement rate limiting
4. Add security headers
5. Monitor for suspicious patterns
**Risk Level:** - No `Secure` flag → cookies sent over plain HTTP
- Current: **Medium** (suitable for internal/development use) - No `HttpOnly` flag → accessible to JavaScript (XSS cookie theft)
- After improvements: **Low** (production-ready) - 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.*

112
docs/TODO.SECURITY.md Normal file
View File

@@ -0,0 +1,112 @@
# 🔒 Security TODO — posterg-website
> Based on `docs/SECURITY_ANALYSIS.md` (2026-02-08).
> Tracks resolution status for all identified vulnerabilities.
---
## ✅ Resolved
### Infrastructure / Deployment
| # | Issue | Severity | Resolution |
|---|-------|----------|------------|
| 1 | No HTTPS — admin credentials exposed in transit | 🔴 CRITICAL | **TLS is terminated upstream by the reverse proxy** (outside nginx). nginx.conf does not need to handle TLS directly. |
| 13 | Deprecated `X-XSS-Protection` header | 🔵 LOW | **Removed** from `nginx/posterg.conf`; see `nginx/SECURITY_HEADERS.md` for rationale. |
---
### Frontend / Assets
| # | Issue | Severity | Resolution |
|---|-------|----------|------------|
| 10 | CDN stylesheet (water.css) without Subresource Integrity | 🟡 MEDIUM | **CDN will not be used in production.** The stylesheet will be self-hosted, eliminating the supply-chain risk entirely. |
---
### Frontend
| # | Issue | Severity | Resolution |
|---|-------|----------|------------|
| 14 | Missing `rel="noreferrer"` on external `target="_blank"` links | 🔵 LOW | `rel="noopener noreferrer"` applied in `public/admin/thanks.php`. |
---
### Code Quality / Defence in Depth
| # | Issue | Severity | Resolution |
|---|-------|----------|------------|
| 15 | Unescaped integer outputs (inconsistency) | 🔵 LOW | Explicit `(int)` casts added in `public/index.php` and `public/search.php`. |
| 16 | Redundant `DATABASE_PATH` constant never used | 🔵 LOW | `define('DATABASE_PATH', …)` removed from `config/bootstrap.php`. |
---
## 🔧 In Progress
### Database / Input Handling
| # | Issue | Severity | Status |
|---|-------|----------|--------|
| 7 | LIKE wildcard injection in admin search (`public/admin/index.php`) | 🟡 MEDIUM | **Needs standardisation of DB input sanitisation across the whole codebase.** The public `Database::searchTheses()` already escapes `%` and `_` correctly; the same pattern must be applied to admin search and any other raw LIKE queries. |
---
## ❌ Not Yet Implemented
### Infrastructure / Deployment
| # | Issue | Severity | Files |
|---|-------|----------|-------|
| 6 | `.htaccess` security rules silently ignored by nginx | 🟠 HIGH | `public/admin/.htaccess` → move all headers/rules into nginx server block |
| 11 | Missing Content-Security-Policy on public pages | 🟡 MEDIUM | `nginx/posterg.conf` → add `Content-Security-Policy` header |
| 13 | Deprecated `X-XSS-Protection` header | 🔵 LOW | ✅ `nginx/posterg.conf` — header removed; documented in `nginx/SECURITY_HEADERS.md` |
---
### Admin Panel — Authentication & Sessions
| # | Issue | Severity | Files / Notes |
|---|-------|----------|-------|
| 2 | No PHP-level authentication in admin panel | 🔴 CRITICAL | All `public/admin/*.php` files. The reverse proxy + nginx Basic Auth is the only auth layer — a single point of failure. CSRF ≠ authentication (CSRF only prevents cross-site forgery on already-open sessions; it does nothing against a direct unauthenticated request). A simple PHP session guard adds real defence-in-depth for ~20 lines using stdlib (`password_verify`, `session_regenerate_id`) with negligible added attack surface — the risk of **not** having it (proxy misconfiguration, bypass, local dev without proxy) outweighs the risk of adding it. |
| 8 | Session cookies not hardened (`Secure`, `HttpOnly`, `SameSite` missing) | 🟡 MEDIUM | All admin PHP files using `session_start()` |
---
### File Upload / Storage
| # | Issue | Severity | Files |
|---|-------|----------|-------|
| 3 | Uploaded files stored inside the webroot | 🟠 HIGH | `public/admin/actions/formulaire.php` → move uploads to `/var/www/posterg/storage/` |
| 4 | File path mismatch — media files broken and insecure | 🟠 HIGH | `formulaire.php`, `memoire.php`, `search.php` → align stored paths with a controller-served endpoint |
| 9 | `error.log` written to a web-accessible path | 🟡 MEDIUM | `public/admin/actions/formulaire.php` → use absolute path outside webroot |
---
### Rate Limiting
| # | Issue | Severity | Files |
|---|-------|----------|-------|
| 5 | Rate limiter bypassed by IP spoofing (`X-Forwarded-For`) | 🟠 HIGH | `lib/RateLimit.php` → use `REMOTE_ADDR` only |
---
### File Import
| # | Issue | Severity | Files |
|---|-------|----------|-------|
| 12 | CSV import missing server-side MIME validation | 🟡 MEDIUM | `public/admin/import.php` → add `finfo` MIME check |
---
## Priority Order for Remaining Work
1. 🔴 **CRITICAL** — Item 2 (add simple PHP session auth guard)
2. 🟠 **HIGH** — Items 3, 4, 5, 6 (file storage overhaul + nginx hardening + rate-limiter fix)
3. 🟡 **MEDIUM** — Items 7, 8, 9, 11, 12 (sanitisation standardisation, session hardening, CSP, error log, CSV MIME)
4. 🔵 **LOW** — ✅ All done (items 1316)
---
*Last updated: 2026-02-08*

30
nginx/SECURITY_HEADERS.md Normal file
View File

@@ -0,0 +1,30 @@
# Security Headers — nginx/posterg.conf
## Headers in use
| Header | Value | Purpose |
|--------|-------|---------|
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME-type sniffing |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage |
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` | Disable unused browser APIs |
## Intentionally omitted headers
### `X-XSS-Protection`
This header was **removed** (was `"1; mode=block"`).
**Why:** `X-XSS-Protection` is deprecated and removed from all modern browsers
(Chrome 78+, Firefox never implemented it, Edge dropped it). Worse, the
`mode=block` behaviour can be [actively exploited](https://portswigger.net/daily-swig/xss-protection-header-is-no-longer-supported-in-any-major-browser)
to expose response bodies that would otherwise be blocked. Sending it provides
no protection and may introduce risk.
**Correct mitigation:** a proper `Content-Security-Policy` header (todo item #11).
## Pending headers
| Header | Status |
|--------|--------|
| `Content-Security-Policy` | ⏳ todo item #11 |

View File

@@ -22,7 +22,8 @@ server {
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; # X-XSS-Protection intentionally omitted — deprecated and counterproductive in modern browsers.
# CSP (Content-Security-Policy) is the correct mitigation (see item #11).
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

View File

@@ -148,7 +148,7 @@ $pageTitle = "Merci";
<?php if ($thesis['baiu_link']): ?> <?php if ($thesis['baiu_link']): ?>
<dt>Lien:</dt> <dt>Lien:</dt>
<dd><a href="<?php echo htmlspecialchars($thesis['baiu_link']); ?>" target="_blank" rel="noopener"> <dd><a href="<?php echo htmlspecialchars($thesis['baiu_link']); ?>" target="_blank" rel="noopener noreferrer">
<?php echo htmlspecialchars($thesis['baiu_link']); ?> <?php echo htmlspecialchars($thesis['baiu_link']); ?>
</a></dd> </a></dd>
<?php endif; ?> <?php endif; ?>

View File

@@ -24,7 +24,7 @@ include APP_ROOT . '/includes/header.php';
<main> <main>
<?php foreach ($itemsToLoad as $item): ?> <?php foreach ($itemsToLoad as $item): ?>
<a href="memoire.php?id=<?= $item["id"] ?>" class="card-link"> <a href="memoire.php?id=<?= (int)$item["id"] ?>" class="card-link">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<h2 class="title"><?= htmlspecialchars($item["title"]) ?></h2> <h2 class="title"><?= htmlspecialchars($item["title"]) ?></h2>
@@ -43,14 +43,14 @@ include APP_ROOT . '/includes/header.php';
<?php if ($totalPages > 1): ?> <?php if ($totalPages > 1): ?>
<nav class="pagination"> <nav class="pagination">
<?php if ($page > 1): ?> <?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>" class="pagination-previous">Précédent</a> <a href="?page=<?= (int)($page - 1) ?>" class="pagination-previous">Précédent</a>
<?php endif; ?> <?php endif; ?>
<?php if ($page < $totalPages): ?> <?php if ($page < $totalPages): ?>
<a href="?page=<?= $page + 1 ?>" class="pagination-next">Suivant</a> <a href="?page=<?= (int)($page + 1) ?>" class="pagination-next">Suivant</a>
<?php endif; ?> <?php endif; ?>
<span class="pagination-info">Page <?= $page ?> sur <?= $totalPages ?></span> <span class="pagination-info">Page <?= (int)$page ?> sur <?= (int)$totalPages ?></span>
</nav> </nav>
<?php endif; ?> <?php endif; ?>

View File

@@ -160,8 +160,8 @@ include APP_ROOT . '/includes/header.php'; ?>
<select name="year"> <select name="year">
<option value="">Toutes les années</option> <option value="">Toutes les années</option>
<?php foreach ($years as $year): ?> <?php foreach ($years as $year): ?>
<option value="<?= $year; ?>" <?= (isset($_GET['year']) && $_GET['year'] == $year) ? 'selected' : ''; ?>> <option value="<?= (int)$year; ?>" <?= (isset($_GET['year']) && $_GET['year'] == $year) ? 'selected' : ''; ?>>
<?= $year; ?> <?= (int)$year; ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@@ -326,14 +326,14 @@ include APP_ROOT . '/includes/header.php'; ?>
<!-- Search results --> <!-- Search results -->
<?php if (!empty($searchParams)): ?> <?php if (!empty($searchParams)): ?>
<div class="notification is-info is-light"> <div class="notification is-info is-light">
<strong><?= $totalItems; ?></strong> résultat<?= $totalItems > 1 ? 's' : ''; ?> trouvé<?= $totalItems > 1 ? 's' : ''; ?> <strong><?= (int)$totalItems; ?></strong> résultat<?= $totalItems > 1 ? 's' : ''; ?> trouvé<?= $totalItems > 1 ? 's' : ''; ?>
</div> </div>
<?php if (count($results) > 0): ?> <?php if (count($results) > 0): ?>
<div class="columns is-multiline"> <div class="columns is-multiline">
<?php foreach ($results as $item): ?> <?php foreach ($results as $item): ?>
<div class="column is-one-fifth"> <div class="column is-one-fifth">
<a href="memoire.php?id=<?= $item['id']; ?>" class="card-link"> <a href="memoire.php?id=<?= (int)$item['id']; ?>" class="card-link">
<div class="card"> <div class="card">
<?php <?php
// Get cover image from thesis_files if available // Get cover image from thesis_files if available
@@ -397,7 +397,7 @@ include APP_ROOT . '/includes/header.php'; ?>
<li> <li>
<a href="?<?= http_build_query(array_merge($_GET, ['page' => $i])); ?>" <a href="?<?= http_build_query(array_merge($_GET, ['page' => $i])); ?>"
class="pagination-link <?= $i === $page ? 'is-current' : ''; ?>"> class="pagination-link <?= $i === $page ? 'is-current' : ''; ?>">
<?= $i; ?> <?= (int)$i; ?>
</a> </a>
</li> </li>
<?php endfor; ?> <?php endfor; ?>