fix(api): prevent IDOR in TenantNamespace Get and Watch handlers - #2471
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds per-namespace authorization to tenant-namespace REST: Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant REST as REST Tier
participant RB as RoleBindingStore
participant Up as UpstreamWatch
Client->>REST: GET /tenantnamespaces/{name}
REST->>RB: List RoleBindings (ns = target)
RB-->>REST: RoleBindings
REST->>REST: hasAccessToNamespaceForUser
alt access allowed
REST-->>Client: 200 TenantNamespace
else access denied
REST-->>Client: 403 / 404
end
Client->>REST: WATCH /tenantnamespaces?fieldSelector=...&labelSelector=...
REST->>Up: Start upstream watch (with selectors)
Up-->>REST: Event(namespace X)
REST->>REST: lookup cached requester identity
REST->>RB: List RoleBindings (ns = X)
RB-->>REST: RoleBindings
REST->>REST: hasAccessToNamespaceForUser
alt access allowed
REST-->>Client: Event(namespace X)
else skip
REST--xClient: (event skipped)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fixed two IDOR vulnerabilities allowing authenticated users to access metadata of any tenant namespace without proper authorization. Changes: - Added hasAccessToNamespace() for efficient single-namespace access checks - Get() now verifies access before returning namespace metadata - Watch() filters events per-namespace with proper authorization - Returns NotFound (not Forbidden) to prevent tenant enumeration Performance optimization: - hasAccessToNamespace() lists RoleBindings only in target namespace instead of listing all cluster RoleBindings (order of magnitude faster) - Watch handler logs authorization errors for security audit Additional fixes: - Handle ServiceAccount subjects with empty namespace correctly - Add klog error logging for failed authorization checks Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
95876bf to
64a3edf
Compare
Added comprehensive unit tests for authorization logic: - hasAccessToNamespace() tests: - User subject access (positive and negative cases) - Group subject access - ServiceAccount subject access - ServiceAccount with empty namespace (defaults to RoleBinding ns) - Privileged groups (system:masters, cozystack-cluster-admin) - Get() handler tests: - Returns namespace when user has access - Returns NotFound when user lacks access (not Forbidden) - Returns NotFound for non-tenant namespaces All tests verify that authorization correctly enforces RoleBinding-based access control and prevents IDOR vulnerabilities. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses critical security vulnerabilities in the TenantNamespace API by implementing granular authorization checks. By shifting from cluster-wide RoleBinding lookups to namespace-scoped queries, the changes not only secure the API against unauthorized access but also provide a substantial performance improvement for Get and Watch operations. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request implements access control checks for Get and Watch operations on TenantNamespace resources to prevent Insecure Direct Object Reference (IDOR) vulnerabilities. It introduces a hasAccessToNamespace helper that verifies user permissions via RoleBindings within the target namespace and includes comprehensive unit tests for these security checks. Feedback focuses on optimizing the Watch loop by caching user group information, refactoring duplicated subject-matching logic into a shared helper to improve maintainability, and adopting structured logging (klog.ErrorS) to align with project standards.
| } | ||
|
|
||
| // Check if user has access to this namespace | ||
| hasAccess, err := r.hasAccessToNamespace(ctx, ns.Name) |
There was a problem hiding this comment.
Calling hasAccessToNamespace inside the watch loop is inefficient because it re-retrieves the user from the context and re-builds the groups map for every single event. Since the user's identity and groups are static for the duration of the watch request, consider extracting the user info and groups map once before starting the goroutine and passing them to a helper function for the access check.
There was a problem hiding this comment.
Good catch. Hoisted username and the groups map out of the watch goroutine in commit 61ed7ad, and added an inner helper hasAccessToNamespaceForUser that takes the cached identity. The watch loop now does only the per-namespace RoleBinding list and subject match per event.
| for i := range rbs.Items { | ||
| for j := range rbs.Items[i].Subjects { | ||
| subj := rbs.Items[i].Subjects[j] | ||
| switch subj.Kind { | ||
| case "Group": | ||
| if _, ok := groups[subj.Name]; ok { | ||
| return true, nil | ||
| } | ||
| case "User": | ||
| if subj.Name == u.GetName() { | ||
| return true, nil | ||
| } | ||
| case "ServiceAccount": | ||
| saNamespace := subj.Namespace | ||
| if saNamespace == "" { | ||
| saNamespace = rbs.Items[i].Namespace | ||
| } | ||
| if u.GetName() == fmt.Sprintf("system:serviceaccount:%s:%s", saNamespace, subj.Name) { | ||
| return true, nil | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The logic for matching a user against RoleBinding subjects is duplicated between filterAccessible and hasAccessToNamespace. This includes the logic for Group, User, and the recently fixed ServiceAccount namespace handling. Refactoring this into a shared helper method (e.g., userMatchesSubjects) would improve maintainability and ensure consistency across the List, Get, and Watch handlers.
There was a problem hiding this comment.
Already addressed in commit 7257b6ae — both filterAccessible and hasAccessToNamespace now go through the shared matchesSubject helper (rest.go:360), which centralizes Group/User/ServiceAccount matching including the empty-namespace fallback.
| // Check if user has access to this namespace | ||
| hasAccess, err := r.hasAccessToNamespace(ctx, ns.Name) | ||
| if err != nil { | ||
| klog.Errorf("Failed to check access for namespace %s in watch: %v", ns.Name, err) |
There was a problem hiding this comment.
Use structured logging with klog.ErrorS instead of klog.Errorf to adhere to the project's Go code standards and Kubernetes best practices for structured logging.
| klog.Errorf("Failed to check access for namespace %s in watch: %v", ns.Name, err) | |
| klog.ErrorS(err, "Failed to check access for namespace in watch", "namespace", ns.Name) |
References
- Use structured logging via logr — flag fmt.Print* and log.Print* in controller code. (link)
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
pkg/registry/core/tenantnamespace/rest.go (1)
156-161:⚠️ Potential issue | 🔴 CriticalPropagate field and label selectors in Watch before authorizing and emitting events.
Watchacceptsoptsbut ignoresopts.FieldSelectorandopts.LabelSelectorbefore streaming events to the client. When a watch request is name-scoped (e.g.,fieldSelector=metadata.name=specific-ns), the Kubernetes API server's RBAC rules withresourceNamescorrectly restrict the request to that single object. However, this handler emits all accessible tenant namespaces instead, bypassing the selector restriction. Either propagate both selectors to the upstreamWatchcall or apply in-process filtering before sending events.Suggested approach
nsWatch, err := r.w.Watch(ctx, nsList, &client.ListOptions{Raw: &metav1.ListOptions{ Watch: true, ResourceVersion: opts.ResourceVersion, + FieldSelector: opts.FieldSelector.String(), + LabelSelector: opts.LabelSelector.String(), }})Then add defensive filtering before authorization:
ns, ok := ev.Object.(*corev1.Namespace) if !ok || !strings.HasPrefix(ns.Name, prefix) { continue } +if opts.FieldSelector != nil && !opts.FieldSelector.Matches(fields.Set{"metadata.name": ns.Name}) { + continue +} +if opts.LabelSelector != nil && !opts.LabelSelector.Matches(labels.Set(ns.Labels)) { + continue +} hasAccess, err := r.hasAccessToNamespace(ctx, ns.Name)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pkg/registry/core/tenantnamespace/rest.go` around lines 156 - 161, The Watch method ignores opts.FieldSelector and opts.LabelSelector causing it to stream all namespaces; update Watch (function Watch and the r.w.Watch call) to propagate opts.FieldSelector and opts.LabelSelector into the upstream client.ListOptions/Metav1.ListOptions so the upstream watch is already filtered, and if for any reason you cannot pass selectors upstream, apply the same filtering logic in-process when reading from nsWatch.ResultChan() (filter events by metadata.name and labels based on opts.FieldSelector/opts.LabelSelector) before authorization/emitting events to the caller to ensure selector restrictions are preserved.
🧹 Nitpick comments (1)
pkg/registry/core/tenantnamespace/rest.go (1)
383-388: Consider sharing the subject-matching logic.The ServiceAccount fallback fix now exists in both
filterAccessibleandhasAccessToNamespace. Extracting a small helper avoids future drift in this security-sensitive check.♻️ Proposed helper shape
+func subjectMatchesUser(subj rbacv1.Subject, bindingNamespace string, username string, groups map[string]struct{}) bool { + switch subj.Kind { + case "Group": + _, ok := groups[subj.Name] + return ok + case "User": + return subj.Name == username + case "ServiceAccount": + saNamespace := subj.Namespace + if saNamespace == "" { + saNamespace = bindingNamespace + } + return username == fmt.Sprintf("system:serviceaccount:%s:%s", saNamespace, subj.Name) + default: + return false + } +}Also applies to: 433-452
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pkg/registry/core/tenantnamespace/rest.go` around lines 383 - 388, Both filterAccessible and hasAccessToNamespace duplicate the ServiceAccount subject-matching/fallback logic; extract that check into a shared helper (e.g., matchesServiceAccountSubject or isServiceAccountUserMatch) and call it from both places. The helper should accept the Subject (subj), the resource item's Namespace (rbs.Items[i].Namespace or itemNamespace), and the user name (u.GetName()), implement the same fallback where subj.Namespace=="" uses the resource namespace, and perform the comparison against fmt.Sprintf("system:serviceaccount:%s:%s", namespace, subj.Name); replace the duplicated blocks in filterAccessible and hasAccessToNamespace with calls to this helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@pkg/registry/core/tenantnamespace/rest.go`:
- Around line 156-161: The Watch method ignores opts.FieldSelector and
opts.LabelSelector causing it to stream all namespaces; update Watch (function
Watch and the r.w.Watch call) to propagate opts.FieldSelector and
opts.LabelSelector into the upstream client.ListOptions/Metav1.ListOptions so
the upstream watch is already filtered, and if for any reason you cannot pass
selectors upstream, apply the same filtering logic in-process when reading from
nsWatch.ResultChan() (filter events by metadata.name and labels based on
opts.FieldSelector/opts.LabelSelector) before authorization/emitting events to
the caller to ensure selector restrictions are preserved.
---
Nitpick comments:
In `@pkg/registry/core/tenantnamespace/rest.go`:
- Around line 383-388: Both filterAccessible and hasAccessToNamespace duplicate
the ServiceAccount subject-matching/fallback logic; extract that check into a
shared helper (e.g., matchesServiceAccountSubject or isServiceAccountUserMatch)
and call it from both places. The helper should accept the Subject (subj), the
resource item's Namespace (rbs.Items[i].Namespace or itemNamespace), and the
user name (u.GetName()), implement the same fallback where subj.Namespace==""
uses the resource namespace, and perform the comparison against
fmt.Sprintf("system:serviceaccount:%s:%s", namespace, subj.Name); replace the
duplicated blocks in filterAccessible and hasAccessToNamespace with calls to
this helper.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 52444651-b888-4c34-8b86-4ee7609b7072
📒 Files selected for processing (2)
pkg/registry/core/tenantnamespace/rest.gopkg/registry/core/tenantnamespace/rest_test.go
| // Check if user has access to this namespace | ||
| hasAccess, err := r.hasAccessToNamespace(ctx, name) | ||
| if err != nil { | ||
| return nil, err |
There was a problem hiding this comment.
Returning 403 error if user doesn't have enough privileges better maps to the kube api rbac strategy.
- Resource exists but user doesn't have access? 403 "user is not allowed to get resources of kind X in namespace Y"
- Resource doesn't exist and user doesn't have access? 403 "user is not allowed to get resource of kind X in namespace Y"
- Resource doesn't exist and user has access? 404 "resource X does not exist"
- Resource exist and user has access? 200
Now the error probably will be 500 since it's a custom error
There was a problem hiding this comment.
Addressed in commit 7257b6ae — denied access now returns apierrors.NewForbidden instead of NewNotFound, matching the kube RBAC convention you described.
Address review feedback from sircthulhu and CodeRabbit: 1. Return Forbidden instead of NotFound for unauthorized access - Get() now returns 403 Forbidden to follow standard K8s RBAC behavior - Previously returned 404 NotFound for security-by-obscurity - Updated test expectations to match new behavior 2. Propagate field and label selectors in Watch handler - Pass opts.FieldSelector and opts.LabelSelector to upstream Watch - Add defensive filtering before authorization to prevent RBAC bypass - Fixes potential issue with resourceNames restrictions 3. Refactor subject-matching logic to eliminate duplication - Extract matchesSubject() helper for Group/User/ServiceAccount checks - Remove duplicated code from filterAccessible and hasAccessToNamespace - Consolidates ServiceAccount namespace fallback logic All tests pass successfully. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Kirill Ilin (sircthulhu)
left a comment
There was a problem hiding this comment.
LGTM with non blocking observation: If I'm not mistaken, returned errors should be typed, so that kubernetes could add correct response code
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@pkg/registry/core/tenantnamespace/rest_test.go`:
- Around line 367-414: The test TestGet_WithAccess only checks obj != nil which
allows wrong types; update the test to assert that the returned object from
REST.Get is a *TenantNamespace (or the concrete type used by your API) and
verify its metadata (at minimum the Name equals "tenant-test"); locate the call
to r.Get in TestGet_WithAccess and add a type assertion to the concrete
TenantNamespace type, check for assertion success, and assert tn.ObjectMeta.Name
(or tn.GetName()) equals "tenant-test" so the test guarantees correct runtime
type and metadata.
- Around line 50-501: Add regression tests covering REST.Watch's per-event
authorization filtering by creating mixed streams of RoleBindings/namespaces so
some events should be filtered and asserting only authorized namespace events
are delivered and label/field selectors are preserved; also strengthen
TestGet_WithAccess to validate the returned object's concrete type and metadata
(Kind, APIVersion, Name/Namespace/Labels) from REST.Get and ensure it matches
the tenant namespace resource shape rather than only checking non-nil. Locate
tests around hasAccessToNamespace, Get, and Watch in the REST test file to
implement these assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: fa3e78d7-5691-4943-aa2b-9ceed6cb7ace
📒 Files selected for processing (2)
pkg/registry/core/tenantnamespace/rest.gopkg/registry/core/tenantnamespace/rest_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- pkg/registry/core/tenantnamespace/rest.go
| // Security tests for IDOR fix | ||
|
|
||
| func TestHasAccessToNamespace_WithUserAccess(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"}, | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "test-user", | ||
| Groups: []string{"system:authenticated"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !hasAccess { | ||
| t.Error("expected user to have access, but got false") | ||
| } | ||
| } | ||
|
|
||
| func TestHasAccessToNamespace_WithoutAccess(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| // RoleBinding for different user | ||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"}, | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "test-user", | ||
| Groups: []string{"system:authenticated"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if hasAccess { | ||
| t.Error("expected user to NOT have access, but got true") | ||
| } | ||
| } | ||
|
|
||
| func TestHasAccessToNamespace_WithGroupAccess(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "Group", Name: "test-group", APIGroup: "rbac.authorization.k8s.io"}, | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "test-user", | ||
| Groups: []string{"system:authenticated", "test-group"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !hasAccess { | ||
| t.Error("expected user to have access via group, but got false") | ||
| } | ||
| } | ||
|
|
||
| func TestHasAccessToNamespace_SystemMasters(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "admin", | ||
| Groups: []string{"system:masters"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !hasAccess { | ||
| t.Error("expected system:masters to have access, but got false") | ||
| } | ||
| } | ||
|
|
||
| func TestHasAccessToNamespace_CozyAdminGroup(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "cozy-admin", | ||
| Groups: []string{"cozystack-cluster-admin"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !hasAccess { | ||
| t.Error("expected cozystack-cluster-admin to have access, but got false") | ||
| } | ||
| } | ||
|
|
||
| func TestHasAccessToNamespace_ServiceAccount(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "ServiceAccount", Name: "test-sa", Namespace: "tenant-test"}, | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "system:serviceaccount:tenant-test:test-sa", | ||
| Groups: []string{"system:authenticated"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !hasAccess { | ||
| t.Error("expected service account to have access, but got false") | ||
| } | ||
| } | ||
|
|
||
| func TestHasAccessToNamespace_ServiceAccountEmptyNamespace(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| // ServiceAccount subject with empty namespace should default to RoleBinding namespace | ||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "ServiceAccount", Name: "test-sa", Namespace: ""}, // Empty namespace | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "system:serviceaccount:tenant-test:test-sa", | ||
| Groups: []string{"system:authenticated"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if !hasAccess { | ||
| t.Error("expected service account with empty namespace to have access, but got false") | ||
| } | ||
| } | ||
|
|
||
| func TestGet_WithAccess(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"}, | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "test-user", | ||
| Groups: []string{"system:authenticated"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if obj == nil { | ||
| t.Fatal("expected object, got nil") | ||
| } | ||
| } | ||
|
|
||
| func TestGet_WithoutAccess(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| ns := &corev1.Namespace{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}, | ||
| } | ||
|
|
||
| // RoleBinding for different user | ||
| rb := &rbacv1.RoleBinding{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-binding", | ||
| Namespace: "tenant-test", | ||
| }, | ||
| Subjects: []rbacv1.Subject{ | ||
| {Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"}, | ||
| }, | ||
| RoleRef: rbacv1.RoleRef{ | ||
| APIGroup: "rbac.authorization.k8s.io", | ||
| Kind: "Role", | ||
| Name: "test-role", | ||
| }, | ||
| } | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| WithObjects(ns, rb). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "test-user", | ||
| Groups: []string{"system:authenticated"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{}) | ||
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| if obj != nil { | ||
| t.Errorf("expected nil object, got %v", obj) | ||
| } | ||
|
|
||
| // Verify it returns Forbidden to follow standard K8s RBAC behavior | ||
| if !apierrors.IsForbidden(err) { | ||
| t.Errorf("expected Forbidden error, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestGet_NonTenantNamespace(t *testing.T) { | ||
| scheme := runtime.NewScheme() | ||
| _ = corev1.AddToScheme(scheme) | ||
| _ = rbacv1.AddToScheme(scheme) | ||
|
|
||
| client := fake.NewClientBuilder(). | ||
| WithScheme(scheme). | ||
| Build() | ||
|
|
||
| r := &REST{ | ||
| c: client, | ||
| gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"}, | ||
| } | ||
|
|
||
| u := &user.DefaultInfo{ | ||
| Name: "test-user", | ||
| Groups: []string{"system:masters"}, | ||
| } | ||
| ctx := request.WithUser(context.Background(), u) | ||
|
|
||
| obj, err := r.Get(ctx, "default", &metav1.GetOptions{}) | ||
| if err == nil { | ||
| t.Fatal("expected error for non-tenant namespace, got nil") | ||
| } | ||
| if obj != nil { | ||
| t.Errorf("expected nil object, got %v", obj) | ||
| } | ||
| if !apierrors.IsNotFound(err) { | ||
| t.Errorf("expected NotFound error, got %v", err) | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify whether tenantnamespace already has dedicated Watch tests elsewhere.
# Expected: security coverage for REST.Watch, especially mixed streams and unauthorized-event filtering.
rg -n --type=go -C3 'func Test.*Watch|\.Watch\(' pkg/registry/core/tenantnamespaceRepository: cozystack/cozystack
Length of output: 556
🏁 Script executed:
# Get the full Watch implementation from rest.go
head -200 pkg/registry/core/tenantnamespace/rest.go | tail -50Repository: cozystack/cozystack
Length of output: 1496
🏁 Script executed:
# Check full rest.go around the Watch method
sed -n '150,200p' pkg/registry/core/tenantnamespace/rest.goRepository: cozystack/cozystack
Length of output: 1525
🏁 Script executed:
# Search entire rest_test.go for any Watch reference (not just test functions)
rg -n --type=go 'Watch' pkg/registry/core/tenantnamespace/rest_test.goRepository: cozystack/cozystack
Length of output: 45
🏁 Script executed:
# Confirm the file size and check if there's more content beyond line 501
wc -l pkg/registry/core/tenantnamespace/rest_test.goRepository: cozystack/cozystack
Length of output: 114
🏁 Script executed:
# Check for any Watch tests in sibling test files or integration tests
find pkg/registry/core/tenantnamespace -name '*test*.go' -type f | xargs rg -l 'Watch' 2>/dev/null || echo "No Watch tests found"Repository: cozystack/cozystack
Length of output: 84
🏁 Script executed:
# Get the full Watch implementation to see if it includes IDOR/authorization checks
sed -n '150,250p' pkg/registry/core/tenantnamespace/rest.goRepository: cozystack/cozystack
Length of output: 2901
🏁 Script executed:
# Check if Watch calls hasAccessToNamespace or any authorization logic
rg -A30 'func \(r \*REST\) Watch' pkg/registry/core/tenantnamespace/rest.goRepository: cozystack/cozystack
Length of output: 938
🏁 Script executed:
# Get the complete Watch implementation by searching for the end of the method
rg -A80 'func \(r \*REST\) Watch' pkg/registry/core/tenantnamespace/rest.go | head -100Repository: cozystack/cozystack
Length of output: 2282
🏁 Script executed:
# Check TestGet_WithAccess assertions more carefully
sed -n '361,410p' pkg/registry/core/tenantnamespace/rest_test.goRepository: cozystack/cozystack
Length of output: 1266
🏁 Script executed:
# Check what the actual Get implementation returns
rg -B5 -A25 'func \(r \*REST\) Get' pkg/registry/core/tenantnamespace/rest.go | head -50Repository: cozystack/cozystack
Length of output: 851
🏁 Script executed:
# Verify the full Get method to understand what object should be returned
sed -n '100,150p' pkg/registry/core/tenantnamespace/rest.goRepository: cozystack/cozystack
Length of output: 1342
Add regression tests for Watch authorization filtering and strengthen Get assertions.
The Watch implementation includes per-event hasAccessToNamespace checks to filter unauthorized namespaces, but this critical security path has zero test coverage. This leaves the event-by-event authorization filtering vulnerable to silent regressions, especially mixed authorized/unauthorized streams and selector propagation.
Additionally, TestGet_WithAccess only verifies obj != nil; it does not assert the object type, Kind, APIVersion, or metadata fields, which would catch object wrapping or metadata mapping regressions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@pkg/registry/core/tenantnamespace/rest_test.go` around lines 50 - 501, Add
regression tests covering REST.Watch's per-event authorization filtering by
creating mixed streams of RoleBindings/namespaces so some events should be
filtered and asserting only authorized namespace events are delivered and
label/field selectors are preserved; also strengthen TestGet_WithAccess to
validate the returned object's concrete type and metadata (Kind, APIVersion,
Name/Namespace/Labels) from REST.Get and ensure it matches the tenant namespace
resource shape rather than only checking non-nil. Locate tests around
hasAccessToNamespace, Get, and Watch in the REST test file to implement these
assertions.
There was a problem hiding this comment.
Get assertions strengthened in commit 61ed7ad to check the concrete *TenantNamespace type plus Name, Kind, and APIVersion. The dedicated Watch test suite (mixed authorized/unauthorized streams, selector propagation) is tracked as a follow-up — hasAccessToNamespace itself has full coverage and the Watch loop is a thin wrapper around it. Will land separately so this security fix can ship.
There was a problem hiding this comment.
@myasnikovdaniil, thanks for the update!
The TestGet_WithAccess strengthening looks good in the current file — the type assertion to *corev1alpha1.TenantNamespace along with the Name, Kind, and APIVersion checks are exactly what was needed there.
For the Watch test suite, the deferral reasoning is sound — hasAccessToNamespace is fully covered and the per-event loop is a straightforward wrapper around it. Just make sure a tracking issue is opened so the follow-up doesn't slip. Would you like me to open one now?
- Hoist user identity extraction out of the Watch goroutine; reuse a cached username and groups map across events instead of re-fetching them per event. Watch now returns Unauthorized up front when no user is present in the context, rather than failing silently per event. - Switch the per-event access-check error log to structured klog.ErrorS to comply with the project Go style guide. - Strengthen TestGet_WithAccess to assert the concrete *TenantNamespace type plus Name, Kind, and APIVersion, so type or metadata regressions fail fast. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
|
Successfully created backport PR for |
|
Successfully created backport PR for |
The auto-generated changelog only listed #2459/#2467 (velero-configmap Role move). Five additional PRs were backported and merged into release-1.3 between then and the v1.3.1 tag (2026-04-28): - #2471/#2524 - fix(api): IDOR in TenantNamespace Get/Watch - #2496/#2505 - feat(linstor): linstor-csi v1.10.6 (Protocol-C dual-attach) - #2462/#2511 - fix(etcd): remove destructive post-upgrade hook - #2421/#2491 - fix(kamaji): memory limits + startup probe - #2498/#2518 - build(linstor): wire linstor-gui into root build target Update the release date to match the actual tag (2026-04-28), rewrite the intro paragraph, and add @kvaps to contributors. Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Summary
Fixed two IDOR (Insecure Direct Object Reference) vulnerabilities in the TenantNamespace API handlers that allowed authenticated users to access metadata of tenant namespaces without proper authorization checks.
Changes
New optimized function: hasAccessToNamespace()
Get() handler
hasAccessToNamespace()for efficient authorizationNotFoundinstead ofForbiddento prevent tenant enumerationWatch() handler
hasAccessToNamespace()for per-event authorizationAdditional fixes
Performance Impact
For Watch with 100 events:
Security Impact
Before: Any authenticated user could:
tenant-*namespace viaGet()Watch(), including creation/modification/deletionAfter: Users can only access tenant namespaces they have explicit RoleBindings for, matching the behavior of the
List()handler.Testing
Manually verified:
gettheir own tenant namespaceswatchevents for their own tenant namespacesNotFound(notForbidden) to prevent enumerationList()behavior remains unchanged and consistent withGet()andWatch()Checklist
Summary by CodeRabbit
Bug Fixes
Tests