security: harden proxy trust + model mass-assignment + terminal-token log hygiene

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 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-26 07:16:10 +02:00
parent f3d2a9592e
commit e38409bf31
4 changed files with 15 additions and 3 deletions

View File

@ -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',

View File

@ -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',

View File

@ -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

View File

@ -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;