PST-025: The shipped gunicorn access log writes one-time bearer tokens that travel in URL paths #754

Closed
opened 2026-10-10 10:56:02 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-025. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: secrets in logs
  • Location: docker/Dockerfile (370-376)
  • Found by: completeness review: chains
  • Related: PST-004 (#733)

Who can do it, and what they need. Anyone who can read the container's stdout or the log pipeline it feeds (docker logs, a Loki/ELK stack, a hosting dashboard) but who has no database access, for example a wider ops or support team. A reverse proxy's own access log has the same content.

How it works.

  1. An administrator issues a recovery link for a member. The member opens GET /accounts/recover/<TOKEN>/ and is redirected (302). gunicorn logs "GET /accounts/recover/<TOKEN>/ HTTP/1.1" 302.
  2. The link stays live until the password is set, for up to one hour. The log reader opens the same URL, which stores the link in their own session, and POSTs a password first.
  3. The same applies to /accounts/invitation/<token>/ (valid for 14 days; the visitor can then sign up on a closed instance), /accounts/password/reset/key/<uid>-<key>/ and /accounts/confirm-email/<key>/, and to ?code=&state= on the OAuth callback.

Impact. One-time account-takeover and registration credentials are persisted outside the database in a place the design says they never reach. An MFA-protected account is still protected, but one without a second factor is taken over silently.

Evidence.

Dockerfile:372-373 `"--access-logfile", "-", "--access-logformat", "%(h)s %(l)s %(u)s %(t)s \"%(r)s\" %(s)s %(b)s \"%(f)s\" ..."`. `%(r)s` is the full request line including path and query.

Verification (verdict: confirmed; severity after verification: Low). Keep severity low. Fix the claim as follows. Drop the OAuth code/state item, which is not replayable because the code is already redeemed before the line is written and the state is session-bound and deleted on use. Drop the email-confirmation key as an account-takeover item, since it only lets a reader mark an address verified. The real, replay-proven set is the recovery link (up to 1 hour, and the window starts at the first GET, which a link-preview bot or the member makes), the invitation (14 days until accepted, opens signup on a closed instance) and the allauth password-reset key (Django PASSWORD_RESET_TIMEOUT, usable until the password changes). Name the attacker as an operator of a log pipeline or hosting dashboard who has no root, docker-group or database access. Anyone with docker logs is root-equivalent and out of scope per the threat model. Note that the operator's reverse proxy writes the same URLs, so the image's format is only one source. Impact is bounded: a second factor still blocks sign-in after the password is set, the legitimate holder loses the race visibly, and the attacker must act before the real recipient finishes. …

Suggested remediation. Log a redacted request path: use a custom gunicorn logger or access-log filter that masks the path segment after /accounts/recover/, /accounts/invitation/ and /accounts/password/reset/key/ and drops query strings on the consent callback. Alternatively move tokens out of the URL path as the recovery form already does after the first request.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-025**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** secrets in logs - **Location:** `docker/Dockerfile` (370-376) - **Found by:** completeness review: chains - **Related:** PST-004 (#733) **Who can do it, and what they need.** Anyone who can read the container's stdout or the log pipeline it feeds (docker logs, a Loki/ELK stack, a hosting dashboard) but who has no database access, for example a wider ops or support team. A reverse proxy's own access log has the same content. **How it works.** 1. An administrator issues a recovery link for a member. The member opens GET /accounts/recover/&lt;TOKEN>/ and is redirected (302). gunicorn logs `"GET /accounts/recover/<TOKEN>/ HTTP/1.1" 302`. 2. The link stays live until the password is set, for up to one hour. The log reader opens the same URL, which stores the link in their own session, and POSTs a password first. 3. The same applies to /accounts/invitation/&lt;token>/ (valid for 14 days; the visitor can then sign up on a closed instance), /accounts/password/reset/key/&lt;uid>-&lt;key>/ and /accounts/confirm-email/&lt;key>/, and to ?code=&state= on the OAuth callback. **Impact.** One-time account-takeover and registration credentials are persisted outside the database in a place the design says they never reach. An MFA-protected account is still protected, but one without a second factor is taken over silently. **Evidence.** ```text Dockerfile:372-373 `"--access-logfile", "-", "--access-logformat", "%(h)s %(l)s %(u)s %(t)s \"%(r)s\" %(s)s %(b)s \"%(f)s\" ..."`. `%(r)s` is the full request line including path and query. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep severity low. Fix the claim as follows. Drop the OAuth `code`/`state` item, which is not replayable because the code is already redeemed before the line is written and the state is session-bound and deleted on use. Drop the email-confirmation key as an account-takeover item, since it only lets a reader mark an address verified. The real, replay-proven set is the recovery link (up to 1 hour, and the window starts at the first GET, which a link-preview bot or the member makes), the invitation (14 days until accepted, opens signup on a closed instance) and the allauth password-reset key (Django PASSWORD_RESET_TIMEOUT, usable until the password changes). Name the attacker as an operator of a log pipeline or hosting dashboard who has no root, docker-group or database access. Anyone with `docker logs` is root-equivalent and out of scope per the threat model. Note that the operator's reverse proxy writes the same URLs, so the image's format is only one source. Impact is bounded: a second factor still blocks sign-in after the password is set, the legitimate holder loses the race visibly, and the attacker must act before the real recipient finishes. … **Suggested remediation.** Log a redacted request path: use a custom gunicorn logger or access-log filter that masks the path segment after /accounts/recover/, /accounts/invitation/ and /accounts/password/reset/key/ and drops query strings on the consent callback. Alternatively move tokens out of the URL path as the recovery form already does after the first request.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:34 +00:00
Author
Owner

Fixed in 79e305281 on main.

Fixed in 79e305281 on main.
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#754
No description provided.