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
- 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
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