mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
$limit = max(1, min(100, intval($limit))); // Max 100 per page
|
|
||||||
$offset = max(0, intval($offset));
|
|
||||||
|
|
||||||
// Optionally limit max offset
|
|
||||||
if ($offset > 10000) {
|
|
||||||
throw new InvalidArgumentException("Page too high");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Recommended Security Improvements
|
|
||||||
|
|
||||||
### Priority 1: Apply Input Validation (HIGH)
|
|
||||||
|
|
||||||
Use the enhanced `Database_secure.php` class which includes:
|
|
||||||
- Wildcard escaping
|
|
||||||
- Length validation
|
|
||||||
- Range validation
|
|
||||||
- ESCAPE clause in LIKE queries
|
|
||||||
|
|
||||||
### Priority 2: Implement Rate Limiting (MEDIUM)
|
|
||||||
|
|
||||||
Example using simple file-based rate limiting:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
session_start();
|
||||||
// rate_limit.php - Simple rate limiter
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
function checkRateLimit($identifier, $maxRequests = 10, $timeWindow = 60) {
|
|
||||||
$cacheDir = __DIR__ . '/cache/rate_limit';
|
|
||||||
if (!is_dir($cacheDir)) {
|
|
||||||
mkdir($cacheDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $cacheDir . '/' . md5($identifier) . '.json';
|
|
||||||
|
|
||||||
$data = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
|
|
||||||
|
|
||||||
// Clean old entries
|
|
||||||
$now = time();
|
|
||||||
$data = array_filter($data, function($timestamp) use ($now, $timeWindow) {
|
|
||||||
return ($now - $timestamp) < $timeWindow;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if limit exceeded
|
|
||||||
if (count($data) >= $maxRequests) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new request
|
|
||||||
$data[] = $now;
|
|
||||||
file_put_contents($file, json_encode($data));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In search.php:
|
|
||||||
$userIP = $_SERVER['REMOTE_ADDR'];
|
|
||||||
if (!checkRateLimit($userIP, 20, 60)) { // 20 requests per minute
|
|
||||||
http_response_code(429);
|
|
||||||
die('Too many requests. Please try again later.');
|
|
||||||
}
|
}
|
||||||
|
// ← No authentication check whatsoever
|
||||||
```
|
```
|
||||||
|
|
||||||
### Priority 3: Add Content Security Policy (LOW)
|
**Fix:** Implement a PHP login system with session-based authentication and add an
|
||||||
|
auth guard at the top of every admin page:
|
||||||
|
|
||||||
Add to header:
|
|
||||||
```php
|
```php
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net;");
|
// public/admin/auth.php
|
||||||
header("X-Content-Type-Options: nosniff");
|
session_start();
|
||||||
header("X-Frame-Options: DENY");
|
if (empty($_SESSION['admin_authenticated'])) {
|
||||||
header("X-XSS-Protection: 1; mode=block");
|
header('Location: /admin/login.php');
|
||||||
```
|
exit;
|
||||||
|
|
||||||
### Priority 4: Add Query Logging (LOW)
|
|
||||||
|
|
||||||
Log suspicious search patterns:
|
|
||||||
```php
|
|
||||||
// Detect potential attacks
|
|
||||||
if (preg_match('/[%_]{10,}/', $params['query'])) {
|
|
||||||
error_log("Suspicious search pattern from {$_SERVER['REMOTE_ADDR']}: {$params['query']}");
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Best Practices Checklist
|
## 🟠 HIGH
|
||||||
|
|
||||||
- [x] Use prepared statements (SQL injection)
|
### 3. Uploaded Files Stored Inside the Webroot
|
||||||
- [x] Escape output with htmlspecialchars() (XSS)
|
|
||||||
- [ ] Escape LIKE wildcards (wildcard injection)
|
**File:** `public/admin/actions/formulaire.php` (lines ~127–131)
|
||||||
- [ ] Validate input lengths (DoS)
|
|
||||||
- [ ] Implement rate limiting (DoS)
|
Thesis files and cover images are stored *inside* the public web directory:
|
||||||
- [ ] Validate pagination limits (resource waste)
|
|
||||||
- [x] Restrict to published data only (access control)
|
```php
|
||||||
- [ ] Add security headers (defense in depth)
|
$uploadBaseDir = __DIR__ . "/data/theses/{$annee}/{$identifier}/";
|
||||||
- [ ] Log suspicious activity (monitoring)
|
$coverDir = __DIR__ . "/data/covers/";
|
||||||
- [ ] Use HTTPS in production (encryption)
|
// 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/';
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Security
|
### 4. File Path Mismatch — Media Files Cannot Be Served to Public
|
||||||
|
|
||||||
### Test 1: SQL Injection
|
**Files:** `formulaire.php` (stores), `memoire.php` and `search.php` (reference)
|
||||||
```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
|
Files are stored at `public/admin/actions/data/theses/YEAR/ID/file.ext` but recorded
|
||||||
```bash
|
in the database as `data/theses/YEAR/ID/file.ext`. When a browser loads `/memoire.php`,
|
||||||
curl "search.php?query=<script>alert('XSS')</script>"
|
relative links resolve to `/data/theses/…`, which nginx blocks:
|
||||||
```
|
|
||||||
**Expected:** Script tags displayed as text, not executed
|
|
||||||
|
|
||||||
### Test 3: Wildcard Injection
|
```php
|
||||||
```bash
|
// memoire.php — tries to embed a URL that nginx denies
|
||||||
curl "search.php?query=%"
|
<embed src="<?= htmlspecialchars($file['file_path']); ?>" type="application/pdf">
|
||||||
```
|
```
|
||||||
**Current:** Returns all results ❌
|
|
||||||
**After fix:** Searches for literal "%" character ✅
|
|
||||||
|
|
||||||
### Test 4: DoS via Long Input
|
```nginx
|
||||||
```bash
|
location ^~ /data/ {
|
||||||
curl "search.php?query=$(python3 -c 'print("a"*100000)')"
|
deny all; # ← This kills the public file references
|
||||||
|
}
|
||||||
```
|
```
|
||||||
**Current:** Processes full string ❌
|
|
||||||
**After fix:** Rejects with error ✅
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Conclusion
|
### 5. Rate Limiter Vulnerable to IP Spoofing
|
||||||
|
|
||||||
**Current Status:** The search system has **good baseline security** against SQL injection and XSS, but needs hardening for production use.
|
**File:** `lib/RateLimit.php` — method `getClientIdentifier()`
|
||||||
|
|
||||||
**Recommended Actions:**
|
```php
|
||||||
1. Apply wildcard escaping (use `Database_secure.php`)
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
2. Add input length validation
|
$ip = $_SERVER['HTTP_X_FORWARDED_FOR']; // ← Fully attacker-controlled
|
||||||
3. Implement rate limiting
|
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
||||||
4. Add security headers
|
$ip = $_SERVER['HTTP_CLIENT_IP']; // ← Also attacker-controlled
|
||||||
5. Monitor for suspicious patterns
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Risk Level:**
|
Any attacker can set `X-Forwarded-For: 1.2.3.4` to a new IP on every request,
|
||||||
- Current: **Medium** (suitable for internal/development use)
|
completely bypassing the rate limiter. The `/search` endpoint has no other
|
||||||
- After improvements: **Low** (production-ready)
|
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.*
|
||||||
|
|||||||
112
docs/TODO.SECURITY.md
Normal file
112
docs/TODO.SECURITY.md
Normal 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 13–16)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-02-08*
|
||||||
30
nginx/SECURITY_HEADERS.md
Normal file
30
nginx/SECURITY_HEADERS.md
Normal 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 |
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user