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.16
VERSION_OVERRIDE: v0.23.17
SOURCE_DATE_EPOCH: "0"
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
BUILDER_IMAGE: pasturestack-websocket-proxy-dapper:ubuntu26
Expand All @@ -34,7 +34,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.16-linux-amd64.tar.xz evidence/first-build.tar.xz
cp dist/artifacts/websocket-proxy-0.23.17-linux-amd64.tar.xz evidence/first-build.tar.xz
docker run --rm --entrypoint /bin/bash \
-v "$PWD/evidence:/evidence:ro" "$BUILDER_IMAGE" \
-lc 'go version -m /evidence/websocket-proxy-linux-amd64' >evidence/product.buildinfo
Expand All @@ -46,7 +46,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.16-linux-amd64.tar.xz evidence/second-build.tar.xz
cp dist/artifacts/websocket-proxy-0.23.17-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
8 changes: 8 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

The migration preserves existing `/v1`, `/v2-beta`, and `/v2` proxy paths, backend connection routes, subscription behavior, token validation inputs, generated client schemas, and proxy-protocol handling.

Verified API Key streams require an authenticated backend Host ticket and the
exact `key-audit-v1,key-delegation-v1` declaration. Compatible Host API patches
use canonical `0.38.x` versions with patch >=5, including `0.38.5` and `0.38.6`;
unknown major/minor families, prereleases, leading zeros, malformed values, and
duplicate version or capability parameters remain rejected. This operational
gate never replaces Engine authorization or live revision/expiry checks, and
does not add a capability requirement to ordinary non-Key streams.

Preferred settings use `platform-*`, `PLATFORM_*`, and
`PROXY_PLATFORM_ADDRESS`. Historical `cattle-*`, `CATTLE_*`, and
`PROXY_CATTLE_ADDRESS` aliases remain only as runtime configuration contracts.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ PastureStack is an independent community effort to preserve, audit, and moderniz

## Capabilities

The source package version is `0.23.16`, rebuilt with Go 1.27.3 for the crypto/x509 fix CVE-2026-111943. Target-bound API Key tickets require exact target, operation, revision, expiry, and ticket digest matches from the trusted Engine. Handshake and periodic live checks fail closed. Persistent console secrets cannot extend an expired or revoked ticket; revocation also clears replay data. Legacy and full-without-expiry token authentication remains unchanged.
The source package version is `0.23.17`, built with Go 1.27.3 and supporting protocol-compatible Host API patch releases. Target-bound API Key tickets require exact target, operation, revision, expiry, and ticket digest matches from the trusted Engine. Handshake and periodic live checks fail closed. Persistent console secrets cannot extend an expired or revoked ticket; revocation also clears replay data. Legacy and full-without-expiry token authentication remains unchanged.

Every verified API Key stream requires an authenticated Host API `0.38.5` connection declaring `key-audit-v1,key-delegation-v1`. Missing capabilities prevent forwarding any execution payload. The Proxy submits only the signed stream ticket and that connection's signed backend ticket to the Engine's fixed audit-only failure endpoint; it returns `BackendAuditCapabilityUnavailable` (503) only after durable acceptance, or `AuditUnavailable` (503) when evidence cannot be accepted. Ordinary non-Key streams do not gain this capability requirement.
Every verified API Key stream requires an authenticated Host API connection declaring exactly `key-audit-v1,key-delegation-v1`, with a canonical numeric `0.38.x` version whose patch is at least `5`. Unknown major/minor families, duplicate or malformed declarations, prereleases, and leading-zero versions fail closed. Missing capabilities prevent forwarding any execution payload. The Proxy submits only the signed stream ticket and that connection's signed backend ticket to the Engine's fixed audit-only failure endpoint; it returns `BackendAuditCapabilityUnavailable` (503) only after durable acceptance, or `AuditUnavailable` (503) when evidence cannot be accepted. Ordinary non-Key streams do not gain this capability requirement.

An expired, signed Key-traced ticket is rejected before creating a backend or persistent session. Its fixed denial receipt uses the ticket's verified identity and an authenticated host proof; signature-only parsing is never an authorization path. Each handshake attempt has a separate audit receipt. Tokens without verified Key attribution retain their existing authentication behavior and cannot claim someone else's audit history.

Expand Down Expand Up @@ -42,9 +42,9 @@ make build
make package
```

The default producer version is `0.23.16`; `VERSION_OVERRIDE` accepts only numeric semantic versions. Packaging
The default producer version is `0.23.17`; `VERSION_OVERRIDE` accepts only numeric semantic versions. Packaging
rejects brand or maintenance suffixes and produces the deterministic,
versioned `websocket-proxy-0.23.16-linux-amd64.tar.xz` asset. Install it with the matching official Engine and host-api package; scoped stream protection cannot be provided by replacing the proxy alone. Package publication and deployment are explicit operations.
versioned `websocket-proxy-0.23.17-linux-amd64.tar.xz` asset. Install only a published artifact with the matching official Engine and host-api package; scoped stream protection cannot be provided by replacing the proxy alone. Package publication and deployment are explicit operations.

See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md).

Expand Down
18 changes: 16 additions & 2 deletions proxy/key_backend_capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/PastureStack/websocket-proxy/common"
Expand Down Expand Up @@ -134,8 +136,20 @@ type keyTraceInitializer interface {
}

func declaredKeyCapabilities(req *http.Request) bool {
query := req.URL.Query()
return len(query["hostApiVersion"]) == 1 && query.Get("hostApiVersion") == "0.38.5" && len(query["hostApiCapabilities"]) == 1 && query.Get("hostApiCapabilities") == "key-audit-v1,key-delegation-v1"
query, err := url.ParseQuery(req.URL.RawQuery)
return err == nil && len(query["hostApiVersion"]) == 1 && compatibleKeyBackendVersion(query.Get("hostApiVersion")) &&
len(query["hostApiCapabilities"]) == 1 && query.Get("hostApiCapabilities") == "key-audit-v1,key-delegation-v1"
}

// Patch releases in the established 0.38 family retain the explicit v1 wire
// contract. This is not a claim that an unknown major/minor is compatible.
func compatibleKeyBackendVersion(version string) bool {
patch, ok := strings.CutPrefix(version, "0.38.")
if !ok {
return false
}
number, err := strconv.ParseUint(patch, 10, 64)
return err == nil && number >= 5 && strconv.FormatUint(number, 10) == patch
}

// The caller must have verified the JWT before considering these claims.
Expand Down
71 changes: 71 additions & 0 deletions proxy/key_backend_capability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,77 @@ func (m *capabilityManager) addBackendWithKeyCapabilities(_ string, ws *websocke
func (*capabilityManager) removeBackend(string, string) {}
func (*capabilityManager) closeConnection(string, string) error { return nil }

func TestDeclaredKeyCapabilitiesCompatiblePatch(t *testing.T) {
for _, version := range []string{"0.38.5", "0.38.6", "0.38.7", "0.38.10", "0.38.100"} {
query := url.Values{"hostApiVersion": {version}, "hostApiCapabilities": {"key-audit-v1,key-delegation-v1"}, "mode": {"host"}}
if !declaredKeyCapabilities(&http.Request{URL: &url.URL{RawQuery: query.Encode()}}) {
t.Errorf("compatible canonical patch %q rejected", version)
}
}
for _, version := range []string{"", "0.38.0", "0.38.4", "0.38", "0.38.5.0", "v0.38.5", "00.38.5", "0.038.5",
"0.38.05", "0.38.+5", "0.38.-5", "0.38.5-alpha", "0.38.5+build", " 0.38.5", "0.38.5 ", "0.38.5\n",
"0.38.18446744073709551616", "0.39.5", "1.38.5", "0.37.999"} {
query := url.Values{"hostApiVersion": {version}, "hostApiCapabilities": {"key-audit-v1,key-delegation-v1"}}
if declaredKeyCapabilities(&http.Request{URL: &url.URL{RawQuery: query.Encode()}}) {
t.Errorf("unsupported version %q accepted", version)
}
}
valid := "hostApiVersion=0.38.6&hostApiCapabilities=key-audit-v1,key-delegation-v1"
for _, query := range []string{
"hostApiCapabilities=key-audit-v1,key-delegation-v1", "hostApiVersion=0.38.6",
valid + "&hostApiVersion=0.38.6", valid + "&hostApiVersion=",
valid + "&hostApiCapabilities=key-audit-v1,key-delegation-v1", valid + "&hostApiCapabilities=",
"hostApiVersion=0.38.6&hostApiCapabilities=", "hostApiVersion=0.38.6&hostApiCapabilities=key-audit-v1",
"hostApiVersion=0.38.6&hostApiCapabilities=key-delegation-v1,key-audit-v1",
"hostApiVersion=0.38.6&hostApiCapabilities=key-audit-v1,key-delegation-v2",
valid + ";invalid", valid + "&hostApiVersion=%zz",
} {
if declaredKeyCapabilities(&http.Request{URL: &url.URL{RawQuery: query}}) {
t.Errorf("malformed or ambiguous declaration accepted: %q", query)
}
}
}

func TestCompatiblePatchBackendRegistrationKeepsKeyAndOrdinaryAuth(t *testing.T) {
scoped, _, verifier, _ := scopedProxyFixture(t)
full := testutils.CreateTokenWithPayload(map[string]interface{}{"hostUuid": "h1", "apiKeyAudit": map[string]interface{}{"version": 1}}, testutils.ParseTestPrivateKey())
ordinary := testutils.CreateTokenWithPayload(map[string]interface{}{"hostUuid": "h1"}, testutils.ParseTestPrivateKey())
for _, version := range []string{"0.38.5", "0.38.6"} {
t.Run(version, func(t *testing.T) {
manager := &capabilityManager{registered: make(chan bool, 1)}
handler := &BackendHandler{proxyManager: manager, parsedPublicKey: testutils.ParseTestPublicKey()}
server := httptest.NewServer(handler)
defer server.Close()
backendToken := testutils.CreateTokenWithPayload(map[string]interface{}{"reportedUuid": "h1"}, testutils.ParseTestPrivateKey())
query := url.Values{"hostApiVersion": {version}, "hostApiCapabilities": {"key-audit-v1,key-delegation-v1"}}
headers := http.Header{"Authorization": []string{"Bearer " + backendToken}}
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"/?"+query.Encode(), headers)
if err != nil {
t.Fatal("authenticated compatible backend registration failed")
}
defer ws.Close()
var capable bool
select {
case capable = <-manager.registered:
case <-time.After(time.Second):
t.Fatal("backend registration barrier timed out")
}
if !capable || manager.calls.Load() != 1 {
t.Fatal("authenticated compatible patch lost capability")
}
backend := &capabilityTestBackend{capable: capable}
frontend := &FrontendHandler{backend: backend, parsedPublicKey: testutils.ParseTestPublicKey(), delegations: verifier}
for _, raw := range []string{scoped, full, ordinary} {
request := httptest.NewRequest("GET", "http://proxy/v2-beta/exec/", nil)
request.Header.Set("Authorization", "Bearer "+raw)
if _, host, err := frontend.auth(request); err != nil || host != "h1" {
t.Fatal("registered capability did not preserve Key and ordinary authentication")
}
}
})
}
}

func TestBackendCapabilityCannotBeForgedWithoutAnAuthenticatedReportedHostTicket(t *testing.T) {
manager := &capabilityManager{registered: make(chan bool, 1)}
handler := &BackendHandler{proxyManager: manager, parsedPublicKey: testutils.ParseTestPublicKey()}
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ if [ "${#maintained_go_files[@]}" -gt 0 ]; then
fi

echo Running: version and dependency policy
test "$(VERSION_OVERRIDE=v0.23.16 bash -c 'source scripts/version; printf %s "$VERSION"')" = "0.23.16"
test "$(VERSION_OVERRIDE=v0.23.17 bash -c 'source scripts/version; printf %s "$VERSION"')" = "0.23.17"
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
Expand Down
2 changes: 1 addition & 1 deletion scripts/version
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ if [ -n "${VERSION_OVERRIDE:-}" ]; then
fi
VERSION=${VERSION_OVERRIDE#v}
else
VERSION="0.23.16"
VERSION="0.23.17"
fi
Loading