CodePress CMS v1.8.0: BotGuard security engine, HAProxy docs & ARIA fix

- Implement BotGuard security engine (Bot, AI, Scraper & Empty UA blocking)
- Add Admin Security page (/admin/security) with toggles, rate limiter & block/allowlists
- Add per-IP RateLimiter handoff in index.php with HTTP 429 response
- Add dynamic /robots.txt generation and noai/noimageai meta tags
- Add RequestLogger status column and blocked badges in admin request logs
- Fix ARIAComponents.php syntax errors on lines 67, 137, 262
- Add HAProxy / PFSense bot blocking & IP forwarding guide (docs/haproxy-bot-blocking.md)
- Update version to 1.8.0 with release notes in version.php and guides
This commit is contained in:
2026-07-29 14:26:48 +02:00
parent 62dd7ddb9c
commit 239762fd3a
18 changed files with 618 additions and 117 deletions
+1 -1
View File
@@ -87,4 +87,4 @@
- [ ] **Keyboard shortcuts** — Ctrl+S om op te slaan in editor, Ctrl+N voor nieuw bestand - [ ] **Keyboard shortcuts** — Ctrl+S om op te slaan in editor, Ctrl+N voor nieuw bestand
- [ ] **Dark mode** — Admin panel dark mode toggle - [ ] **Dark mode** — Admin panel dark mode toggle
- [ ] **Responsive admin** — Admin sidebar inklapbaar op mobiel (nu is het gestacked) - [ ] **Responsive admin** — Admin sidebar inklapbaar op mobiel (nu is het gestacked)
- [ ] **ARIAComponents.php parse error** — Bestaande PHP parse error op regel 67 (`syntax error, unexpected double-quote mark`) - [x] **ARIAComponents.php parse error** — Bestaande PHP parse error op regel 67 opgelost (`'UTF-8)` -> `'UTF-8')`)
+8
View File
@@ -66,6 +66,11 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
<i class="bi bi-sliders"></i> Configuratie <i class="bi bi-sliders"></i> Configuratie
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'security' ? 'active' : '' ?>" href="/admin/security">
<i class="bi bi-shield-check"></i> Beveiliging
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'plugins' ? 'active' : '' ?>" href="/admin/plugins"> <a class="nav-link <?= ($route ?? '') === 'plugins' ? 'active' : '' ?>" href="/admin/plugins">
<i class="bi bi-plug"></i> Plugins <i class="bi bi-plug"></i> Plugins
@@ -135,6 +140,9 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
case 'config': case 'config':
require __DIR__ . '/pages/config.php'; require __DIR__ . '/pages/config.php';
break; break;
case 'security':
require __DIR__ . '/pages/security.php';
break;
# Media route (removed from menu) # Media route (removed from menu)
// Uncomment if media functionality is needed elsewhere // Uncomment if media functionality is needed elsewhere
// require __DIR__ . '/pages/media.php'; // require __DIR__ . '/pages/media.php';
+15 -3
View File
@@ -70,6 +70,7 @@
<th>IP</th> <th>IP</th>
<th>Pagina</th> <th>Pagina</th>
<th>Type / Gebruiker</th> <th>Type / Gebruiker</th>
<th>Status</th>
<th>Taal</th> <th>Taal</th>
<th>User agent</th> <th>User agent</th>
<th>Referrer</th> <th>Referrer</th>
@@ -77,7 +78,7 @@
</thead> </thead>
<tbody> <tbody>
<?php foreach ($requestLogs as $log): ?> <?php foreach ($requestLogs as $log): ?>
<tr> <tr class="<?= str_starts_with($log['status'] ?? 'ok', 'blocked') ? 'table-danger' : '' ?>">
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td> <td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td> <td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
<td><?= htmlspecialchars($log['page']) ?></td> <td><?= htmlspecialchars($log['page']) ?></td>
@@ -86,9 +87,20 @@
<i class="bi <?= $log['visitor_info']['icon'] ?>"></i> <?= $log['visitor_info']['label'] ?> <i class="bi <?= $log['visitor_info']['icon'] ?>"></i> <?= $log['visitor_info']['label'] ?>
</span> </span>
</td> </td>
<td>
<?php if (str_starts_with($log['status'] ?? 'ok', 'blocked')): ?>
<span class="badge bg-danger" title="<?= htmlspecialchars($log['status']) ?>">
<i class="bi bi-shield-x"></i> Geblokkeerd
</span>
<?php else: ?>
<span class="badge bg-success" title="Toegestaan">
<i class="bi bi-check-circle"></i> OK
</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($log['lang']) ?></td> <td><?= htmlspecialchars($log['lang']) ?></td>
<td class="small text-muted" title="<?= htmlspecialchars($log['ua']) ?>"><?= htmlspecialchars(substr($log['ua'], 0, 60)) ?><?= strlen($log['ua']) > 60 ? '…' : '' ?></td> <td class="small text-muted" title="<?= htmlspecialchars($log['ua']) ?>"><?= htmlspecialchars(substr($log['ua'], 0, 50)) ?><?= strlen($log['ua']) > 50 ? '…' : '' ?></td>
<td class="small text-muted" title="<?= htmlspecialchars($log['referrer']) ?>"><?= htmlspecialchars(substr($log['referrer'], 0, 40)) ?><?= strlen($log['referrer']) > 40 ? '…' : '' ?></td> <td class="small text-muted" title="<?= htmlspecialchars($log['referrer']) ?>"><?= htmlspecialchars(substr($log['referrer'], 0, 30)) ?><?= strlen($log['referrer']) > 30 ? '…' : '' ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+114
View File
@@ -0,0 +1,114 @@
<h2 class="mb-4"><i class="bi bi-shield-check"></i> Beveiliging & Bot Bescherming</h2>
<form method="POST" action="/admin/security">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="bi bi-robot"></i> Bot, AI & Scraper Blokkering
</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="block_ai_bots" name="block_ai_bots" value="1" <?= !empty($sec['block_ai_bots']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="block_ai_bots">
<i class="bi bi-cpu text-danger"></i> AI Crawlers & Scrapers blokkeren (403 Forbidden)
</label>
<div class="form-text">Blokkeert bekende AI-bots zoals <code>GPTBot</code>, <code>ChatGPT-User</code>, <code>ClaudeBot</code>, <code>PerplexityBot</code>, <code>CCBot</code>, <code>Google-Extended</code>, <code>Bytespider</code>, <code>Applebot-Extended</code>, etc.</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="block_scrapers" name="block_scrapers" value="1" <?= !empty($sec['block_scrapers']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="block_scrapers">
<i class="bi bi-bug text-warning"></i> Geautomatiseerde Scrapers & Tools blokkeren (403 Forbidden)
</label>
<div class="form-text">Blokkeert automatische scraping tools zoals <code>HTTrack</code>, <code>Scrapy</code>, <code>PhantomJS</code>, <code>HeadlessChrome</code>, <code>cURL</code>, <code>Wget</code>, <code>Python-requests</code>, <code>libwww-perl</code>, etc.</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="block_empty_user_agent" name="block_empty_user_agent" value="1" <?= !empty($sec['block_empty_user_agent']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="block_empty_user_agent">
<i class="bi bi-slash-circle text-secondary"></i> Verzoeken met lege User-Agent header blokkeren
</label>
<div class="form-text">Veel eenvoudige bots en aanvalscripts sturen geen User-Agent header mee.</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="block_search_engines" name="block_search_engines" value="1" <?= !empty($sec['block_search_engines']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold text-danger" for="block_search_engines">
<i class="bi bi-search"></i> Legitieme zoekmachines blokkeren (Google, Bing, DuckDuckGo, etc.)
</label>
<div class="form-text text-danger">Let op: Schakel dit alleen in als je wilt dat de hele site niet in zoekmachines (zoals Google en Bing) verschijnt.</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-speedometer2"></i> Snelheidsbeperking (Rate Limiting per IP)
</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="rate_limit_enabled" name="rate_limit_enabled" value="1" <?= !empty($sec['rate_limit_enabled']) ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="rate_limit_enabled">Rate Limiter inschakelen</label>
<div class="form-text">Voorkomt dat scrapers of bots de site overbelasten door tientallen verzoeken per seconde uit te voeren. Overschrijding geeft HTTP 429.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="rate_limit_max" class="form-label">Maximaal aantal verzoeken per IP</label>
<input type="number" class="form-control" id="rate_limit_max" name="rate_limit_max" min="10" max="1000" value="<?= (int)($sec['rate_limit_max'] ?? 60) ?>">
<small class="form-text text-muted">Aanbevolen: 60 verzoeken.</small>
</div>
<div class="col-md-6 mb-3">
<label for="rate_limit_window" class="form-label">Tijdvenster (in seconden)</label>
<input type="number" class="form-control" id="rate_limit_window" name="rate_limit_window" min="10" max="3600" value="<?= (int)($sec['rate_limit_window'] ?? 60) ?>">
<small class="form-text text-muted">Aanbevolen: 60 seconden (1 minuut).</small>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-list-check"></i> Eigen Filters & IP-Lijsten
</div>
<div class="card-body">
<div class="mb-3">
<label for="custom_blocked_agents" class="form-label fw-bold">Aangepaste User-Agent Blocklist</label>
<textarea class="form-control font-monospace" id="custom_blocked_agents" name="custom_blocked_agents" rows="3" placeholder="Typ één User-Agent patroon per regel (bijv. MyCustomBot)"><?= htmlspecialchars(implode("\n", $sec['custom_blocked_agents'] ?? [])) ?></textarea>
<div class="form-text">Verzoeken waarvan de User-Agent dit patroon bevat krijgen een 403 Forbidden.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="allowed_ips" class="form-label fw-bold text-success"><i class="bi bi-shield-check"></i> IP Whitelist (Altijd Toegang)</label>
<textarea class="form-control font-monospace" id="allowed_ips" name="allowed_ips" rows="3" placeholder="Één IP per regel (bijv. 82.169.10.20)"><?= htmlspecialchars(implode("\n", $sec['allowed_ips'] ?? [])) ?></textarea>
<div class="form-text text-success">IP's op de whitelist worden nooit geblokkeerd door bot-filters of rate limiting.</div>
</div>
<div class="col-md-6 mb-3">
<label for="blocked_ips" class="form-label fw-bold text-danger"><i class="bi bi-shield-x"></i> IP Blocklist (Altijd Geblokkeerd)</label>
<textarea class="form-control font-monospace" id="blocked_ips" name="blocked_ips" rows="3" placeholder="Één IP per regel (bijv. 198.51.100.4)"><?= htmlspecialchars(implode("\n", $sec['blocked_ips'] ?? [])) ?></textarea>
<div class="form-text text-danger">IP's op de blocklist krijgen altijd direct een HTTP 403 Forbidden.</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg mb-4">
<i class="bi bi-check-lg"></i> Beveiligingsinstellingen opslaan
</button>
</form>
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-file-earmark-text"></i> Dynamische <code>robots.txt</code> Preview
</div>
<div class="card-body p-0">
<div class="bg-dark text-light p-3 rounded-bottom">
<pre class="m-0 text-light font-monospace small"><?= htmlspecialchars($robotsPreview ?? '') ?></pre>
</div>
</div>
<div class="card-footer text-muted small">
Deze <code>robots.txt</code> wordt automatisch geserveerd op <code>/robots.txt</code> en past zich aan je instellingen aan.
</div>
</div>
+3 -3
View File
@@ -64,7 +64,7 @@ class ARIAComponents {
$label = $options['aria-label'] ?? 'Hoofdmenu'; $label = $options['aria-label'] ?? 'Hoofdmenu';
$orientation = $options['orientation'] ?? 'horizontal'; $orientation = $options['orientation'] ?? 'horizontal';
$html = '<nav id="' . $id . '" role="navigation" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8) . '">'; $html = '<nav id="' . $id . '" role="navigation" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '">';
$html .= '<ul role="menubar" aria-orientation="' . $orientation . '">'; $html .= '<ul role="menubar" aria-orientation="' . $orientation . '">';
foreach ($menu as $index => $item) { foreach ($menu as $index => $item) {
@@ -134,7 +134,7 @@ class ARIAComponents {
$label = $options['aria-label'] ?? 'Formulier'; $label = $options['aria-label'] ?? 'Formulier';
$html = '<form id="' . $id . '" method="' . $method . '" action="' . htmlspecialchars($action, ENT_QUOTES, 'UTF-8') . '" '; $html = '<form id="' . $id . '" method="' . $method . '" action="' . htmlspecialchars($action, ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'role="form" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8) . '" '; $html .= 'role="form" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'novalidate>'; $html .= 'novalidate>';
foreach ($fields as $index => $field) { foreach ($fields as $index => $field) {
@@ -259,7 +259,7 @@ class ARIAComponents {
public static function createAccessibleBreadcrumb($breadcrumbs, $options = []) { public static function createAccessibleBreadcrumb($breadcrumbs, $options = []) {
$label = $options['aria-label'] ?? 'Broodkruimelnavigatie'; $label = $options['aria-label'] ?? 'Broodkruimelnavigatie';
$html = '<nav aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8) . '">'; $html = '<nav aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '">';
$html .= '<ol class="breadcrumb">'; $html .= '<ol class="breadcrumb">';
foreach ($breadcrumbs as $index => $crumb) { foreach ($breadcrumbs as $index => $crumb) {
+189
View File
@@ -0,0 +1,189 @@
<?php
/**
* BotGuard - Bot, AI Crawler, and Scraper detection & protection
*/
class BotGuard
{
/**
* Map of bot signatures by category and pattern
*/
public static function getBotSignatures(): array
{
return [
'ai' => [
'GPTBot' => 'AI (GPTBot)',
'ChatGPT-User' => 'AI (ChatGPT)',
'Claude-Web' => 'AI (Claude)',
'ClaudeBot' => 'AI (ClaudeBot)',
'anthropic-ai' => 'AI (Anthropic)',
'Google-Extended' => 'AI (Gemini/Google)',
'CCBot' => 'AI (CommonCrawl)',
'PerplexityBot' => 'AI (Perplexity)',
'Amazonbot' => 'AI (Amazon)',
'cohere-ai' => 'AI (Cohere)',
'OAI-SearchBot' => 'AI (OpenAI)',
'Bytespider' => 'AI (ByteDance)',
'FacebookBot' => 'AI (Meta/FB)',
'Applebot-Extended' => 'AI (Apple)',
'Meta-ExternalAgent' => 'AI (Meta)',
'Diffbot' => 'AI (Diffbot)',
'ImagesiftBot' => 'AI (Imagesift)',
'Omgilibot' => 'AI (Omgili)',
'Timpibot' => 'AI (Timpi)',
],
'search' => [
'Googlebot' => 'Zoekmachine (Google)',
'Bingbot' => 'Zoekmachine (Bing)',
'BingPreview' => 'Zoekmachine (Bing)',
'Slurp' => 'Zoekmachine (Yahoo)',
'DuckDuckBot' => 'Zoekmachine (DuckDuckGo)',
'Baiduspider' => 'Zoekmachine (Baidu)',
'YandexBot' => 'Zoekmachine (Yandex)',
'Sogou' => 'Zoekmachine (Sogou)',
'Exabot' => 'Zoekmachine (Exabot)',
'facebot' => 'Zoekmachine (Facebook)',
],
'scraper' => [
'HTTrack' => 'Scraper (HTTrack)',
'Scrapy' => 'Scraper (Scrapy)',
'PhantomJS' => 'Scraper (PhantomJS)',
'HeadlessChrome' => 'Scraper (Headless)',
'curl' => 'Scraper (cURL)',
'wget' => 'Scraper (Wget)',
'python-requests' => 'Scraper (Python)',
'python-urllib' => 'Scraper (Python)',
'Go-http-client' => 'Scraper (Go)',
'libwww-perl' => 'Scraper (Perl)',
'Java/' => 'Scraper (Java)',
'Postman' => 'Scraper (Postman)',
]
];
}
/**
* Identify a User-Agent string
*
* @param string $ua User-Agent string
* @return array Array with category, pattern, and display label
*/
public static function identify(string $ua): array
{
if (trim($ua) === '') {
return [
'category' => 'empty',
'pattern' => 'empty',
'label' => 'Lege User-Agent'
];
}
$signatures = self::getBotSignatures();
foreach ($signatures['ai'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['category' => 'ai', 'pattern' => $pattern, 'label' => $label];
}
}
foreach ($signatures['search'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['category' => 'search', 'pattern' => $pattern, 'label' => $label];
}
}
foreach ($signatures['scraper'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['category' => 'scraper', 'pattern' => $pattern, 'label' => $label];
}
}
if (preg_match('/(bot|crawler|spider|slurp)/i', $ua)) {
return ['category' => 'generic', 'pattern' => 'generic_bot', 'label' => 'Bot'];
}
return ['category' => 'human', 'pattern' => 'human', 'label' => 'Mens'];
}
/**
* Determine if a request should be blocked based on security settings
*
* @param string $ua User-Agent string
* @param array $securitySettings Security configuration array
* @return string|null Reason string if blocked, null if allowed
*/
public static function shouldBlock(string $ua, array $securitySettings): ?string
{
$trimmedUa = trim($ua);
// 1. Empty User-Agent check
if ($trimmedUa === '') {
if (!empty($securitySettings['block_empty_user_agent'])) {
return 'blocked:empty_ua';
}
return null;
}
// 2. Custom User-Agent blocklist
$customBlocked = $securitySettings['custom_blocked_agents'] ?? [];
if (is_array($customBlocked)) {
foreach ($customBlocked as $pattern) {
$pattern = trim($pattern);
if ($pattern !== '' && stripos($trimmedUa, $pattern) !== false) {
return 'blocked:custom_agent';
}
}
}
// 3. Category signature check
$identity = self::identify($trimmedUa);
$category = $identity['category'];
if ($category === 'ai' && !empty($securitySettings['block_ai_bots'])) {
return 'blocked:ai';
}
if ($category === 'search' && !empty($securitySettings['block_search_engines'])) {
return 'blocked:search';
}
if ($category === 'scraper' && !empty($securitySettings['block_scrapers'])) {
return 'blocked:scraper';
}
if ($category === 'generic' && (!empty($securitySettings['block_scrapers']) || !empty($securitySettings['block_ai_bots']))) {
return 'blocked:generic_bot';
}
return null;
}
/**
* Generate dynamic robots.txt content based on security settings
*
* @param array $securitySettings Security configuration array
* @return string Robots.txt content
*/
public static function generateRobotsTxt(array $securitySettings): string
{
$out = "# robots.txt generated dynamically by CodePress CMS\n\n";
// Global rule for search engines
if (!empty($securitySettings['block_search_engines'])) {
$out .= "User-agent: *\nDisallow: /\n\n";
} else {
$out .= "User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /cms\n\n";
}
// Block specific AI bots if enabled
if (!empty($securitySettings['block_ai_bots'])) {
$signatures = self::getBotSignatures();
$out .= "# Block AI Crawlers & Scrapers\n";
foreach (array_keys($signatures['ai']) as $aiBot) {
$out .= "User-agent: {$aiBot}\nDisallow: /\n";
}
$out .= "\n";
}
return $out;
}
}
+2
View File
@@ -1103,6 +1103,8 @@ class CodePressCMS {
'author_git' => 'https://git.noorlander.info/E.Noorlander', 'author_git' => 'https://git.noorlander.info/E.Noorlander',
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system', 'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based', 'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
'block_ai_bots' => !empty($this->config['security']['block_ai_bots']),
'block_search_engines' => !empty($this->config['security']['block_search_engines']),
'cms_version' => ($this->config['show_version'] ?? true) && isset($this->config['version_info']) ? $this->config['version_info']['version'] : '', 'cms_version' => ($this->config['show_version'] ?? true) && isset($this->config['version_info']) ? $this->config['version_info']['version'] : '',
// Theme colors // Theme colors
'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd', 'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd',
+26 -88
View File
@@ -9,7 +9,7 @@ class RequestLogger
$this->logFile = $logFile; $this->logFile = $logFile;
} }
public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage): void public function log(string $page, string $ip, string $userAgent, string $referrer, string $host, string $acceptLanguage, string $status = 'ok'): void
{ {
$dir = dirname($this->logFile); $dir = dirname($this->logFile);
if (!is_dir($dir)) { if (!is_dir($dir)) {
@@ -19,7 +19,7 @@ class RequestLogger
$timestamp = date('Y-m-d H:i:s'); $timestamp = date('Y-m-d H:i:s');
$ua = substr(preg_replace('/[[:cntrl:]]/', '', $userAgent), 0, 500); $ua = substr(preg_replace('/[[:cntrl:]]/', '', $userAgent), 0, 500);
$ref = substr(preg_replace('/[[:cntrl:]]/', '', $referrer), 0, 500); $ref = substr(preg_replace('/[[:cntrl:]]/', '', $referrer), 0, 500);
$line = "[{$timestamp}] [{$ip}] [{$host}] [{$acceptLanguage}] [{$page}] [{$ua}] [{$ref}]\n"; $line = "[{$timestamp}] [{$ip}] [{$host}] [{$acceptLanguage}] [{$page}] [{$ua}] [{$ref}] [{$status}]\n";
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); @file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
} }
@@ -73,73 +73,26 @@ class RequestLogger
public static function detectVisitorInfo(string $ua, string $user = ''): array public static function detectVisitorInfo(string $ua, string $user = ''): array
{ {
if (empty($ua)) { if (class_exists('BotGuard')) {
$userLabel = ($user && $user !== 'Gast') ? $user : 'Onbekend'; $id = BotGuard::identify($ua);
return ['type' => 'unknown', 'label' => $userLabel, 'badge' => 'secondary', 'icon' => 'bi-question-circle']; $cat = $id['category'];
} $label = $id['label'];
$bots = [ if ($cat === 'ai') {
'AI' => [
'GPTBot' => 'AI (GPTBot)',
'ChatGPT-User' => 'AI (ChatGPT)',
'Claude-Web' => 'AI (Claude)',
'ClaudeBot' => 'AI (ClaudeBot)',
'anthropic-ai' => 'AI (Anthropic)',
'Google-Extended' => 'AI (Gemini/Google)',
'CCBot' => 'AI (CommonCrawl)',
'PerplexityBot' => 'AI (Perplexity)',
'Amazonbot' => 'AI (Amazon)',
'cohere-ai' => 'AI (Cohere)',
'OAI-SearchBot' => 'AI (OpenAI)',
'Bytespider' => 'AI (ByteDance)',
'FacebookBot' => 'AI (Meta/FB)',
'Applebot-Extended' => 'AI (Apple)',
],
'Search' => [
'Googlebot' => 'Zoekmachine (Google)',
'Bingbot' => 'Zoekmachine (Bing)',
'BingPreview' => 'Zoekmachine (Bing)',
'Slurp' => 'Zoekmachine (Yahoo)',
'DuckDuckBot' => 'Zoekmachine (DuckDuckGo)',
'Baiduspider' => 'Zoekmachine (Baidu)',
'YandexBot' => 'Zoekmachine (Yandex)',
'Sogou' => 'Zoekmachine (Sogou)',
'Exabot' => 'Zoekmachine (Exabot)',
'facebot' => 'Zoekmachine (Facebook)',
],
'Scraper' => [
'HTTrack' => 'Scraper (HTTrack)',
'Scrapy' => 'Scraper (Scrapy)',
'PhantomJS' => 'Scraper (PhantomJS)',
'HeadlessChrome' => 'Scraper (Headless)',
'curl' => 'Scraper (cURL)',
'wget' => 'Scraper (Wget)',
'python' => 'Scraper (Python)',
'Postman' => 'Scraper (Postman)',
],
];
foreach ($bots['AI'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['type' => 'ai', 'label' => $label, 'badge' => 'danger', 'icon' => 'bi-robot']; return ['type' => 'ai', 'label' => $label, 'badge' => 'danger', 'icon' => 'bi-robot'];
} }
} if ($cat === 'search') {
foreach ($bots['Search'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['type' => 'search', 'label' => $label, 'badge' => 'primary', 'icon' => 'bi-search']; return ['type' => 'search', 'label' => $label, 'badge' => 'primary', 'icon' => 'bi-search'];
} }
} if ($cat === 'scraper') {
foreach ($bots['Scraper'] as $pattern => $label) {
if (stripos($ua, $pattern) !== false) {
return ['type' => 'scraper', 'label' => $label, 'badge' => 'warning text-dark', 'icon' => 'bi-bug']; return ['type' => 'scraper', 'label' => $label, 'badge' => 'warning text-dark', 'icon' => 'bi-bug'];
} }
} if ($cat === 'generic') {
if (preg_match('/(bot|crawler|spider|slurp)/i', $ua)) {
return ['type' => 'bot', 'label' => 'Bot', 'badge' => 'secondary', 'icon' => 'bi-robot']; return ['type' => 'bot', 'label' => 'Bot', 'badge' => 'secondary', 'icon' => 'bi-robot'];
} }
if ($cat === 'empty') {
return ['type' => 'empty', 'label' => 'Lege UA', 'badge' => 'secondary', 'icon' => 'bi-slash-circle'];
}
$userPrefix = ($user && $user !== 'Gast') ? htmlspecialchars($user) . ' (' : ''; $userPrefix = ($user && $user !== 'Gast') ? htmlspecialchars($user) . ' (' : '';
$userSuffix = ($user && $user !== 'Gast') ? ')' : ''; $userSuffix = ($user && $user !== 'Gast') ? ')' : '';
@@ -152,37 +105,18 @@ class RequestLogger
]; ];
} }
return ['type' => 'unknown', 'label' => 'Bezoeker', 'badge' => 'secondary', 'icon' => 'bi-person'];
}
public static function detectBot(): ?string public static function detectBot(): ?string
{ {
$ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (empty($ua)) return null; if (class_exists('BotGuard')) {
$id = BotGuard::identify($ua);
$bots = [ if (in_array($id['category'], ['ai', 'search', 'scraper'], true)) {
'AI' => [ return strtoupper($id['category']);
'GPTBot', 'ChatGPT-User', 'Claude-Web', 'ClaudeBot',
'anthropic-ai', 'Google-Extended', 'CCBot',
'PerplexityBot', 'Amazonbot', 'cohere-ai',
'OAI-SearchBot', 'Bytespider', 'FacebookBot',
'Applebot-Extended',
],
'Search' => [
'Googlebot', 'Bingbot', 'BingPreview', 'Slurp',
'DuckDuckBot', 'Baiduspider', 'YandexBot',
'Sogou', 'Exabot', 'facebot',
],
'Scraper' => [
'HTTrack', 'Scrapy', 'PhantomJS', 'HeadlessChrome',
],
];
foreach ($bots as $category => $patterns) {
foreach ($patterns as $pattern) {
if (stripos($ua, $pattern) !== false) {
return $category;
} }
} }
}
return null; return null;
} }
@@ -197,12 +131,15 @@ class RequestLogger
$logs = []; $logs = [];
foreach ($content as $line) { foreach ($content as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]*)\]$/', trim($line), $m)) { $trimmed = trim($line);
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]+)\] \[([^\]]*)\] \[([^\]]*)\](?: \[([^\]]*)\])?$/', $trimmed, $m)) {
$user = $m[3]; $user = $m[3];
// Handle backwards compatibility where host (e.g. localhost:8080 or domain.com) was logged
if (str_contains($user, '.') || str_contains($user, ':') || $user === 'cli') { if (str_contains($user, '.') || str_contains($user, ':') || $user === 'cli') {
$user = 'Gast'; $user = 'Gast';
} }
$status = $m[8] ?? 'ok';
if ($status === '') $status = 'ok';
$visitorInfo = self::detectVisitorInfo($m[6], $user); $visitorInfo = self::detectVisitorInfo($m[6], $user);
$logs[] = [ $logs[] = [
'time' => $m[1], 'time' => $m[1],
@@ -213,6 +150,7 @@ class RequestLogger
'page' => $m[5], 'page' => $m[5],
'ua' => $m[6], 'ua' => $m[6],
'referrer' => $m[7], 'referrer' => $m[7],
'status' => $status,
]; ];
} }
} }
+12
View File
@@ -33,6 +33,18 @@ if (!file_exists($configJsonPath)) {
'auto_link_pages' => true, 'auto_link_pages' => true,
'search_enabled' => true, 'search_enabled' => true,
'breadcrumbs_enabled' => true 'breadcrumbs_enabled' => true
],
'security' => [
'block_ai_bots' => true,
'block_scrapers' => true,
'block_search_engines' => false,
'block_empty_user_agent' => true,
'rate_limit_enabled' => true,
'rate_limit_max' => 60,
'rate_limit_window' => 60,
'custom_blocked_agents' => [],
'blocked_ips' => [],
'allowed_ips' => []
] ]
]; ];
@file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); @file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+3
View File
@@ -33,6 +33,9 @@ if (file_exists($autoloader)) {
} }
// Load template engine - renders HTML with {{variable}} placeholders and conditionals // Load template engine - renders HTML with {{variable}} placeholders and conditionals
require_once 'class/Cache.php';
require_once 'class/RateLimiter.php';
require_once 'class/BotGuard.php';
require_once 'class/SimpleTemplate.php'; require_once 'class/SimpleTemplate.php';
// Load Logger class - structured logging with log levels // Load Logger class - structured logging with log levels
+7
View File
@@ -18,6 +18,13 @@
<!-- SEO Meta Tags --> <!-- SEO Meta Tags -->
<meta name="description" content="{{seo_description}}"> <meta name="description" content="{{seo_description}}">
<meta name="keywords" content="{{seo_keywords}}"> <meta name="keywords" content="{{seo_keywords}}">
{{#block_ai_bots}}
<meta name="robots" content="noai, noimageai">
<meta name="tdm-reservation" content="1">
{{/block_ai_bots}}
{{#block_search_engines}}
<meta name="robots" content="noindex, nofollow">
{{/block_search_engines}}
<!-- Author Links --> <!-- Author Links -->
<link rel="author" href="{{author_website}}"> <link rel="author" href="{{author_website}}">
+12
View File
@@ -28,5 +28,17 @@
"auto_link_pages": true, "auto_link_pages": true,
"search_enabled": true, "search_enabled": true,
"breadcrumbs_enabled": true "breadcrumbs_enabled": true
},
"security": {
"block_ai_bots": true,
"block_scrapers": true,
"block_search_engines": false,
"block_empty_user_agent": true,
"rate_limit_enabled": true,
"rate_limit_max": 60,
"rate_limit_window": 60,
"custom_blocked_agents": [],
"blocked_ips": [],
"allowed_ips": []
} }
} }
+67
View File
@@ -0,0 +1,67 @@
# HAProxy & PFSense Bot, AI & Scraper Blokkering
Deze handleiding legt uit hoe je op netwerkniveau (via HAProxy op PFSense) bots, AI-crawlers en scrapers blokkeert **voordat ze je CodePress CMS webserver bereiken**.
---
## 1. Randvoorwaarde: Echte Bezoeker IP's doorsturen (`X-Forwarded-For`)
Om te zorgen dat CodePress CMS en HAProxy het echte IP-adres van de bezoeker zien (in plaats van het interne PFSense IP zoals `192.168.210.1`):
1. Ga in PFSense naar **Services → HAProxy → Frontend** en bewerk je Frontend.
2. Zorg dat het **Type** op **`http / https (offloading)`** staat.
3. Vink onder **Advanced settings** aan: **`Use option forwardfor`**.
4. Voeg onderaan bij **Advanced pass thru** toe:
```text
http-request set-header X-Real-IP %[src]
```
5. Sla op en klik op **Apply Changes**.
---
## 2. HAProxy ACL Regels (User-Agent Blokkering)
Voeg in HAProxy Frontend onder **Advanced pass thru** de volgende ACL-regels toe om AI-bots en scrapers direct een `403 Forbidden` te geven:
```text
# Detecteer AI Bots
acl is_ai_bot req.fhdr(User-Agent) -i -m sub GPTBot ChatGPT-User ClaudeBot Claude-Web CCBot PerplexityBot Bytespider FacebookBot Google-Extended Applebot-Extended
# Detecteer Scrapers & Automated Tools
acl is_scraper req.fhdr(User-Agent) -i -m sub HTTrack Scrapy HeadlessChrome PhantomJS curl wget python-requests libwww-perl
# Detecteer Lege User-Agents
acl is_empty_ua req.fhdr(User-Agent) -m len 0
# Blokkeer als een van de regels matcht
http-request deny deny_status 403 if is_ai_bot
http-request deny deny_status 403 if is_scraper
http-request deny deny_status 403 if is_empty_ua
```
---
## 3. HAProxy Rate Limiting (Stick Tables)
Om overbelasting door agressieve scrapers te voorkomen op netwerkniveau, kun je een stick table toevoegen aan je HAProxy Frontend (**Advanced pass thru**):
```text
# Houd verzoeken per IP bij (1 minuut venster)
stick-table type ip size 100k expire 1m store gpc0,http_req_rate(60s)
tcp-request connection track-sc0 src
# Blokkeer IP als er meer dan 60 verzoeken per minuut worden gedaan
acl is_abuser sc0_http_req_rate gt 60
http-request deny deny_status 429 if is_abuser
```
---
## 4. Datacenter IP Blokkering met pfBlockerNG
Veel kwaadaardige bots en scrapers draaien op goedkope cloud/datacenter servers (AWS, Hetzner, OVH, DigitalOcean, Linode).
1. Installeer **pfBlockerNG-devel** via **System → Package Manager** in PFSense.
2. Ga naar **Firewall → pfBlockerNG → IP → IPv4**.
3. Voeg de ASN / Datacenter Feeds toe van bekende cloud providers (bijv. AWS, Hetzner, OVH, DigitalOcean).
4. Zet de actie op **Deny Both** (blokkeert zowel inkomend als uitgaand verkeer naar die datacenter IP-ranges).
+2
View File
@@ -302,6 +302,8 @@ Media files (images, PDFs, video, audio) can be placed in any `content/` subdire
| `content-dir-create` | Create directory (POST) | | `content-dir-create` | Create directory (POST) |
| `content-dir-delete` | Delete directory (empty only) | | `content-dir-delete` | Delete directory (empty only) |
| `config` | Configuration form (title, homepage, language, SEO, author, features) | | `config` | Configuration form (title, homepage, language, SEO, author, features) |
| `security` | Security settings (bot/AI blocking, rate limiting, IP block/allowlist) |
| `update` | One-click system update via Git pull |
| `theme` | Theme management | | `theme` | Theme management |
| `plugins` | Plugin overview | | `plugins` | Plugin overview |
| `plugins-new` | Create new plugin | | `plugins-new` | Create new plugin |
+1
View File
@@ -304,6 +304,7 @@ Media bestanden (afbeeldingen, PDFs, video, audio) kunnen in elke `content/` sub
| `content-dir-create` | Map aanmaken (POST) | | `content-dir-create` | Map aanmaken (POST) |
| `content-dir-delete` | Map verwijderen (alleen leeg) | | `content-dir-delete` | Map verwijderen (alleen leeg) |
| `config` | Configuratieformulier (titel, startpagina, taal, SEO, auteur, features) | | `config` | Configuratieformulier (titel, startpagina, taal, SEO, auteur, features) |
| `security` | Beveiligingsinstellingen (bot/AI blokkering, rate limiting, IP block/allowlist) |
| `theme` | Thema beheer | | `theme` | Thema beheer |
| `plugins` | Plugin overzicht | | `plugins` | Plugin overzicht |
| `plugins-new` | Nieuwe plugin aanmaken | | `plugins-new` | Nieuwe plugin aanmaken |
+62
View File
@@ -88,6 +88,10 @@ switch ($route) {
handleConfig($auth, $appConfig); handleConfig($auth, $appConfig);
break; break;
case 'security':
handleSecurity($auth, $appConfig);
break;
case 'update': case 'update':
handleUpdate($auth, $appConfig); handleUpdate($auth, $appConfig);
break; break;
@@ -747,6 +751,64 @@ function handleConfig(AdminAuth $auth, array $config): void
require __DIR__ . '/../admin/templates/layout.php'; require __DIR__ . '/../admin/templates/layout.php';
} }
function handleSecurity(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$configJson = $config['config_json'];
$message = '';
$messageType = '';
// Load current config
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = [];
$sec = $configData['security'] ?? [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$configData['security']['block_ai_bots'] = !empty($_POST['block_ai_bots']);
$configData['security']['block_scrapers'] = !empty($_POST['block_scrapers']);
$configData['security']['block_search_engines'] = !empty($_POST['block_search_engines']);
$configData['security']['block_empty_user_agent'] = !empty($_POST['block_empty_user_agent']);
$configData['security']['rate_limit_enabled'] = !empty($_POST['rate_limit_enabled']);
$configData['security']['rate_limit_max'] = max(10, min(1000, (int)($_POST['rate_limit_max'] ?? 60)));
$configData['security']['rate_limit_window'] = max(10, min(3600, (int)($_POST['rate_limit_window'] ?? 60)));
$parseLines = function($text) {
$lines = explode("\n", str_replace("\r", "", $text));
$clean = [];
foreach ($lines as $line) {
$item = trim($line);
if ($item !== '') {
$clean[] = $item;
}
}
return array_values(array_unique($clean));
};
$configData['security']['custom_blocked_agents'] = $parseLines($_POST['custom_blocked_agents'] ?? '');
$configData['security']['blocked_ips'] = $parseLines($_POST['blocked_ips'] ?? '');
$configData['security']['allowed_ips'] = $parseLines($_POST['allowed_ips'] ?? '');
file_put_contents($configJson, json_encode($configData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' wijzigde beveiligingsinstellingen');
$message = 'Beveiligingsinstellingen opgeslagen.';
$messageType = 'success';
$sec = $configData['security'];
}
}
require_once __DIR__ . '/../cms/core/class/BotGuard.php';
$robotsPreview = BotGuard::generateRobotsTxt($sec);
$route = 'security';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleTheme(AdminAuth $auth, array $config): void function handleTheme(AdminAuth $auth, array $config): void
{ {
$user = $auth->getCurrentUser(); $user = $auth->getCurrentUser();
+68 -9
View File
@@ -82,6 +82,13 @@ if (strpos($path, '/-assets/') === 0) {
exit; exit;
} }
// Serve dynamic robots.txt
if ($path === '/robots.txt') {
header('Content-Type: text/plain; charset=utf-8');
echo BotGuard::generateRobotsTxt($config['security'] ?? []);
exit;
}
// Block direct access to content files // Block direct access to content files
if (strpos($path, '/content/') === 0) { if (strpos($path, '/content/') === 0) {
http_response_code(403); http_response_code(403);
@@ -89,16 +96,54 @@ if (strpos($path, '/content/') === 0) {
exit; exit;
} }
// Load RequestLogger for bot detection and request logging // Load RequestLogger for IP & request logging
require_once __DIR__ . '/../cms/core/class/RequestLogger.php'; require_once __DIR__ . '/../cms/core/class/RequestLogger.php';
// Bot detection — block known bots/AI scrapers early // Execute security checks & rate limiting
if (RequestLogger::detectBot() !== null) { $clientIp = RequestLogger::getClientIp();
http_response_code(403); $secSettings = $config['security'] ?? [
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>403 Forbidden</title><meta name="robots" content="noindex,nofollow"></head><body><h1>403 Forbidden</h1><p>Access denied.</p></body></html>'; 'block_ai_bots' => true,
exit; 'block_scrapers' => true,
'block_search_engines' => false,
'block_empty_user_agent' => true,
'rate_limit_enabled' => true,
'rate_limit_max' => 60,
'rate_limit_window' => 60,
];
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$isAllowedIp = in_array($clientIp, $secSettings['allowed_ips'] ?? [], true);
$requestStatus = 'ok';
if (!$isAllowedIp) {
// 1. IP Blocklist
if (in_array($clientIp, $secSettings['blocked_ips'] ?? [], true)) {
$requestStatus = 'blocked:ip';
}
// 2. BotGuard User-Agent check
if ($requestStatus === 'ok') {
$botBlockReason = BotGuard::shouldBlock($userAgent, $secSettings);
if ($botBlockReason !== null) {
$requestStatus = $botBlockReason;
}
}
// 3. Rate Limiting per IP
if ($requestStatus === 'ok' && !empty($secSettings['rate_limit_enabled'])) {
$cacheDir = dirname(__DIR__) . '/admin/storage/cache';
$rateLimiter = new RateLimiter(
(int)($secSettings['rate_limit_max'] ?? 60),
(int)($secSettings['rate_limit_window'] ?? 60),
new FileCache($cacheDir)
);
if (!$rateLimiter->isAllowed($clientIp)) {
$requestStatus = 'blocked:ratelimit';
}
}
} }
// Instantiate CMS instance
$cms = new CodePressCMS($config); $cms = new CodePressCMS($config);
// Log page view (not for media/assets) // Log page view (not for media/assets)
@@ -112,12 +157,26 @@ if (!str_starts_with($path, '/-media/') && !str_starts_with($path, '/-assets/'))
$logger = new RequestLogger($requestLogFile); $logger = new RequestLogger($requestLogFile);
$logger->log( $logger->log(
$_GET['page'] ?? $cms->getEffectiveDefaultPage(), $_GET['page'] ?? $cms->getEffectiveDefaultPage(),
RequestLogger::getClientIp(), $clientIp,
$_SERVER['HTTP_USER_AGENT'] ?? '', $userAgent,
$_SERVER['HTTP_REFERER'] ?? '', $_SERVER['HTTP_REFERER'] ?? '',
$loggedInUser, $loggedInUser,
$_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '' $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '',
$requestStatus
); );
} }
// Block request if status is not ok
if ($requestStatus !== 'ok') {
if ($requestStatus === 'blocked:ratelimit') {
http_response_code(429);
header('Retry-After: ' . (int)($secSettings['rate_limit_window'] ?? 60));
echo '<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><title>429 Te Veel Verzoeken</title><meta name="robots" content="noindex,nofollow"></head><body><h1>429 Te Veel Verzoeken</h1><p>Probeer het over een minuut opnieuw.</p></body></html>';
} else {
http_response_code(403);
echo '<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><title>403 Toegang Geweigerd</title><meta name="robots" content="noindex,nofollow"></head><body><h1>403 Toegang Geweigerd</h1><p>Toegang geweigerd.</p></body></html>';
}
exit;
}
$cms->render(); $cms->render();
+16 -3
View File
@@ -6,12 +6,25 @@
*/ */
return [ return [
'version' => '1.7.1', 'version' => '1.8.0',
'release_date' => '2026-07-28', 'release_date' => '2026-07-29',
'codename' => 'Auto', 'codename' => 'BotGuard',
'status' => 'stable', 'status' => 'stable',
'changelog' => [ 'changelog' => [
'1.8.0' => [
'date' => '2026-07-29',
'changes' => [
'BotGuard security engine for configurable Bot, AI, and Scraper protection',
'Admin Security page (/admin/security) with toggles, rate limiting, and block/allowlists',
'Per-IP rate limiting (configurable max requests & window)',
'Dynamic robots.txt generation (/robots.txt) and noai/noimageai meta tags',
'Real IP detection with HAProxy/PFSense and multi-proxy header support',
'Status column and filtering in admin request logs',
'One-click System Update via admin console (/admin/update)',
'Git-safe local configuration (config.json & admin.json auto-generated and ignored)',
]
],
'1.7.1' => [ '1.7.1' => [
'date' => '2026-07-28', 'date' => '2026-07-28',
'changes' => [ 'changes' => [