Azure / Azure/azure-sdk-for-python

Key Vault: ChallengeAuthPolicy request-replay fix (#47742) not ported to azure-keyvault-secrets / -certificates, and absent from all stable releases

Open
#48,508 2 comments 1 reaction 0 assignees View on GitHub
Client customer-reported KeyVault needs-team-attention question
Dominant language
Python
Stars
5.6k
Forks
3.4k
Avg merge
1d 21h
Merged PRs (30d)
193

Description

### Summary

The `ChallengeAuthPolicy` request-replay bug fixed by #47742 ("Fix Challenge Auth replay bug and update tests", merged 2026-07-08) was applied to `azure-keyvault-keys` and `azure-keyvault-administration`, but **not** to `azure-keyvault-secrets` or `azure-keyvault-certificates`, which vendor their own copies of `_shared/challenge_auth_policy.py`.

Separately, **the fix is not present in any stable release yet** — every current stable Key Vault package predates the merge, including `azure-keyvault-keys` itself.

To be clear about severity: I am **not** reporting this as a security vulnerability. The replayed request goes back to the vault that originally received it, and the bearer token is the shared `https://vault.azure.net/.default` audience that vault was already sent. `verify_challenge_resource` additionally blocks the cross-resource case. This is a correctness bug — an intended request is silently replaced by a duplicate of an earlier one.

### Current state at HEAD

Occurrence count of `_request_copy` in each package's `_shared/challenge_auth_policy.py`:

| Package | `_request_copy` at HEAD | Status |
|---|---|---|
| `azure-keyvault-keys` | 1 | fixed by #47742 |
| `azure-keyvault-administration` | 0 | fixed |
| `azure-keyvault-securitydomain` | 0 | not affected |
| **`azure-keyvault-secrets`** | **4** | **unported** |
| **`azure-keyvault-certificates`** | **4** | **unported** |

In the unfixed copies the stash is stored on the **policy instance**, which is created once per client rather than once per request:

- `self._request_copy: Optional[HttpRequest] = None` — client-level state
- `self._request_copy = request.http_request` — stores the in-flight request
- `if self._request_copy: request.http_request = self._request_copy` — transplants it onto a *later* request

### Stable releases

| Package | Latest stable | Uploaded | Contains fix? |
|---|---|---|---|
| `azure-keyvault-secrets` | 4.11.0 | 2026-04-17 | no |
| `azure-keyvault-certificates` | 4.11.1 | 2026-05-05 | no |
| `azure-keyvault-keys` | 4.11.1 | 2026-05-19 | no — predates the 2026-07-08 merge |
| `azure-keyvault-administration` | 4.7.0 | 2026-05-19 | no — predates the merge |

The fix currently ships only in the pre-release `azure-keyvault-keys` 4.12.0b3.

### Reproduction

This is the regression test added by #47742 (`test_request_body_not_reused_across_requests`), re-pointed at the unported packages. No Azure account, no network — the transport is a `Mock`.

```
pip install azure-keyvault-secrets==4.11.0 azure-keyvault-certificates==4.11.1 azure-keyvault-keys==4.12.0b3
```

Send a bodied `POST` to vault-A (elicits a challenge), then a bodiless `GET` to vault-B through the **same client**, and inspect the 4th outbound request:

```
=== azure-keyvault-keys 4.12.0b3 (POSITIVE CONTROL - contains #47742) ===
4th request method : GET (expected GET)
4th request url : https://vault-b.vault.azure.net/secrets/unrelated
4th request body : None (expected None)
RESULT: ok (per-request stash)

=== azure-keyvault-secrets 4.11.0 (UNPORTED) ===
4th request method : POST (expected GET)
4th request url : https://vault-a.vault.azure.net/secrets/db-password
4th request body : b'a duck' (expected None)
RESULT: prior request replayed

=== azure-keyvault-certificates 4.11.1 (UNPORTED) ===
... identical: POST, vault-a URL, body b'a duck'
```

The positive control discriminates: the fixed package passes under the identical harness, so the result is a property of the code under test rather than of the test.

Full reproduction script

```python
import time
from unittest.mock import Mock

from azure.core.pipeline import Pipeline
from azure.core.rest import HttpRequest
from azure.core.credentials import AccessToken

CHALLENGE = Mock(
status_code=401,
headers={
"WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", '
"resource=https://vault.azure.net"
},
)

def exercise(policy_cls, label):
first_content = b"a duck"
first_url = "https://vault-a.vault.azure.net/secrets/db-password"
second_url = "https://vault-b.vault.azure.net/secrets/unrelated"
seen = {}

class C:
n = 0

def send(request):
C.n += 1
if C.n == 1:
return CHALLENGE
if C.n == 2:
return Mock(status_code=200)
if C.n == 3:
return CHALLENGE
if C.n == 4:
seen["method"], seen["url"], seen["body"] = request.method, request.url, request.body
return Mock(status_code=200)
raise ValueError("unexpected request")

cred = Mock(spec_set=["get_token"],
get_token=Mock(return_value=AccessToken("token", time.time() + 3600)))
pipeline = Pipeline(policies=[policy_cls(credential=cred)], transport=Mock(send=send))

req = HttpRequest("POST", first_url)
req.set_bytes_body(first_content)
pipeline.run(req)
pipeline.run(HttpRequest("GET", second_url))

replayed = seen.get("body") == first_content or seen.get("url") == first_url
print(f"\n=== {label} ===")
print(f" 4th request method : {seen.get('method')} (expected GET)")
print(f" 4th request url : {seen.get('url')}")
print(f" expected : {second_url}")
print(f" 4th request body : {seen.get('body')!r} (expected None)")
print(" RESULT: " + ("prior request replayed" if replayed else "ok (per-request stash)"))
return replayed

if __name__ == "__main__":
from azure.keyvault.keys._shared.challenge_auth_policy import ChallengeAuthPolicy as KeysPolicy
from azure.keyvault.secrets._shared.challenge_auth_policy import ChallengeAuthPolicy as SecretsPolicy
from azure.keyvault.certificates._shared.challenge_auth_policy import ChallengeAuthPolicy as CertsPolicy

exercise(KeysPolicy, "azure-keyvault-keys (POSITIVE CONTROL - fixed by #47742)")
exercise(SecretsPolicy, "azure-keyvault-secrets (UNPORTED)")
exercise(CertsPolicy, "azure-keyvault-certificates (UNPORTED)")
```

### Impact

A call intended for one vault is emitted as a duplicate of an earlier call — the intended operation does not happen, and the earlier one is repeated. For `secrets` and `certificates` the replayed body is secret material being written a second time, so an unintended duplicate write or rotation is possible.

### Suggested fix

Port #47742 to `azure-keyvault-secrets` and `azure-keyvault-certificates` (make the stash per-request rather than per-policy), and ship it in a stable release — the fix currently exists only in the pre-release `azure-keyvault-keys` 4.12.0b3.

Root cause of the divergence is the duplicated `_shared` directories: each package vendors its own copy of `challenge_auth_policy.py`, so a fix in one does not propagate. The .NET and Java SDKs are unaffected — they use a single shared implementation.

Contributor guide

Open the contributing guide

Research direction

Start with azure/keyvault/secrets/_shared/challenge_auth_policy.py and azure/keyvault/certificates/_shared/challenge_auth_policy.py, comparing them with the fixed keys copy and the test_request_body_not_reused_across_requests regression test from #47742. Run the no-network Mock reproduction against both unported policies; done means later requests retain their method, URL, and body, and the fix is included in a stable release.

Written by the indexing model from the issue text.

Assessment

Tech stack
azure, python
Domain
api, authentication, cloud
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.