From 4ab53f33f67949385dda68a6099618e32d824812 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Thu, 11 Jun 2026 09:55:02 +0200 Subject: [PATCH] fix(triggers): restrict link() to an allow-listed host (SSRF/token leak) The link() expression function performed an HTTP GET using the fx-provided *http.Client, which in production is the OAuth2 client-credentials client carrying the stack bearer token (broad ledger/wallets/payments scopes). Because link() targets a URI taken from a user-controlled trigger expression (reachable via POST /v2/triggers/{id}/test, which also returns the response body), an authenticated caller could point it at an arbitrary host and exfiltrate the stack token, or reach internal-only services (SSRF). Restrict link() to an allow-listed host (the configured stack URL), reject non-http(s) schemes, and close the response body. With no allow-listed host configured, link() network calls are denied. The allowlist is threaded through triggers.NewModule(stack, stackURL, taskQueue). --- cmd/root.go | 3 +- internal/triggers/expression.go | 56 +++++++++++++++++++++++++++++-- internal/triggers/module.go | 4 +-- internal/triggers/trigger_test.go | 43 +++++++++++++++++++++++- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index cb4ff5a..5afccb8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -76,6 +76,7 @@ func commonOptions(cmd *cobra.Command) (fx.Option, error) { } stack, _ := cmd.Flags().GetString(stackFlag) + stackURL, _ := cmd.Flags().GetString(stackURLFlag) temporalTaskQueue, _ := cmd.Flags().GetString(temporal.TemporalTaskQueueFlag) return fx.Options( @@ -97,7 +98,7 @@ func commonOptions(cmd *cobra.Command) (fx.Option, error) { auth.FXModuleFromFlags(cmd), licence.FXModuleFromFlags(cmd, ServiceName), workflow.NewModule(stack, temporalTaskQueue), - triggers.NewModule(stack, temporalTaskQueue), + triggers.NewModule(stack, stackURL, temporalTaskQueue), fx.Provide(func() *bunconnect.ConnectionOptions { return connectionOptions }), diff --git a/internal/triggers/expression.go b/internal/triggers/expression.go index b9ccd85..b7eabb2 100644 --- a/internal/triggers/expression.go +++ b/internal/triggers/expression.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "go.temporal.io/sdk/temporal" @@ -16,6 +18,34 @@ import ( type expressionEvaluator struct { httpClient *http.Client + // allowedHosts is the set of hosts link() is permitted to call. It exists + // to prevent the (credential-bearing) HTTP client from being pointed at an + // arbitrary, attacker-controlled host via a user-defined trigger + // expression (SSRF + bearer-token exfiltration). An empty set denies every + // network call. + allowedHosts map[string]struct{} +} + +// checkLinkURL enforces that a link() target uses an http(s) scheme and points +// at an allow-listed host (typically the stack gateway the HTTP client is +// scoped to). +func (h *expressionEvaluator) checkLinkURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return temporal.NewNonRetryableApplicationError( + fmt.Sprintf("invalid link url: %s", raw), "APPLICATION", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return temporal.NewNonRetryableApplicationError( + fmt.Sprintf("link url scheme not allowed: %q", u.Scheme), "APPLICATION", + fmt.Errorf("scheme %q not allowed", u.Scheme)) + } + if _, ok := h.allowedHosts[strings.ToLower(u.Host)]; !ok { + return temporal.NewNonRetryableApplicationError( + fmt.Sprintf("link url host not allowed: %q", u.Host), "APPLICATION", + fmt.Errorf("host %q is not in the allowlist", u.Host)) + } + return nil } func (h *expressionEvaluator) link(params ...any) (any, error) { @@ -54,10 +84,16 @@ func (h *expressionEvaluator) link(params ...any) (any, error) { fmt.Errorf("link '%s' not defined for object", rel), ) case 1: + if err := h.checkLinkURL(filteredLinks[0].URI); err != nil { + return nil, err + } rsp, err := h.httpClient.Get(filteredLinks[0].URI) if err != nil { return nil, errors.Wrapf(err, "reading resource: %s", filteredLinks[0].URI) } + defer func() { + _ = rsp.Body.Close() + }() if rsp.StatusCode >= 400 { return nil, fmt.Errorf("unexpected status code when reading resource: %d", rsp.StatusCode) } @@ -141,9 +177,25 @@ func (h *expressionEvaluator) evalVariables(rawObject any, vars map[string]strin return results, nil } -func NewExpressionEvaluator(httpClient *http.Client) *expressionEvaluator { +// NewExpressionEvaluator builds an evaluator whose link() function may only +// reach the provided hosts. Each entry may be a bare host ("example.com:8080") +// or a full URL, in which case only its host is retained. With no allowed host, +// link() network calls are denied. +func NewExpressionEvaluator(httpClient *http.Client, allowedHosts ...string) *expressionEvaluator { + hosts := make(map[string]struct{}, len(allowedHosts)) + for _, h := range allowedHosts { + if h == "" { + continue + } + if u, err := url.Parse(h); err == nil && u.Host != "" { + hosts[strings.ToLower(u.Host)] = struct{}{} + continue + } + hosts[strings.ToLower(h)] = struct{}{} + } return &expressionEvaluator{ - httpClient: httpClient, + httpClient: httpClient, + allowedHosts: hosts, } } diff --git a/internal/triggers/module.go b/internal/triggers/module.go index e512770..c5ba572 100644 --- a/internal/triggers/module.go +++ b/internal/triggers/module.go @@ -14,11 +14,11 @@ import ( "go.uber.org/fx" ) -func NewModule(stack, taskQueue string) fx.Option { +func NewModule(stack, stackURL, taskQueue string) fx.Option { return fx.Options( fx.Provide(NewManager), fx.Provide(func(httpClient *http.Client) *expressionEvaluator { - return NewExpressionEvaluator(httpClient) + return NewExpressionEvaluator(httpClient, stackURL) }), fx.Provide(func() *triggerWorkflow { return NewWorkflow(stack, taskQueue, true) diff --git a/internal/triggers/trigger_test.go b/internal/triggers/trigger_test.go index 635c2e1..2789b00 100644 --- a/internal/triggers/trigger_test.go +++ b/internal/triggers/trigger_test.go @@ -168,10 +168,51 @@ func TestEvalVariables(t *testing.T) { } { testCase := testCase t.Run(testCase.name, func(t *testing.T) { - e := NewExpressionEvaluator(http.DefaultClient) + e := NewExpressionEvaluator(http.DefaultClient, srv.URL) evaluated, err := e.evalVariables(testCase.rawObject, testCase.variables) require.NoError(t, err) require.Equal(t, testCase.expectedResult, evaluated) }) } } + +func TestLinkHostAllowlist(t *testing.T) { + var hit bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = true + _, _ = w.Write([]byte(`{"data": {"role": "admin"}}`)) + })) + t.Cleanup(srv.Close) + + rawObject := map[string]any{ + "links": []map[string]any{ + {"name": "source_account", "uri": srv.URL}, + }, + } + variables := map[string]string{"role": `link(event, "source_account").role`} + + t.Run("denied when host not allowlisted", func(t *testing.T) { + hit = false + e := NewExpressionEvaluator(http.DefaultClient, "allowed.example.com") + _, err := e.evalVariables(rawObject, variables) + require.Error(t, err) + require.False(t, hit, "a non-allowlisted host must never be contacted") + }) + + t.Run("denied with empty allowlist", func(t *testing.T) { + hit = false + e := NewDefaultExpressionEvaluator() + _, err := e.evalVariables(rawObject, variables) + require.Error(t, err) + require.False(t, hit) + }) + + t.Run("allowed when host matches", func(t *testing.T) { + hit = false + e := NewExpressionEvaluator(http.DefaultClient, srv.URL) + result, err := e.evalVariables(rawObject, variables) + require.NoError(t, err) + require.Equal(t, map[string]string{"role": "admin"}, result) + require.True(t, hit) + }) +}