From 76781ecaebe8e9724dcdee7cf6464ff83f3e4a0e Mon Sep 17 00:00:00 2001 From: Antoine Date: Mon, 1 Jun 2026 16:01:44 -0400 Subject: [PATCH] Forward platform JWT and version User-Agent on the build-log-streamer WebSocket The build-log-streamer WebSocket previously opened with only an Origin header, so the server had no way to authorize the stream. When the caller is authenticated, offer the platform JWT via Sec-WebSocket-Protocol as 'bearer.' alongside the real 'build-log-streamer.activestate.com.v1' subprotocol the server echoes back (its allow-list contains only the real subprotocol, so the token never appears in the upgrade response). The browser WebSocket API can't set custom request headers, so the dashboard carries the JWT the same way -- both clients stay symmetric. Also send the versioned State Tool User-Agent on the Upgrade so the server can see which client versions are connecting. The token is threaded as a plain string from the runtime options into Connect, so pkg/runtime gains no authentication dependency; an empty token means anonymous (unchanged behavior). Includes a real-handshake test that asserts the offered subprotocols, the negotiated subprotocol, and the User-Agent the server receives. --- internal/runbits/runtime/runtime.go | 1 + pkg/platform/api/buildlogstream/streamer.go | 27 ++++- .../api/buildlogstream/streamer_test.go | 114 ++++++++++++++++++ pkg/runtime/internal/buildlog/buildlog.go | 14 ++- pkg/runtime/options.go | 6 + pkg/runtime/setup.go | 6 +- 6 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 pkg/platform/api/buildlogstream/streamer_test.go diff --git a/internal/runbits/runtime/runtime.go b/internal/runbits/runtime/runtime.go index 55d4b8ea50..1b4aa69754 100644 --- a/internal/runbits/runtime/runtime.go +++ b/internal/runbits/runtime/runtime.go @@ -257,6 +257,7 @@ func Update( runtime.WithAnnotations(proj.Owner(), proj.Name(), commitID), runtime.WithEventHandlers(pg.Handle, ah.handle), runtime.WithPreferredLibcVersion(prime.Config().GetString(constants.PreferredGlibcVersionConfig)), + runtime.WithAuthToken(prime.Auth().BearerToken()), } if opts.Archive != nil { rtOpts = append(rtOpts, runtime.WithArchive(opts.Archive.Dir, opts.Archive.PlatformID, checkout.ArtifactExt)) diff --git a/pkg/platform/api/buildlogstream/streamer.go b/pkg/platform/api/buildlogstream/streamer.go index e3794b7506..619a13508c 100644 --- a/pkg/platform/api/buildlogstream/streamer.go +++ b/pkg/platform/api/buildlogstream/streamer.go @@ -6,18 +6,41 @@ import ( "github.com/gorilla/websocket" "golang.org/x/net/context" + "github.com/ActiveState/cli/internal/constants" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/pkg/platform/api" ) -func Connect(ctx context.Context) (*websocket.Conn, error) { +// wsSubprotocol is the "real" subprotocol the build-log-streamer echoes back. +// The server's upgrader allow-list contains only this value, so the +// bearer. entry we also offer never appears in the upgrade response, +// keeping the token out of proxy/browser response logs. +const wsSubprotocol = "build-log-streamer.activestate.com.v1" + +// Connect opens the build-log-streamer WebSocket. When jwt is non-empty it is +// offered via Sec-WebSocket-Protocol as `bearer.` (alongside +// wsSubprotocol, which the server echoes back) so the server can authorize the +// stream. The browser WebSocket API can't set custom request headers, so the +// dashboard carries the JWT the same way; using the subprotocol here keeps the +// State Tool and dashboard clients symmetric. +func Connect(ctx context.Context, jwt string) (*websocket.Conn, error) { url := api.GetServiceURL(api.BuildLogStreamer) header := make(http.Header) header.Add("Origin", "https://"+url.Host) + // Send the versioned State Tool User-Agent so the server can see which + // State Tool versions are connecting (e.g. to size the unauthenticated + // tail before tightening the gate). + header.Set("User-Agent", constants.UserAgent) + + dialer := *websocket.DefaultDialer // copy so we don't mutate the package global + dialer.Subprotocols = []string{wsSubprotocol} + if jwt != "" { + dialer.Subprotocols = []string{"bearer." + jwt, wsSubprotocol} + } logging.Debug("Creating websocket for %s (origin: %s)", url.String(), header.Get("Origin")) - conn, _, err := websocket.DefaultDialer.DialContext(ctx, url.String(), header) + conn, _, err := dialer.DialContext(ctx, url.String(), header) if err != nil { return nil, errs.Wrap(err, "Could not create websocket dialer") } diff --git a/pkg/platform/api/buildlogstream/streamer_test.go b/pkg/platform/api/buildlogstream/streamer_test.go new file mode 100644 index 0000000000..66e72545fa --- /dev/null +++ b/pkg/platform/api/buildlogstream/streamer_test.go @@ -0,0 +1,114 @@ +package buildlogstream + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ActiveState/cli/internal/constants" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// upgradeRequest captures the headers the build-log-streamer server saw on the +// WS Upgrade. The mock handler writes the fields from the server goroutine and +// closes recorded; callers must await() before reading the fields so there's a +// happens-before edge (the read would otherwise race the handler's write). +type upgradeRequest struct { + protocols []string + userAgent string + recorded chan struct{} +} + +// await blocks until the mock handler has recorded the Upgrade headers (or +// fails the test if that never happens). Establishes the happens-before edge +// for safely reading protocols/userAgent. +func (u *upgradeRequest) await(t *testing.T) { + t.Helper() + select { + case <-u.recorded: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for mock build-log-streamer to record the Upgrade headers") + } +} + +// startMockBLS stands up a real WebSocket server that records the Upgrade +// request headers, and redirects Connect's resolved service URL at it via the +// per-service override env var honored by api.GetServiceURL. Returns a pointer +// populated after Connect runs. +func startMockBLS(t *testing.T) *upgradeRequest { + t.Helper() + got := &upgradeRequest{recorded: make(chan struct{})} + + upgrader := websocket.Upgrader{ + CheckOrigin: func(*http.Request) bool { return true }, + // Echo back only the "real" subprotocol; the bearer. entry must + // not be selected (mirrors the build-log-streamer's allow-list). + Subprotocols: []string{wsSubprotocol}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.protocols = r.Header.Values("Sec-WebSocket-Protocol") + got.userAgent = r.Header.Get("User-Agent") + close(got.recorded) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("mock build-log-streamer failed to upgrade the WS connection: %v", err) + return + } + _ = conn.Close() + })) + t.Cleanup(srv.Close) + + // http://127.0.0.1:port -> ws://127.0.0.1:port + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) + + return got +} + +func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) { + got := startMockBLS(t) + + conn, err := Connect(context.Background(), "header.payload.signature") + require.NoError(t, err) + defer conn.Close() + + // The server must negotiate the real subprotocol, never the bearer. + // entry (it's not in the server's allow-list) — so the token can't leak + // into the upgrade response. + assert.Equal(t, wsSubprotocol, conn.Subprotocol(), + "server must negotiate the real subprotocol, not bearer.") + + got.await(t) + joined := strings.Join(got.protocols, ",") + assert.Contains(t, joined, "bearer.header.payload.signature", + "client must offer the JWT as a bearer. subprotocol") + assert.Contains(t, joined, wsSubprotocol, + "client must still offer the real subprotocol the server echoes back") + assert.Contains(t, got.userAgent, "state/", + "client must send the versioned State Tool User-Agent so the server can monitor versions") +} + +func TestConnect_AnonymousOffersNoBearer(t *testing.T) { + got := startMockBLS(t) + + conn, err := Connect(context.Background(), "") + require.NoError(t, err) + defer conn.Close() + + assert.Equal(t, wsSubprotocol, conn.Subprotocol(), + "server must negotiate the real subprotocol") + + got.await(t) + joined := strings.Join(got.protocols, ",") + assert.NotContains(t, joined, "bearer.", + "anonymous Connect must not offer a bearer subprotocol") + assert.Contains(t, joined, wsSubprotocol, + "anonymous Connect must still offer the real subprotocol") + assert.Contains(t, got.userAgent, "state/", + "client must send the versioned State Tool User-Agent even when anonymous") +} diff --git a/pkg/runtime/internal/buildlog/buildlog.go b/pkg/runtime/internal/buildlog/buildlog.go index 2a4cb9490b..94a7c29501 100644 --- a/pkg/runtime/internal/buildlog/buildlog.go +++ b/pkg/runtime/internal/buildlog/buildlog.go @@ -59,16 +59,21 @@ type BuildLog struct { eventHandlers []events.HandlerFunc logFilePath string onArtifactReadyFuncs map[strfmt.UUID][]func() + // authToken is the platform JWT forwarded to the build-log-streamer WS so + // the server can authorize the stream. Empty for unauthenticated callers. + authToken string } // New creates a new BuildLog instance that allows us to wait for incoming build log information -// artifactMap comprises all artifacts (from the runtime closure) that are in the recipe, alreadyBuilt is set of artifact IDs that have already been built in the past -func New(recipeID strfmt.UUID, artifactMap buildplan.ArtifactIDMap) *BuildLog { +// artifactMap comprises all artifacts (from the runtime closure) that are in the recipe. +// authToken is the platform JWT forwarded to the build-log-streamer WS (empty if unauthenticated). +func New(recipeID strfmt.UUID, artifactMap buildplan.ArtifactIDMap, authToken string) *BuildLog { return &BuildLog{ recipeID: recipeID, artifactMap: artifactMap, eventHandlers: []events.HandlerFunc{}, onArtifactReadyFuncs: map[strfmt.UUID][]func(){}, + authToken: authToken, } } @@ -92,9 +97,10 @@ func (b *BuildLog) OnArtifactReady(id strfmt.UUID, cb func()) { b.onArtifactReadyFuncs[id] = append(b.onArtifactReadyFuncs[id], cb) } -// NewWithCustomConnections creates a new BuildLog instance with all physical connections managed by the caller +// Wait connects to the build-log streamer and blocks until the build completes, +// dispatching build events to the registered handlers as they arrive. func (b *BuildLog) Wait(ctx context.Context) error { - conn, err := buildlogstream.Connect(ctx) + conn, err := buildlogstream.Connect(ctx, b.authToken) if err != nil { return errs.Wrap(err, "Could not connect to build-log streamer build updates") } diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 7c012fb491..95c2e0bf53 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -9,6 +9,12 @@ func WithEventHandlers(handlers ...events.HandlerFunc) SetOpt { return func(opts *Opts) { opts.EventHandlers = handlers } } +// WithAuthToken forwards the platform JWT to the build-log-streamer WebSocket +// so the server can authorize the stream. Empty token = anonymous. +func WithAuthToken(token string) SetOpt { + return func(opts *Opts) { opts.AuthToken = token } +} + func WithBuildlogFilePath(path string) SetOpt { return func(opts *Opts) { opts.BuildlogFilePath = path } } diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index f789991acc..6c1532a7d0 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -50,6 +50,10 @@ type Opts struct { Portable bool CacheSize int + // AuthToken is the platform JWT forwarded to the build-log-streamer WS so + // the server can authorize the stream. Empty for unauthenticated callers. + AuthToken string + FromArchive *fromArchive // Annotations are used strictly to pass information for the purposes of analytics @@ -238,7 +242,7 @@ func (s *setup) update() error { return errs.Wrap(err, "Could not create runtime config dir") } - blog := buildlog.New(s.buildplan.LegacyRecipeID(), s.toBuild). + blog := buildlog.New(s.buildplan.LegacyRecipeID(), s.toBuild, s.opts.AuthToken). WithEventHandler(s.opts.EventHandlers...). WithLogFile(filepath.Join(s.path, configDir, buildLogFile))