Files
tasks/service/worker/batcher/activities_namespace_test.go
d07c025149 Check namespaces in batch workflow (#9767)
## What changed and why?
### **Security**

- Check namespaces in batch workflow
([CVE-2026-5199](https://www.cve.org/cverecord?id=CVE-2026-5199), LOW)

## How did you test it?
- [x] built
- [ ] run locally and tested manually
- [ ] covered by existing tests
- [x] added new unit test(s)
- [x] added new functional test(s)

---

> [!NOTE]
> **Medium Risk**
> Tightens namespace validation for privileged batcher activities to
prevent cross-namespace request spoofing; mistakes here could block
legitimate batch jobs or still leave gaps if other call paths use
unvalidated namespaces.
> 
> **Overview**
> Adds strict namespace isolation checks to the batcher worker:
`BatchActivityWithProtobuf` now validates that `NamespaceId` *and* any
provided `Request.Namespace`/`AdminRequest.Namespace` match the worker’s
bound namespace, and consistently uses that bound namespace for
downstream frontend calls (including reset-by-type).
> 
> Adds targeted tests: new unit tests to reject mismatched namespace
strings and to assert `startTaskProcessor` uses the worker namespace for
signals, plus a functional test ensuring a batch terminate in one
namespace does not affect workflows in another.
> 
> <sup>Written by [Cursor
Bugbot]() for commit
3a526967f24328f5583f04eead42cf18c2700e4d. This will update automatically
on new commits. Configure
[here]().</sup>

---------

Co-authored-by: Sean Kane <[email protected]>
2026-04-01 12:02:20 -07:00

157 lines
5.7 KiB
Go

package batcher
import (
"context"
"testing"
"github.com/stretchr/testify/require"
batchpb "go.temporal.io/api/batch/v1"
commonpb "go.temporal.io/api/common/v1"
workflowpb "go.temporal.io/api/workflow/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/testsuite"
"go.temporal.io/server/api/adminservice/v1"
batchspb "go.temporal.io/server/api/batch/v1"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/metrics"
"go.temporal.io/server/common/namespace"
"go.temporal.io/server/common/testing/mockapi/workflowservicemock/v1"
"go.uber.org/mock/gomock"
"golang.org/x/time/rate"
)
// These tests guard against cross-namespace escalation via the batcher activity.
// The per-NS worker's frontendClient dials internal-frontend (NoopClaimMapper →
// RoleAdmin), so the activity MUST NOT forward a user-controlled namespace string
// to frontendClient calls. The namespace for all downstream operations must be
// the worker's bound namespace.
const (
boundNSName = "bound-ns"
boundNSID = "bound-ns-id"
otherNSName = "other-ns"
)
func newBoundActivities(frontend workflowservice.WorkflowServiceClient) *activities {
return &activities{
activityDeps: activityDeps{
MetricsHandler: metrics.NoopMetricsHandler,
Logger: log.NewTestLogger(),
FrontendClient: frontend,
},
namespace: namespace.Name(boundNSName),
namespaceID: namespace.ID(boundNSID),
rps: dynamicconfig.GetIntPropertyFnFilteredByNamespace(50),
concurrency: dynamicconfig.GetIntPropertyFnFilteredByNamespace(1),
}
}
// TestBatchActivityWithProtobuf_RejectsMismatchedRequestNamespace verifies that
// BatchActivityWithProtobuf rejects a request whose Request.Namespace differs
// from the worker's bound namespace, even when NamespaceId is valid.
// This blocks the cross-namespace attack where an attacker submits a valid
// NamespaceId for their own namespace but sets Request.Namespace to a victim's.
func TestBatchActivityWithProtobuf_RejectsMismatchedRequestNamespace(t *testing.T) {
ts := testsuite.WorkflowTestSuite{}
env := ts.NewTestActivityEnvironment()
a := newBoundActivities(nil)
env.RegisterActivity(a.BatchActivityWithProtobuf)
input := &batchspb.BatchOperationInput{
NamespaceId: boundNSID, // ID check passes; name check must catch the mismatch
Request: &workflowservice.StartBatchOperationRequest{
Namespace: otherNSName, // mismatched — must be rejected
Operation: &workflowservice.StartBatchOperationRequest_SignalOperation{
SignalOperation: &batchpb.BatchOperationSignal{Signal: "s"},
},
Executions: []*commonpb.WorkflowExecution{{WorkflowId: "w"}},
},
}
_, err := env.ExecuteActivity(a.BatchActivityWithProtobuf, input)
require.Error(t, err)
require.ErrorContains(t, err, errNamespaceMismatch.Error())
}
// TestBatchActivityWithProtobuf_RejectsMismatchedAdminRequestNamespace verifies
// that the same namespace mismatch check applies to admin batch requests.
func TestBatchActivityWithProtobuf_RejectsMismatchedAdminRequestNamespace(t *testing.T) {
ts := testsuite.WorkflowTestSuite{}
env := ts.NewTestActivityEnvironment()
a := newBoundActivities(nil)
env.RegisterActivity(a.BatchActivityWithProtobuf)
input := &batchspb.BatchOperationInput{
NamespaceId: boundNSID,
AdminRequest: &adminservice.StartAdminBatchOperationRequest{
Namespace: otherNSName, // mismatched — must be rejected
Executions: []*commonpb.WorkflowExecution{{WorkflowId: "w"}},
},
}
_, err := env.ExecuteActivity(a.BatchActivityWithProtobuf, input)
require.Error(t, err)
require.ErrorContains(t, err, errNamespaceMismatch.Error())
}
// TestStartTaskProcessor_UsesWorkerBoundNamespaceForSignal verifies that when
// BatchActivityWithProtobuf dispatches via startTaskProcessor, the namespace
// delivered to frontendClient.SignalWorkflowExecution is the worker's bound
// namespace. This is a belt-and-suspenders check: even if the early validation
// above is ever relaxed, the namespace used in operations stays worker-bound.
func TestStartTaskProcessor_UsesWorkerBoundNamespaceForSignal(t *testing.T) {
r := require.New(t)
ctrl := gomock.NewController(t)
mockFE := workflowservicemock.NewMockWorkflowServiceClient(ctrl)
var captured *workflowservice.SignalWorkflowExecutionRequest
mockFE.EXPECT().
SignalWorkflowExecution(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req *workflowservice.SignalWorkflowExecutionRequest, _ ...any) (*workflowservice.SignalWorkflowExecutionResponse, error) {
captured = req
return &workflowservice.SignalWorkflowExecutionResponse{}, nil
})
a := newBoundActivities(mockFE)
// Simulate the namespace that BatchActivityWithProtobuf derives —
// with the fix this is always a.namespace.String().
ns := a.namespace.String()
batchOp := &batchspb.BatchOperationInput{
NamespaceId: boundNSID,
Request: &workflowservice.StartBatchOperationRequest{
Namespace: ns,
Operation: &workflowservice.StartBatchOperationRequest_SignalOperation{
SignalOperation: &batchpb.BatchOperationSignal{Signal: "s"},
},
},
}
taskCh := make(chan task, 1)
respCh := make(chan taskResponse, 1)
taskCh <- task{
executionInfo: &workflowpb.WorkflowExecutionInfo{
Execution: &commonpb.WorkflowExecution{WorkflowId: "w"},
},
page: &page{},
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
a.startTaskProcessor(ctx, batchOp, ns, taskCh, respCh,
rate.NewLimiter(rate.Inf, 1), nil, mockFE,
metrics.NoopMetricsHandler, log.NewTestLogger())
}()
<-respCh
cancel()
<-done
r.NotNil(captured)
r.Equal(boundNSName, captured.Namespace,
"frontendClient.SignalWorkflowExecution must receive the worker's bound namespace")
}