diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 4b32666..c021bd8 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 env: - VERSION_OVERRIDE: v0.23.13 + VERSION_OVERRIDE: v0.23.14 SOURCE_DATE_EPOCH: "0" TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 BUILDER_IMAGE: pasturestack-websocket-proxy-dapper:ubuntu26 @@ -30,7 +30,7 @@ jobs: set -o pipefail make ci 2>&1 | tee evidence/build-test.log cp bin/websocket-proxy evidence/websocket-proxy-linux-amd64 - cp dist/artifacts/websocket-proxy-0.23.13-linux-amd64.tar.xz evidence/first-build.tar.xz + cp dist/artifacts/websocket-proxy-0.23.14-linux-amd64.tar.xz evidence/first-build.tar.xz - name: Verify a byte-identical second package id: reproduce @@ -38,7 +38,7 @@ jobs: set -o pipefail rm -rf bin dist make package 2>&1 | tee evidence/reproducible-build.log - cp dist/artifacts/websocket-proxy-0.23.13-linux-amd64.tar.xz evidence/second-build.tar.xz + cp dist/artifacts/websocket-proxy-0.23.14-linux-amd64.tar.xz evidence/second-build.tar.xz cmp evidence/first-build.tar.xz evidence/second-build.tar.xz sha256sum evidence/first-build.tar.xz evidence/second-build.tar.xz \ | tee evidence/package-sha256.txt diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 9be11b5..bedc076 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -5,6 +5,9 @@ The migration preserves existing `/v1`, `/v2-beta`, and `/v2` proxy paths, backe Preferred settings use `platform-*`, `PLATFORM_*`, and `PROXY_PLATFORM_ADDRESS`. Historical `cattle-*`, `CATTLE_*`, and `PROXY_CATTLE_ADDRESS` aliases remain only as runtime configuration contracts. +`PROXY_PLATFORM_PUBLIC_ORIGIN` is an optional deployment contract for a +canonical public HTTP(S) origin when TLS terminates before an internal HTTP +hop; requests for any other host keep listener-derived forwarding values. The generated legacy API client is no longer shipped. Its required certificate discovery behavior is implemented against the compatible schema and credential links with authenticated same-origin requests. diff --git a/README.md b/README.md index a8380d7..1f0d986 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current reviewed release is `v0.23.13`, consumed by PastureStack Server -`v1.6.410`. It uses +The current reviewed release is `v0.23.14`, consumed by PastureStack Server +`v1.6.411`. It uses Go 1.27.0 and Docker CLI 29.7.2, a digest-locked Ubuntu 26.04 build image, a dated Ubuntu snapshot, Go module checksums, an offline vendored dependency set, and checksum-verified toolchain archives. Product-owned imports, configuration fields, proxy @@ -29,6 +29,12 @@ or newer. Current dependency provenance is locked by `go.mod`, `go.sum`, and Use `--platform-address`, `PLATFORM_ACCESS_KEY`, and `PLATFORM_SECRET_KEY`. Historical `--cattle-address`, `PROXY_CATTLE_ADDRESS`, and `CATTLE_*` settings remain compatibility fallbacks. Set `PASTURESTACK_LOCALE=en-US` or `zh-TW` for operator messages. +When TLS terminates in front of the Server container, set +`PROXY_PLATFORM_PUBLIC_ORIGIN` to the exact public origin, for example +`https://stack.example.com`. The proxy uses it only when the request host +matches that configured authority, so internal HTTP hops retain public HTTPS +links without trusting browser-supplied forwarding headers. + The persistent console broker uses `/v1/exec/sessions/{sessionId}`. Session creation and deletion require a same-origin request; WebSocket attachment authenticates with the @@ -47,9 +53,9 @@ make build make package ``` -Set `VERSION_OVERRIDE=v0.23.13` to reproduce this reviewed release. Packaging +Set `VERSION_OVERRIDE=v0.23.14` to reproduce this reviewed release. Packaging rejects brand or maintenance suffixes and produces the deterministic, -versioned `websocket-proxy-0.23.13-linux-amd64.tar.xz` asset. Publishing a +versioned `websocket-proxy-0.23.14-linux-amd64.tar.xz` asset. Publishing a future version remains a separate explicit decision. See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md). diff --git a/SECURITY.md b/SECURITY.md index f9f59d4..490fb6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,9 @@ support commitment and is not published or deployed automatically. interceptor requests. - Client-supplied forwarding headers are replaced with the connection identity established by the listener; they are never trusted as authoritative input. +- A configured public origin may restore the external scheme and port after an + internal HTTP hop, but only for requests whose host matches that exact + administrator-configured authority. - Proxy Protocol is accepted from loopback sources by default. Deployments using a remote load balancer must explicitly configure its source networks with `--trusted-proxy-cidrs` (or `PROXY_TRUSTED_PROXY_CIDRS`). diff --git a/proxy/config.go b/proxy/config.go index baf6482..fab4c6c 100644 --- a/proxy/config.go +++ b/proxy/config.go @@ -38,6 +38,7 @@ type Config struct { ParentPid int ProxyProtoHTTPSPorts map[int]bool TrustedProxyCIDRs []*net.IPNet + PlatformPublicOrigin *url.URL PlatformAccessKey string PlatformSecretKey string TLSListenAddr string @@ -55,6 +56,7 @@ func GetConfig() (*Config, error) { var keyContents string var proxyProtoHTTPSPorts string var trustedProxyCIDRs string + var platformPublicOrigin string var apiInterceptorConfigFile string var legacyPlatformAddr string @@ -73,6 +75,7 @@ func GetConfig() (*Config, error) { flag.IntVar(&c.ParentPid, "parent-pid", 0, "If provided, this process will exit when the specified parent process stops running.") flag.StringVar(&proxyProtoHTTPSPorts, "https-proxy-protocol-ports", "", "If proxy protocol is used, a list of proxy ports that will allow us to recognize that the connection was over https.") flag.StringVar(&trustedProxyCIDRs, "trusted-proxy-cidrs", "127.0.0.0/8,::1/128", "Comma-separated source CIDRs allowed to send Proxy Protocol headers.") + flag.StringVar(&platformPublicOrigin, "platform-public-origin", "", "Optional canonical public http(s) origin used when this proxy runs behind TLS termination.") flag.StringVar(&apiInterceptorConfigFile, "api-interceptor-config-file", "", "Location of the config.json that defines the API interceptors.") if !flag.Parsed() { @@ -124,11 +127,32 @@ func GetConfig() (*Config, error) { if parseErr != nil { return nil, parseErr } + c.PlatformPublicOrigin, parseErr = parsePlatformPublicOrigin(platformPublicOrigin) + if parseErr != nil { + return nil, parseErr + } c.APIInterceptorConfigFile = apiInterceptorConfigFile return c, nil } +func parsePlatformPublicOrigin(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return nil, errors.New("platform public origin must be an absolute http(s) origin") + } + if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, errors.New("platform public origin must not contain credentials, a path, a query, or a fragment") + } + parsed.Path = "" + return parsed, nil +} + func parseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) { var networks []*net.IPNet for _, value := range strings.Split(raw, ",") { diff --git a/proxy/config_security_test.go b/proxy/config_security_test.go index 79394f4..d5954c3 100644 --- a/proxy/config_security_test.go +++ b/proxy/config_security_test.go @@ -162,6 +162,23 @@ func TestParseTrustedProxyCIDRs(t *testing.T) { } } +func TestParsePlatformPublicOrigin(t *testing.T) { + origin, err := parsePlatformPublicOrigin("https://stack.example.test/") + if err != nil || origin.String() != "https://stack.example.test" { + t.Fatalf("valid public origin was rejected: origin=%v err=%v", origin, err) + } + for _, invalid := range []string{ + "ftp://stack.example.test", + "https://user:secret@stack.example.test", + "https://stack.example.test/a/path", + "https://stack.example.test?query=value", + } { + if _, err := parsePlatformPublicOrigin(invalid); err == nil { + t.Fatalf("invalid public origin was accepted: %q", invalid) + } + } +} + func TestExtractCertificateArchiveRejectsMissingAndDuplicateEntries(t *testing.T) { _, err := extractCertificateArchive(certificateArchive(t, map[string]string{ "ca.pem": "ca", diff --git a/proxy/frontend_http_handler.go b/proxy/frontend_http_handler.go index 9172f57..ba61cab 100644 --- a/proxy/frontend_http_handler.go +++ b/proxy/frontend_http_handler.go @@ -17,8 +17,9 @@ import ( type FrontendHTTPHandler struct { FrontendHandler - HTTPSPorts map[int]bool - TokenLookup *TokenLookup + HTTPSPorts map[int]bool + PublicOrigin *url.URL + TokenLookup *TokenLookup } func (h *FrontendHTTPHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { @@ -46,7 +47,7 @@ func (h *FrontendHTTPHandler) serveHTTP(rw http.ResponseWriter, req *http.Reques scheme, _ := data["scheme"].(string) stripProxyAuthenticationQuery(req) - proxyprotocol.AddHeaders(req, h.HTTPSPorts) + proxyprotocol.AddHeaders(req, h.HTTPSPorts, h.PublicOrigin) proxyprotocol.AddForwardedFor(req) reader, writer, err := NewHTTPPipe(rw, h.backend, hostKey) diff --git a/proxy/proxy.go b/proxy/proxy.go index 14c478f..26436c2 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/http/httputil" + "net/url" "os" "regexp" "sync" @@ -70,8 +71,9 @@ func (s *Starter) StartProxy() error { backend: bpm, parsedPublicKey: s.Config.PublicKey, }, - HTTPSPorts: s.Config.ProxyProtoHTTPSPorts, - TokenLookup: tokenLookup, + HTTPSPorts: s.Config.ProxyProtoHTTPSPorts, + PublicOrigin: s.Config.PlatformPublicOrigin, + TokenLookup: tokenLookup, }) platformProxy, platformWsProxy, err := newPlatformProxies(s.Config) @@ -247,8 +249,9 @@ func newWSProxy(config *Config) (http.Handler, error) { } reverseProxy := &proxyProtocolConverter{ - p: platformProxy, - httpsPorts: config.ProxyProtoHTTPSPorts, + p: platformProxy, + httpsPorts: config.ProxyProtoHTTPSPorts, + publicOrigin: config.PlatformPublicOrigin, } wsProxy := &platformWSProxy{ @@ -268,8 +271,9 @@ func newPlatformProxies(config *Config) (*proxyProtocolConverter, *platformWSPro } reverseProxy := &proxyProtocolConverter{ - httpsPorts: config.ProxyProtoHTTPSPorts, - p: apiProxyHandler, + httpsPorts: config.ProxyProtoHTTPSPorts, + publicOrigin: config.PlatformPublicOrigin, + p: apiProxyHandler, } wsProxy := &platformWSProxy{ @@ -281,12 +285,13 @@ func newPlatformProxies(config *Config) (*proxyProtocolConverter, *platformWSPro } type proxyProtocolConverter struct { - httpsPorts map[int]bool - p http.Handler + httpsPorts map[int]bool + publicOrigin *url.URL + p http.Handler } func (h *proxyProtocolConverter) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - proxyprotocol.AddHeaders(req, h.httpsPorts) + proxyprotocol.AddHeaders(req, h.httpsPorts, h.publicOrigin) h.p.ServeHTTP(rw, req) } @@ -301,7 +306,7 @@ func (h *platformWSProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { http.Error(rw, "Cross-origin websocket request denied", http.StatusForbidden) return } - proxyprotocol.AddHeaders(req, h.reverseProxy.httpsPorts) + proxyprotocol.AddHeaders(req, h.reverseProxy.httpsPorts, h.reverseProxy.publicOrigin) h.serveWebsocket(rw, req) } else { h.reverseProxy.ServeHTTP(rw, req) diff --git a/proxy/proxyprotocol/http_integration.go b/proxy/proxyprotocol/http_integration.go index e6adb92..63213b5 100644 --- a/proxy/proxyprotocol/http_integration.go +++ b/proxy/proxyprotocol/http_integration.go @@ -3,6 +3,7 @@ package proxyprotocol import ( "net" "net/http" + "net/url" "strconv" "strings" ) @@ -11,9 +12,10 @@ const ( xForwardedProto string = "X-Forwarded-Proto" xForwardedPort string = "X-Forwarded-Port" xForwardedFor string = "X-Forwarded-For" + xForwardedHost string = "X-Forwarded-Host" ) -func AddHeaders(req *http.Request, httpsPorts map[int]bool) { +func AddHeaders(req *http.Request, httpsPorts map[int]bool, publicOrigin *url.URL) { proxyProtoInfo := getInfo(req.RemoteAddr) if proxyProtoInfo != nil { proto := "http" @@ -32,6 +34,53 @@ func AddHeaders(req *http.Request, httpsPorts map[int]bool) { req.Header.Del(xForwardedPort) req.Header.Set(xForwardedFor, requestClientIP(req)) } + + // A configured origin is explicit deployment authority, not a forwarded + // client claim. Apply it only to requests for that same public host; all + // other hosts retain transport-derived values above. + if publicOriginMatchesRequest(req, publicOrigin) { + req.Header.Set(xForwardedProto, publicOrigin.Scheme) + req.Header.Set(xForwardedHost, publicOrigin.Host) + port := publicOrigin.Port() + if port == "" { + if publicOrigin.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + req.Header.Set(xForwardedPort, port) + } else { + req.Header.Set(xForwardedHost, req.Host) + } +} + +func publicOriginMatchesRequest(req *http.Request, origin *url.URL) bool { + if origin == nil || req == nil || req.Host == "" { + return false + } + + requestAuthority, err := url.Parse("//" + req.Host) + if err != nil || requestAuthority.User != nil || requestAuthority.Hostname() == "" { + return false + } + if !strings.EqualFold(requestAuthority.Hostname(), origin.Hostname()) { + return false + } + + defaultPort := "80" + if origin.Scheme == "https" { + defaultPort = "443" + } + requestPort := requestAuthority.Port() + if requestPort == "" { + requestPort = defaultPort + } + originPort := origin.Port() + if originPort == "" { + originPort = defaultPort + } + return requestPort == originPort } func AddForwardedFor(req *http.Request) { diff --git a/proxy/proxyprotocol/security_test.go b/proxy/proxyprotocol/security_test.go index 6fe3fdf..9998e79 100644 --- a/proxy/proxyprotocol/security_test.go +++ b/proxy/proxyprotocol/security_test.go @@ -3,6 +3,7 @@ package proxyprotocol import ( "net" "net/http/httptest" + "net/url" "strings" "testing" ) @@ -27,7 +28,7 @@ func TestForwardedHeadersReplaceUntrustedClientValues(t *testing.T) { req.Header.Set(xForwardedFor, "203.0.113.99") req.Header.Set(xForwardedProto, "https") req.Header.Set(xForwardedPort, "443") - AddHeaders(req, nil) + AddHeaders(req, nil, nil) if got := req.Header.Get(xForwardedFor); got != "2001:db8::10" { t.Fatalf("untrusted forwarded address survived: %q", got) } @@ -37,6 +38,59 @@ func TestForwardedHeadersReplaceUntrustedClientValues(t *testing.T) { if got := req.Header.Get(xForwardedPort); got != "" { t.Fatalf("untrusted forwarded port survived: %q", got) } + if got := req.Header.Get(xForwardedHost); got != "example.test" { + t.Fatalf("untrusted forwarded host survived: %q", got) + } +} + +func TestConfiguredPublicOriginCorrectsTLSAfterInternalHTTPProxying(t *testing.T) { + origin, err := url.Parse("https://stack.example.test") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "http://stack.example.test/v2-beta/schema", nil) + req.RemoteAddr = "127.0.0.1:4321" + req.Header.Set(xForwardedProto, "http") + req.Header.Set(xForwardedPort, "8080") + + AddHeaders(req, nil, origin) + + if got := req.Header.Get(xForwardedProto); got != "https" { + t.Fatalf("public protocol was not corrected: %q", got) + } + if got := req.Header.Get(xForwardedHost); got != "stack.example.test" { + t.Fatalf("public host was not corrected: %q", got) + } + if got := req.Header.Get(xForwardedPort); got != "443" { + t.Fatalf("public port was not corrected: %q", got) + } + if got := req.Header.Get(xForwardedFor); got != "127.0.0.1" { + t.Fatalf("connection-derived client address was lost: %q", got) + } +} + +func TestConfiguredPublicOriginDoesNotApplyToAnotherHost(t *testing.T) { + origin, err := url.Parse("https://stack.example.test") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "http://attacker.example/v2-beta/schema", nil) + req.RemoteAddr = "192.0.2.10:4321" + req.Header.Set(xForwardedProto, "https") + req.Header.Set(xForwardedHost, "stack.example.test") + req.Header.Set(xForwardedPort, "443") + + AddHeaders(req, nil, origin) + + if got := req.Header.Get(xForwardedProto); got != "http" { + t.Fatalf("foreign host inherited the public protocol: %q", got) + } + if got := req.Header.Get(xForwardedHost); got != "attacker.example" { + t.Fatalf("foreign host inherited the configured public host: %q", got) + } + if got := req.Header.Get(xForwardedPort); got != "" { + t.Fatalf("foreign host inherited the public port: %q", got) + } } func TestProxyProtocolDefaultsToLoopbackSourcesOnly(t *testing.T) { diff --git a/scripts/validate b/scripts/validate index 2b21180..efc667d 100755 --- a/scripts/validate +++ b/scripts/validate @@ -30,7 +30,7 @@ if [ "${#maintained_go_files[@]}" -gt 0 ]; then fi echo Running: version and dependency policy -test "$(VERSION_OVERRIDE=v0.23.13 bash -c 'source scripts/version; printf %s "$VERSION"')" = "0.23.13" +test "$(VERSION_OVERRIDE=v0.23.14 bash -c 'source scripts/version; printf %s "$VERSION"')" = "0.23.14" if VERSION_OVERRIDE=v0.23.12-maintenance.1 bash -c 'source scripts/version' >/dev/null 2>&1; then echo "non-numeric version suffix was accepted" >&2 exit 1 @@ -55,6 +55,8 @@ grep -F 'stripProxyAuthenticationQuery(req)' proxy/frontend_http_handler.go >/de grep -F 'stripProxyAuthenticationCookies(req)' proxy/frontend_http_handler.go >/dev/null grep -F 'req.Header.Set(xForwardedFor, requestClientIP(req))' proxy/proxyprotocol/http_integration.go >/dev/null grep -F '"trusted-proxy-cidrs", "127.0.0.0/8,::1/128"' proxy/config.go >/dev/null +grep -F '"platform-public-origin"' proxy/config.go >/dev/null +grep -F 'publicOriginMatchesRequest(req, publicOrigin)' proxy/proxyprotocol/http_integration.go >/dev/null grep -F 'MaxWireMessageBytes' common/common.go >/dev/null if grep -R -E 'CheckOrigin:[[:space:]]*func\([^)]*\)[[:space:]]*bool[[:space:]]*\{[[:space:]]*return[[:space:]]+true' \ --include='*.go' --exclude='*_test.go' --exclude=validate proxy backend common; then