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
parent
f804e25a66
commit
c914790a46
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue