A weak POSTULO_SECRET_KEY starts the instance, and it also encrypts every stored credential #111

Closed
opened 2026-09-07 18:54:28 +00:00 by tiagoagueda · 0 comments
Owner

Found by

A security audit, from the configuration rather than from a probe. Nothing here is
exploitable by a stranger; everything here is one operator's typo away from mattering a
great deal.

What happens now

prod.py refuses to start with no key:

if not SECRET_KEY:
    raise ImproperlyConfigured("POSTULO_SECRET_KEY must be set. Generate one with: ...")

It says nothing about a short one. POSTULO_SECRET_KEY=changeme starts perfectly well.

Django notices -- security.W009 is exactly this check -- but the container entrypoint runs:

python manage.py check --deploy --fail-level ERROR

W009 is a warning, so it prints one line into a start-up log nobody reads and the
instance serves traffic. SILENCED_SYSTEM_CHECKS silences only W021, so this is not a
deliberate exemption; it is the fail level sitting one step above where the check is.

Why it matters more here than in most Django applications

That key does not only sign sessions and password-reset links. plugins/secrets.py:

material = getattr(settings, "POSTULO_FIELD_KEY", "") or settings.SECRET_KEY
digest = hashlib.sha256(b"postulo-connection-secrets:" + material.encode("utf-8")).digest()

So by default the Fernet key protecting every stored connection credential -- somebody's
Telegram bot token, their Paperless password, their Nextcloud login -- is derived from the
same value. A guessable SECRET_KEY is a guessable encryption key for other people's
passwords to other people's services, and the derivation is a single unsalted SHA-256, so
guessing is cheap.

POSTULO_FIELD_KEY exists and avoids that, and it is optional and easy to miss.

What would fix it

Refuse at start-up rather than warn, beside the check that is already there: a minimum
length and a refusal of the obvious placeholders. An operator who has genuinely chosen a
short key can be given a variable to say so; one who pasted changeme should be stopped
before the first request rather than told in a log line.

--fail-level WARNING in the entrypoint would also do it and would catch the next such
check too -- but it turns every future Django security warning into a start-up failure,
which is a larger decision than this issue should make on its own.

What the audit checked and found sound

Recorded so nobody repeats it. pip-audit --strict over the full dependency set: nothing.
check --deploy: otherwise clean. zizmor at the project's own policy: nothing. No CSRF
exemptions anywhere, no raw SQL beyond a SELECT 1 health probe, no fields = "__all__",
and no form that writes owner or is_staff.

Classification

Security, bug. Tier 1: a foreseeable operator mistake with a large blast radius and an
unusually cheap fix.

## Found by A security audit, from the configuration rather than from a probe. Nothing here is exploitable by a stranger; everything here is one operator's typo away from mattering a great deal. ## What happens now `prod.py` refuses to start with **no** key: ```python if not SECRET_KEY: raise ImproperlyConfigured("POSTULO_SECRET_KEY must be set. Generate one with: ...") ``` It says nothing about a **short** one. `POSTULO_SECRET_KEY=changeme` starts perfectly well. Django notices -- `security.W009` is exactly this check -- but the container entrypoint runs: ```sh python manage.py check --deploy --fail-level ERROR ``` W009 is a **warning**, so it prints one line into a start-up log nobody reads and the instance serves traffic. `SILENCED_SYSTEM_CHECKS` silences only `W021`, so this is not a deliberate exemption; it is the fail level sitting one step above where the check is. ## Why it matters more here than in most Django applications That key does not only sign sessions and password-reset links. `plugins/secrets.py`: ```python material = getattr(settings, "POSTULO_FIELD_KEY", "") or settings.SECRET_KEY digest = hashlib.sha256(b"postulo-connection-secrets:" + material.encode("utf-8")).digest() ``` So by default the Fernet key protecting **every stored connection credential** -- somebody's Telegram bot token, their Paperless password, their Nextcloud login -- is derived from the same value. A guessable `SECRET_KEY` is a guessable encryption key for other people's passwords to other people's services, and the derivation is a single unsalted SHA-256, so guessing is cheap. `POSTULO_FIELD_KEY` exists and avoids that, and it is optional and easy to miss. ## What would fix it Refuse at start-up rather than warn, beside the check that is already there: a minimum length and a refusal of the obvious placeholders. An operator who has genuinely chosen a short key can be given a variable to say so; one who pasted `changeme` should be stopped before the first request rather than told in a log line. `--fail-level WARNING` in the entrypoint would also do it and would catch the next such check too -- but it turns every future Django security warning into a start-up failure, which is a larger decision than this issue should make on its own. ## What the audit checked and found sound Recorded so nobody repeats it. `pip-audit --strict` over the full dependency set: nothing. `check --deploy`: otherwise clean. `zizmor` at the project's own policy: nothing. No CSRF exemptions anywhere, no raw SQL beyond a `SELECT 1` health probe, no `fields = "__all__"`, and no form that writes `owner` or `is_staff`. ## Classification Security, bug. Tier 1: a foreseeable operator mistake with a large blast radius and an unusually cheap fix.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 18:54:28 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#111
No description provided.