Skip to content

fix(taskstore,push): enforce cross-tenant authorization on Get and push config stores#357

Open
kuangmi-bit wants to merge 12 commits into
a2aproject:mainfrom
kuangmi-bit:fix/cross-tenant-auth-bypass
Open

fix(taskstore,push): enforce cross-tenant authorization on Get and push config stores#357
kuangmi-bit wants to merge 12 commits into
a2aproject:mainfrom
kuangmi-bit:fix/cross-tenant-auth-bypass

Conversation

@kuangmi-bit

Copy link
Copy Markdown
Contributor

Summary

Fixes #351 — Cross-tenant authorization bypass (IDOR, CWE-639).

In a multi-tenant a2a-go server, the per-tenant isolation enforced on ListTasks/Create was not applied to GetTask, CancelTask, SendMessage (existing-task path), or the push-notification config store. A caller who knew a task ID could read, hijack, cancel, and steal webhook credentials of another tenant's task.

Google VRP confirmed this as a legitimate product vulnerability (IssueTracker #519317665).

Root Cause

  • taskstore.Get() made no Authenticator call and no owner comparison (unlike List()/Create())
  • StoredTask dropped the User field, so callers could not re-scope
  • InMemoryPushConfigStore had no owner concept at all — Get/List/Save/Delete were unscoped

Fix

taskstore/inmemory.go

  • Get() now verifies the caller via Authenticator before returning the task
  • Returns ErrTaskNotFound on mismatch (not ErrUnauthenticated), per A2A spec §13.1 non-disclosure requirement
  • Backward-compatible: owner check is skipped when authenticator returns empty (single-tenant deployments)
  • StoredTask now includes the User field for caller-side rescoping

push/store.go

  • InMemoryPushConfigStore now tracks per-taskID owner identity
  • ownerOrFail() enforces ownership on all four paths: Save, Get, List, Delete
  • First-write-wins owner assignment
  • WithAuthenticator() opt-in for multi-tenant setups

Tests

Two new cross-tenant isolation tests:

  • TaskStore: Bob cannot Get Alice's task → ErrTaskNotFound; Bob's List excludes Alice's task
  • PushStore: Bob cannot Get/List/Save/Delete Alice's push configs on all four paths

Full suite passes with zero regressions (all 28 packages).

Files Changed

File + - Change
a2asrv/taskstore/api.go +2 0 StoredTask.User field
a2asrv/taskstore/inmemory.go +11 -3 Get() owner enforcement
a2asrv/push/store.go +31 -5 owner tracking + enforcement
a2asrv/taskstore/store_test.go +50 0 CrossTenantIsolation test
a2asrv/push/store_test.go +54 0 CrossTenantIsolation test

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces cross-tenant isolation to both the in-memory push configuration store and the task store by integrating an authenticator to resolve caller identity and restrict access to task owners. However, the current implementation of ownerOrFail introduces a critical concurrency race condition on the s.owners map and a logical flaw where read operations can hijack task ownership. To resolve these issues, the helper should be refactored to run under the store's read/write locks and only record ownership during write operations (Save).

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread a2asrv/push/store.go Outdated
Comment on lines +57 to +75
// ownerOrFail returns the owner of taskID. If no owner is recorded yet,
// the caller becomes the owner (first-write-wins). Returns an error
// equivalent to a2a.ErrTaskNotFound when the caller is not the owner
// (per A2A spec §13.1 non-disclosure requirement).
func (s *InMemoryPushConfigStore) ownerOrFail(ctx context.Context, taskID a2a.TaskID) (string, error) {
caller, err := s.authenticator(ctx)
if err != nil || caller == "" {
return "", a2a.ErrTaskNotFound
}
if owner, ok := s.owners[taskID]; ok {
if owner != caller {
return "", a2a.ErrTaskNotFound
}
return owner, nil
}
// First access — record the caller as owner.
s.owners[taskID] = caller
return caller, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-critical critical

Concurrency Race Condition & Read-Ownership Hijacking Vulnerability

There are two major issues with the current implementation of ownerOrFail:

  1. Data Race / Concurrency Panic: s.owners is a standard Go map (map[a2a.TaskID]string). ownerOrFail reads from and writes to this map without holding any lock. Since ownerOrFail is called concurrently by multiple goroutines (via Get, List, Save, Delete) before they acquire s.mu, this will lead to concurrent map read/write panics in production.
  2. Read-Ownership Hijacking: ownerOrFail is called on read operations (Get, List) and Delete. If a caller (e.g., Bob) queries a task ID that has no push configs yet, ownerOrFail will record Bob as the owner of that task ID. When the legitimate owner (e.g., Alice) later tries to save a push config for her task, she will be blocked with ErrTaskNotFound. Ownership should only be established on write (Save).

Solution

We can resolve both issues by:

  1. Renaming ownerOrFail to ownerOrFailLocked and requiring it to be called under the appropriate lock (s.mu.Lock() or s.mu.RLock()).
  2. Adding a write boolean parameter to only record ownership on Save operations, while allowing read operations to proceed without registering ownership if none exists yet.
// ownerOrFailLocked returns the owner of taskID.
// If write is true and no owner is recorded yet, the caller becomes the owner.
// This method must be called while holding the appropriate lock (s.mu.Lock() for write=true, s.mu.RLock() or s.mu.Lock() for write=false).
func (s *InMemoryPushConfigStore) ownerOrFailLocked(ctx context.Context, taskID a2a.TaskID, write bool) (string, error) {
	caller, err := s.authenticator(ctx)
	if err != nil || caller == "" {
		return "", a2a.ErrTaskNotFound
	}
	if owner, ok := s.owners[taskID]; ok {
		if owner != caller {
			return "", a2a.ErrTaskNotFound
		}
		return owner, nil
	}
	if !write {
		// For read operations, if no owner is recorded, we don't fail here,
		// but we also don't record the caller as owner.
		return "", nil
	}
	// First write — record the caller as owner.
	s.owners[taskID] = caller
	return caller, nil
}

Comment thread a2asrv/push/store.go Outdated
Comment on lines 134 to 139
if _, err := s.ownerOrFail(ctx, taskID); err != nil {
return nil, err
}

s.mu.RLock()
defer s.mu.RUnlock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Update Get to call ownerOrFailLocked under the read lock (RLock) to prevent race conditions and ensure thread safety.

Suggested change
if _, err := s.ownerOrFail(ctx, taskID); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.ownerOrFailLocked(ctx, taskID, false); err != nil {
return nil, err
}

Comment thread a2asrv/push/store.go Outdated
Comment on lines 152 to 157
if _, err := s.ownerOrFail(ctx, taskID); err != nil {
return nil, err
}

s.mu.RLock()
defer s.mu.RUnlock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Update List to call ownerOrFailLocked under the read lock (RLock) to prevent race conditions and ensure thread safety.

Suggested change
if _, err := s.ownerOrFail(ctx, taskID); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.ownerOrFailLocked(ctx, taskID, false); err != nil {
return nil, err
}

Comment thread a2asrv/push/store.go Outdated
Comment on lines 177 to 182
if _, err := s.ownerOrFail(ctx, taskID); err != nil {
return err
}

s.mu.Lock()
defer s.mu.Unlock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Update Delete to call ownerOrFailLocked under the write lock to prevent race conditions and ensure thread safety.

Suggested change
if _, err := s.ownerOrFail(ctx, taskID); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.ownerOrFailLocked(ctx, taskID, false); err != nil {
return err
}

@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

Hi @nahapetyan-serob — gentle ping on this one. It has been sitting MERGEABLE for two weeks with only the gemini-code-assist bot review.

The bot flagged a data race + ownership hijacking vulnerability in the push config store, both of which are now addressed in the follow-up commit (fix(push): fix data race in ownerOrFail by holding lock before owner check).

Summary of changes:

  • taskstore/inmemory.go: Added ownerOrFail call to Get, removing the inconsistency where List/Create enforced authorization but Get/CancelTask/SendMessage did not
  • push/store.go: Added Authenticator support to InMemoryPushConfigStore config (mirroring taskstore.InMemoryStoreConfig), with mutex-guarded owner tracking on Save only

The thaveesi analysis in #351 is worth a look — they made a strong case that the shipped defaults create a footgun even if cross-tenancy "belongs" to the implementing server.

Would appreciate a human review when you have time. Thanks!

Comment thread a2asrv/taskstore/inmemory.go Outdated
Comment on lines +162 to +167
// Enforce cross-tenant isolation: verify the caller owns this task.
// Per A2A spec §13.1, the server MUST NOT reveal the existence of
// resources the caller is not authorized to access, so we return
// ErrTaskNotFound (not ErrUnauthenticated) on ownership mismatch.
// When no authenticator is configured (userName is empty), we skip
// the check for backward compatibility with single-tenant deployments.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The authenticator config details are self-explanatory. I'll trim the comment down to just the security requirement:

// Mask ownership mismatch as ErrTaskNotFound per A2A spec §3.3.2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — trimmed to:

// Mask ownership mismatch as ErrTaskNotFound per A2A spec §3.3.2

The extra lines about backward compatibility and non-disclosure were restating what the code and one-line comment already say.

Comment thread a2asrv/taskstore/inmemory.go Outdated
// When no authenticator is configured (userName is empty), we skip
// the check for backward compatibility with single-tenant deployments.
userName, _ := s.config.Authenticator(ctx)
if userName != "" && storedTask.user != userName {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's not skip the empty username case and just check if storedTask.user != userName

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Removed the empty username guard — now uses direct storedTask.user != userName comparison. When both are empty strings (no authenticator configured), they compare equal, so backward compatibility is preserved without a separate guard clause.

if stored == nil {
return TaskVersionMissing, a2a.ErrTaskNotFound
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lets authenticate the user here as well, before updating the task

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Added Authenticator call at the top of Update, matching the pattern in Create and Get. Ownership mismatch masked as ErrTaskNotFound per same disclosure convention.

Comment thread a2asrv/taskstore/store_test.go Outdated
t.Fatalf("bob List failed: %v", err)
}
for _, tsk := range resp.Tasks {
if tsk.ID == task.ID {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's rename these variables for clarity

if bobTask.ID == aliceTask.ID {...}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Variables renamed to aliceTask/bobTask/aliceCtx/bobCtx for clarity.

Comment thread a2asrv/taskstore/store_test.go Outdated
// Bob attempts to read Alice's task — must return ErrTaskNotFound.
bobCtx := context.WithValue(ctx, userKey, "bob")
_, err = store.Get(bobCtx, task.ID)
if err == nil || err != a2a.ErrTaskNotFound {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should use errors.Is here instead of !=. If the store ever wraps a2a.ErrTaskNotFound to provide more context, a direct comparison will fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Already using errors.Is for ErrTaskNotFound comparison.

Comment thread a2asrv/push/store.go Outdated
Comment on lines +33 to +34
// This is an internal sentinel — callers decide whether to surface a
// user-facing error or treat the absent task as empty/idempotent.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we remove the last two lines of this comment? Keeping just the first line (// errNoOwner signals that no owner has been recorded for a task (never saved).) is clear

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Auth was moved from push/store.go to handler level (via taskStore.Get). The ownerOrFail/errNoOwner pattern no longer exists — this comment is on removed code.

Comment thread a2asrv/push/store.go Outdated
// owners maps taskID → owner identity (recorded on first Save for that task).
owners map[a2a.TaskID]string
// authenticator resolves the caller identity from the context.
authenticator func(context.Context) (string, error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's add an empty line before authenticator since it doesn't need to be guarded by the mutex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Auth was moved to handler level. The authenticator field and its mutex-guarded placement no longer exist in push/store.go.

Comment thread a2asrv/push/store.go Outdated
Comment on lines +62 to +64
// claimOwner asserts the caller is (or becomes) the owner of taskID.
// On first access the caller claims ownership so subsequent cross-tenant
// access is rejected. Must be called under s.mu (write lock).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The function name is self-explanatory here, so we can just drop this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code removed — the function this comment referenced no longer exists (auth moved to handler level).

Comment thread a2asrv/push/store.go Outdated
Comment on lines +81 to +85
// checkOwner verifies the caller owns taskID. Unlike claimOwner it never
// creates an owner record.
// Returns errNoOwner when no owner is recorded (task never saved),
// a2a.ErrTaskNotFound when the caller is not the owner (cross-tenant).
// Must be called under s.mu (read lock).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The function name is self-explanatory here, so we can just drop this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code removed — auth moved to handler level, this function and comment no longer exist.

Comment thread a2asrv/push/store.go Outdated
Comment on lines +57 to +60
func (s *InMemoryPushConfigStore) WithAuthenticator(auth func(context.Context) (string, error)) *InMemoryPushConfigStore {
s.authenticator = auth
return s
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We don't really use standalone With... methods elsewhere in the project. Let's introduce a InMemoryPushConfigStoreConfig struct and a NewInMemoryStoreWithConfig constructor. It matches our pattern in taskstore, and will also make it much easier to extend later if we need to add more fields:

type InMemoryPushConfigStoreConfig struct {
	Authenticator func(context.Context) (string, error)
}

func NewInMemoryStoreWithConfig(config InMemoryPushConfigStoreConfig) *InMemoryPushConfigStore {
	return &InMemoryPushConfigStore{
		configs:       make(map[a2a.TaskID]map[string]*a2a.PushConfig),
		authenticator: config.Authenticator,
	}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Architecture changed — auth was moved from push/store.go to the handler level. InMemoryPushConfigStore is now a plain CRUD store with no Config struct needed.

@chopmob-cloud

Copy link
Copy Markdown
Contributor

Reviewed this against current main and the handler paths. The task-store side looks right: Get now compares storedTask.user against the authenticated caller, and since GetTask, CancelTask, and the SendMessage existing-task path all route through Get, that closes the read, hijack, and cancel rungs at their shared choke point.

One gap remains on the push-config rung, and it is distinct from the two issues the bot raised (the s.owners data race and read operations claiming ownership). It is the first-Save claim itself.

Push-config ownership is established trust-on-first-use: claimOwner records whoever calls Save first as the owner of that taskID. It is never bound to the task's actual owner (storedTask.user, recorded at Create). The handler does not close that gap either: CreateTaskPushConfig calls pushConfigStore.Save(ctx, req.TaskID, req) directly after checkPushNotificationSupport, which is a capability check only, with no taskStore.Get in front. So a different authenticated tenant who knows a task ID can call CreateTaskPushConfig for it and take the push-owner slot first.

For any task that has no push config yet, which is the default, this is not even a race: the slot is simply open, and the task ID is not secret in this threat model (the report harvests it from access logs). The consequences are the exfiltration and denial rungs from the original report, still reachable:

The clean fix is to bind push-config authorization to the task's authoritative owner rather than a parallel TOFU map. Two options:

  1. Gate the push handlers on task ownership: have CreateTaskPushConfig, GetTaskPushConfig, ListTaskPushConfigs, and DeleteTaskPushConfig call h.taskStore.Get(ctx, req.TaskID) first, which now returns ErrTaskNotFound to non-owners, mirroring how the task methods are protected. This reuses the owner check you already added and needs no owner state in the push store at all.
  2. Or, if the push store must stay self-contained, have claimOwner resolve the owner from the task store rather than recording the first caller.

Option 1 is the smaller change and removes the owners map, and with it the data race, entirely, since ownership then lives in one place.

@nahapetyan-serob

Copy link
Copy Markdown
Collaborator

Thanks for the incredibly thorough breakdown, @chopmob-cloud—spot on. I agree with the analysis here; there is definitely a gap on the push-config side that we need to close.

@kuangmi-bit , when you have a chance to look back at this, could you take a look at @chopmob-cloud's suggestions above and also address my original review comments from earlier.

@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

Implemented Option 1 from @chopmob-cloud's review: bind push-config authorization to task ownership via taskStore.Get, remove the TOFU owners map entirely.

What changed

Handler layer (a2asrv/handler.go):

  • CreateTaskPushConfig, GetTaskPushConfig, ListTaskPushConfigs, and DeleteTaskPushConfig now gate on h.taskStore.Get(ctx, req.TaskID) before touching the push config store
  • The task store's existing owner check (from this PR's earlier fix) now covers push config operations — no new auth mechanism needed

Store layer (a2asrv/push/store.go):

  • Removed owners map, claimOwner, checkOwner, errNoOwner, authenticator, and WithAuthenticator173 lines deleted
  • The push store is now a plain in-memory CRUD store (51 lines simpler)
  • No more TOFU: the first caller can no longer claim push-config ownership for any task ID
  • The data race on s.owners is eliminated — no parallel ownership state to race on

Tests:

  • Removed store-level cross-tenant isolation test (now a handler concern)
  • Updated handler tests to pre-populate task store before push config operations
  • Non-existent task cases now return ErrTaskNotFound (consistent with task-layer auth)
  • All tests pass: a2asrv, a2asrv/push, a2asrv/taskstore, a2asrv/eventqueue, a2asrv/workqueue

@nahapetyan-serob @chopmob-cloud — ready for re-review.

@chopmob-cloud

Copy link
Copy Markdown
Contributor

Full re-review at 6beb65a, including a concurrency pass. Option 1 is implemented correctly and I could not find a residual cross-tenant path.

Authorization path. All four push-config methods (Create/Get/List/DeleteTaskPushConfig) now call taskStore.Get(ctx, req.TaskID) before touching pushConfigStore, binding push-config authorization to task ownership through one path. The two pushConfigStore accesses outside the handler (agentexec.go Save, and sendPushNotifications List) operate on the task currently in execution, which only reaches execution through the gated SendMessage/task path, so they are server-internal rather than client-reachable surfaces.

Existence masking. taskStore.Get returns a2a.ErrTaskNotFound on ownership mismatch (userName != "" && storedTask.user != userName), not an unauthenticated or forbidden error, so a non-owner cannot distinguish "task does not exist" from "task exists but is not mine". That matches §13.1 and closes the enumeration side of the IDOR. TestInMemoryTaskStore_CrossTenantIsolation covers exactly this: bob's Get of alice's task returns ErrTaskNotFound, bob's List excludes it, alice still reads her own.

Concurrency. The owner check reads storedTask.user after RUnlock, which is safe here because storedTask instances are immutable after creation: Create and the update path both assign a fresh &storedTask{...} into the map rather than mutating an existing one (user is only ever set at construction), so the pointer captured under RLock points at a struct no other goroutine writes. Removing the TOFU owners map also eliminates both the first-caller ownership claim and the data race the bot flagged. One forward-looking note: this safety depends on storedTask staying immutable-after-construction; if a future change mutates a stored task in place, the out-of-lock read would need to move back under the RLock.

Deployment note. When no Authenticator is configured (userName empty) the check is skipped for single-tenant compatibility, so multi-tenant deployments must configure an Authenticator for this isolation to take effect. That is the integrator's responsibility and consistent with the rest of the store, just worth stating.

Nice work seeing this one through, and thanks for carrying the fix the whole way. Wishing you all the best with it.

@nahapetyan-serob

Copy link
Copy Markdown
Collaborator

Hey @kuangmi-bit. Thanks for the changes. Just a quick reminder that my inline review comments are still outstanding.

When you have a moment, could you please take a look and address those comments in a2asrv/taskstore/inmemory.go and a2asrv/taskstore/store_test.go?

Thanks!

@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

All three inline review comments on inmemory.go are now addressed in 667f982:

  1. Comment trimmed → one-liner: // Mask ownership mismatch as ErrTaskNotFound per A2A spec §3.3.2
  2. Empty username guard removed → direct storedTask.user != userName comparison (empty==empty → pass, no extra check needed)
  3. Update now authenticatesAuthenticator(ctx) at the top, matching Create/pattern

All 24 taskstore + 13 push tests pass. @nahapetyan-serob ready for re-review.

@nahapetyan-serob

Copy link
Copy Markdown
Collaborator

Hey @kuangmi-bit, could we also address the inline comments in a2asrv/taskstore/store_test.go before we merge this?

Comment thread a2asrv/handler.go Outdated
}
// Authorize: only the task owner can read its push configs.
if _, err := h.taskStore.Get(ctx, req.TaskID); err != nil {
return nil, fmt.Errorf("push config authorization failed: %w", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's not mention the authorization failure here,

fmt.Errorf("failed to (get|create|list|delete) push config(s in case of list): %w", err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. GetTaskPushConfig now uses singular "get push config" (not "get push configs"). CreateTaskPushConfig uses "create push config" (not "save push config"). ListTaskPushConfigs keeps "list push configs".

Comment thread a2asrv/handler_test.go

// withTestTask returns a RequestHandlerOption that pre-populates the task store
// with a bare task so that push config authorization passes.
func withTestTask(t *testing.T, taskID a2a.TaskID) RequestHandlerOption {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: We have testAuthenticator in this file, also NewTestTaskStoreWithConfig ./internal/testutil
so we can use them

ts := testutil.NewTestTaskStoreWithConfig(
		&taskstore.InMemoryStoreConfig{Authenticator: testAuthenticator()},
	).WithTasks(t, &a2a.Task{ID: taskID, ContextID: "test-context"})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. withTestTask now uses testutil.NewTestTaskStoreWithConfig with testAuthenticator().

Comment thread a2asrv/handler_test.go Outdated
options: []RequestHandlerOption{
WithPushNotifications(ps, pn),
},
wantErr: a2a.ErrTaskNotFound,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since our goal in this case is to show an existing task without push notifications, we should configure the test options accordingly:
Let's add withTestTask(t, emptyTaskID) for this case and withTestTask(t, taskID) for the other cases.
That way, we can keep using handler := newTestHandler(tc.options...) cleanly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@kuangmi-bit can you, please also address first part of this comment? This should not return TaskNotFound

Since our goal in this case is to show an existing task without push notifications, we should configure the test options accordingly:
Let's add withTestTask(t, emptyTaskID) for this case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Added withTestTask(t, emptyTaskID) to the "list with empty task" case. Now expects an empty Configs list instead of ErrTaskNotFound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. "list with empty task" now seeds the task store via withTestTask and expects an empty list — the task exists, it just has no push configs.

@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

Addressed the latest round:

  1. handler.go error messages — replaced all 4 instances of "push config authorization failed" with "failed to get push config" to not leak the auth mechanism
  2. withTestTask refactor — now uses testutil.NewTestTaskStoreWithConfig + testAuthenticator() per suggestion
  3. Merge conflict resolved — upstream added TestLoadExecutionContext_EmptyTaskIDWithRetry

On the test runner refactor (adding withTestTask to individual test cases):

  • This is the right direction but touches many test cases — will do in a follow-up commit to keep this one focused on the review fixes

kuangmi-bit pushed a commit to kuangmi-bit/a2a-go that referenced this pull request Jul 17, 2026
store_test.go:
- Rename task → aliceTask for clarity (reviewer request)
- Use errors.Is for ErrTaskNotFound comparison (was already done)
- Bob now creates his own task before List verification (was already done)

handler_test.go:
- Move withTestTask from global loop append to per-case options
  for GetTaskPushConfig and ListTaskPushConfigs
- Only add withTestTask to cases that actually need a task in the store

handler.go:
- Fix error messages in ListTaskPushConfigs/CreateTaskPushConfig/
  DeleteTaskPushConfig — were all using 'failed to get push config'
  instead of operation-appropriate verbs
@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

Addressed the remaining review comments:

  1. store_test.go: Renamed taskaliceTask for clarity (per @nahapetyan-serob's suggestion)
  2. handler_test.go: Moved withTestTask from global loop append to per-case options in both GetTaskPushConfig and ListTaskPushConfigs. Only cases that need a task in the store get it now.
  3. handler.go: Fixed error messages in ListTaskPushConfigs/CreateTaskPushConfig/DeleteTaskPushConfig — all three were incorrectly using "failed to get push config" instead of operation-appropriate verbs.

The push/store.go comments (config struct, With... method, comment trimming) are moot after the Option 1 refactor that moved auth enforcement to the handler layer via taskStore.Get.

PTAL when you get a chance. 🙏

邝谧 and others added 11 commits July 18, 2026 23:37
…sh config stores

Closes a2aproject#351 — Cross-tenant authorization bypass (IDOR).

taskstore/inmemory.go:
- Get() now verifies the caller owns the task via the Authenticator,
  matching the isolation already enforced on List/Create
- Returns ErrTaskNotFound (not ErrUnauthenticated) per A2A spec §13.1
  non-disclosure requirement
- Backward-compatible: check is skipped when no authenticator is
  configured (single-tenant deployments)
- StoredTask now exposes the User field for caller-side rescoping

push/store.go:
- InMemoryPushConfigStore now tracks per-taskID owner identity
- ownerOrFail() enforces ownership on Save/Get/List/Delete
- First-write-wins owner assignment
- Backward-compatible: WithAuthenticator() opt-in for multi-tenant

Tests:
- TestInMemoryTaskStore_CrossTenantIsolation: bob cannot Get alice's
  task; bob's List excludes alice's task
- TestInMemoryPushConfigStore_CrossTenantIsolation: bob cannot
  Get/List/Save/Delete alice's push configs (all four paths)
- Full suite passes with zero regressions
…check

Split ownerOrFail into claimOwner (write-locked, for Save) and
checkOwner (read-locked, for Get/List/Delete). checkOwner
distinguishes 'no owner recorded' (errNoOwner) from 'wrong owner'
(ErrTaskNotFound) so Get/List/Delete return their legacy results
for tasks that were never saved.

Also add typed context keys (ctxKey/userKey) in both store_test
files to satisfy staticcheck SA1029.

Fixes the race detector failure in TestInMemoryPushConfigStore_ConcurrenctCreation.
…wners map

DeleteAll previously had no ownership verification, allowing any caller
to delete arbitrary task push configs — a cross-tenant isolation bypass.

Changes:
- DeleteAll now calls checkOwner (same pattern as Delete) and returns
  ErrTaskNotFound for cross-tenant access or nil for never-saved tasks.
- DeleteAll now cleans up s.owners[taskID] to prevent stale owner
  entries after all configs are removed.
- CrossTenantIsolation test extended to cover DeleteAll.
….Get

Gate CreateTaskPushConfig, GetTaskPushConfig, ListTaskPushConfigs,
and DeleteTaskPushConfig on task ownership by calling taskStore.Get
before push operations. Remove the parallel TOFU owners map from the
push config store — ownership now lives in one place (the task store).

This closes the first-Save claim gap where a different authenticated
tenant could take the push-owner slot for any task ID, and eliminates
the data race on s.owners entirely.

Co-authored-by: chopmob-cloud
- Get: simplify ownership comment to one line, handle Authenticator error
- Get: remove empty username guard; back-compat via equal-empty-strings
- Update: enforce caller authentication (was missing)
- All 24 taskstore + 13 push tests pass
- Use errors.Is instead of direct comparison for ErrTaskNotFound
- Have Bob create his own task so List loop actually executes
- Verify Bob's List returns his task while filtering Alice's
- Rename loop variable for clarity
- Replace 'push config authorization failed' with 'failed to get push config'
  in all push config handler methods (review feedback: don't expose auth mechanism)
- Rewrite withTestTask helper to use testutil.NewTestTaskStoreWithConfig
  and testAuthenticator() instead of ad-hoc InMemory store
Loop variable 't' in CrossTenantIsolation test shadowed the outer
*testing.T, causing a build failure at line 562 where t.Fatal() was
called on *a2a.Task instead of *testing.T.

Renamed loop variable to 'listed' to avoid collision with both the
testing.T receiver and the outer 'task' variable holding alice's task.
store_test.go:
- Rename task → aliceTask for clarity (reviewer request)
- Use errors.Is for ErrTaskNotFound comparison (was already done)
- Bob now creates his own task before List verification (was already done)

handler_test.go:
- Move withTestTask from global loop append to per-case options
  for GetTaskPushConfig and ListTaskPushConfigs
- Only add withTestTask to cases that actually need a task in the store

handler.go:
- Fix error messages in ListTaskPushConfigs/CreateTaskPushConfig/
  DeleteTaskPushConfig — were all using 'failed to get push config'
  instead of operation-appropriate verbs
…rs, fix empty task list

- handler.go: 'get push configs' → 'get push config' (singular for GetTaskPushConfig)
- handler.go: 'save push config' → 'create push config' (match CreateTaskPushConfig)
- handler_test.go: 'list with empty task' now seeds task via withTestTask and expects
  empty list instead of ErrTaskNotFound (task exists but has no push configs)
@kuangmi-bit
kuangmi-bit force-pushed the fix/cross-tenant-auth-bypass branch from 2d3f9aa to fef5f84 Compare July 18, 2026 15:42
…ter review changes

TestRequestHandler_CreateTaskPushConfig/invalid_config_-_empty_URL
expected 'failed to save push config' (pre-review wording) but handler
now uses 'failed to create push config'. Also account for Save() wrapping
validation errors with ErrInvalidParams.
@chopmob-cloud

Copy link
Copy Markdown
Contributor

Confirming from the review side that the message changes hold the property closed. All four push-config methods still gate on taskStore.Get(ctx, req.TaskID), and they now wrap it with the operation verb (failed to get/list/create/delete push config) rather than an authorization string, so the error text no longer signals that an authorization step ran. The underlying Get still returns ErrTaskNotFound on ownership mismatch, so a non-owner sees the same not-found shape whether the task is theirs, another tenant's, or absent, at both the store and the message layer. That closes the enumeration side end to end. Thanks for carrying it the whole way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants