Skip to content
Closed
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
3 changes: 2 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}),
Expand Down
56 changes: 54 additions & 2 deletions internal/triggers/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"

"go.temporal.io/sdk/temporal"

Expand All @@ -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) {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist is only checked before the initial request. http.Client.Get follows redirects, and because this is still the OAuth2 client/transport, a 30x from an allowlisted stack URL to another host will issue the redirected request with the bearer token attached. That keeps the token-exfiltration path open if the stack host has any redirect endpoint. Please either disable redirects for this client or install a CheckRedirect hook that re-runs the same allowlist check for every redirect target before following it.

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)
}
Expand Down Expand Up @@ -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,
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/triggers/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 42 additions & 1 deletion internal/triggers/trigger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Loading