From e38409bf31ddb472262cc9d78766934716d61368 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 26 Jun 2026 07:16:10 +0200 Subject: [PATCH] security: harden proxy trust + model mass-assignment + terminal-token log hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the security audit (the brute-force/rate-limiting angle matters more now that the password policy is min(6)/no-complexity, so IP-keyed throttles must not be spoofable): - bootstrap/app.php: trustProxies(at: '*') → trust only PRIVATE ranges in prod. Caddy reaches app:80 from the docker bridge (private IP) so it stays trusted, but a public/off-network source can no longer forge X-Forwarded-For to spoof request()->ip() and bypass the login/2FA/forgot throttles + the brute-force ban. - TerminalSession + HostCredential: replace $guarded=[] with an explicit $fillable allowlist; add $hidden=[secret,passphrase] to HostCredential so it never serializes its credential fields. - nginx /terminal/ws: access_log off — the single-use session token rode the query string into the access log. Verified: full suite 467 pass; all panel pages 200 with zero console errors; server terminal still connects + runs commands. Co-Authored-By: Claude Opus 4.8 --- app/Models/HostCredential.php | 6 +++++- app/Models/TerminalSession.php | 3 ++- bootstrap/app.php | 7 ++++++- docker/nginx/nginx.conf | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/Models/HostCredential.php b/app/Models/HostCredential.php index 00ed3a2..cbd9fad 100644 --- a/app/Models/HostCredential.php +++ b/app/Models/HostCredential.php @@ -11,7 +11,11 @@ use Illuminate\Database\Eloquent\Model; */ class HostCredential extends Model { - protected $guarded = []; + /** Explicit allowlist (never $guarded=[]) so request input can't set unintended columns. */ + protected $fillable = ['host', 'port', 'username', 'auth_type', 'secret', 'passphrase']; + + /** Never serialize the credential fields (mirrors SshCredential). */ + protected $hidden = ['secret', 'passphrase']; protected $casts = [ 'port' => 'integer', diff --git a/app/Models/TerminalSession.php b/app/Models/TerminalSession.php index 77d57c5..d2ea3d3 100644 --- a/app/Models/TerminalSession.php +++ b/app/Models/TerminalSession.php @@ -14,7 +14,8 @@ class TerminalSession extends Model { public const UPDATED_AT = null; - protected $guarded = []; + /** Explicit allowlist (never $guarded=[]) so request input can't set unintended columns. */ + protected $fillable = ['token', 'user_id', 'kind', 'server_id', 'expires_at', 'used_at', 'created_at']; protected $casts = [ 'expires_at' => 'datetime', diff --git a/bootstrap/app.php b/bootstrap/app.php index 3fe343b..13debc7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -28,8 +28,13 @@ return Application::configure(basePath: dirname(__DIR__)) // the sole peer. In dev the app port is published on the host, so trusting forged // proxy headers would let a LAN peer fake HTTPS (forcing a Secure cookie over // plain HTTP and breaking the bare-IP recovery login). + // + // Trust only PRIVATE ranges, not '*': Caddy reaches app:80 from the docker bridge (a private + // IP), so it stays trusted, but anything arriving from a public source (a stray published + // port, an off-network relay) can no longer forge X-Forwarded-For to spoof request()->ip() + // and defeat the IP-keyed login/2FA/forgot throttles + the brute-force ban. if (($_SERVER['APP_ENV'] ?? getenv('APP_ENV') ?: 'local') === 'production') { - $middleware->trustProxies(at: '*'); + $middleware->trustProxies(at: ['127.0.0.1/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']); } // PanelScheme first (scheme/host enforcement before auth), so an HTTP request to diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index df0c06e..96fa7bd 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -28,6 +28,8 @@ http { # Terminal sidecar websocket (xterm.js ↔ PTY). Same origin; the sidecar authorizes the # single-use token before opening the PTY. Long read timeout — sessions are long-lived. location = /terminal/ws { + # The single-use session token rides the query string; don't write it to the access log. + access_log off; resolver 127.0.0.11 valid=10s; set $terminal_upstream terminal:3000; proxy_pass http://$terminal_upstream;