-
Notifications
You must be signed in to change notification settings - Fork 3
Security
Model Hotel implements multiple layers of security to protect sensitive data and prevent unauthorized access. This document describes the security architecture, encryption mechanisms, authentication flows, and protective measures.
Provider API keys are encrypted using AES-256-GCM. The MASTER_KEY environment variable is never used directly as the encryption key. Instead, it is fed through Argon2id key derivation (with a per-provider random salt) to produce a 256-bit AES key. This means the MASTER_KEY can be any length - only the derived key is ever used in cryptographic operations.
Each provider gets a unique random 32-byte salt (generated via crypto/rand), stored alongside the ciphertext and nonce in the database (key_salt column). This ensures each provider has a unique derived key - compromising one does not compromise others.
| Parameter | Value |
|---|---|
| Salt | Random 32-byte per-provider (stored in key_salt column) |
| Time cost | 1 |
| Memory cost | 8 MB |
| Threads | 4 |
| Output length | 32 bytes (256 bits) |
Decrypted provider keys are held in an in-memory cache to avoid re-deriving the Argon2id key on every request:
-
TTL: 10 minutes (configurable via
key_cache_ttlsetting) - cached entries expire and must be re-decrypted -
Thread safety: Protected by
sync.RWMutex- multiple goroutines can read concurrently; writes are exclusive - Cache key: Derived from the hex-encoded ciphertext + nonce + salt, so changing a provider's key invalidates the cache entry naturally
- Eviction: A background goroutine runs periodically, purging expired entries
-
Warm-up: On startup, all enabled providers' keys are pre-loaded into the cache (
WarmKeyCache)
This is a security trade-off: caching reduces Argon2id computation overhead on hot paths, but means decrypted keys exist in memory for up to 10 minutes. In practice, this is acceptable because:
- The keys are short-lived in cache (10-minute TTL)
- An attacker with memory access has already compromised the process
- Argon2id is intentionally expensive - without caching, each request would pay the full derivation cost
Virtual keys use the sk- prefix (e.g., sk-a1b2c3d4e5f6a7b8) and are stored as SHA-256 hashes only:
- The raw key is generated using
crypto/rand(16 random bytes, hex-encoded with thesk-prefix) - The raw key is shown once on creation, then discarded - it is never stored in plaintext
- Only the SHA-256 hash is persisted in the
virtual_keystable - Lookup compares SHA-256 of the provided Bearer token against the stored hash
- A key preview (
sk-...01) is stored for display purposes in the UI
The admin token is SHA-256 hashed before storage:
- Plaintext token is displayed once on first run (in the server logs)
- Hash stored in
<DATA_DIR>/admin-tokenwith0600permissions (owner read/write only) - Regenerate by deleting the file and restarting
-
Constant-time comparison via
crypto/subtle.ConstantTimeCompareprevents timing attacks - Legacy plaintext tokens are automatically migrated to hashed format on next validation - if the stored file is not 64 hex characters, it is assumed to be a legacy plaintext token, hashed, and the file is overwritten with the hash
The generated token is 32 hex characters (derived from a random UUID hashed with SHA-256, truncated).
Stored admin token hashes use the sha256: prefix format:
sha256:<64-character-hex-hash>
Legacy formats (bare 64-char hex or plaintext) are automatically migrated on first access.
The admin API requires a Bearer token in the Authorization header:
Authorization: Bearer <admin-token>
Validation flow:
- Extract token from
Authorization: Bearerheader - Compute SHA-256 hash of provided token
- Compare against stored hash using
crypto/subtle.ConstantTimeCompare - Return HTTP 401 with generic "Invalid admin token" message on failure
The constant-time comparison prevents timing attacks that could leak information about the valid token.
When WEBAUTHN_RP_ID is set, users can log in with FIDO2/WebAuthn passkeys (Touch ID, Windows Hello, YubiKey, etc.) as an alternative to the admin token. Passkey login is disabled by default.
Dual authentication middleware: The AuthMiddleware in internal/api/admin.go checks both methods:
- Admin token (fast, in-memory) - checked first using the SHA-256 hash
-
WebAuthn session token (DB-backed) - checked as fallback when
webauthnSessionMgris configured
The admin token always works. The WebAuthn path is nil-safe: when WEBAUTHN_RP_ID is not set, webauthnSessionMgr is nil and the fallback is skipped entirely.
Session tokens:
- Generated using
crypto/rand(32 bytes, hex-encoded) - SHA-256 hashed before database storage - the raw token is never persisted
- 30-day TTL (hardcoded in
internal/webauthn/session.go) - Constant-time comparison via
crypto/subtle.ConstantTimeCompare - Tokens are revoked on credential deletion or explicit logout
WebAuthn routes:
| Route | Method | Auth | Description |
|---|---|---|---|
/api/webauthn/available |
GET | None (public) | Check if WebAuthn is enabled |
/api/webauthn/login/start |
POST | IP rate-limited | Begin passkey login |
/api/webauthn/login/finish |
POST | IP rate-limited | Complete passkey login, receive session token |
/api/webauthn/register/start |
POST | Admin/session token | Begin credential registration |
/api/webauthn/register/finish |
POST | Admin/session token | Complete credential registration |
/api/webauthn/credentials |
GET | Admin/session token | List registered credentials |
/api/webauthn/credentials/{id} |
PUT | Admin/session token | Rename a credential |
/api/webauthn/credentials/{id} |
DELETE | Admin/session token | Delete a credential |
/api/webauthn/logout |
POST | Admin/session token | Revoke the current session token |
Login endpoints are IP rate-limited to prevent brute-force probing of passkeys. Registration and credential management require admin or session token auth.
SSE events:
| Event Type | When |
|---|---|
webauthn.credential_registered |
A new passkey is registered |
webauthn.credential_deleted |
A passkey is deleted |
Login screen with WebAuthn passkey option visible when WEBAUTHN_RP_ID is configured.
Settings page - Passkey credential management, showing registered credentials with rename and delete options.
The proxy API requires a virtual key in the Authorization header:
Authorization: Bearer <virtual-key>
Validation flow:
- Extract key from
Authorization: Bearerheader - Compute SHA-256 hash of provided key
- Look up hash in
virtual_keysdatabase table - On success: store key hash in request context for downstream middleware
- On success: update
last_used_attimestamp asynchronously (fire-and-forget with 5-second timeout) - Return HTTP 401 with generic "Invalid virtual key" message on failure
The generic error message prevents enumeration attacks (attackers cannot determine if a key format is valid vs. completely invalid).
Model Hotel uses a two-layer rate limiting system:
Applied before authentication, before the per-key limiter:
- Independent buckets per client IP address
- Configurable via DB settings:
-
rate_limit_ip_rps(default: 30) -
rate_limit_ip_burst(default: 60) -
rate_limit_ip_enabled(default: true)
-
- Always active when
RATE_LIMIT_ENABLED=true(cannot be bypassed by users) - Mounted before auth middleware to catch unauthenticated floods (brute-force key guessing, etc.)
- Respects
X-Forwarded-ForandX-Real-IPheaders only when request originates from trusted proxy (configured viaTRUSTED_PROXIESCIDR list)
Applied after authentication:
- Independent buckets per key (no cross-key interference)
- Configurable via DB settings:
-
rate_limit_rps(default: 10) -
rate_limit_burst(default: 20)
-
- Per-key overrides supported (set when creating virtual key)
- Unlimited mode: set
rate_limit_rps=0to disable limiting for specific keys
Both layers share:
-
rate_limit_max_wait_ms(default: 200ms) - maximum time a request waits in the rate-limiter queue before being rejected with HTTP 429 - Returns standard
Retry-AfterandX-RateLimit-*headers on 429 responses -
Environment variable kill-switch (
RATE_LIMIT_ENABLED=false) completely removes the rate-limiting middleware - it is not merely "disabled", it becomes a no-op - Runtime toggle via
rate_limit_enabled/rate_limit_ip_enabledsettings (only effective when the env var istrue) - When rate limiting is re-enabled after being disabled, all existing buckets are reset
- Unused buckets are cleaned up after 10 minutes of inactivity
When a request exceeds the instantaneous rate limit but is within max_wait_ms:
- Request is queued (not rejected)
- Waits for token availability (up to
max_wait_ms) - Proceeds if token becomes available within timeout
- Returns HTTP 429 if timeout exceeded
This provides smoother handling of bursty traffic while still enforcing limits.
All requests are subject to MAX_REQUEST_SIZE (default: 10 MB). The middleware uses http.MaxBytesReader which enforces the limit at the stream level - the entire request body is never buffered beyond this limit.
Exceeded limits return HTTP 413 (Payload Too Large).
Cross-Origin Resource Sharing is controlled by the CORS_ORIGINS environment variable (default: http://localhost:5173,http://localhost:8081). Only origins in this list are allowed to make browser requests.
The middleware:
- Checks the
Originheader against the allowlist - Sets
Access-Control-Allow-Originonly for matching origins - Sets
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS - Sets
Access-Control-Allow-Headers: Content-Type, Authorization - Sets
Access-Control-Allow-Credentials: true - Sets
Access-Control-Max-Age: 86400(24-hour preflight cache) - Handles
OPTIONSpreflight requests with204 No Content
All HTTP responses include standard security headers (set globally via middleware):
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
X-Frame-Options |
DENY |
Prevents clickjacking via iframes |
Referrer-Policy |
strict-origin-when-cross-origin |
Controls referrer information sent with requests |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Enforces HTTPS connections (when TLS is active) |
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' |
Prevents injection of unauthorized scripts and resources |
The ValidateProviderURL function enforces multiple security checks to prevent SSRF (Server-Side Request Forgery):
-
HTTPS by default - HTTP is only allowed if
ALLOW_HTTP_PROVIDERS=true -
Loopback block -
localhost,127.0.0.1,::1are rejected by default (prevents SSRF) - IP resolution check - All resolved IPs are checked for loopback addresses (blocks DNS rebinding)
-
IPv6 loopback -
::1and IPv6-mapped loopback addresses are blocked -
Allowed hosts - Optional allowlist via
ALLOWED_PROVIDER_HOSTS:- Built-in provider hosts (
api.openai.com,api.nano-gpt.com,api.z.ai,api.deepseek.com,api.anthropic.com,ollama.com,opencode.ai,api.x.ai,generativelanguage.googleapis.com,api.cohere.com,api.cohere.ai,openrouter.ai) are always allowed regardless of the allowlist - Hosts explicitly listed in
ALLOWED_PROVIDER_HOSTSbypass the loopback restriction - this is intentional to allowlocalhostfor local Ollama or testing scenarios - When
ALLOWED_PROVIDER_HOSTSis empty (the default), any non-loopback HTTPS URL is accepted
- Built-in provider hosts (
While ValidateProviderURL blocks dangerous URLs at configuration time, the SafeDialer in internal/proxy/safe_dialer.go provides runtime SSRF protection when the proxy makes outbound connections to providers.
- Resolve first, dial by IP: The dialer first resolves the hostname to a list of IP addresses, then checks all IPs against blocked ranges (private, loopback, link-local, cloud-metadata). If all are blocked, the connection is refused.
- DNS rebinding protection: By resolving first and dialing by IP (not hostname), the dialer closes the TOCTOU gap where DNS could resolve to a different address between check and dial.
- Redirect validation: HTTP redirect targets are also validated - the redirect host's IPs are checked against the same blocked ranges.
-
Known bypass via
KNOWN_PROXIES: IPs within CIDRs listed inKNOWN_PROXIESbypass the private-IP block, allowing connections to internal LLM servers (e.g. self-hosted Ollama on 10.0.0.5:11434). -
Host bypass via
ALLOWED_PROVIDER_HOSTS: Hostnames inALLOWED_PROVIDER_HOSTSskip the SafeDialer IP checks entirely.
| Category | CIDR/Address | Reason |
|---|---|---|
| Unspecified |
0.0.0.0/8, ::
|
Unusable addresses |
| Loopback |
127.0.0.0/8, ::1
|
Localhost |
| Private |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
|
Internal networks |
| Link-local |
169.254.0.0/16, fe80::/10
|
Link-local |
| Cloud metadata | 169.254.169.254 |
AWS/GCP/Azure metadata endpoint |
| Variable | Default | Description |
|---|---|---|
MASTER_KEY |
(required) | Base secret for Argon2id key derivation. Should be 32+ random bytes. Never log or commit. |
ADMIN_TOKEN |
(auto-generated) | Admin API authentication token. Generated on first boot if not provided. |
RATE_LIMIT_ENABLED |
true |
Master kill-switch for all rate limiting. false removes middleware entirely. |
RATE_LIMIT_IP_RPS |
30 |
Default requests per second for IP-based limiting. |
RATE_LIMIT_IP_BURST |
60 |
Default burst size for IP-based limiting. |
MAX_REQUEST_SIZE |
10485760 |
Maximum request body size (10MB). |
CORS_ORIGINS |
http://localhost:5173,http://localhost:8081 |
Comma-separated list of allowed origins. |
ALLOW_HTTP_PROVIDERS |
false |
Allow HTTP (non-HTTPS) provider URLs. Enable only for local development. |
ALLOWED_PROVIDER_HOSTS |
(empty) | Comma-separated allowlist of provider hostnames. Empty = all non-loopback HTTPS allowed. |
TRUSTED_PROXIES |
(empty) | CIDR list of trusted proxy IPs. Required for X-Forwarded-For header validation. Controls inbound trust only. |
KNOWN_PROXIES |
(empty) | CIDR list of internal LLM server networks. Bypasses SafeDialer private-IP blocking for outbound connections. |
WEBAUTHN_RP_ID |
(empty) | Relying Party ID for WebAuthn/FIDO2 passkey login. Empty = disabled. |
DATA_DIR |
./data |
Directory for admin token storage. Must have restricted permissions. |
- Set
MASTER_KEYto a cryptographically random value (32+ bytes) - Store
MASTER_KEYin environment or secrets manager - never in version control - Restrict
DATA_DIRpermissions to owner-only (0700) - Set
ADMIN_TOKENexplicitly in production (don't rely on auto-generation) - Configure
CORS_ORIGINSto match your frontend domain(s) - Set
TRUSTED_PROXIESif running behind a load balancer or reverse proxy - Set
KNOWN_PROXIESif using self-hosted LLM servers on private networks - Set
WEBAUTHN_RP_IDto enable passkey authentication (leave empty to disable) - Keep
RATE_LIMIT_ENABLED=truein production - Monitor rate limit 429 responses for potential attacks
- Use HTTPS for all provider URLs in production
- Regularly rotate virtual keys and provider API keys
- Review access logs for unusual patterns
Provider API Keys:
- Update the provider's encrypted key via admin API
- Old key is immediately invalidated (cache entry expires naturally)
- New key is encrypted with v2 scheme (per-provider salt)
Virtual Keys:
- Delete the old virtual key via admin API
- Create a new virtual key
- Distribute new key to clients
- Old key hash is removed from database - immediate invalidation
Admin Token:
- Stop the server
- Delete
<DATA_DIR>/admin-token - Restart server - new token generated and logged
- Update all clients with new token
- Alert on unusual 401/403 rates (potential brute-force)
- Alert on rate limit 429 spikes (potential DoS)
- Monitor provider key decryption failures (potential
MASTER_KEYmismatch) - Track virtual key usage patterns for anomaly detection
- Log admin API access (with key hashes, never plaintext tokens)
Provider API keys are cached in decrypted form for up to 10 minutes to avoid Argon2id computation overhead on every request. This is an intentional trade-off:
- Risk: Decrypted keys exist in process memory
- Mitigation: Short TTL (10 min), process isolation, memory protection
- Acceptable because: Attacker with memory access has already compromised the host
Argon2id parameters (t=1, m=8MB, p=4) are below RFC 9106 minimums (t=3, m=64MB):
-
Rationale:
MASTER_KEYis a high-entropy random value (32+ bytes), not a user-chosen password - Threat model: Argon2id's primary defense is against low-entropy brute-force, which does not apply here
- Benefit: Lower latency on every provider key decrypt (including per-request operations)
If you discover a security vulnerability, please report it privately before public disclosure. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
Last updated: 2026-06-02
- Virtual Keys - Virtual key creation, hashing, and management
- Privacy - Data handling, logging, and privacy guarantees
- Request Logging - Request log structure and retention
Last synced from hugalafutro/model-hotel@b0da0d8 on 2026-07-08 23:42 UTC. Edit these pages under wiki/ (and images under docs/screenshots/) in the main repo, not here.
- π¨ Home
- βοΈ Configuration
- π Development
- π Virtual Keys
- π₯ Multi-User
- π API Reference
- π Request Logging
- π Model Discovery
- π Failover and Hotel Routing
- π Alerting
- π¨ High Availability