Files
pad/internal/server/handlers_cli_auth_test.go
xarmian 0a24078554 fix(server): derive CLI auth URL scheme from r.TLS, gate X-Forwarded-Proto on trusted proxies (TASK-665) (#190)
handleCreateCLIAuthSession previously accepted X-Forwarded-Proto from any
client to pick the URL scheme, letting an attacker forge https:// in the
terminal link printed by `pad auth login` on plain-HTTP self-host
deployments. Low-impact phishing (the user clicks in their own terminal),
but the safe default is to ignore unauthenticated proxy headers.

- Factor out cliAuthScheme(r, trustedCIDRs) with explicit precedence:
  1. r.TLS != nil  -> "https"
  2. peer in PAD_TRUSTED_PROXIES -> use X-Forwarded-Proto (first value,
     case-insensitive, must be "http" or "https")
  3. otherwise -> "http"
- Use rawPeerAddr so the check works even after TrustedProxyRealIP has
  rewritten r.RemoteAddr.
- Table-driven tests cover TLS, untrusted-peer spoofing, trusted-peer
  forwarding, chained/case-insensitive/garbage X-Forwarded-Proto values.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 09:27:45 -04:00

115 lines
3.1 KiB
Go

package server
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestCLIAuthScheme(t *testing.T) {
// Parse a trusted proxy CIDR once for reuse.
trustedCIDRs := ParseTrustedProxyCIDRs("10.0.0.0/8")
if len(trustedCIDRs) != 1 {
t.Fatalf("fixture: expected 1 CIDR, got %d", len(trustedCIDRs))
}
cases := []struct {
name string
tls bool
remoteAddr string
proto string
trustedCIDRs []*net.IPNet
want string
}{
{
name: "TLS terminated at server is always https",
tls: true,
remoteAddr: "203.0.113.5:12345",
proto: "http", // ignored
want: "https",
},
{
name: "Plain HTTP with no trusted proxies ignores X-Forwarded-Proto",
remoteAddr: "203.0.113.5:12345",
proto: "https",
trustedCIDRs: nil,
want: "http",
},
{
name: "Plain HTTP from untrusted peer ignores X-Forwarded-Proto",
remoteAddr: "203.0.113.5:12345",
proto: "https",
trustedCIDRs: trustedCIDRs,
want: "http",
},
{
name: "Plain HTTP from trusted peer with X-Forwarded-Proto=https returns https",
remoteAddr: "10.0.0.1:12345",
proto: "https",
trustedCIDRs: trustedCIDRs,
want: "https",
},
{
name: "Plain HTTP from trusted peer with X-Forwarded-Proto=http returns http",
remoteAddr: "10.0.0.1:12345",
proto: "http",
trustedCIDRs: trustedCIDRs,
want: "http",
},
{
name: "Plain HTTP from trusted peer with X-Forwarded-Proto=HTTPS (case-insensitive)",
remoteAddr: "10.0.0.1:12345",
proto: "HTTPS",
trustedCIDRs: trustedCIDRs,
want: "https",
},
{
name: "Plain HTTP from trusted peer with chained X-Forwarded-Proto takes first value",
remoteAddr: "10.0.0.1:12345",
proto: "https, http",
trustedCIDRs: trustedCIDRs,
want: "https",
},
{
name: "Plain HTTP from trusted peer with garbage X-Forwarded-Proto falls back to http",
remoteAddr: "10.0.0.1:12345",
proto: "javascript:",
trustedCIDRs: trustedCIDRs,
want: "http",
},
{
name: "Plain HTTP from trusted peer with empty X-Forwarded-Proto returns http",
remoteAddr: "10.0.0.1:12345",
proto: "",
trustedCIDRs: trustedCIDRs,
want: "http",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/cli/sessions", nil)
req.Host = "pad.example.com"
req.RemoteAddr = tc.remoteAddr
if tc.proto != "" {
req.Header.Set("X-Forwarded-Proto", tc.proto)
}
if tc.tls {
req.TLS = &tls.ConnectionState{}
}
// Mirror what CapturePeerAddr would do in the real chain so that
// rawPeerAddr returns the untampered peer.
ctx := context.WithValue(req.Context(), peerAddrCtxKey{}, tc.remoteAddr)
req = req.WithContext(ctx)
got := cliAuthScheme(req, tc.trustedCIDRs)
if got != tc.want {
t.Errorf("cliAuthScheme() = %q, want %q", got, tc.want)
}
})
}
}