a2aproject / a2aproject/a2a-python

Security Improvement: Add SSRF protection for Push Notification webhooks and authorization checks for Task operations

Open
#786 7 comments 0 reactions 1 assignee Claimed by @rohityan View on GitHub
component: server status: needs review
Dominant language
Python
Stars
2.1k
Forks
496
Avg merge
4d 17h
Merged PRs (30d)
12

Description

## Summary

The current SDK implementation has two architectural gaps that could lead to security issues in production deployments:

1. **Push Notification webhook URLs are used without SSRF protections** — any URL provided via `PushNotificationConfig` is passed directly to `httpx.post()` with no validation
2. **Task operations have no authorization layer** — tasks are looked up by ID only, allowing any client to access, cancel, or modify any other client's tasks

These are not obscure edge cases — they affect the default behavior that every developer inherits when building on this SDK.

---

### Issue 1: SSRF via Push Notification Webhooks

**Affected code:**
- `src/a2a/server/tasks/base_push_notification_sender.py` (lines 53-62)

```python
url = push_info.url # user-controlled, no validation
response = await self._client.post(
url,
json=notification.model_dump(mode="json", exclude_none=True),
headers=headers,
)
```

The URL from `PushNotificationConfig.url` (defined in `src/a2a/types.py`, line 840) is stored and used without any validation:

- No scheme restriction (allows `file://`, `gopher://`, etc.)
- No IP/hostname blocklist (allows `127.0.0.1`, `169.254.169.254`, internal hostnames)
- No DNS rebinding protection
- No redirect policy

Both `InMemoryPushNotificationConfigStore.set_info()` and `DatabasePushNotificationConfigStore.set_info()` store the URL as-is.

**Impact:** A malicious client can register a push notification config with an internal URL (e.g., cloud metadata endpoint, internal services) and trigger SSRF when the server sends notifications.

**Suggested fix:**
- Validate URL scheme (allow only `https://`, optionally `http://`)
- Resolve the hostname and reject private/loopback IP ranges (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, link-local)
- Consider adding a configurable allowlist/blocklist for webhook destinations
- Disable or limit redirects on the HTTP client

---

### Issue 2: Cross-Client Task IDOR (Missing Authorization)

**Affected code:**
- `src/a2a/server/request_handlers/default_request_handler.py`
- `on_get_task()` (line 117)
- `on_cancel_task()` (line 131)
- `on_message_send()` (line 289)
- `on_resubscribe_to_task()` (line 513)
- `on_set_task_push_notification_config()` (line 461)
- `on_get_task_push_notification_config()` (line 484)
- `on_delete_task_push_notification_config()` (line 581)

All task operations retrieve tasks using only the task ID:

```python
task: Task | None = await self.task_store.get(params.id, context)
```

Although `ServerCallContext` (defined in `src/a2a/server/context.py`) is passed through, it is never used for authorization checks. The `Task` model (`src/a2a/types.py`, lines 1855-1887) has no owner/user field, making ownership checks impossible even if a developer wanted to add them.

The `InMemoryTaskStore.get()` and `DatabaseTaskStore.get()` implementations both look up tasks by ID alone with no authorization logic.

**Impact:** In any multi-client deployment, Client A can read, cancel, or modify tasks belonging to Client B simply by guessing or enumerating task IDs.

**Suggested fix:**
- Add an `owner` (or `client_id`) field to the `Task` model
- Populate it from `ServerCallContext.user` when a task is created
- Check ownership in `TaskStore.get()` / `cancel()` / etc., or in `DefaultRequestHandler` before returning results
- At minimum, provide a hook or middleware interface so developers can plug in their own authorization logic without forking the SDK

---

### Why this matters for an SDK

While input validation is always partly the developer's responsibility, an SDK/reference implementation sets the pattern that developers follow. Currently:

- The **default path is insecure** — a developer has to actively work to add these protections
- There are no hooks, middleware, or configuration options to enable these protections
- The official samples and documentation don't warn about these gaps
- As a reference implementation, this code will be copied and adapted by many downstream projects

Adding basic protections (or at minimum, configurable validation hooks) in the SDK itself would significantly reduce the attack surface across the entire A2A ecosystem.

---

### Environment

- a2a-python version: latest main branch (commit fa14dbf)
- Python: 3.12+

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.