harden(fleet,infra): enforce credential revocation on the poller + prod container hardening
From the whole-codebase re-audit (deferred MEDIUM findings, Codex-reviewed): - poller: CredentialVault enforces a credential's disabled_at lock only at connect(), but PollMetrics reuses one long-lived SSH connection per server, so a credential disabled mid-run kept streaming metrics over the already-open session. Add Server::withActiveCredential() (whereHas credential whereNull disabled_at); the poller selects via it and prunes any cached client whose server left the active set, so a revocation drops the server on the next tick and closes its session promptly. - prod compose: add security_opt no-new-privileges:true to every service + a generous pids_limit (fork-bomb backstop). cap_drop/read_only are deliberately left out — they need a per-service prod smoke test (nginx :80 bind, entrypoint chown) before enabling; documented inline. - Caddyfile + compose: strengthen the TRUSTED_PROXY_CIDR guidance — an over-broad value in external-TLS mode lets any client forge X-Forwarded-For and defeat the IP-keyed throttles + ban. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/v1-foundation
parent
bc2f2b527f
commit
6927987a0d
|
|
@ -36,10 +36,22 @@ class PollMetrics extends Command
|
||||||
$clients = [];
|
$clients = [];
|
||||||
|
|
||||||
do {
|
do {
|
||||||
$servers = Server::has('credential')->get();
|
// Only servers with a PRESENT, non-disabled credential. A credential disabled
|
||||||
|
// between ticks drops the server here, so its cached connection is no longer polled
|
||||||
|
// (the vault refuses a fresh connect, but a reused long-lived session would not
|
||||||
|
// re-check disabled_at). Prune any cached client whose server just left the set so
|
||||||
|
// the revoked server's open session is closed promptly, not left idle until exit.
|
||||||
|
$servers = Server::withActiveCredential()->get();
|
||||||
|
$activeIds = $servers->pluck('id')->all();
|
||||||
|
foreach (array_keys($clients) as $id) {
|
||||||
|
if (! in_array($id, $activeIds, true)) {
|
||||||
|
$clients[$id]->disconnect();
|
||||||
|
unset($clients[$id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($servers->isEmpty()) {
|
if ($servers->isEmpty()) {
|
||||||
$this->warn('Keine Server mit Credential — nichts zu pollen.');
|
$this->warn('Keine Server mit aktivem Credential — nichts zu pollen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($servers as $server) {
|
foreach ($servers as $server) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -30,4 +31,15 @@ class Server extends Model
|
||||||
{
|
{
|
||||||
return $this->hasOne(SshCredential::class);
|
return $this->hasOne(SshCredential::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servers whose stored SSH credential is present AND not disabled — the live poll set.
|
||||||
|
* A disabled ("gesperrt") credential is refused by CredentialVault::resolve() at connect
|
||||||
|
* time, but the poller reuses a long-lived connection, so revocation only takes effect if
|
||||||
|
* the server also drops OUT of this query on the next tick.
|
||||||
|
*/
|
||||||
|
public function scopeWithActiveCredential(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->whereHas('credential', fn (Builder $q) => $q->whereNull('disabled_at'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,15 @@ x-image: &image
|
||||||
APP_DEBUG: "false"
|
APP_DEBUG: "false"
|
||||||
networks:
|
networks:
|
||||||
- clusev
|
- clusev
|
||||||
|
# Defense-in-depth (applies to app/reverb/queue/scheduler via <<: *image). no-new-privileges
|
||||||
|
# blocks privilege escalation through setuid binaries — the image's drop-privileges startup only
|
||||||
|
# LOWERS privileges, so it stays compatible. pids_limit is a fork-bomb backstop, set generously so
|
||||||
|
# php-fpm/queue workers never hit it under normal load. cap_drop:[ALL] and read_only are
|
||||||
|
# deliberately NOT set here: they need a per-service prod smoke test first (nginx :80 bind needs
|
||||||
|
# NET_BIND_SERVICE, php-fpm/nginx/supervisor write pid/sockets/logs), so enable them after testing.
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
pids_limit: 1024
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
|
@ -97,6 +106,9 @@ services:
|
||||||
APP_INTERNAL_URL: "http://app:80"
|
APP_INTERNAL_URL: "http://app:80"
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
pids_limit: 512
|
||||||
networks:
|
networks:
|
||||||
- clusev
|
- clusev
|
||||||
|
|
||||||
|
|
@ -113,8 +125,10 @@ services:
|
||||||
# Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so
|
# Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so
|
||||||
# no SITE_ADDRESS is needed. Only the ACME contact comes from .env.
|
# no SITE_ADDRESS is needed. Only the ACME contact comes from .env.
|
||||||
ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}"
|
ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}"
|
||||||
# External reverse-proxy TLS: set to the upstream proxy's CIDR so Caddy trusts
|
# External reverse-proxy TLS: set to the upstream proxy's EXACT address (a /32, or its
|
||||||
# its X-Forwarded-Proto. Leave as 127.0.0.1/32 when Caddy terminates TLS itself.
|
# tightest CIDR) so Caddy trusts its X-Forwarded-Proto/-For. Leave as 127.0.0.1/32 when
|
||||||
|
# Caddy terminates TLS itself. SECURITY: never a broad range / 0.0.0.0/0 — a too-wide value
|
||||||
|
# lets any client forge X-Forwarded-For and defeat the IP-keyed throttles + brute-force ban.
|
||||||
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-127.0.0.1/32}"
|
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-127.0.0.1/32}"
|
||||||
volumes:
|
volumes:
|
||||||
# Mount the DIRECTORY (not the single Caddyfile): a single-file bind mount keeps the
|
# Mount the DIRECTORY (not the single Caddyfile): a single-file bind mount keeps the
|
||||||
|
|
@ -124,6 +138,9 @@ services:
|
||||||
- ./docker/caddy:/etc/caddy:ro
|
- ./docker/caddy:/etc/caddy:ro
|
||||||
- caddy-data:/data # ACME account + certs — MUST persist across recreate
|
- caddy-data:/data # ACME account + certs — MUST persist across recreate
|
||||||
- caddy-config:/config
|
- caddy-config:/config
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
pids_limit: 512
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
- reverb
|
- reverb
|
||||||
|
|
@ -147,6 +164,9 @@ services:
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
pids_limit: 1024
|
||||||
networks:
|
networks:
|
||||||
- clusev
|
- clusev
|
||||||
# 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
|
||||||
|
|
@ -158,6 +178,9 @@ services:
|
||||||
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:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
|
security_opt:
|
||||||
|
- "no-new-privileges:true"
|
||||||
|
pids_limit: 512
|
||||||
networks:
|
networks:
|
||||||
- clusev
|
- clusev
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@
|
||||||
auto_https disable_redirects
|
auto_https disable_redirects
|
||||||
|
|
||||||
# Honor X-Forwarded-* from a trusted upstream proxy (external-TLS mode). Defaults to
|
# Honor X-Forwarded-* from a trusted upstream proxy (external-TLS mode). Defaults to
|
||||||
# 127.0.0.1/32 (trust nothing external); set TRUSTED_PROXY_CIDR to the proxy's address.
|
# 127.0.0.1/32 (trust nothing external); set TRUSTED_PROXY_CIDR to the upstream proxy's
|
||||||
|
# EXACT address (a /32, or its tightest CIDR). SECURITY: never a broad range and never
|
||||||
|
# 0.0.0.0/0 — a too-wide value lets any client forge X-Forwarded-For, which then drives
|
||||||
|
# request()->ip() in the app and defeats the IP-keyed login/2FA throttles + the honeypot
|
||||||
|
# brute-force ban (an attacker can spoof a victim's IP to get it banned, or a whitelisted
|
||||||
|
# IP to evade the ban entirely).
|
||||||
servers {
|
servers {
|
||||||
trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32}
|
trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metrics poller keeps one long-lived SSH connection per server and reuses it across
|
||||||
|
* ticks. CredentialVault::resolve() enforces a credential's `disabled_at` lock only at
|
||||||
|
* connect() time, so a connection opened BEFORE a credential is disabled would keep pulling
|
||||||
|
* metrics over the already-open session. The poll set must therefore exclude servers whose
|
||||||
|
* credential is disabled (or missing) at the query, so a revoked credential drops out of the
|
||||||
|
* live loop on the next tick — Server::withActiveCredential() is that gate.
|
||||||
|
*/
|
||||||
|
class PollMetricsCredentialTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function serverWithCredential(string $name, string $ip, ?string $disabledAt): Server
|
||||||
|
{
|
||||||
|
$server = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
|
||||||
|
$server->credential()->create([
|
||||||
|
'username' => 'root',
|
||||||
|
'auth_type' => 'password',
|
||||||
|
'secret' => 'x',
|
||||||
|
'disabled_at' => $disabledAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_only_servers_with_an_active_credential_are_pollable(): void
|
||||||
|
{
|
||||||
|
$enabled = $this->serverWithCredential('enabled', '10.0.0.1', null);
|
||||||
|
$disabled = $this->serverWithCredential('disabled', '10.0.0.2', now()->toDateTimeString());
|
||||||
|
$noCredential = Server::create(['name' => 'bare', 'ip' => '10.0.0.3', 'ssh_port' => 22, 'status' => 'online']);
|
||||||
|
|
||||||
|
$ids = Server::withActiveCredential()->pluck('id');
|
||||||
|
|
||||||
|
$this->assertTrue($ids->contains($enabled->id)); // enabled credential → polled
|
||||||
|
$this->assertFalse($ids->contains($disabled->id)); // disabled (revoked) credential → excluded
|
||||||
|
$this->assertFalse($ids->contains($noCredential->id)); // no credential → excluded
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue