CVE-2026-93991: authorizing a namespace selector against the namespace it excludes
Argo Workflows' archive endpoint ran an access review for the namespace named in a metadata.namespace!= field selector, then ran the negated query. One field is read by three components and two of them disagree about what it means. Affected 4.1.0 through 4.1.3; fixed in 4.1.4.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. No cluster was deployed: the reproduction runs inside the project’s own Go test suite, against its own fake Kubernetes clients, on my own machine. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory, the fixed release and the CVE record were public.
Argo Workflows’ server exposes an archive of completed workflows at /api/v1/archived-workflows. It accepts a Kubernetes-style field selector, and since 4.1.0 that selector understands negation:
GET /api/v1/archived-workflows?listOptions.fieldSelector=metadata.namespace!=user-ns
The handler ran a SelfSubjectAccessReview for user-ns, got back yes — truthfully, because the caller does hold list workflows there — and then executed WHERE namespace != 'user-ns'.
So a tenant scoped to one namespace reads the archived workflows of every namespace that is not theirs: spec.arguments.parameters values, artifact repository paths and keys, container images, status.outputs, and status.message from failed runs. Nothing is forged, nothing is guessed, and the permission check is answered honestly. It is asked about the wrong namespace.
Affected 4.1.0 through 4.1.3 — the whole current release line at the time. Upgrade to 4.1.4. The advisory is GHSA-q65w-j2vp-47c4; the identifier is CVE-2026-93991, assigned by VulnCheck. The advisory does not carry the identifier, so cite both.
One field, three readers
server/utils/list_options.go parses the selector into two fields of a ListOptions struct:
if after, ok := strings.CutPrefix(selector, "metadata.namespace!="); ok {
namespace = after
namespaceFilter = "NotEquals"
}
metadata.namespace!=user-ns becomes Namespace: "user-ns", NamespaceFilter: "NotEquals".
server/workflowarchive/archived_workflow_server.go:56, at v4.1.3, reads the first field and not the second:
// verify if we have permission to list Workflows
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, options.Namespace, "")
persist/sqldb/selector.go reads both:
if options.NamespaceFilter == "NotEquals" {
selector = selector.And(namespaceNotEqual(options.Namespace))
} else {
selector = selector.And(namespaceEqual(options.Namespace))
}
ListOptions.Namespace therefore carries two incompatible meanings in one request. To the authorizer it is the namespace being requested. To the query builder it is the namespace being excluded. The second field, NamespaceFilter, is what distinguishes them, and the authorizer never looks at it.
This is not a parsing bug and not an injection. Both readers are internally consistent; the struct is ambiguous and only one reader was taught to disambiguate it.
What the access review actually asked
auth.CanI reaches Kubernetes through two hops, and the namespace argument lands in a SelfSubjectAccessReview:
// util/auth/auth.go
review, err := kubeclientset.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &auth.SelfSubjectAccessReview{
Spec: auth.SelfSubjectAccessReviewSpec{
ResourceAttributes: &auth.ResourceAttributes{
Namespace: namespace,
Verb: verb,
Group: "argoproj.io",
Resource: resource,
},
},
}, metav1.CreateOptions{})
ResourceAttributes.Namespace: "" is the cluster-scoped question — may you list this resource across all namespaces — and a namespace-scoped RoleBinding does not satisfy it. That is the whole repair, and it is why the fix is three lines rather than a redesign.
One operational consequence: CanIArgo logs the namespace it submits at debug level, so a server running with debug logging keeps a record of which namespace each review was about. On an affected version, an archive list review naming the caller’s own namespace, on a request whose selector was negated, is the event.
The guard existed in the sibling since February
Live workflows are served by a different file, server/workflow/workflow_server.go, and at the same v4.1.3 tag it looks like this:
// verify if we have permission to list Workflows
targetNamespace := options.Namespace
if options.NamespaceFilter == "NotEquals" {
targetNamespace = ""
}
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, targetNamespace, "")
Same call, same arguments, same error string underneath. The difference is the targetNamespace variable.
Both the negation operator and that guard arrived in one commit, 82eec03, on 15 February 2026:
persist/sqldb/selector.go +11 -2 negation in SQL
server/utils/list_options.go +18 -1 the parser
server/workflow/workflow_server.go +7 -2 the guard
server/workflowarchive/archived_workflow_server_test.go +14 -0 a test for the other server
server/workflowarchive/archived_workflow_server.go (not touched)
Two servers consume ListOptions.Namespace. The guard reached one. The other received a test case:
// namespace NotEquals
resp, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{
ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace!=excluded-ns"}})
require.NoError(t, err)
assert.Len(t, resp.Items, 1)
which sends the vulnerable request and asserts that it succeeds.
Why that test passes either way
The archive package stubs authorization with one reactor holding one boolean:
allowed := true
kubeClient.AddReactor("create", "selfsubjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
return true, &authorizationv1.SelfSubjectAccessReview{
Status: authorizationv1.SubjectAccessReviewStatus{Allowed: allowed},
}, nil
})
The reactor never inspects action. It cannot answer differently for user-ns and victim-ns, so no test in that file can observe which namespace was submitted — which is the only observable that distinguishes the bug from correct behaviour. The February test passes on vulnerable code and on fixed code alike.
The reproduction therefore is a reactor, not an exploit. Replace the global boolean with one that reads sar.Spec.ResourceAttributes.Namespace, and at v4.1.3:
CONTROL 1 ok: namespace=victim-ns -> PermissionDenied (SARs seen: [victim-ns])
CONTROL 2 ok: namespace=user-ns -> allowed (SARs seen: [user-ns])
BYPASS: SAR namespaces asked for = [user-ns]
BYPASS: options handed to the archive = Namespace="user-ns" NamespaceFilter="NotEquals"
Control 1 establishes that the stub genuinely denies a namespace the caller does not hold; control 2 that it allows the one they do. Without both, the third line proves nothing — a stub that says yes to everything produces the same output on patched code. The result is that the only namespace ever submitted for review is the caller’s own, while the predicate handed to the archive is its negation.
Range, measured per tag
Two conditions have to hold together: the parser must understand NotEquals, and the archive server must lack the guard. Checked at each tag rather than inferred from the commit date:
v3.6.19 … v4.0.11 listopt_NotEquals=0 wfserver_guard=0 archive_guard=0 not affected
v4.1.0 listopt_NotEquals=1 wfserver_guard=1 archive_guard=0 affected
v4.1.1 v4.1.2 v4.1.3 identical to v4.1.0 affected
The 3.6 and 4.0 lines are not affected because the selector does not exist there: metadata.namespace!= parses as a plain metadata.namespace value and the negation never reaches SQL. It also requires the workflow archive to be enabled, which means persistence configured — no other opt-in.
The fix
a409723, released in v4.1.4. Two files.
archived_workflow_server.go, +7 −2 — the sibling’s guard, plus the error string now reporting the namespace actually submitted:
// A negated namespace selector returns every other namespace, so it needs cluster-wide permission
targetNamespace := options.Namespace
if options.NamespaceFilter == "NotEquals" {
targetNamespace = ""
}
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, targetNamespace, "")
archived_workflow_server_test.go, +18 −1 — the reactor gains the ability to deny:
// when set, only access reviews for this namespace are allowed, whatever allowed says
allowedNamespace := ""
...
ok := allowed
if allowedNamespace != "" {
sar := action.(k8stesting.CreateAction).GetObject().(*authorizationv1.SelfSubjectAccessReview)
ok = sar.Spec.ResourceAttributes != nil && sar.Spec.ResourceAttributes.Namespace == allowedNamespace
}
with a ListArchivedWorkflowsNamespaceNotEquals case that sets allowedNamespace = "user-ns" and asserts the negated selector is now denied. The February test is still there and still asserts success; it now runs under a stub that can tell the two namespaces apart.
Where else this shape lives
The generalisable part is not “check your archive endpoint”. It is a struct field whose meaning depends on a second field, consumed by an authorizer and by a query builder that read a different number of fields.
Concretely, in any codebase with a CanI-style call:
- Grep for the option field, not the guard.
options.Namespacehas more readers thanNamespaceFilterdoes; every reader of the first that is not also a reader of the second is a candidate. - A filter-mode field added to an options struct is the signal.
NotEquals,Exclude,Invert,negate— each one retroactively changes what every existing reader of the paired value field was computing. - When a feature commit contains its own security guard, count the call sites. The author already identified the hazard and wrote the answer; what remains is arithmetic.
- Read the test files a commit touched whose implementation it did not.
archived_workflow_server_test.go +14 −0with no accompanyingarchived_workflow_server.gostates that someone exercised that component, watched it accept the new input, and did not ask whether it was permitted to. - A test stub that can only say yes cannot host an authorization test. Before trusting “the package has permission tests”, check whether its authorizer stub inspects the request at all.
Reported 12 September through the project’s private advisory flow, four days after another researcher reported the same defect; my advisory was closed as a duplicate and the credit merged onto theirs. Fixed and released in v4.1.4 on 18 September. Identifier assigned by VulnCheck on 19 September, crediting thesecguy45 and me.
Eurico Nicacio — @h3llh0und