harden honeypot/ban path: close evasions, unmask & audit-DoS vectors

Security re-audit + adversarial red-team of the honeypot deception and
brute-force ban layer. Fixes (all with regression tests):

HoneypotController::trap
- BAN EVASION: keyed ban off getRealMethod() not isMethod('post') — the
  latter honors _method / X-HTTP-Method-Override, so a POST spoofed as PUT
  dodged the ban entirely. Now any non-safe verb to a decoy bans.
- REFLECTED-BAN vs EVASION: removed the Origin-based skip; the Origin header
  is attacker-controlled, so it was a trivial universal ban-evasion (send a
  foreign Origin) worse than the narrow griefing it prevented.
- UNMASK/500: mb_scrub all attacker-controlled meta (ua/query/path/creds) so
  invalid UTF-8 can't throw JsonEncodingException on the array-cast column.
- Ban now runs FIRST and independently of the audit (separate guarded
  try/catch) so a failing audit can never skip the ban; deceive() always 200.
- AUDIT DoS: per-IP hourly cap via atomic Cache::add(0)+increment.

DetectHoneytoken::trip
- Same guarded ban-first / audit-second structure + per-IP audit cap
  (bounds an exempt or rotating source) + mb_scrub on the path.

BruteforceGuard::banNow
- Dedup the auth.ip_banned audit per IP+reason on a 60s window (was
  full bantime): collapses the scanner flood without masking a legitimate
  re-ban after an operator unban, and still audits distinct reasons.

Supply-chain / release / dashboard
- Pin prod base images (caddy/mariadb/redis) by @sha256 digest.
- Atomic branch+tag push in clusev-release.sh (no orphan untagged commit).
- Route github.ref_name through a validated $TAG env in ci-staging.yml.
- Strip inline credentials from the origin URL in set-repository-url.sh
  (greedy match handles an @ inside the password).
- WireGuard peer name via Js::from in wire:click; settings badge shows the
  real role; Threats pill/top_ip account for honeypot_login attempts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 15:05:35 +02:00
parent f804e25a66
commit c914790a46
13 changed files with 337 additions and 56 deletions

View File

@ -112,6 +112,11 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
# Route the tag through an env var instead of interpolating ${{ github.ref_name }} straight into
# run: shells (template-injection hardening / actionlint). The "Enforce a clean stable SemVer tag"
# step below still validates $GITHUB_REF_NAME before any step consumes it.
env:
TAG: ${{ github.ref_name }}
steps: steps:
# Hard stop: only an EXACT stable SemVer tag may cause a public side-effect. The `if:` above # Hard stop: only an EXACT stable SemVer tag may cause a public side-effect. The `if:` above
# cannot regex, so `vfoo` / `v1.2` / `v2026` would slip through — fail here, before any publish. # cannot regex, so `vfoo` / `v1.2` / `v2026` would slip through — fail here, before any publish.
@ -133,8 +138,8 @@ jobs:
id: img id: img
run: | run: |
set -euo pipefail set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}" --format '{{.Manifest.Digest}}') appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${TAG}" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}" --format '{{.Manifest.Digest}}') termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${TAG}" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT" { echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@ -152,14 +157,14 @@ jobs:
git config --global user.name 'clusev-release' git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev' git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "${{ github.ref_name }}" "$PWD/pub" "Release ${{ github.ref_name }}" src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
# release-images.lock — consumed by sub-project #2 (install/update pull). # release-images.lock — consumed by sub-project #2 (install/update pull).
printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \ printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \
"${{ github.ref_name }}" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \ "$TAG" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \
"${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock
git -C pub add -f release-images.lock git -C pub add -f release-images.lock
git -C pub commit -q --amend --no-edit git -C pub commit -q --amend --no-edit
git -C pub tag -f "${{ github.ref_name }}" git -C pub tag -f "$TAG"
# Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release # Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release
# ref therefore ALWAYS has a pullable image. A push failure after this leaves only unreferenced, # ref therefore ALWAYS has a pullable image. A push failure after this leaves only unreferenced,
# content-addressed image tags — re-running the promote republishes them idempotently and retries # content-addressed image tags — re-running the promote republishes them idempotently and retries
@ -167,8 +172,8 @@ jobs:
- name: Publish images to public GHCR - name: Publish images to public GHCR
run: | run: |
set -euo pipefail set -euo pipefail
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${{ github.ref_name }}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}" docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}" docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}"
# Atomic: main + tag land together, or neither does. # Atomic: main + tag land together, or neither does.
- name: Push code + tag to public (atomic) - name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/${{ github.ref_name }}" run: git -C pub push --atomic origin HEAD:main "refs/tags/${TAG}"

View File

@ -6,6 +6,7 @@ use App\Models\AuditEvent;
use App\Services\BruteforceGuard; use App\Services\BruteforceGuard;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
@ -18,6 +19,14 @@ use Illuminate\Support\Str;
*/ */
class HoneypotController extends Controller class HoneypotController extends Controller
{ {
/**
* Per-IP cap on decoy audit rows written in a rolling hour. The decoy routes run WITHOUT
* BlockBannedIp (an already-banned prober still gets the bait), so a single source can loop
* over wildcard paths indefinitely this bounds how far it can grow audit_events. The ban +
* the deception still happen past the cap; only the (redundant) extra audit rows are dropped.
*/
private const AUDIT_CAP_PER_HOUR = 60;
public function trap(Request $request): Response public function trap(Request $request): Response
{ {
// Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit). // Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit).
@ -33,46 +42,89 @@ class HoneypotController extends Controller
$ip = (string) $request->ip(); $ip = (string) $request->ip();
// An active (non-safe) method is a real attack attempt; a safe read (GET/HEAD/OPTIONS) is merely
// viewing a decoy. Only an attempt bans, so simply opening a fake page never locks anyone out of
// the real panel (an operator testing the honeypot, or a mistaken click, stays free).
//
// Use getRealMethod() — the RAW REQUEST_METHOD — NOT isMethod()/getMethod(): those honor the
// `_method` body/query field and the X-HTTP-Method-Override header, so an attacker could send a
// real POST spoofed as `_method=PUT` to make isMethod('post') false and dodge the instant-ban
// entirely (the decoys are Route::any, so the override still routes here). Ban any non-safe verb.
$isAttempt = ! in_array($request->getRealMethod(), ['GET', 'HEAD', 'OPTIONS'], true);
// BAN FIRST, and independently of the audit. The instant-ban IS the protection; it must never be
// skipped because a later step throws on hostile input. Guarded on its own so that even a ban
// failure (e.g. a momentary DB outage) still serves the deception below.
//
// We deliberately do NOT try to spare a "cross-site forged" POST by inspecting the Origin header:
// the header is attacker-controlled, so any Origin-based skip is a trivial UNIVERSAL ban-evasion
// (a scanner just sends a foreign Origin) — strictly worse than the narrow reflected-griefing it
// would prevent. Honeypot bans are time-limited and unban-able from the Threats page.
if ($isAttempt) {
try {
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
} catch (\Throwable $e) {
report($e);
}
}
// Audit the probe (best-effort, rate-capped). Fully isolated: a logging failure must not affect
// the ban above or the response — an unhandled throw here would otherwise surface a Laravel 500
// that UNMASKS the fake.
try {
$this->auditProbe($request, $ip, $isAttempt);
} catch (\Throwable $e) {
report($e);
}
return $this->deceive($request);
}
/** Record the probe as an audit event, rate-capped per source IP (see AUDIT_CAP_PER_HOUR). */
private function auditProbe(Request $request, string $ip, bool $isAttempt): void
{
// Bound audit growth per source IP WITHOUT a check-then-act race: `add` seeds the counter with a
// 1-hour TTL on the first hit only, then the atomic `increment` both counts and returns the new
// total (Redis INCR keeps the TTL). Concurrent hits can at most overshoot by the concurrency
// count — fine for a DoS bound. Past the cap we drop the redundant row (the ban already happened).
$capKey = 'honeypot:audit:'.md5($ip);
Cache::add($capKey, 0, now()->addHour());
if (Cache::increment($capKey) > self::AUDIT_CAP_PER_HOUR) {
return;
}
// Attacker-controlled strings are scrubbed to valid UTF-8 before they reach the array-cast
// `meta` column: a raw invalid byte (e.g. a lone 0x80 in the password field or User-Agent)
// would make json_encode throw JsonEncodingException on the cast. mb_scrub replaces ill-formed
// sequences with the Unicode substitute char.
$clean = static fn (?string $v, int $n = 190): string => Str::limit(mb_scrub((string) $v), $n);
$meta = [ $meta = [
'ua' => Str::limit((string) $request->userAgent(), 190), 'ua' => $clean($request->userAgent()),
'method' => $request->method(), 'method' => $request->method(),
'query' => Str::limit((string) $request->getQueryString(), 190), 'query' => $clean($request->getQueryString()),
]; ];
// Capture the credentials a prober typed into the fake login — their guesses, which are // Capture the credentials a prober typed into the fake login — their guesses, which are threat
// threat intel (a legitimate user never reaches a decoy). The field names span the // intel (a legitimate user never reaches a decoy). The field names span the impersonated apps:
// impersonated apps: WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the // WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the generic form.
// generic form (username/password). This is the attacker's OWN submission, so logging +
// banning stays keyed to their IP — no reflected-victim risk.
$user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username'); $user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username');
$pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password'); $pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password');
if (is_string($user) && $user !== '') { if (is_string($user) && $user !== '') {
$meta['tried_user'] = Str::limit($user, 120); $meta['tried_user'] = $clean($user, 120);
} }
if (is_string($pass) && $pass !== '') { if (is_string($pass) && $pass !== '') {
$meta['tried_pass'] = Str::limit($pass, 120); $meta['tried_pass'] = $clean($pass, 120);
} }
// A POST is a SUBMITTED fake login — an actual attack attempt; a GET is merely viewing a decoy.
// Only an attempt bans the source IP, so merely opening a fake page never locks anyone out of
// the real panel (an operator testing the honeypot, or a mistaken click, stays free). The probe
// is still logged either way, so the dashboard sees it.
$isAttempt = $request->isMethod('post');
AuditEvent::create([ AuditEvent::create([
'user_id' => null, 'user_id' => null,
'actor' => 'system', 'actor' => 'system',
'action' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit', 'action' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit',
'target' => Str::limit($request->path(), 190), 'target' => $clean($request->path()),
'ip' => $ip, 'ip' => $ip,
'meta' => $meta, 'meta' => $meta,
]); ]);
if ($isAttempt) {
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
}
return $this->deceive($request);
} }
/** Pick a convincing fake response for the probed path. */ /** Pick a convincing fake response for the probed path. */

View File

@ -6,6 +6,8 @@ use App\Models\AuditEvent;
use App\Services\BruteforceGuard; use App\Services\BruteforceGuard;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
@ -26,6 +28,9 @@ class DetectHoneytoken
/** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */ /** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */
private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key']; private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key'];
/** Per-IP cap on honeytoken audit rows per rolling hour (bounds an exempt/rotating source). */
private const AUDIT_CAP_PER_HOUR = 60;
public function __construct(private BruteforceGuard $guard) {} public function __construct(private BruteforceGuard $guard) {}
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
@ -81,16 +86,40 @@ class DetectHoneytoken
{ {
$ip = (string) $request->ip(); $ip = (string) $request->ip();
$this->guard->banNow($ip, 'honeytoken'); // Ban first, then audit — each guarded independently (mirrors HoneypotController::trap). A write
// failure (DB hiccup, un-run migration) must never turn a honeytoken trip into a 500 that leaks
// this is a real app, nor let a failing audit skip the ban. No per-IP audit cap is needed here:
// BlockBannedIp runs BEFORE this middleware, so a banned IP is 403'd upstream and never reaches
// trip() again — the honeytoken audit cannot be looped.
try {
$this->guard->banNow($ip, 'honeytoken');
} catch (\Throwable $e) {
report($e);
}
AuditEvent::create([ try {
'user_id' => null, // Bound audit growth per source IP WITHOUT a race (add seeds a 1h TTL once, atomic increment
'actor' => 'system', // counts + returns the new total). An EXEMPT IP is never banned (banNow no-ops), so
'action' => 'security.honeytoken_used', // BlockBannedIp cannot throttle its repeat trips; and a briefly-stale ban-check cache can let
'target' => $canaryName, // a few through before the ban takes hold. Past the cap we drop the row — the ban + 403 still
'ip' => $ip, // happen. Mirrors HoneypotController::auditProbe.
'meta' => ['path' => $request->path()], $capKey = 'honeytoken:audit:'.md5($ip);
]); Cache::add($capKey, 0, now()->addHour());
if (Cache::increment($capKey) <= self::AUDIT_CAP_PER_HOUR) {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'security.honeytoken_used',
'target' => $canaryName,
'ip' => $ip,
// path() is derived from the (attacker-controlled) URL — scrub to valid UTF-8 so a
// percent-decoded invalid byte can't make the array-cast meta column's json_encode throw.
'meta' => ['path' => Str::limit(mb_scrub($request->path()), 190)],
]);
}
} catch (\Throwable $e) {
report($e);
}
return response()->view('errors.blocked', [], 403); return response()->view('errors.blocked', [], 403);
} }

View File

@ -137,7 +137,7 @@ class Index extends Component
// Most active source across all honeypot/token events (null when there is no traffic yet). // Most active source across all honeypot/token events (null when there is no traffic yet).
$topIp = AuditEvent::query() $topIp = AuditEvent::query()
->whereIn('action', ['security.honeypot_hit', 'security.honeytoken_used']) ->whereIn('action', ['security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used'])
->whereNotNull('ip') ->whereNotNull('ip')
->selectRaw('ip, COUNT(*) as hits') ->selectRaw('ip, COUNT(*) as hits')
->groupBy('ip') ->groupBy('ip')

View File

@ -151,14 +151,23 @@ class BruteforceGuard
); );
Cache::forget('bruteforce:banned:'.$canonical); Cache::forget('bruteforce:banned:'.$canonical);
AuditEvent::create([ // Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober
'user_id' => null, // looping the fake login would write one auth.ip_banned row per request. Emit the audit at most
'actor' => 'system', // once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key
'action' => 'auth.ip_banned', // includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a
'target' => $canonical, // honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the
'ip' => $canonical, // rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban,
'meta' => ['reason' => $reason], // which is minutes of human time later, well past this window (so it is not tied to unban()).
]); if (Cache::add('bruteforce:banaudit:'.$canonical.':'.$reason, 1, 60)) {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.ip_banned',
'target' => $canonical,
'ip' => $canonical,
'meta' => ['reason' => $reason],
]);
}
return true; return true;
} }

View File

@ -101,7 +101,9 @@ services:
- clusev - clusev
caddy: caddy:
image: caddy:2-alpine # pin by @sha256 digest before the first prod push # Pinned by digest (supply-chain integrity): a mutable tag could silently swap the image under us.
# Bump: docker buildx imagetools inspect caddy:2-alpine --format '{{.Manifest.Digest}}'
image: caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${APP_PORT:-80}:80" - "${APP_PORT:-80}:80"
@ -130,7 +132,8 @@ services:
- clusev - clusev
mariadb: mariadb:
image: mariadb:11 # Pinned by digest (see caddy). Bump: docker buildx imagetools inspect mariadb:11 --format '{{.Manifest.Digest}}'
image: mariadb:11@sha256:efb4959ef2c835cd735dbc388eb9ad6aab0c78dd64febcd51bc17481111890c4
restart: unless-stopped restart: unless-stopped
environment: environment:
MARIADB_DATABASE: "${DB_DATABASE:-clusev}" MARIADB_DATABASE: "${DB_DATABASE:-clusev}"
@ -149,7 +152,8 @@ services:
# no host ports in prod — DB is reachable only on the internal clusev network # no host ports in prod — DB is reachable only on the internal clusev network
redis: redis:
image: redis:7-alpine # Pinned by digest (see caddy). Bump: docker buildx imagetools inspect redis:7-alpine --format '{{.Manifest.Digest}}'
image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}" command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}"
volumes: volumes:

View File

@ -73,7 +73,10 @@ _do_stage() { # target
*) url="$origin" ;; # local/file remote (tests): push directly *) url="$origin" ;; # local/file remote (tests): push directly
esac esac
if ! git -C "$PROJ" push "$url" "$BRANCH" >/dev/null 2>&1 || ! git -C "$PROJ" push "$url" "$newtag" >/dev/null 2>&1; then # Atomic: the branch commit and its tag land together, or neither does. A non-atomic two-push (the
# old form) could advance origin/$BRANCH and then fail the tag push, leaving an UNTAGGED release
# commit on the remote that _rollback (reset --hard origin/$BRANCH) can no longer undo.
if ! git -C "$PROJ" push --atomic "$url" "$BRANCH" "$newtag" >/dev/null 2>&1; then
echo "push-failed" >&2; _rollback "$newtag"; return 1 echo "push-failed" >&2; _rollback "$newtag"; return 1
fi fi
printf '%s' "$newtag" printf '%s' "$newtag"

View File

@ -38,7 +38,7 @@
class="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-line px-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/40 hover:text-accent-text"> class="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-line px-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/40 hover:text-accent-text">
<x-icon name="help-circle" class="h-3.5 w-3.5" />{{ __('onboarding.relaunch') }} <x-icon name="help-circle" class="h-3.5 w-3.5" />{{ __('onboarding.relaunch') }}
</button> </button>
<x-badge tone="neutral">{{ __('settings.role_admin') }}</x-badge> <x-badge tone="neutral">{{ $u->role?->label() ?? __('settings.role_admin') }}</x-badge>
<x-two-factor-badge :enabled="$twoFactorEnabled" /> <x-two-factor-badge :enabled="$twoFactorEnabled" />
</div> </div>
</div> </div>

View File

@ -5,8 +5,11 @@
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('threats.eyebrow') }}</p> <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('threats.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('threats.title') }}</h2> <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('threats.title') }}</h2>
</div> </div>
<x-status-pill :status="$probes_24h > 0 ? 'warning' : 'online'"> {{-- Escalate on ANY hostile activity: red if someone tried the fake login, amber for mere
{{ __('threats.probes_pill', ['count' => $probes_24h]) }} probes, green only when truly quiet. Count = all decoy hits so the pill never reads a
calm "0 probes" while login attempts are landing. --}}
<x-status-pill :status="$login_attempts_24h > 0 ? 'offline' : ($probes_24h > 0 ? 'warning' : 'online')">
{{ __('threats.probes_pill', ['count' => $probes_24h + $login_attempts_24h]) }}
</x-status-pill> </x-status-pill>
</div> </div>

View File

@ -289,7 +289,7 @@
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span> <span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
</span> </span>
<div class="flex w-full justify-end"> <div class="flex w-full justify-end">
<x-btn variant="danger-soft" size="sm" wire:click="confirmRemovePeer('{{ $peer['name'] !== '' ? $peer['name'] : $peer['pubkey'] }}')"> <x-btn variant="danger-soft" size="sm" wire:click="confirmRemovePeer({{ \Illuminate\Support\Js::from($peer['name'] !== '' ? $peer['name'] : $peer['pubkey']) }})">
<x-icon name="trash" class="h-3.5 w-3.5" />{{ __('wireguard.remove') }} <x-icon name="trash" class="h-3.5 w-3.5" />{{ __('wireguard.remove') }}
</x-btn> </x-btn>
</div> </div>

View File

@ -9,6 +9,11 @@ proj="${1:?project dir required}"
envfile="${2:?env file required}" envfile="${2:?env file required}"
url="$(git -C "$proj" remote get-url origin 2>/dev/null || true)" url="$(git -C "$proj" remote get-url origin 2>/dev/null || true)"
# Strip any inline credentials (https://user:token@host → https://host). A clone origin may carry an
# embedded push token (the release flow uses https://oauth2:${token}@host); writing that verbatim would
# leak the secret into .env — and CLUSEV_REPOSITORY surfaces in the Versions/update UI. Keep it clean.
# [^/]* is greedy so it strips up to the LAST '@' before the path — handles an '@' inside the password.
url="$(printf '%s' "$url" | sed -E 's#^(https?://)[^/]*@#\1#')"
url="${url%.git}" # normalise — the API host is the same with or without .git url="${url%.git}" # normalise — the API host is the same with or without .git
url="${url%/}" # drop a trailing slash url="${url%/}" # drop a trailing slash
[ -n "$url" ] || exit 0 # no origin (e.g. tarball install) → leave .env untouched [ -n "$url" ] || exit 0 # no origin (e.g. tarball install) → leave .env untouched

View File

@ -4,7 +4,10 @@ namespace Tests\Feature;
use App\Models\AuditEvent; use App\Models\AuditEvent;
use App\Models\BannedIp; use App\Models\BannedIp;
use App\Services\BruteforceGuard;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase; use Tests\TestCase;
class HoneypotSubmitTest extends TestCase class HoneypotSubmitTest extends TestCase
@ -68,4 +71,140 @@ class HoneypotSubmitTest extends TestCase
// …but the ban still keeps them off the REAL login (BlockBannedIp -> 403 for a banned guest). // …but the ban still keeps them off the REAL login (BlockBannedIp -> 403 for a banned guest).
$this->call('GET', '/login', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(403); $this->call('GET', '/login', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(403);
} }
public function test_probe_with_invalid_utf8_does_not_500_still_bans_and_audits(): void
{
config(['clusev.honeypot.enabled' => true]);
// A lone 0x80/0xFF in the User-Agent (which bypasses TrimStrings) is invalid UTF-8. Un-scrubbed
// it makes the array-cast `meta` column's json_encode throw — an HTTP 500 that unmasks the fake
// AND aborts the ban, letting an attacker evade the trap by appending a bad byte. Must not happen.
$res = $this->call(
'POST', '/wp-login.php',
['log' => 'admin', 'pwd' => 'Hunter2!'],
[], [],
['HTTP_USER_AGENT' => "scanner/\x80\xFF", 'REMOTE_ADDR' => '203.0.113.90']
);
$res->assertStatus(200);
$res->assertSee('WordPress', false);
$e = AuditEvent::where('action', 'security.honeypot_login')->where('ip', '203.0.113.90')->latest('id')->first();
$this->assertNotNull($e); // create() did NOT throw
$this->assertNotFalse(json_encode($e->meta)); // stored meta is valid UTF-8 / re-encodable
$this->assertSame('admin', $e->meta['tried_user']);
$this->assertTrue(BannedIp::where('ip', '203.0.113.90')->exists());
}
public function test_a_forged_origin_header_does_not_evade_the_ban(): void
{
config(['clusev.honeypot.enabled' => true]);
// The Origin header is attacker-controlled, so the ban MUST NOT depend on it. A scanner sending a
// foreign Origin (the obvious evasion attempt) is banned exactly like one that sends none — the
// POST is still a login attempt on a decoy. Regression guard for the removed Origin-based skip.
$this->call(
'POST', '/wp-login.php',
['log' => 'admin', 'pwd' => 'x'],
[], [],
['HTTP_ORIGIN' => 'https://evil.example.test', 'REMOTE_ADDR' => '203.0.113.91']
)->assertStatus(200);
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_login')->where('ip', '203.0.113.91')->exists());
$this->assertTrue(BannedIp::where('ip', '203.0.113.91')->exists());
}
public function test_ban_still_lands_when_the_audit_write_fails(): void
{
config(['clusev.honeypot.enabled' => true]);
// The instant-ban IS the protection and must NOT be gated by the audit. Drop audit_events so
// EVERY AuditEvent::create throws (the honeypot row AND banNow's own auth.ip_banned row), then
// confirm the POST is still banned and still gets the fake body — the ban write (banned_ips) runs
// before any audit and both are independently guarded, so a broken audit never disarms the trap.
Schema::drop('audit_events');
$this->call('POST', '/wp-login.php', ['log' => 'admin', 'pwd' => 'x'], [], [], ['REMOTE_ADDR' => '203.0.113.92'])
->assertStatus(200);
$this->assertTrue(BannedIp::where('ip', '203.0.113.92')->exists());
}
public function test_repeated_login_attempts_write_a_single_ban_audit(): void
{
config(['clusev.honeypot.enabled' => true]);
// The honeypot bans on EVERY decoy POST; without the ban-audit dedup, a prober looping the fake
// login would flood auth.ip_banned. Expect exactly one ban-audit row for the source IP.
foreach (range(1, 3) as $ignored) {
$this->call('POST', '/wp-login.php', ['log' => 'a', 'pwd' => 'b'], [], [], ['REMOTE_ADDR' => '203.0.113.93'])
->assertStatus(200);
}
$this->assertSame(1, AuditEvent::where('action', 'auth.ip_banned')->where('ip', '203.0.113.93')->count());
$this->assertTrue(BannedIp::where('ip', '203.0.113.93')->exists());
}
public function test_audit_cap_stops_new_rows_but_still_bans(): void
{
config(['clusev.honeypot.enabled' => true]);
$ip = '203.0.113.94';
// Simulate this IP having already blown past the per-hour audit cap (the decoys run without
// BlockBannedIp, so one source can loop wildcard paths forever). Past the cap: no NEW audit row,
// but the ban still lands — DoS-bounded logging without weakening protection or the deception.
Cache::put('honeypot:audit:'.md5($ip), 1000, now()->addHour());
$this->call('POST', '/wp-login.php', ['log' => 'a', 'pwd' => 'b'], [], [], ['REMOTE_ADDR' => $ip])
->assertStatus(200);
// No NEW probe-audit row past the cap (the ban itself still writes ONE auth.ip_banned row, so we
// assert on the honeypot action specifically — not every event for this IP).
$this->assertSame(0, AuditEvent::where('action', 'security.honeypot_login')->where('ip', $ip)->count());
$this->assertTrue(BannedIp::where('ip', $ip)->exists());
}
public function test_a_method_override_does_not_evade_the_ban(): void
{
config(['clusev.honeypot.enabled' => true]);
// A real POST spoofed as PUT via the `_method` body field must NOT dodge the ban — the trap keys
// off the RAW wire method (getRealMethod), not getMethod()/isMethod() which honor the override.
$this->call('POST', '/wp-login.php', ['_method' => 'PUT', 'log' => 'admin', 'pwd' => 'x'], [], [], ['REMOTE_ADDR' => '203.0.113.95'])
->assertStatus(200);
$this->assertTrue(BannedIp::where('ip', '203.0.113.95')->exists());
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_login')->where('ip', '203.0.113.95')->exists());
// Same via the X-HTTP-Method-Override header vector.
$this->call('POST', '/wp-login.php', ['log' => 'admin', 'pwd' => 'x'], [], [], ['HTTP_X_HTTP_METHOD_OVERRIDE' => 'PATCH', 'REMOTE_ADDR' => '203.0.113.96'])
->assertStatus(200);
$this->assertTrue(BannedIp::where('ip', '203.0.113.96')->exists());
}
public function test_distinct_reason_bans_of_one_ip_are_each_audited(): void
{
$guard = app(BruteforceGuard::class);
// Same reason within the 60s dedup window collapses to one audit; a DISTINCT reason is still
// audited (the dedup key is per IP+reason). Expect 2 rows: one honeypot, one honeytoken.
$guard->banNow('203.0.113.97', 'honeypot');
$guard->banNow('203.0.113.97', 'honeypot');
$guard->banNow('203.0.113.97', 'honeytoken');
$this->assertSame(2, AuditEvent::where('action', 'auth.ip_banned')->where('ip', '203.0.113.97')->count());
}
public function test_a_reban_after_the_dedup_window_is_audited(): void
{
$guard = app(BruteforceGuard::class);
$ip = '203.0.113.100';
// The ban-audit dedup is a short 60s window (not the full ban time), so a legitimate later re-ban
// — e.g. after an operator unban, minutes later — is audited again rather than silently swallowed.
$guard->banNow($ip, 'honeypot');
$this->travel(61)->seconds();
$guard->banNow($ip, 'honeypot');
$this->assertSame(2, AuditEvent::where('action', 'auth.ip_banned')->where('ip', $ip)->count());
}
} }

View File

@ -9,6 +9,7 @@ use App\Models\User;
use App\Services\BruteforceGuard; use App\Services\BruteforceGuard;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase; use Tests\TestCase;
class HoneypotTest extends TestCase class HoneypotTest extends TestCase
@ -108,6 +109,37 @@ class HoneypotTest extends TestCase
); );
} }
public function test_honeytoken_trip_still_bans_and_403s_when_the_audit_write_fails(): void
{
$canary = config('clusev.honeypot.canaries.api_key');
// The ban + 403 must survive a broken audit table (DB hiccup / un-run migration): dropping
// audit_events makes every AuditEvent::create throw (banNow's auth.ip_banned AND the honeytoken
// row), yet the trip must NOT become a 500 that leaks this is a real app, and the ban (a separate
// banned_ips write, run first + guarded) must still land.
Schema::drop('audit_events');
$this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['api_key' => $canary])
->assertStatus(403);
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
}
public function test_honeytoken_audit_is_capped_per_ip_but_still_bans(): void
{
$canary = config('clusev.honeypot.canaries.api_key');
$ip = '203.0.113.98';
// An exempt/rotating source could otherwise loop honeytoken trips to grow audit_events. Over the
// per-hour cap: no NEW honeytoken audit row, but the ban + 403 still land.
Cache::put('honeytoken:audit:'.md5($ip), 1000, now()->addHour());
$this->fromIp('POST', '/broadcasting/auth', $ip, ['api_key' => $canary])->assertStatus(403);
$this->assertSame(0, AuditEvent::where('action', 'security.honeytoken_used')->where('ip', $ip)->count());
$this->assertTrue(BannedIp::where('ip', $ip)->exists());
}
public function test_login_post_with_password_equal_to_canary_does_not_ban(): void public function test_login_post_with_password_equal_to_canary_does_not_ban(): void
{ {
// `password` is no longer scanned — a login carrying a value that happens to equal a canary // `password` is no longer scanned — a login carrying a value that happens to equal a canary