Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/security-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,15 +30,15 @@ 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
run: |
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
Expand Down
3 changes: 3 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
24 changes: 24 additions & 0 deletions proxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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() {
Expand Down Expand Up @@ -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, ",") {
Expand Down
17 changes: 17 additions & 0 deletions proxy/config_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions proxy/frontend_http_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 15 additions & 10 deletions proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"regexp"
"sync"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand All @@ -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)
}

Expand All @@ -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)
Expand Down
51 changes: 50 additions & 1 deletion proxy/proxyprotocol/http_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package proxyprotocol
import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
)
Expand All @@ -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"
Expand All @@ -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) {
Expand Down
Loading