SEC2-037: The JSON log scrubs only invitation and recovery tokens, so a password-reset or email-confirmation key can reach postulo.log #979

Closed
opened 2026-10-17 10:39:03 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Low · Category: Data Exposure · Reference: CWE-532 (Insertion of Sensitive Information into Log File); OWASP A09:2021 (Security Logging and Monitoring Failures)

Location:

  • src/postulo/core/logs.py:155-160 (SECRET_ROUTES: two routes)
  • src/postulo/core/logs.py:189-215 (scrub and _plain, applied to every record's message and request extra)
  • src/postulo/core/logredact.py:19-24 (the access-log list: recover, invitation, confirm-email, password reset key, login code confirm)

Issue

#754 settled which path segments are one-time secrets, and logredact.redact strips them from gunicorn's access log. Postulo's own JSON log, fixed for #733, has its own shorter list:

SECRET_ROUTES = (
    ("accounts:invite_accept", "token"),
    ("accounts:recovery_open", "token"),
)

Django's django.request logger writes "Internal Server Error: %s" / "Not Found: %s" with request.path and attaches the request, and _plain writes scrub(value.path). A 500 while serving GET /accounts/password/reset/key/<uidb36>-<key>/ (the one request in allauth's flow where the key is in the path) or GET /accounts/confirm-email/<key>/ therefore writes the live key into postulo.log, which LogsView shows every administrator and /logs hands to a collector, while the same line in the access log is redacted. The reset key stays valid until the password changes or PASSWORD_RESET_TIMEOUT passes. The confirmation key is the weaker of the two (it only marks an address verified, though #731 and #891 show why that still matters); login/code/confirm/ carries no secret in its path, the code being posted in the form.

Impact

Narrow: it needs a server error on the one request that carries the key, and then the reader is an administrator or whoever holds POSTULO_LOGS_TOKEN. The consequence when it happens is the account-takeover link #754 describes, in the one log the project promises is safe to hand on.

Fix

Use one list: make logs.scrub() call logredact.redact() after its own route-derived pattern, so the two logs redact the same segments and any future addition lands in one place. Done when: tests/test_hardening_754.py also formats a django.request record whose request.path is a reset-key and a confirm-email path through JSONFormatter and asserts the key is gone from both the message and the request field.

#733 (closed) - fixed the query string and two path tokens in the JSON log. #754 (closed) - fixed the access log with the fuller list. The gap between the two lists is new.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Low · **Category:** Data Exposure · **Reference:** CWE-532 (Insertion of Sensitive Information into Log File); OWASP A09:2021 (Security Logging and Monitoring Failures) **Location:** - `src/postulo/core/logs.py:155-160` (`SECRET_ROUTES`: two routes) - `src/postulo/core/logs.py:189-215` (`scrub` and `_plain`, applied to every record's message and `request` extra) - `src/postulo/core/logredact.py:19-24` (the access-log list: recover, invitation, confirm-email, password reset key, login code confirm) ## Issue #754 settled which path segments are one-time secrets, and `logredact.redact` strips them from gunicorn's access log. Postulo's own JSON log, fixed for #733, has its own shorter list: ```python SECRET_ROUTES = ( ("accounts:invite_accept", "token"), ("accounts:recovery_open", "token"), ) ``` Django's `django.request` logger writes "Internal Server Error: %s" / "Not Found: %s" with `request.path` and attaches the request, and `_plain` writes `scrub(value.path)`. A 500 while serving `GET /accounts/password/reset/key/<uidb36>-<key>/` (the one request in allauth's flow where the key is in the path) or `GET /accounts/confirm-email/<key>/` therefore writes the live key into `postulo.log`, which `LogsView` shows every administrator and `/logs` hands to a collector, while the same line in the access log is redacted. The reset key stays valid until the password changes or `PASSWORD_RESET_TIMEOUT` passes. The confirmation key is the weaker of the two (it only marks an address verified, though #731 and #891 show why that still matters); `login/code/confirm/` carries no secret in its path, the code being posted in the form. ## Impact Narrow: it needs a server error on the one request that carries the key, and then the reader is an administrator or whoever holds `POSTULO_LOGS_TOKEN`. The consequence when it happens is the account-takeover link #754 describes, in the one log the project promises is safe to hand on. ## Fix Use one list: make `logs.scrub()` call `logredact.redact()` after its own route-derived pattern, so the two logs redact the same segments and any future addition lands in one place. Done when: `tests/test_hardening_754.py` also formats a `django.request` record whose `request.path` is a reset-key and a confirm-email path through `JSONFormatter` and asserts the key is gone from both the message and the `request` field. ## Related #733 (closed) - fixed the query string and two path tokens in the JSON log. #754 (closed) - fixed the access log with the fuller list. The gap between the two lists is new.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:39:07 +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#979
No description provided.