diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceddc80..1b99ff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,13 @@ jobs: - name: Vet run: go vet ./... + # Pinned, not @latest. staticcheck v0.8.0 requires Go >= 1.26, which + # this job cannot satisfy under GOTOOLCHAIN=local, so @latest breaks the + # build the moment upstream releases ahead of our Go matrix. Bump this + # deliberately alongside go-version. - name: Staticcheck run: | - go install honnef.co/go/tools/cmd/staticcheck@latest + go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 staticcheck ./... - name: Test diff --git a/README.md b/README.md index 99a8f79..291b540 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,16 @@ phoenix exec --env API_KEY=phoenix://myapp/api-key -- env | grep API_KEY ``` > The default server config listens on `127.0.0.1:9090` over plain HTTP. -> This is safe for local-only use. For LAN or production deployment, -> enable TLS — see [LAN Deployment](docs/lan-deployment.md). +> That is the supported default — loopback traffic never touches the network. +> The moment other machines need access, enable `"tls": {"enabled": true}` +> (one line; the `--init` certificate is used automatically) — see +> [LAN Deployment](docs/lan-deployment.md). The server warns loudly at +> startup if it is ever bound to a non-loopback address without TLS. +> +> **Deployment envelope:** Phoenix is LAN-scoped by design. A single machine +> or a local network you operate is the supported territory; exposing Phoenix +> to the internet (port forwarding, public ingress) is out of scope and +> unsupported. See [Threat Model](docs/threat-model.md). For the full first-run walkthrough, see [Getting Started](docs/getting-started.md). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3a6ac52..f0f6e2b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,46 @@ # Release Notes +## v0.17.0 (2026-08-20) + +### Secure Transport: LAN-Scoped by Design, Loud When It Matters + +Phoenix is **LAN-scoped by design**. Use outside a LAN — internet/WAN +exposure, public ingress, hostile-network operation — is out of scope and +unsupported. Being in scope is not the same as being trusted: a LAN +includes containers on shared bridges, IoT devices, and anything that has +compromised one host, so TLS matters whenever a request crosses a wire. + +Plaintext on loopback remains the default and is unchanged. Loopback +traffic never reaches a network interface, so TLS adds nothing there. + +- **New first-class `tls:` config block**, independent of `mtls:`. TLS + serving previously existed only inside the mTLS block, so operators had + to enable client-certificate authentication just to obtain HTTPS. + `"tls": {"enabled": true}` now works on its own, defaulting to the + server leaf certificate that `--init` already generates. `mtls:` is now + purely the client-certificate layer. +- **Loud warning on insecure transport.** Binding a non-loopback address + without TLS previously produced no signal at all — a one-line config + change silently moved bearer tokens and secret values onto the wire. + The server now prints a prominent startup banner naming exactly what is + exposed and the one-line fix. It warns; it never refuses to start. +- **TLS for the MCP HTTP transport** — `phoenix mcp-server --http` gains + `--tls-cert`/`--tls-key` (or `PHOENIX_MCP_TLS_CERT`/`PHOENIX_MCP_TLS_KEY`), + and carries the same warning on plaintext non-loopback binds. +- **Client-side warnings** in the CLI and Go SDK for non-loopback + `http://` server URLs. Warn and proceed; never fail. +- **Docs**: `docs/threat-model.md` gains a deployment-envelope section and + its mitigations were re-audited against that envelope; + `docs/lan-deployment.md` is rewritten onto the `tls:` block with mTLS as + a separate optional step. No silent non-loopback `http://` examples + remain anywhere in the docs. +- **New e2e scenario 19** covering transport behavior. + +**Upgrading:** no action required. Existing configs behave identically, +including `auth.mtls.enabled: true`, which still implies TLS. If you bind +a non-loopback address without TLS you will now see a startup warning — +that is the point, and the fix is one line. + ## v0.16.0 (2026-08-16) ### Hermes Caller Metadata Audit Capture diff --git a/cmd/phoenix-server/main.go b/cmd/phoenix-server/main.go index 3313930..15fd322 100644 --- a/cmd/phoenix-server/main.go +++ b/cmd/phoenix-server/main.go @@ -36,6 +36,7 @@ import ( "github.com/phoenixsec/phoenix/internal/session" "github.com/phoenixsec/phoenix/internal/store" "github.com/phoenixsec/phoenix/internal/token" + "github.com/phoenixsec/phoenix/internal/transport" "github.com/phoenixsec/phoenix/internal/version" ) @@ -357,6 +358,9 @@ func main() { } log.Printf(" mTLS: enabled (require=%v)", cfg.Auth.MTLS.Require) log.Printf(" Bearer: %v", cfg.Auth.Bearer.Enabled) + } else if cfg.TLSEnabled() { + // TLS without mTLS: encrypt the pipe, no client certificates. + tlsCfg = &tls.Config{MinVersion: tls.VersionTLS13} } log.Printf("Phoenix server starting on %s", cfg.Server.Listen) @@ -373,13 +377,21 @@ func main() { IdleTimeout: 60 * time.Second, } - if tlsCfg != nil { + if cfg.TLSEnabled() { // Use the dedicated server leaf cert for TLS identity (NOT the CA cert) - log.Printf(" TLS: enabled (server cert: %s)", cfg.Auth.MTLS.ServerCert) - if err := httpSrv.ListenAndServeTLS(cfg.Auth.MTLS.ServerCert, cfg.Auth.MTLS.ServerKey); err != nil { + serverCert, serverKey := cfg.TLSCertKey() + log.Printf(" TLS: enabled (server cert: %s)", serverCert) + if err := httpSrv.ListenAndServeTLS(serverCert, serverKey); err != nil { log.Fatalf("server error: %v", err) } } else { + log.Printf(" TLS: disabled (plaintext HTTP)") + if !transport.IsLoopbackListen(cfg.Server.Listen) { + // Loud, never fatal: loopback plaintext is the supported default, + // but a non-loopback bind without TLS puts bearer tokens and + // secret values on the wire in cleartext. + log.Printf("\n%s", transport.ServerPlaintextWarning(cfg.Server.Listen, cfg.Dashboard.Enabled)) + } if err := httpSrv.ListenAndServe(); err != nil { log.Fatalf("server error: %v", err) } diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index 41c4633..0cc76b3 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -54,6 +54,7 @@ import ( "github.com/phoenixsec/phoenix/internal/crypto" "github.com/phoenixsec/phoenix/internal/policy" "github.com/phoenixsec/phoenix/internal/store" + "github.com/phoenixsec/phoenix/internal/transport" "github.com/phoenixsec/phoenix/internal/version" ) @@ -130,6 +131,18 @@ func main() { cmd := os.Args[1] args := os.Args[2:] + // Warn (never fail) when the configured server is plaintext HTTP on a + // non-loopback address: bearer tokens and secret values would cross the + // network unencrypted. Loopback plaintext is the supported default. + switch cmd { + case "version", "--version", "-V", "help", "--help", "-h": + // No server contact. + default: + if w := transport.ClientPlaintextWarning(serverURL); w != "" { + fmt.Fprintln(os.Stderr, w) + } + } + var err error switch cmd { case "get": @@ -273,6 +286,8 @@ func main() { case "mcp-server": httpAddr := "" mcpToken := os.Getenv("PHOENIX_MCP_TOKEN") + mcpTLSCert := os.Getenv("PHOENIX_MCP_TLS_CERT") + mcpTLSKey := os.Getenv("PHOENIX_MCP_TLS_KEY") for i := 0; i < len(args); i++ { switch { case args[i] == "--http" && i+1 < len(args): @@ -285,10 +300,20 @@ func main() { i++ case strings.HasPrefix(args[i], "--mcp-token="): mcpToken = strings.TrimPrefix(args[i], "--mcp-token=") + case args[i] == "--tls-cert" && i+1 < len(args): + mcpTLSCert = args[i+1] + i++ + case strings.HasPrefix(args[i], "--tls-cert="): + mcpTLSCert = strings.TrimPrefix(args[i], "--tls-cert=") + case args[i] == "--tls-key" && i+1 < len(args): + mcpTLSKey = args[i+1] + i++ + case strings.HasPrefix(args[i], "--tls-key="): + mcpTLSKey = strings.TrimPrefix(args[i], "--tls-key=") } } if httpAddr != "" { - err = cmdMCPHTTP(httpAddr, mcpToken) + err = cmdMCPHTTP(httpAddr, mcpToken, mcpTLSCert, mcpTLSKey) } else { err = cmdMCP(args) } @@ -367,7 +392,8 @@ Usage: phoenix sessions revoke Revoke a session phoenix approve Approve a step-up session request phoenix mcp-server Run MCP server (stdio JSON-RPC) - phoenix mcp-server --http :8080 Run MCP server (Streamable HTTP) + phoenix mcp-server --http 127.0.0.1:8080 Run MCP server (Streamable HTTP) + phoenix mcp-server --http --tls-cert --tls-key Serve MCP over HTTPS phoenix init Initialize data directory Environment: @@ -380,7 +406,9 @@ Environment: PHOENIX_POLICY Path to attestation policy file (JSON) PHOENIX_TOOL Tool/skill name for attestation (X-Phoenix-Tool header) PHOENIX_ROLE Role name for auto-mint session identity - PHOENIX_MCP_TOKEN Bearer token for MCP HTTP client auth (--http mode)`) + PHOENIX_MCP_TOKEN Bearer token for MCP HTTP client auth (--http mode) + PHOENIX_MCP_TLS_CERT TLS server certificate for MCP HTTP mode (--tls-cert) + PHOENIX_MCP_TLS_KEY TLS server key for MCP HTTP mode (--tls-key)`) } // requireAuth checks that at least one auth method is configured diff --git a/cmd/phoenix/mcp_http.go b/cmd/phoenix/mcp_http.go index cafce01..47f4f68 100644 --- a/cmd/phoenix/mcp_http.go +++ b/cmd/phoenix/mcp_http.go @@ -6,17 +6,23 @@ // // Usage: // -// phoenix mcp-server --http :8080 --mcp-token +// phoenix mcp-server --http 127.0.0.1:8080 --mcp-token // // Or via environment: // -// PHOENIX_MCP_TOKEN= phoenix mcp-server --http :8080 +// PHOENIX_MCP_TOKEN= phoenix mcp-server --http 127.0.0.1:8080 +// +// To serve MCP clients on other hosts, enable TLS: +// +// phoenix mcp-server --http 0.0.0.0:8080 --mcp-token \ +// --tls-cert /data/phoenix/server.crt --tls-key /data/phoenix/server.key package main import ( "bytes" cryptorand "crypto/rand" "crypto/subtle" + "crypto/tls" "encoding/hex" "encoding/json" "fmt" @@ -26,6 +32,8 @@ import ( "strings" "sync" "time" + + "github.com/phoenixsec/phoenix/internal/transport" ) type mcpSession struct { @@ -184,13 +192,20 @@ func (s *mcpHTTPServer) validSession(id string) bool { } // cmdMCPHTTP starts the MCP server in Streamable HTTP mode. -func cmdMCPHTTP(addr string, mcpToken string) error { +// tlsCert/tlsKey enable HTTPS serving (the Phoenix --init server leaf works: +// /server.crt and /server.key). Without TLS on a +// non-loopback address, a loud warning is emitted — but the server still +// starts: plaintext loopback is the supported default. +func cmdMCPHTTP(addr, mcpToken, tlsCert, tlsKey string) error { if err := requireAuth(); err != nil { return err } if mcpToken == "" { return fmt.Errorf("MCP HTTP mode requires --mcp-token or PHOENIX_MCP_TOKEN") } + if (tlsCert == "") != (tlsKey == "") { + return fmt.Errorf("--tls-cert and --tls-key must be set together") + } logger := log.New(os.Stderr, "phoenix-mcp-http: ", log.LstdFlags) @@ -204,6 +219,21 @@ func cmdMCPHTTP(addr string, mcpToken string) error { mux := http.NewServeMux() mux.Handle("/mcp", srv) + httpSrv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + if tlsCert != "" { + httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS13} + logger.Printf("listening on %s (TLS)", addr) + return httpSrv.ListenAndServeTLS(tlsCert, tlsKey) + } + + if !transport.IsLoopbackListen(addr) { + logger.Printf("\n%s", transport.MCPPlaintextWarning(addr)) + } logger.Printf("listening on %s", addr) - return http.ListenAndServe(addr, mux) + return httpSrv.ListenAndServe() } diff --git a/config.example.json b/config.example.json index d254a31..c10ed51 100644 --- a/config.example.json +++ b/config.example.json @@ -2,6 +2,9 @@ "server": { "listen": "127.0.0.1:9090" }, + "tls": { + "enabled": false + }, "store": { "backend": "file", "path": "/data/phoenix/store.json", diff --git a/docs/admin-token-lifecycle.md b/docs/admin-token-lifecycle.md index ae52830..2cf3726 100644 --- a/docs/admin-token-lifecycle.md +++ b/docs/admin-token-lifecycle.md @@ -46,7 +46,7 @@ phoenix agent create app-deployer -t "deploy-token" --acl "myapp/**:read,write" If mTLS is enabled (required for `phoenix cert issue`): ```bash -export PHOENIX_SERVER="https://phoenix.home:9090" +export PHOENIX_SERVER="https://phoenix:9090" export PHOENIX_CA_CERT="/data/phoenix/ca.crt" phoenix cert issue app-runtime -o /etc/phoenix/certs ``` diff --git a/docs/authentication.md b/docs/authentication.md index 0374813..3bf9419 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -4,6 +4,15 @@ Phoenix supports bearer tokens, mTLS client certificates, and sealed-response ke pairs. These can be combined — mTLS for identity, bearer for bootstrap, sealed keys for context-safe delivery. +**Transport is separate from authentication.** TLS (HTTPS) comes from the +server's `tls` config block and does not require mTLS or client certificates — +bearer tokens work over TLS unchanged. mTLS is an authentication method layered +on top of TLS. Any auth method on this page can run over either transport; +whenever requests cross a wire (any non-loopback listener), enable the `tls` +block so tokens and secret values are encrypted in transit. See +[Configuration](configuration.md#transport-security) and +[LAN Deployment](lan-deployment.md). + ## Bearer tokens Create an agent with scoped permissions: @@ -47,7 +56,7 @@ phoenix cert issue deployer -o /etc/phoenix/certs/ This writes `deployer.crt`, `deployer.key`, and `ca.crt`. ```bash -export PHOENIX_SERVER="https://phoenix.home:9090" +export PHOENIX_SERVER="https://192.168.1.10:9090" export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt" export PHOENIX_CLIENT_CERT="/etc/phoenix/certs/deployer.crt" export PHOENIX_CLIENT_KEY="/etc/phoenix/certs/deployer.key" diff --git a/docs/configuration.md b/docs/configuration.md index c3bec9d..8745f26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,13 +3,18 @@ ## Configuration reference The server reads a JSON config file. `config.example.json` is a starter template. -The example binds to loopback (`127.0.0.1`): binding to a non-loopback address -requires enabling TLS/mTLS — never expose the plaintext HTTP listener beyond -localhost. +The example binds to loopback (`127.0.0.1`), where plaintext HTTP is the safe, +supported default. Binding a non-loopback address means requests cross a wire: +enable the `tls` block. The server starts either way, but logs a prominent +`INSECURE TRANSPORT` warning when a non-loopback bind would put bearer tokens +and secret values on the network in cleartext. | Field | Description | Default | |-------|-------------|---------| | `server.listen` | Bind address | `127.0.0.1:9090` | +| `tls.enabled` | Serve HTTPS (independent of mTLS) | `false` | +| `tls.cert` | Server certificate path | `auth.mtls.server_cert` (the `--init` leaf) | +| `tls.key` | Server private key path | `auth.mtls.server_key` | | `store.path` | Encrypted store file | `/data/store.json` | | `store.master_key` | Master key file | `/data/master.key` | | `store.backend` | Secret backend (`file` or `1password`) | `file` | @@ -109,15 +114,16 @@ The dashboard uses cookie-based auth with HMAC-signed tokens and CSRF protection Login attempts are rate-limited with exponential backoff (5 failures before lockout, up to 60s delay, per source IP). Failed login attempts are logged to the audit trail. -**Transport security:** When the server runs with mTLS enabled or behind a TLS -reverse proxy (detected via `X-Forwarded-Proto: https`), the session cookie is -set with `Secure` flag automatically. For production use: +**Transport security:** When the server serves TLS (the `tls` block or mTLS) +or sits behind a TLS reverse proxy (detected via `X-Forwarded-Proto: https`), +the session cookie is set with `Secure` flag automatically. For production use: -- Run behind a TLS reverse proxy (e.g., Nginx Proxy Manager), or -- Use the built-in mTLS mode (`auth.mtls.enabled: true`), or -- Restrict to loopback access only (`server.listen: "127.0.0.1:9090"`) +- Restrict to loopback access only (`server.listen: "127.0.0.1:9090"`), or +- Enable native TLS (`"tls": {"enabled": true}` — no client certs needed), or +- Run behind a TLS reverse proxy (e.g., Nginx Proxy Manager) -Do not expose the dashboard over plain HTTP on a network you do not control. +Do not expose the dashboard over plain HTTP on a network you do not control — +the server logs a loud startup warning if you do. ## 1Password runtime backend (broker mode, read-only) @@ -143,23 +149,44 @@ Behavior: ## Transport security +Phoenix is LAN-scoped by design — internet/WAN exposure is out of scope and +unsupported (see [Threat Model](threat-model.md)). Within that envelope: + The default configuration starts the server on plain HTTP at `127.0.0.1:9090`. -This is safe for local-only use — the server is not reachable from the network. +This is safe for local-only use — loopback traffic never reaches a NIC, so the +server is not reachable from the network and TLS would add nothing. + +The moment the listener crosses a wire, enable TLS. `phoenix-server --init` +generates a CA and server certificate; `"tls": {"enabled": true}` serves HTTPS +with them — no client certificates, no mTLS required: + +```json +{ + "server": { "listen": "0.0.0.0:9090" }, + "tls": { "enabled": true } +} +``` + +`tls.cert` / `tls.key` override the certificate paths; when unset, the `--init` +server leaf (`auth.mtls.server_cert` / `server_key` paths) is used. mTLS is a +separate, optional layer for client-certificate authentication — enabling +`auth.mtls` also serves TLS, as it always has, but plain HTTPS no longer +requires it. -`phoenix-server --init` generates a CA and server certificate, but does **not** -enable TLS by default. To enable TLS, set `auth.mtls.enabled: true` in the -config. This activates server-side TLS using the generated certificate. You can -set `auth.mtls.require: false` to accept TLS connections without requiring -client certificates (agents can still use bearer tokens). +If you bind a non-loopback address without TLS, the server still starts but +logs a prominent `INSECURE TRANSPORT` warning: bearer tokens and every secret +value it returns would cross the network in cleartext. | Deployment | Config | Result | |-----------|--------|--------| -| Local only | Default (`127.0.0.1`, mTLS disabled) | Plain HTTP on loopback — safe | -| LAN / remote | `0.0.0.0`, `auth.mtls.enabled: true` | TLS with optional client certs | -| Production | `0.0.0.0`, `auth.mtls.enabled: true`, `require: true` | Full mTLS required | +| Local only | Default (`127.0.0.1`, TLS disabled) | Plain HTTP on loopback — safe | +| LAN / remote | `0.0.0.0`, `"tls": {"enabled": true}` | HTTPS, bearer tokens still work | +| LAN + client certs | add `auth.mtls.enabled: true` (`require: false`) | HTTPS with optional client certs | +| Locked down | `auth.mtls.enabled: true`, `require: true` | Full mTLS required | | Behind reverse proxy | `127.0.0.1`, proxy terminates TLS | Plain HTTP on loopback, TLS to clients | -See [LAN Deployment](lan-deployment.md) for the full multi-host setup. +See [LAN Deployment](lan-deployment.md) for the full multi-host setup, +including re-issuing the server certificate with LAN IP/hostname SANs. ## Architecture summary @@ -220,7 +247,10 @@ volumes: ``` **Important:** The generated config defaults to `127.0.0.1:9090`. For Docker port -mapping to work, set `server.listen` to `0.0.0.0:9090` in the config. +mapping to work, set `server.listen` to `0.0.0.0:9090` in the config. That is a +non-loopback bind: published ports are reachable from the LAN, so also set +`"tls": {"enabled": true}` (or bind the mapping to loopback with +`-p 127.0.0.1:9090:9090` if only this host needs access). ## Related docs diff --git a/docs/dashboard.md b/docs/dashboard.md index 65f7912..c8c6869 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -69,39 +69,52 @@ enabled, bootstrap trust, attestation requirements, and seal key. The dashboard session cookie carries admin-equivalent access to session and approval management. Protecting it in transit is critical. -### Recommended: TLS reverse proxy +### Default: loopback only -The safest general deployment is behind a TLS-terminating reverse proxy -(Nginx, Caddy, NPM, Traefik): +If `server.listen` is `127.0.0.1:9090` (the default), the dashboard is +only reachable from the server host itself. This is safe for local-only +operator access — plain HTTP on loopback never touches the network. Use +SSH port forwarding for remote browser access: -``` -Browser --[HTTPS]--> Reverse Proxy --[HTTP]--> Phoenix (127.0.0.1:9090) +```bash +ssh -L 9090:127.0.0.1:9090 user@phoenix-host +# Then open http://127.0.0.1:9090/dashboard/ in your browser ``` -The proxy sets `X-Forwarded-Proto: https`, which Phoenix detects to -enable the `Secure` cookie flag. The server itself can bind to loopback. +### Network access: native TLS -### Alternative: mTLS mode +If other machines need to reach the dashboard, enable the server's TLS: + +```json +{ "tls": { "enabled": true } } +``` -If Phoenix runs with `auth.mtls.enabled: true`, TLS is native and the -cookie gets `Secure` automatically via `r.TLS != nil`. +TLS is native (using the `--init` server certificate — import `ca.crt` +into the browser's trust store or accept the certificate), and the +cookie gets `Secure` automatically via `r.TLS != nil`. No client +certificates or mTLS are required; `auth.mtls` can be layered on top for +API client-certificate auth and also provides TLS by itself, as before. -### Alternative: loopback only +### Network access: TLS reverse proxy -If `server.listen` is `127.0.0.1:9090` (the default), the dashboard is -only reachable from the server host itself. This is safe for local-only -operator access. Use SSH port forwarding for remote browser access: +Alternatively, deploy behind a TLS-terminating reverse proxy +(Nginx, Caddy, NPM, Traefik): -```bash -ssh -L 9090:127.0.0.1:9090 user@phoenix-host -# Then open http://127.0.0.1:9090/dashboard/ in your browser ``` +Browser --[HTTPS]--> Reverse Proxy --[HTTP]--> Phoenix (127.0.0.1:9090) +``` + +The proxy sets `X-Forwarded-Proto: https`, which Phoenix detects to +enable the `Secure` cookie flag. The server itself binds to loopback. ### Not acceptable Do not expose the dashboard over plain HTTP on a network you do not fully control. The session cookie would be transmitted in cleartext, -making session hijacking trivial. +making session hijacking trivial. If the dashboard is enabled on a +non-loopback bind without TLS, the server logs a prominent +`INSECURE TRANSPORT` startup warning that names the dashboard cookie +exposure — it warns and continues; fixing the config is on you. ## Configuration @@ -225,9 +238,9 @@ but-invalid cookies generate `dashboard.auth` denied entries. Use this checklist when enabling the dashboard on an existing Phoenix server. 1. Choose transport mode: - - [ ] TLS reverse proxy (recommended), or - - [ ] Native mTLS (`auth.mtls.enabled: true`), or - - [ ] Loopback only (`server.listen: "127.0.0.1:..."`) + - [ ] Loopback only (`server.listen: "127.0.0.1:..."`, the default), or + - [ ] Native TLS (`"tls": {"enabled": true}`), or + - [ ] TLS reverse proxy in front of a loopback bind 2. Choose credential mode: - [ ] Password (recommended for production) diff --git a/docs/getting-started.md b/docs/getting-started.md index 0f06b7e..6f73731 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,15 +53,25 @@ This generates: ### Deploying on a LAN? The default config listens on `127.0.0.1` (loopback only) over plain HTTP. -If other machines need to reach this server: +That is the supported default: loopback traffic never touches the network, so +TLS adds nothing on a single machine. If other machines need to reach this +server, requests will cross a wire — enable TLS at the same time: -1. Set `server.listen` to `0.0.0.0:9090` and `auth.mtls.enabled` to `true` +1. Set `server.listen` to `0.0.0.0:9090` and `tls.enabled` to `true` + (this uses the server certificate generated by `--init`; no client + certificates or mTLS required) 2. Re-issue the server cert with your LAN IP: ```bash phoenix-server --reissue-cert --san 192.168.1.10 --config /data/phoenix/config.json ``` 3. Restart the server +If you bind a non-loopback address without TLS, the server starts anyway but +logs a prominent `INSECURE TRANSPORT` warning — bearer tokens and secret +values would cross the network in cleartext. Phoenix is LAN-scoped by design: +a LAN is as far as the supported envelope goes, and exposing Phoenix to the +internet is out of scope entirely (see [Threat Model](threat-model.md)). + For the full multi-host setup walkthrough, see [LAN Deployment](lan-deployment.md). ## Verify file permissions @@ -87,8 +97,8 @@ Phoenix server starting on 127.0.0.1:9090 ``` > **Binding to all interfaces:** The default listen address is `127.0.0.1:9090`. -> To accept connections from other hosts, enable TLS and set `server.listen` -> to `0.0.0.0:9090`. See [LAN Deployment](lan-deployment.md). +> To accept connections from other hosts, set `server.listen` to `0.0.0.0:9090` +> **and** enable `"tls": {"enabled": true}`. See [LAN Deployment](lan-deployment.md). ## First secret operations @@ -101,8 +111,9 @@ phoenix get myapp/api-key ``` > **Note:** The default config starts the server on plain HTTP at `127.0.0.1:9090`. -> This is safe for local-only use — the server is not reachable from the network. -> For LAN or production deployment, enable TLS first. See [LAN Deployment](lan-deployment.md). +> This is safe for local-only use — the server is not reachable from the network, +> and plain `http://` to loopback is the expected setup here. For LAN deployment, +> enable the `tls` block first. See [LAN Deployment](lan-deployment.md). For the full command reference (`set`, `get`, `list`, `delete`, `resolve`, `exec`, `import`, `export`, `audit`), see [CLI Usage](cli-usage.md). @@ -131,9 +142,11 @@ phoenix-server --hash-password After restarting, open `http://127.0.0.1:9090/dashboard/` in a browser. **Important:** The dashboard is safe over plain HTTP only when the server -listens on loopback (`127.0.0.1`). If the server is reachable over a -network, put it behind a TLS reverse proxy or enable mTLS first. Do not -serve the dashboard over plain HTTP on a network you do not control. +listens on loopback (`127.0.0.1`). If the server is reachable over a network, +enable the `tls` block (or put it behind a TLS reverse proxy) first — the +server warns loudly at startup if the dashboard is enabled on a non-loopback +bind without TLS. Do not serve the dashboard over plain HTTP on a network you +do not control. See [Dashboard](dashboard.md) for the full deployment guide and security model. ## Next steps diff --git a/docs/integrations.md b/docs/integrations.md index 4f91cda..e9e361c 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -6,7 +6,15 @@ Phoenix includes a built-in MCP server. Transport options: - `phoenix mcp-server` → stdio JSON-RPC -- `phoenix mcp-server --http :8080 --mcp-token ` → Streamable HTTP on `/mcp` +- `phoenix mcp-server --http 127.0.0.1:8080 --mcp-token ` → Streamable HTTP on `/mcp` +- add `--tls-cert`/`--tls-key` to serve the HTTP transport over HTTPS + (required whenever MCP clients connect from other machines; on a + non-loopback bind without TLS the transport starts but logs a loud + `INSECURE TRANSPORT` warning) + +`https://` server URLs below assume the Phoenix server has its `tls` config +block enabled and clients trust the CA via `PHOENIX_CA_CERT` — see +[LAN Deployment](lan-deployment.md). No mTLS is required for this. Example Claude MCP config: @@ -18,6 +26,7 @@ Example Claude MCP config: "args": ["mcp-server"], "env": { "PHOENIX_SERVER": "https://phoenix:9090", + "PHOENIX_CA_CERT": "/etc/phoenix/certs/ca.crt", "PHOENIX_TOKEN": "..." } } @@ -29,6 +38,7 @@ Streamable HTTP mode example: ```bash export PHOENIX_SERVER="https://phoenix:9090" +export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt" export PHOENIX_TOKEN="" export PHOENIX_MCP_TOKEN="" phoenix mcp-server --http 127.0.0.1:8080 @@ -148,6 +158,7 @@ to mint a short-lived session for the role: ```bash export PHOENIX_SERVER=https://phoenix:9090 +export PHOENIX_CA_CERT=/etc/phoenix/ca.crt export PHOENIX_TOKEN= # NOT a phxs_... session token export PHOENIX_ROLE=openclaw-gateway ``` @@ -168,6 +179,7 @@ export PHOENIX_CLIENT_KEY=/etc/phoenix/openclaw.key ```bash export PHOENIX_SERVER=https://phoenix:9090 +export PHOENIX_CA_CERT=/etc/phoenix/ca.crt export PHOENIX_TOKEN= # Do NOT set PHOENIX_ROLE in this mode. ``` @@ -239,6 +251,13 @@ services: openclaw: image: ghcr.io/openclaw/openclaw:latest environment: + # Plaintext http:// is acceptable here ONLY because this traffic stays + # on the single-host Compose default network (no published Phoenix + # port, nothing crosses a physical wire). The Phoenix CLI still prints + # a plaintext warning for this non-loopback URL — expected in this + # topology. If Phoenix publishes a port or the network spans hosts, + # enable the server's tls block and switch to https:// with + # PHOENIX_CA_CERT (see the TLS variant below). PHOENIX_SERVER: "http://phoenix:9090" PHOENIX_TOKEN: "${OPENCLAW_PHOENIX_TOKEN}" PHOENIX_ROLE: "openclaw-gateway" @@ -252,6 +271,23 @@ volumes: phoenix-data: ``` +TLS variant — enable `"tls": {"enabled": true}` in the Phoenix config, then: + +```yaml + openclaw: + environment: + PHOENIX_SERVER: "https://phoenix:9090" + PHOENIX_CA_CERT: "/etc/phoenix/ca.crt" + PHOENIX_TOKEN: "${OPENCLAW_PHOENIX_TOKEN}" + PHOENIX_ROLE: "openclaw-gateway" + volumes: + - phoenix-data:/etc/phoenix:ro # or copy just ca.crt +``` + +The `--init` server certificate covers `localhost`/`127.0.0.1` only; for the +`phoenix` service hostname, re-issue it once with +`phoenix-server --reissue-cert --san phoenix --config /data/phoenix/config.json`. + For mTLS instead of bearer tokens, mount certs into the OpenClaw container and set `PHOENIX_CA_CERT`, `PHOENIX_CLIENT_CERT`, and `PHOENIX_CLIENT_KEY`. diff --git a/docs/lan-deployment.md b/docs/lan-deployment.md index dd9b18d..1228958 100644 --- a/docs/lan-deployment.md +++ b/docs/lan-deployment.md @@ -6,6 +6,12 @@ This guide covers the topology, server setup, certificate distribution, agent provisioning, and verification for a multi-host deployment. It is written so that an agent (or a human) can execute it end-to-end with no prior Phoenix experience. +A LAN is the outer edge of Phoenix's supported envelope — internet/WAN +exposure is out of scope (see [Threat Model](threat-model.md)). And a LAN is +not a trust boundary: the moment requests cross a wire, enable TLS. That is +one config line, covered in step 1. mTLS (client certificates) is a separate, +optional hardening step — you do not need it to get HTTPS. + ## Topology ```text @@ -15,8 +21,9 @@ an agent (or a human) can execute it end-to-end with no prior Phoenix experience | | | [Phoenix Server] [Host A] [Host B] ... [Host N] 192.168.1.10 agent-a agent-b agent-n - port 9090 phoenix CLI phoenix CLI phoenix CLI - mTLS cert mTLS cert mTLS cert + port 9090 (TLS) phoenix CLI phoenix CLI phoenix CLI + ca.crt ca.crt ca.crt + (+ optional mTLS client cert per agent) ``` One server, N clients. Each client machine runs the `phoenix` CLI (and optionally @@ -48,22 +55,26 @@ Edit `/data/phoenix/config.json` to bind to all interfaces and enable TLS: "server": { "listen": "0.0.0.0:9090" }, + "tls": { + "enabled": true + }, "auth": { "bearer": { "enabled": true - }, - "mtls": { - "enabled": true, - "require": false } } } ``` -Setting `auth.mtls.enabled: true` activates TLS using the server certificate -generated during init. Setting `require: false` means client certs are optional -at this stage — agents can still authenticate with bearer tokens. You can -tighten this later with `"require": true` once all agents have certs. +`"tls": {"enabled": true}` serves HTTPS using the server certificate generated +during init — no client certificates involved, agents authenticate with bearer +tokens exactly as before. mTLS is a separate optional layer added in step 3. + +If you bind a non-loopback address without enabling TLS, the server still +starts, but it logs a prominent `INSECURE TRANSPORT` warning: every bearer +token and secret value would cross the network in cleartext, readable by +anything on the segment. The `tls` block above is the one-line fix the +warning points at. ### Re-issue the server certificate with LAN SANs @@ -89,8 +100,8 @@ to the paths in the config. You can add as many `--san` flags as needed phoenix-server --config /data/phoenix/config.json ``` -You should see `TLS: enabled` and `mTLS: enabled (require=false)` in the -startup output. +You should see `TLS: enabled (server cert: ...)` in the startup output, and +no `INSECURE TRANSPORT` warning. ## 2. Create agent identities @@ -112,9 +123,32 @@ phoenix agent create host-b-deployer \ --acl "deploy/**:read;infra/**:read" ``` -## 3. Issue mTLS certificates +## 3. Add mTLS (optional) -Issue a client cert per agent: +Bearer tokens over TLS are a complete, supported setup — you can skip to +step 4 and distribute only `ca.crt`. mTLS adds client-certificate identity +on top of TLS: stronger caller authentication, and the prerequisite for +`require_mtls` / `deny_bearer` attestation policies. + +Enable mTLS in the server config (this also loads the CA for cert issuance): + +```json +{ + "auth": { + "bearer": { + "enabled": true + }, + "mtls": { + "enabled": true, + "require": false + } + } +} +``` + +`require: false` means client certs are optional — agents without certs can +still authenticate with bearer tokens. Tighten to `"require": true` once all +agents have certs. Restart the server, then issue a client cert per agent: ```bash phoenix cert issue host-a-builder -o /tmp/certs/host-a/ @@ -128,7 +162,14 @@ Each command produces: ## 4. Distribute certificates to client machines -Copy the cert bundle to each client. For example with `scp`: +Every client needs `ca.crt` so it can verify the server's TLS certificate. +If you skipped mTLS, that single file is the only thing to distribute: + +```bash +scp /data/phoenix/ca.crt user@192.168.1.20:/etc/phoenix/certs/ +``` + +With mTLS, copy the full cert bundle to each client. For example with `scp`: ```bash # To Host A @@ -148,7 +189,17 @@ On each client machine, lock down permissions (`chmod 700` on the directory, ## 5. Configure client machines On each client host, set environment variables for the agent. Add to the agent's -shell profile, systemd unit, or MCP config: +shell profile, systemd unit, or MCP config. + +Bearer token over TLS (no mTLS): + +```bash +export PHOENIX_SERVER="https://192.168.1.10:9090" +export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt" +export PHOENIX_TOKEN="" +``` + +With mTLS client certificates: ```bash export PHOENIX_SERVER="https://192.168.1.10:9090" @@ -235,7 +286,10 @@ on available policy fields and testing with `phoenix policy test`. If you want browser-based approval and session management, enable the dashboard. Since this is a LAN deployment with the server on `0.0.0.0`, you **must** use -TLS — either native mTLS (already configured above) or a reverse proxy. +TLS — the native `tls` block (already configured above) or a reverse proxy. +The startup warning covers this case too: a dashboard on a non-loopback bind +without TLS means the dashboard password and session cookie cross the network +in cleartext. Generate a password hash and add to the server config: @@ -252,10 +306,10 @@ phoenix-server --hash-password } ``` -Restart the server. With mTLS enabled, the `Secure` cookie flag is set +Restart the server. With TLS enabled, the `Secure` cookie flag is set automatically. Access the dashboard at `https://192.168.1.10:9090/dashboard/`. -If you are using a reverse proxy instead of native mTLS, ensure the proxy +If you are using a reverse proxy instead of native TLS, ensure the proxy sets `X-Forwarded-Proto: https` so Phoenix enables the `Secure` flag. **Do not** enable the dashboard on a LAN deployment without TLS. The session diff --git a/docs/migration-env-to-phoenix.md b/docs/migration-env-to-phoenix.md index b821c02..a62f514 100644 --- a/docs/migration-env-to-phoenix.md +++ b/docs/migration-env-to-phoenix.md @@ -88,6 +88,7 @@ If your agent platform uses MCP over HTTP instead of local stdio, run Phoenix MC ```bash export PHOENIX_SERVER="https://phoenix:9090" +export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt" export PHOENIX_TOKEN="" export PHOENIX_MCP_TOKEN="" phoenix mcp-server --http 127.0.0.1:8080 diff --git a/docs/multi-agent-setup.md b/docs/multi-agent-setup.md index 2cf54b4..a428dfc 100644 --- a/docs/multi-agent-setup.md +++ b/docs/multi-agent-setup.md @@ -67,6 +67,7 @@ reference. ```bash export PHOENIX_SERVER="https://phoenix:9090" +export PHOENIX_CA_CERT="/etc/phoenix/certs/ca.crt" export PHOENIX_TOKEN="" export PHOENIX_SEAL_KEY="/etc/phoenix/keys/builder.seal.key" ``` @@ -75,10 +76,16 @@ export PHOENIX_SEAL_KEY="/etc/phoenix/keys/builder.seal.key" ```ini Environment=PHOENIX_SERVER=https://phoenix:9090 +Environment=PHOENIX_CA_CERT=/etc/phoenix/certs/ca.crt Environment=PHOENIX_TOKEN= Environment=PHOENIX_SEAL_KEY=/etc/phoenix/keys/deployer.seal.key ``` +(`https://` assumes the server's `tls` block is enabled; `PHOENIX_CA_CERT` +points at the Phoenix CA so the internal certificate validates. On a single +machine with the default loopback listener, `http://127.0.0.1:9090` without +a CA cert is the expected setup.) + For MCP configuration, add the same env vars to the agent's MCP server config. See [Integrations](integrations.md) for the config format. diff --git a/docs/sealed-responses.md b/docs/sealed-responses.md index f2cb47c..c4ee3a1 100644 --- a/docs/sealed-responses.md +++ b/docs/sealed-responses.md @@ -80,6 +80,7 @@ When set, `phoenix get`, `phoenix resolve`, and `phoenix exec` automatically: "args": ["mcp-server"], "env": { "PHOENIX_SERVER": "https://phoenix:9090", + "PHOENIX_CA_CERT": "/etc/phoenix/certs/ca.crt", "PHOENIX_TOKEN": "...", "PHOENIX_SEAL_KEY": "/etc/phoenix/keys/myagent.seal.key" } diff --git a/docs/session-identity.md b/docs/session-identity.md index 1c1023f..5147b58 100644 --- a/docs/session-identity.md +++ b/docs/session-identity.md @@ -196,7 +196,8 @@ Set `PHOENIX_ROLE` in your MCP server config: "command": "phoenix", "args": ["mcp-server"], "env": { - "PHOENIX_SERVER": "https://phoenix.home:9090", + "PHOENIX_SERVER": "https://phoenix:9090", + "PHOENIX_CA_CERT": "/etc/phoenix/certs/ca.crt", "PHOENIX_TOKEN": "bootstrap-token", "PHOENIX_ROLE": "dev" } diff --git a/docs/threat-model.md b/docs/threat-model.md index 7afb86a..edb75fe 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,13 +1,52 @@ # Phoenix Threat Model -This document describes the core threats Phoenix is designed to mitigate, the trust boundaries, and what remains out of scope. +This document describes the deployment envelope Phoenix is designed for, the core threats it mitigates, the trust boundaries, and what remains out of scope. + +## Deployment envelope: LAN-scoped by design + +Phoenix is built for a single machine or a local network — a workstation, a +homelab, a small team's LAN. That is the product envelope, not a suggestion: + +- **In scope**: loopback deployments (the default) and LAN deployments where + the operator controls the hosts that matter. +- **Out of scope and unsupported**: internet/WAN exposure, public ingress, + and operation on networks assumed hostile. Do not port-forward Phoenix, + put it behind public ingress, or run it on infrastructure you do not + control. Every mitigation claim in this document assumes the envelope; + none of them are load-bearing against internet-scale attackers. + +**LAN is in scope, but the LAN is not a trust boundary.** A typical LAN +carries containers on shared bridges, IoT devices, guest WiFi, and anything +an attacker gained by compromising one host. Phoenix is a credential +concentrator: sniffing a single bearer token yields everything that token's +ACL permits, not one secret. Treat every wire crossing as hostile even +inside the envelope. + +### Transport rules that follow from the envelope + +1. **Plaintext HTTP on loopback is the safe, supported default.** Loopback + traffic never reaches a NIC; TLS adds nothing there, and the dominant + deployment is single-machine. +2. **The moment traffic crosses a wire, enable TLS.** Binding a non-loopback + address (`0.0.0.0`, a LAN IP) without TLS puts bearer tokens and secret + values on the network in cleartext. The server detects this at startup + and emits a loud warning naming exactly what is exposed — it warns, it + never refuses to start. The CLI, SDK, and MCP transport apply the same + posture to plaintext non-loopback URLs. +3. **TLS does not require mTLS.** The `tls:` config block enables HTTPS on + its own, using the certificate generated by `--init`. mTLS remains an + optional client-certificate layer on top. See + [LAN Deployment](lan-deployment.md). ## Scope and assumptions - Phoenix protects secret access for AI-agent-driven workflows. - Agents and tools are treated as potentially untrusted consumers. - The Phoenix host, config, and key material are assumed to be administratively controlled. -- Network attackers, prompt-injection side effects, and credential leakage are in scope. +- LAN-resident network attackers (sniffing, spoofing, a compromised + neighbor host), prompt-injection side effects, and credential leakage + are in scope. Internet-scale network attackers are not — see the + deployment envelope above. ## Security goals @@ -43,8 +82,16 @@ This document describes the core threats Phoenix is designed to mitigate, the tr Reference-first workflows (`phoenix://` + policy-gated resolution) reduce plaintext exposure in config/prompt paths. - **Unauthorized cross-agent access** Per-agent ACL path rules enforce least privilege. +- **On-wire credential and secret interception** + TLS (the `tls:` config block, or mTLS) encrypts every request that crosses + a wire. The safe default is loopback, where no wire is crossed; the server + warns loudly at startup when a non-loopback bind would put bearer tokens + and secret values on the network in cleartext. - **Credential replay / misuse** mTLS, IP binding, cert pinning, nonce challenge, and short-lived tokens reduce replay value. Session tokens are scoped, time-limited, and individually revocable. + Note: IP binding and `source_ip` policy are meaningful only inside the LAN + envelope, where addresses are operator-assigned and not attacker-chosen. + They are not a defense against internet-scale attackers (out of scope). - **Operational leakage** Structured audit logs record allows/denies without writing secret values. - **At-rest theft of store data** @@ -110,8 +157,8 @@ one credential and are distinguished only by source IP in the audit trail | Threat | Mitigation | Residual risk | |--------|-----------|---------------| -| Brute-force login | Rate limiting with exponential backoff, audit logging of failures | PIN mode has smaller keyspace; use password for non-loopback deployments | -| Session cookie theft (network) | `Secure` flag set when TLS detected; `HttpOnly` prevents JS access; `SameSite=Strict` prevents CSRF via cross-origin requests | Plain HTTP on a shared network exposes the cookie — **do not do this** | +| Brute-force login | Rate limiting with exponential backoff, audit logging of failures | PIN mode has smaller keyspace; use password for non-loopback deployments. Per-IP rate limiting assumes LAN-envelope addresses; it is not sized for internet-scale credential stuffing (out of scope) | +| Session cookie theft (network) | `Secure` flag set when TLS detected; `HttpOnly` prevents JS access; `SameSite=Strict` prevents CSRF via cross-origin requests | Plain HTTP on a shared network exposes the cookie — **do not do this**. The server warns loudly at startup when the dashboard is enabled on a non-loopback bind without TLS | | Session cookie theft (XSS) | `HttpOnly` flag; no user-supplied content rendered unescaped; embedded static assets (no CDN/external JS) | XSS in Go `html/template` is unlikely but not impossible if templates are modified | | CSRF | Token-per-session in signed cookie, validated on all POST actions | Relies on `SameSite=Strict` + token check | | Unauthorized approval | Same `ValidateForMint` safety checks as the API; role, bootstrap, attestation, and seal key all re-verified | A compromised dashboard session can approve any pending request | @@ -122,18 +169,24 @@ one credential and are distinguished only by source IP in the audit trail The dashboard **must** be deployed behind one of: -1. **TLS reverse proxy** — proxy terminates TLS, sets `X-Forwarded-Proto: https`, - Phoenix detects this and sets `Secure` on the cookie. The server itself can - bind to loopback. This is the recommended approach. +1. **Loopback only** — `server.listen: "127.0.0.1:..."`. Dashboard is unreachable + from the network. Use SSH port forwarding for remote browser access. This is + the default and the simplest safe option. -2. **Native mTLS** — `auth.mtls.enabled: true`. TLS is native, cookie gets `Secure` - via `r.TLS != nil`. +2. **Native TLS** — `"tls": {"enabled": true}`. TLS is native (using the + `--init` server certificate), cookie gets `Secure` via `r.TLS != nil`. + No client certificates required. Add `auth.mtls` on top if you also want + client-certificate authentication for the API. -3. **Loopback only** — `server.listen: "127.0.0.1:..."`. Dashboard is unreachable - from the network. Use SSH port forwarding for remote browser access. +3. **TLS reverse proxy** — proxy terminates TLS, sets `X-Forwarded-Proto: https`, + Phoenix detects this and sets `Secure` on the cookie. The server itself + binds to loopback. Exposing the dashboard over plain HTTP on a network you do not fully control is -**not acceptable**. The session cookie would transit in cleartext. +**not acceptable**. The session cookie would transit in cleartext. The server +emits a prominent startup warning when the dashboard is enabled on a +non-loopback bind without TLS — it warns rather than refusing to start, and +the warning is the last thing logged before the server begins serving. ### When to disable the dashboard @@ -175,14 +228,19 @@ For the full sealed responses design, wire format, policy controls ## Out of scope / non-goals +- Internet/WAN exposure, public ingress, or operation on hostile networks + (see the deployment envelope — this is unsupported, not merely discouraged) - Host root compromise or kernel compromise - Hardware side-channel attacks - Physical extraction from an already-compromised host - Full enterprise HSM/KMS assurance model - Per-operator identity on the dashboard (shared credential model) +- DDoS resistance and internet-scale rate limiting ## Recommended operational controls +- Keep the listener on loopback unless other machines need access; the + moment you bind a non-loopback address, enable the `tls:` block. - Use mTLS + `deny_bearer` for high-value paths. - Keep admin ACLs narrow and rotate credentials regularly. - Enable nonce + short-lived tokens in higher-risk deployments. diff --git a/internal/config/config.go b/internal/config/config.go index 269ccb3..da38f4c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( // Config is the top-level server configuration. type Config struct { Server ServerConfig `json:"server"` + TLS TLSConfig `json:"tls"` Store StoreConfig `json:"store"` Crypto CryptoConfig `json:"crypto"` Auth AuthConfig `json:"auth"` @@ -36,6 +37,19 @@ type BearerAuthConfig struct { Enabled bool `json:"enabled"` } +// TLSConfig controls HTTPS serving (transport encryption). It is +// independent of mTLS: enabling TLS encrypts the pipe without requiring +// client certificates. Cert and Key default to the internal CA server +// leaf generated by --init (auth.mtls.server_cert / server_key), so +// `"tls": {"enabled": true}` works out of the box on an initialized +// data directory. Enabling auth.mtls also serves TLS, exactly as before — +// mTLS is client-certificate strictness layered on top of TLS. +type TLSConfig struct { + Enabled bool `json:"enabled"` + Cert string `json:"cert,omitempty"` // Server certificate path (default: auth.mtls.server_cert) + Key string `json:"key,omitempty"` // Server private key path (default: auth.mtls.server_key) +} + // MTLSConfig controls mTLS client certificate authentication. type MTLSConfig struct { Enabled bool `json:"enabled"` @@ -196,8 +210,11 @@ func DefaultConfig() *Config { // area without having to cross-reference multiple docs. func ExampleConfig() *Config { cfg := DefaultConfig() - // Loopback by default: binding to a non-loopback address requires enabling - // TLS/mTLS — never expose the plaintext HTTP listener beyond localhost. + // Loopback plaintext is the supported default: loopback traffic never + // reaches a NIC. The moment the listener crosses a wire (any non-loopback + // bind), enable the tls block — the server starts either way, but warns + // loudly when bearer tokens and secret values would cross the network + // in cleartext. cfg.Server.Listen = "127.0.0.1:9090" cfg.Store.Backend = "file" cfg.Attestation.Nonce.Enabled = false @@ -262,6 +279,24 @@ func (c *Config) Backend() string { return c.Store.Backend } +// TLSEnabled reports whether the server should serve HTTPS. The tls block +// enables transport encryption on its own; enabling auth.mtls implies TLS +// (client-certificate auth cannot exist without it). +func (c *Config) TLSEnabled() bool { + return c.TLS.Enabled || c.Auth.MTLS.Enabled +} + +// TLSCertKey returns the effective server certificate and key paths: +// tls.cert/tls.key when set, otherwise the mTLS server leaf paths written +// by --init (auth.mtls.server_cert / server_key). +func (c *Config) TLSCertKey() (cert, key string) { + cert, key = c.TLS.Cert, c.TLS.Key + if cert == "" && key == "" { + cert, key = c.Auth.MTLS.ServerCert, c.Auth.MTLS.ServerKey + } + return cert, key +} + // Validate checks that all required fields are set. func (c *Config) Validate() error { if c.Server.Listen == "" { @@ -300,6 +335,17 @@ func (c *Config) Validate() error { if !c.Auth.Bearer.Enabled && !c.Auth.MTLS.Enabled { return errors.New("at least one auth mode must be enabled (auth.bearer.enabled or auth.mtls.enabled)") } + // TLS: cert and key are set together or not at all (mixing one explicit + // path with an implicit mTLS fallback is a misconfiguration trap). + if (c.TLS.Cert == "") != (c.TLS.Key == "") { + return errors.New("tls.cert and tls.key must be set together") + } + if c.TLS.Enabled { + cert, key := c.TLSCertKey() + if cert == "" || key == "" { + return errors.New("tls.enabled requires tls.cert and tls.key paths (or the auth.mtls.server_cert/server_key paths written by --init)") + } + } // mTLS requires CA and server cert paths if c.Auth.MTLS.Enabled { if c.Auth.MTLS.CACert == "" || c.Auth.MTLS.CAKey == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 45eeab8..60f6a9c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -503,3 +503,107 @@ func TestOPConfigJSON(t *testing.T) { t.Errorf("cache_ttl = %q, want %q", parsed.OnePassword.CacheTTL, "60s") } } + +func TestTLSEnabledCombinations(t *testing.T) { + cfg := DefaultConfig() + if cfg.TLSEnabled() { + t.Fatal("default config should not enable TLS") + } + + cfg.TLS.Enabled = true + if !cfg.TLSEnabled() { + t.Fatal("tls.enabled=true should enable TLS") + } + + cfg = DefaultConfig() + cfg.Auth.MTLS.Enabled = true + if !cfg.TLSEnabled() { + t.Fatal("auth.mtls.enabled=true should imply TLS (backward compat)") + } +} + +func TestTLSCertKeyFallsBackToMTLSPaths(t *testing.T) { + cfg := DefaultConfig() + cfg.Auth.MTLS.ServerCert = "/data/server.crt" + cfg.Auth.MTLS.ServerKey = "/data/server.key" + + cert, key := cfg.TLSCertKey() + if cert != "/data/server.crt" || key != "/data/server.key" { + t.Fatalf("expected mTLS leaf fallback, got cert=%q key=%q", cert, key) + } + + cfg.TLS.Cert = "/custom/tls.crt" + cfg.TLS.Key = "/custom/tls.key" + cert, key = cfg.TLSCertKey() + if cert != "/custom/tls.crt" || key != "/custom/tls.key" { + t.Fatalf("explicit tls paths should win, got cert=%q key=%q", cert, key) + } +} + +func TestValidateTLSEnabledRequiresPaths(t *testing.T) { + cfg := DefaultConfig() + cfg.TLS.Enabled = true + if err := cfg.Validate(); err == nil { + t.Fatal("expected error: tls.enabled with no cert/key anywhere") + } + + // mTLS leaf paths present (as written by --init) satisfy the requirement + // even with mTLS disabled. + cfg.Auth.MTLS.ServerCert = "/data/server.crt" + cfg.Auth.MTLS.ServerKey = "/data/server.key" + if err := cfg.Validate(); err != nil { + t.Fatalf("tls.enabled with --init mTLS leaf paths should pass: %v", err) + } + + // Explicit tls paths also satisfy it. + cfg = DefaultConfig() + cfg.TLS.Enabled = true + cfg.TLS.Cert = "/custom/tls.crt" + cfg.TLS.Key = "/custom/tls.key" + if err := cfg.Validate(); err != nil { + t.Fatalf("tls.enabled with explicit paths should pass: %v", err) + } +} + +func TestValidateTLSCertKeyMustBeSetTogether(t *testing.T) { + cfg := DefaultConfig() + cfg.TLS.Cert = "/custom/tls.crt" + if err := cfg.Validate(); err == nil { + t.Fatal("expected error: tls.cert without tls.key") + } + cfg = DefaultConfig() + cfg.TLS.Key = "/custom/tls.key" + if err := cfg.Validate(); err == nil { + t.Fatal("expected error: tls.key without tls.cert") + } +} + +func TestTLSConfigJSON(t *testing.T) { + cfg := DefaultConfig() + cfg.TLS.Enabled = true + cfg.TLS.Cert = "/data/server.crt" + cfg.TLS.Key = "/data/server.key" + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var parsed Config + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !parsed.TLS.Enabled || parsed.TLS.Cert != "/data/server.crt" || parsed.TLS.Key != "/data/server.key" { + t.Fatalf("tls block did not round-trip: %+v", parsed.TLS) + } + + // The tls block must appear in generated configs even when disabled, + // so operators can discover the one-line fix. + data, err = json.Marshal(DefaultConfig()) + if err != nil { + t.Fatalf("marshal default: %v", err) + } + if !strings.Contains(string(data), `"tls"`) { + t.Fatal("default config JSON should include the tls block") + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..54f541f --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,139 @@ +// Package transport provides loopback detection and the shared plaintext +// transport warnings emitted by the Phoenix server, CLI, and MCP transport. +// +// Phoenix is LAN-scoped by design: plaintext HTTP on loopback is the safe, +// supported default, and TLS must be enabled the moment traffic crosses a +// wire. Nothing here refuses to run — the job of this package is to make +// the silent transition from loopback to network exposure loud. +package transport + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// IsLoopbackHost reports whether host (an IP literal or hostname, no port) +// refers unambiguously to the loopback interface. The empty host (a bind +// like ":9090") means all interfaces and is NOT loopback. Hostnames other +// than "localhost" are not resolved — they are treated as non-loopback so +// that ambiguity fails toward warning. +func IsLoopbackHost(host string) bool { + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + +// IsLoopbackListen reports whether a listen address ("host:port") binds +// only the loopback interface. Addresses that fail to parse are treated +// as non-loopback (warn on ambiguity). +func IsLoopbackListen(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // No port (or unparseable): judge the whole string as a host. + return IsLoopbackHost(addr) + } + return IsLoopbackHost(host) +} + +// IsPlaintextNonLoopbackURL reports whether rawurl is a plain http:// URL +// pointing at a non-loopback host — the case where bearer tokens and secret +// values would cross a network unencrypted. https:// URLs and loopback +// http:// URLs return false. Unparseable URLs return false (the request +// will fail on its own; there is nothing useful to warn about). +func IsPlaintextNonLoopbackURL(rawurl string) bool { + u, err := url.Parse(rawurl) + if err != nil { + return false + } + if !strings.EqualFold(u.Scheme, "http") { + return false + } + return !IsLoopbackHost(u.Hostname()) +} + +// ServerPlaintextWarning returns the multi-line startup warning for a +// Phoenix server binding a non-loopback address without TLS. dashboard +// adds the dashboard-specific exposure line. +func ServerPlaintextWarning(listen string, dashboard bool) string { + var b strings.Builder + line := func(format string, args ...interface{}) { + if format == "" { + b.WriteString("!!\n") + return + } + fmt.Fprintf(&b, "!! "+format+"\n", args...) + } + line("------------------------------------------------------------------") + line("INSECURE TRANSPORT: PLAINTEXT HTTP ON A NON-LOOPBACK ADDRESS") + line("") + line("server.listen is %q and TLS is not enabled.", listen) + line("Every request to this server crosses the network unencrypted.") + line("Anything on the same network segment can read:") + line(" - bearer tokens (each one grants its full ACL scope)") + line(" - every secret value this server returns") + if dashboard { + line(" - dashboard passwords and session cookies") + } + line("") + line("Fix (one line in config.json):") + line(" \"tls\": { \"enabled\": true }") + line("The server certificate generated by --init is used automatically.") + line("Clients connect with https:// and PHOENIX_CA_CERT=/ca.crt.") + line("If clients use a LAN IP or hostname, add it to the certificate:") + line(" phoenix-server --reissue-cert --san --config ") + line("See docs/lan-deployment.md.") + b.WriteString("!! ------------------------------------------------------------------") + return b.String() +} + +// MCPPlaintextWarning returns the multi-line startup warning for the MCP +// Streamable HTTP transport listening on a non-loopback address without TLS. +func MCPPlaintextWarning(listen string) string { + var b strings.Builder + line := func(format string, args ...interface{}) { + if format == "" { + b.WriteString("!!\n") + return + } + fmt.Fprintf(&b, "!! "+format+"\n", args...) + } + line("------------------------------------------------------------------") + line("INSECURE TRANSPORT: PLAINTEXT HTTP ON A NON-LOOPBACK ADDRESS") + line("") + line("The MCP HTTP transport is listening on %q without TLS.", listen) + line("Every request crosses the network unencrypted. Anything on the") + line("same network segment can read:") + line(" - the MCP bearer token (full access to this MCP server)") + line(" - every secret value returned to MCP clients") + line("") + line("Fix: pass --tls-cert and --tls-key (the Phoenix --init server") + line("certificate works: /server.crt and /server.key),") + line("or bind to 127.0.0.1 and keep MCP clients on this host.") + line("See docs/lan-deployment.md.") + b.WriteString("!! ------------------------------------------------------------------") + return b.String() +} + +// ClientPlaintextWarning returns a short warning for a client (CLI) that is +// about to send bearer tokens over plaintext HTTP to a non-loopback server. +// Returns "" when serverURL does not warrant a warning. +func ClientPlaintextWarning(serverURL string) string { + if !IsPlaintextNonLoopbackURL(serverURL) { + return "" + } + return fmt.Sprintf( + "WARNING: PHOENIX_SERVER is plaintext http:// to a non-loopback address (%s).\n"+ + "Bearer tokens and secret values will cross the network unencrypted.\n"+ + "Fix: enable the server's \"tls\" config block, switch this URL to https://,\n"+ + "and set PHOENIX_CA_CERT to the Phoenix CA certificate (docs/lan-deployment.md).", + serverURL) +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go new file mode 100644 index 0000000..4a2d192 --- /dev/null +++ b/internal/transport/transport_test.go @@ -0,0 +1,105 @@ +package transport + +import ( + "strings" + "testing" +) + +func TestIsLoopbackListen(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"127.0.0.1:9090", true}, + {"127.0.0.2:9090", true}, // whole 127/8 block is loopback + {"localhost:9090", true}, + {"LOCALHOST:9090", true}, + {"[::1]:9090", true}, + {"0.0.0.0:9090", false}, + {"[::]:9090", false}, + {":9090", false}, // empty host binds all interfaces + {"192.168.1.10:9090", false}, + {"10.0.0.5:80", false}, + {"phoenix.internal:9090", false}, // hostnames are not resolved: warn + {"phoenix.internal", false}, // no port: judged as host + {"127.0.0.1", true}, // no port: judged as host + {"", false}, + } + for _, tt := range tests { + if got := IsLoopbackListen(tt.addr); got != tt.want { + t.Errorf("IsLoopbackListen(%q) = %v, want %v", tt.addr, got, tt.want) + } + } +} + +func TestIsPlaintextNonLoopbackURL(t *testing.T) { + tests := []struct { + url string + want bool + }{ + {"http://127.0.0.1:9090", false}, + {"http://localhost:9090", false}, + {"http://[::1]:9090", false}, + {"http://192.168.1.10:9090", true}, + {"http://0.0.0.0:9090", true}, + {"http://phoenix.internal:9090", true}, + {"HTTP://192.168.1.10:9090", true}, + {"https://192.168.1.10:9090", false}, // encrypted: no warning + {"https://phoenix.internal:9090", false}, + {"", false}, + {"not a url", false}, + } + for _, tt := range tests { + if got := IsPlaintextNonLoopbackURL(tt.url); got != tt.want { + t.Errorf("IsPlaintextNonLoopbackURL(%q) = %v, want %v", tt.url, got, tt.want) + } + } +} + +func TestServerPlaintextWarning(t *testing.T) { + w := ServerPlaintextWarning("0.0.0.0:9090", false) + for _, want := range []string{ + "INSECURE TRANSPORT", + `"0.0.0.0:9090"`, + "bearer tokens", + "secret value", + `"tls": { "enabled": true }`, + "--reissue-cert", + } { + if !strings.Contains(w, want) { + t.Errorf("server warning missing %q:\n%s", want, w) + } + } + if strings.Contains(w, "dashboard") { + t.Errorf("dashboard line present when dashboard disabled:\n%s", w) + } + + wd := ServerPlaintextWarning("0.0.0.0:9090", true) + if !strings.Contains(wd, "dashboard passwords and session cookies") { + t.Errorf("dashboard warning line missing:\n%s", wd) + } +} + +func TestMCPPlaintextWarning(t *testing.T) { + w := MCPPlaintextWarning("0.0.0.0:8080") + for _, want := range []string{"INSECURE TRANSPORT", `"0.0.0.0:8080"`, "MCP bearer token", "--tls-cert"} { + if !strings.Contains(w, want) { + t.Errorf("MCP warning missing %q:\n%s", want, w) + } + } +} + +func TestClientPlaintextWarning(t *testing.T) { + if w := ClientPlaintextWarning("http://127.0.0.1:9090"); w != "" { + t.Errorf("loopback URL should not warn, got:\n%s", w) + } + if w := ClientPlaintextWarning("https://192.168.1.10:9090"); w != "" { + t.Errorf("https URL should not warn, got:\n%s", w) + } + w := ClientPlaintextWarning("http://192.168.1.10:9090") + for _, want := range []string{"WARNING", "http://192.168.1.10:9090", "unencrypted", "PHOENIX_CA_CERT"} { + if !strings.Contains(w, want) { + t.Errorf("client warning missing %q:\n%s", want, w) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index ede160a..87d64aa 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,4 @@ // go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0" package version -var Version = "0.16.0" +var Version = "0.17.0" diff --git a/sdk/go/phoenix/client.go b/sdk/go/phoenix/client.go index 4fb51db..c6fc37c 100644 --- a/sdk/go/phoenix/client.go +++ b/sdk/go/phoenix/client.go @@ -9,9 +9,12 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "os" "strings" + "sync" "time" ) @@ -80,6 +83,8 @@ func New(server, token string) *Client { token = os.Getenv("PHOENIX_TOKEN") } + warnPlaintextOnce(server) + c := &Client{ Server: server, Token: token, @@ -91,6 +96,47 @@ func New(server, token string) *Client { return c } +// plaintextWarned tracks server URLs already warned about, so a process +// creating many clients warns once per distinct server. +var plaintextWarned sync.Map + +// warnPlaintextOnce emits a stderr warning when the server URL is plain +// http:// to a non-loopback host: bearer tokens and secret values would +// cross the network unencrypted. Phoenix is LAN-scoped with plaintext +// loopback as the supported default — this warns on the transition, it +// never fails. Hostnames other than "localhost" are not resolved; they +// are treated as non-loopback so ambiguity warns. +func warnPlaintextOnce(server string) { + if !isPlaintextNonLoopback(server) { + return + } + if _, loaded := plaintextWarned.LoadOrStore(server, struct{}{}); loaded { + return + } + fmt.Fprintf(os.Stderr, + "phoenix: WARNING: server URL is plaintext http:// to a non-loopback address (%s).\n"+ + "phoenix: Bearer tokens and secret values will cross the network unencrypted.\n"+ + "phoenix: Enable the server's \"tls\" config block and use https:// (see docs/lan-deployment.md).\n", + server) +} + +// isPlaintextNonLoopback reports whether rawurl is plain http:// to a +// non-loopback host. +func isPlaintextNonLoopback(rawurl string) bool { + u, err := url.Parse(rawurl) + if err != nil || !strings.EqualFold(u.Scheme, "http") { + return false + } + host := u.Hostname() + if strings.EqualFold(host, "localhost") { + return false + } + if ip := net.ParseIP(host); ip != nil { + return !ip.IsLoopback() + } + return true +} + // NewWithRole creates a client and mints a scoped session for the given role. // The bootstrap token (from token param or PHOENIX_TOKEN env) is used only for // the mint request and then replaced with the scoped session token. diff --git a/sdk/go/phoenix/client_test.go b/sdk/go/phoenix/client_test.go index 912c291..065f637 100644 --- a/sdk/go/phoenix/client_test.go +++ b/sdk/go/phoenix/client_test.go @@ -359,3 +359,24 @@ func TestRevokeSession(t *testing.T) { t.Fatalf("RevokeSession: %v", err) } } + +func TestIsPlaintextNonLoopback(t *testing.T) { + tests := []struct { + url string + want bool + }{ + {"http://127.0.0.1:9090", false}, + {"http://localhost:9090", false}, + {"http://[::1]:9090", false}, + {"http://192.168.1.10:9090", true}, + {"http://0.0.0.0:9090", true}, + {"http://phoenix.internal:9090", true}, + {"https://192.168.1.10:9090", false}, + {"https://phoenix.internal:9090", false}, + } + for _, tt := range tests { + if got := isPlaintextNonLoopback(tt.url); got != tt.want { + t.Errorf("isPlaintextNonLoopback(%q) = %v, want %v", tt.url, got, tt.want) + } + } +} diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 32e1b8a..a209e47 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -47,7 +47,7 @@ The core suite needs Bash, Go, curl, Python 3, and jq. Set `GO_BIN` for a non-PA | 16 | elevated step-up deny/approve/expiry/renewal | implemented | | 17 | sealed-response S1–S8 plus policy/keypair regressions | implemented | | 18 | v0.13.5 → current in-place data/config/ACL/audit upgrade | implemented | -| 19 | — | reserved for secure-transport coverage | +| 19 | secure transport: `tls:` block HTTPS, non-loopback plaintext warnings (server/CLI/MCP), mTLS backward compat, MCP `--tls-cert`/`--tls-key` | implemented; non-loopback warning asserted via unbindable TEST-NET-1 address so no listener leaves loopback | | 20 | Hermes caller metadata: capture, sanitization/cap, unlisted-header exclusion, ACL spoof resistance, `X-Hermes-Tool` vs `X-Phoenix-Tool` | implemented | Active sessions are intentionally memory-only. Scenario 18 verifies that an old in-memory token is rejected after restart and that the preserved role configuration can mint a new compatible session. diff --git a/tests/e2e/scenario-19-transport.sh b/tests/e2e/scenario-19-transport.sh new file mode 100755 index 0000000..38bed9c --- /dev/null +++ b/tests/e2e/scenario-19-transport.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Scenario 19 — secure transport: tls block, plaintext warnings, mTLS compat. +# +# Covers the transport model: +# - loopback plaintext is the default and starts silently (no warning) +# - a non-loopback bind without TLS emits the loud INSECURE TRANSPORT +# warning and Phoenix still proceeds (warn, never refuse) +# - "tls": {"enabled": true} serves HTTPS with the --init leaf, no mTLS +# - auth.mtls.enabled continues to serve TLS unchanged (backward compat) +# - CLI warns (but works) on plaintext non-loopback PHOENIX_SERVER +# - MCP HTTP transport serves TLS via --tls-cert/--tls-key and warns on +# plaintext non-loopback binds +# +# Per harness convention, nothing here binds a non-loopback interface. The +# non-loopback warning is asserted with a TEST-NET-1 address (192.0.2.1, +# RFC 5737): the warning is emitted before the bind attempt, so the log +# proves Phoenix warned and proceeded; the bind then fails at the OS level. +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +scenario_start 19 "secure transport: tls block, plaintext warnings, mTLS compat" +init_scenario 19 +PORT="${PHOENIX_SERVER##*:}" + +# --- 1. Loopback plaintext default: starts, serves, and does not warn --- +start_server +log "loopback plaintext default" +server_log="$(cat "$RUN_DIR/server.log")" +assert_contains "$server_log" "TLS: disabled (plaintext HTTP)" "loopback plaintext should state TLS is off" +assert_not_contains "$server_log" "INSECURE TRANSPORT" "loopback plaintext must not warn" + +cli_stderr="$(phoenix_admin status 2>&1 >/dev/null)" +assert_not_contains "$cli_stderr" "WARNING" "CLI must not warn on loopback http://" +ok "loopback plaintext: silent, serving" + +# --- 2. CLI warns (but works) on plaintext non-loopback PHOENIX_SERVER --- +# 0.0.0.0 is judged non-loopback (it binds/means all interfaces) but on +# Linux connecting to it reaches the loopback listener, so the command +# must warn on stderr AND still succeed. +cli_stderr="$(PHOENIX_SERVER="http://0.0.0.0:$PORT" phoenix_admin status 2>&1 >/dev/null)" +assert_contains "$cli_stderr" "WARNING" "CLI must warn on non-loopback http://" +assert_contains "$cli_stderr" "unencrypted" "CLI warning must name the exposure" +ok "CLI warns on non-loopback plaintext and still completes" +stop_server + +# --- 3. Non-loopback plaintext server: loud warning, no refusal --- +run_expect_warning() { + local logfile="$1"; shift + local rc=0 + timeout 10 "$PHOENIX_SERVER_BIN" --config "$RUN_DIR/config.json" >"$logfile" 2>&1 || rc=$? + # TEST-NET-1 cannot be assigned here, so the OS rejects the bind after + # the warning is printed. rc 0 would mean it served — also a failure. + [[ "$rc" -ne 0 ]] || die "server unexpectedly bound a TEST-NET-1 address" +} + +config_patch "d['server']['listen']='192.0.2.1:$PORT'" +run_expect_warning "$RUN_DIR/warn.log" +warn_log="$(cat "$RUN_DIR/warn.log")" +assert_contains "$warn_log" "INSECURE TRANSPORT: PLAINTEXT HTTP ON A NON-LOOPBACK ADDRESS" "non-loopback plaintext must warn" +assert_contains "$warn_log" "bearer tokens" "warning must name bearer token exposure" +assert_contains "$warn_log" "secret value" "warning must name secret value exposure" +assert_contains "$warn_log" '"tls": { "enabled": true }' "warning must show the one-line fix" +assert_contains "$warn_log" "server error: listen" "warning must precede the bind attempt (warn, never refuse)" +assert_not_contains "$warn_log" "dashboard" "no dashboard line when dashboard is disabled" +ok "non-loopback plaintext: loud warning, startup not refused" + +# --- 4. Dashboard enabled adds the dashboard exposure line --- +config_patch "d['dashboard']={'enabled': True, 'pin': '123456'}" +run_expect_warning "$RUN_DIR/warn-dash.log" +warn_log="$(cat "$RUN_DIR/warn-dash.log")" +assert_contains "$warn_log" "dashboard passwords and session cookies" "dashboard exposure line missing" +ok "dashboard on non-loopback plaintext: warning names the cookie exposure" +config_patch "d['dashboard']={'enabled': False}" + +# --- 5. tls.enabled=true: HTTPS with the --init leaf, no mTLS involved --- +config_patch "d['server']['listen']='127.0.0.1:$PORT'; d['tls']={'enabled': True}" +PHOENIX_SERVER="https://127.0.0.1:$PORT" +export PHOENIX_SERVER +start_server +server_log="$(cat "$RUN_DIR/server.log")" +assert_contains "$server_log" "TLS: enabled" "tls.enabled must serve TLS" +assert_not_contains "$server_log" "INSECURE TRANSPORT" "TLS-enabled server must not warn" +assert_not_contains "$server_log" "mTLS: enabled" "tls block must not activate mTLS" + +# Plain http against the TLS listener must fail. +if curl -fsS -m 3 "http://127.0.0.1:$PORT/v1/health" >/dev/null 2>&1; then + die "plaintext request succeeded against a TLS listener" +fi + +# CLI over https with the CA cert: works, no warning. +cli_out="$(PHOENIX_CA_CERT="$RUN_DIR/ca.crt" phoenix_admin status 2>"$RUN_DIR/cli-tls.stderr")" +assert_contains "$cli_out" "Server: ok" "CLI status over TLS should report ok" +cli_stderr="$(cat "$RUN_DIR/cli-tls.stderr")" +assert_not_contains "$cli_stderr" "WARNING" "CLI must not warn on https://" +ok "tls.enabled serves HTTPS with the --init certificate (no mTLS)" +stop_server + +# --- 6. Backward compat: mtls.enabled alone still serves TLS unchanged --- +config_patch "d['tls']={'enabled': False}; d['auth']['mtls']['enabled']=True; d['auth']['mtls']['require']=False" +start_server +server_log="$(cat "$RUN_DIR/server.log")" +assert_contains "$server_log" "mTLS: enabled (require=false)" "mtls-only config must keep enabling mTLS" +assert_contains "$server_log" "TLS: enabled" "mtls-only config must keep serving TLS" +ok "existing mtls-only configs serve TLS unchanged" +stop_server + +# --- 7. MCP HTTP transport: TLS serving and plaintext warning --- +MCP_PORT="$(free_port)" +MCP_TOKEN="synthetic-mcp-token-scenario19" +PHOENIX_SERVER="http://127.0.0.1:$PORT" +export PHOENIX_SERVER +config_patch "d['auth']['mtls']['enabled']=False" +start_server + +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" mcp-server \ + --http "127.0.0.1:$MCP_PORT" --mcp-token "$MCP_TOKEN" \ + --tls-cert "$RUN_DIR/server.crt" --tls-key "$RUN_DIR/server.key" \ + >"$RUN_DIR/mcp.log" 2>&1 & +MCP_PID=$! +for i in $(seq 1 50); do + if curl -fsS --cacert "$RUN_DIR/ca.crt" -m 2 -o /dev/null -w '' \ + -X POST "https://127.0.0.1:$MCP_PORT/mcp" \ + -H "Authorization: Bearer $MCP_TOKEN" -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' 2>/dev/null; then + break + fi + kill -0 "$MCP_PID" 2>/dev/null || { cat "$RUN_DIR/mcp.log" >&2; die "MCP TLS server exited during startup"; } + sleep 0.1 +done +mcp_resp="$(curl -fsS --cacert "$RUN_DIR/ca.crt" -X POST "https://127.0.0.1:$MCP_PORT/mcp" \ + -H "Authorization: Bearer $MCP_TOKEN" -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}')" +assert_contains "$mcp_resp" '"result"' "MCP initialize over TLS should answer" +assert_contains "$(cat "$RUN_DIR/mcp.log")" "(TLS)" "MCP log should state TLS listening" +kill "$MCP_PID" 2>/dev/null || true +wait "$MCP_PID" 2>/dev/null || true +ok "MCP HTTP transport serves TLS with --tls-cert/--tls-key" + +# Mismatched flags fail closed. +rc=0 +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" mcp-server --http "127.0.0.1:$MCP_PORT" \ + --mcp-token "$MCP_TOKEN" --tls-cert "$RUN_DIR/server.crt" >"$RUN_DIR/mcp-mismatch.log" 2>&1 || rc=$? +[[ "$rc" -ne 0 ]] || die "mcp-server accepted --tls-cert without --tls-key" +assert_contains "$(cat "$RUN_DIR/mcp-mismatch.log")" "--tls-cert and --tls-key must be set together" "mismatch error missing" +ok "MCP HTTP transport rejects a lone --tls-cert" + +# Plaintext non-loopback MCP bind warns before the (failing) bind attempt. +rc=0 +PHOENIX_TOKEN="$ADMIN_TOKEN" timeout 10 "$PHOENIX" mcp-server --http "192.0.2.1:$MCP_PORT" \ + --mcp-token "$MCP_TOKEN" >"$RUN_DIR/mcp-warn.log" 2>&1 || rc=$? +[[ "$rc" -ne 0 ]] || die "MCP server unexpectedly bound a TEST-NET-1 address" +mcp_warn="$(cat "$RUN_DIR/mcp-warn.log")" +assert_contains "$mcp_warn" "INSECURE TRANSPORT" "MCP non-loopback plaintext must warn" +assert_contains "$mcp_warn" "MCP bearer token" "MCP warning must name the token exposure" +ok "MCP HTTP transport warns on non-loopback plaintext" + +stop_server +ok "Scenario 19 passed"