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:
@@ -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')`)
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}">
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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' => [
|
||||||
|
|||||||
Reference in New Issue
Block a user