Files
xamxam/docs/SECURITY_ANALYSIS.md
Théophile Gervreau-Mercier 87971f9c23 refactor: extract templates from public/
- Created /templates for main site (header.php, footer.php)
- Created /templates/admin for admin section (head.php, footer.php)
- Removed /public/includes and /public/admin/inc
- Updated all references in code and docs
- Tests passing 

Cleaner separation: /public only contains web-accessible files (PHP entry points + assets)
2026-02-12 12:15:41 +01:00

13 KiB
Raw Blame History

🔒 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).

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 ~127131)

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: src/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

File: public/admin/index.php (lines ~3842)

$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 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():

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: templates/admin/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.


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 . '/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.