PST-039: safe_next() accepts a bare word, which redirect() then tries to reverse as a view name and answers 500 #768

Closed
opened 2026-10-10 10:56:12 +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-039. 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: redirect handling / robustness
  • Location: src/postulo/core/redirects.py (17-24; redirects.py:18-25)
  • Found by: completeness review: coverage, completeness review: tests against claims
  • Related: PST-038 (#767), PST-040 (#769)

Who can do it, and what they need. A signed-in member forging their own POST (CSRF tokens stop a third party from doing it for them). The value cannot take anybody off-site.

How it works.

POST /tables/applications/settings/ (or /applications/bulk/, /listings/bulk/, /jobs/companies/bulk/, /applications/reminders/<pk>/done/, /applications/suggestions/<pk>/decline/, /applications/interviews/<pk>/outcome/, /tables/<name>/views/, /career/links/check/) with next=abc or next=1. url_has_allowed_host_and_scheme treats a scheme-less, host-less string as a relative URL and returns it; redirect('abc') then calls reverse('abc'), which raises NoReverseMatch because the string contains no '/' or '.'. Observed: next=abc -> NoReverseMatch (500) on table_settings, reminder_complete and applications:bulk; next=1 likewise; next=foo.bar -> Location: foo.bar (a path-relative reference that lands under the POST path); next=applications:board is rejected (colon is read as a scheme); a colon-free URL name such as account_login would be reversed and followed.

Impact. Not an open redirect, and the promise that every next stays on this host holds. The weakness is that safe_next returns things that are not paths, so redirect()'s name-resolution turns a malformed value into a 500 and lets a request choose a named route. Low severity robustness gap; tests/security/test_redirects.py only tries off-site absolute and protocol-relative values, never a bare word.

Evidence.

core/redirects.py: `if target and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()): return target`. Scratch output: ('NEXT','core:table_settings','abc','EXC','NoReverseMatch'), ('NEXT','applications:reminder_complete','abc','EXC','NoReverseMatch'), ('NEXT','applications:bulk','1','EXC','NoReverseMatch'), ('NEXT','core:table_settings','foo.bar',302,'foo.bar'). POST fuzz of every route with next=<26 digits> also hit core:table_views, listings:bulk, jobs:company_bulk, applications:suggestion_action, applications:interview_outcome and resume:link_check_all.

Verification (verdict: confirmed; severity after verification: Low). Keep low. Precondition: an authenticated member forging their own POST (nothing a third party can cause). Effect: a self-inflicted 500 plus an ERROR traceback in the log. The list of affected endpoints is incomplete: applications:status (applications/views.py:721), :1072, the logo endpoints (jobs/views.py:532 and :546) and documents/views.py:835-866 use the same redirect(safe_next()) pattern. The listed lines 1217 and 1479-1497 and the nine-endpoint count are fine. Suggested fix: make safe_next return the fallback unless the value starts with '/' (and not '//'), or use HttpResponseRedirect, which does no name resolution. Add a bare-word case to tests/security/test_redirects.py.

Suggested remediation. Make safe_next return the fallback unless the target starts with a single '/' (and not '//' or '/\'), or run it through urlsplit and rebuild path+query; at minimum prefix a relative target with '/'. Extend test_redirects.py to try bare words, digits and dotted names.

Also covered by this issue.

  • safe_next() returns bare words that redirect() then tries to reverse as view names: next=foo, next=?x=1 or next=#frag gives a 500 on about 25 endpoints (src/postulo/core/redirects.py (redirects.py:18-25); Informational). Unhandled exception for any POST whose next has this shape; the promise that every next goes through safe_next() holds, but the helper does not return something safe to hand to redirect(). …
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-039**. 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:** redirect handling / robustness - **Location:** `src/postulo/core/redirects.py` (17-24; redirects.py:18-25) - **Found by:** completeness review: coverage, completeness review: tests against claims - **Related:** PST-038 (#767), PST-040 (#769) **Who can do it, and what they need.** A signed-in member forging their own POST (CSRF tokens stop a third party from doing it for them). The value cannot take anybody off-site. **How it works.** POST /tables/applications/settings/ (or /applications/bulk/, /listings/bulk/, /jobs/companies/bulk/, /applications/reminders/&lt;pk>/done/, /applications/suggestions/&lt;pk>/decline/, /applications/interviews/&lt;pk>/outcome/, /tables/&lt;name>/views/, /career/links/check/) with next=abc or next=1. url_has_allowed_host_and_scheme treats a scheme-less, host-less string as a relative URL and returns it; redirect('abc') then calls reverse('abc'), which raises NoReverseMatch because the string contains no '/' or '.'. Observed: next=abc -> NoReverseMatch (500) on table_settings, reminder_complete and applications:bulk; next=1 likewise; next=foo.bar -> `Location: foo.bar` (a path-relative reference that lands under the POST path); next=applications:board is rejected (colon is read as a scheme); a colon-free URL name such as account_login would be reversed and followed. **Impact.** Not an open redirect, and the promise that every next stays on this host holds. The weakness is that safe_next returns things that are not paths, so redirect()'s name-resolution turns a malformed value into a 500 and lets a request choose a named route. Low severity robustness gap; tests/security/test_redirects.py only tries off-site absolute and protocol-relative values, never a bare word. **Evidence.** ```text core/redirects.py: `if target and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()): return target`. Scratch output: ('NEXT','core:table_settings','abc','EXC','NoReverseMatch'), ('NEXT','applications:reminder_complete','abc','EXC','NoReverseMatch'), ('NEXT','applications:bulk','1','EXC','NoReverseMatch'), ('NEXT','core:table_settings','foo.bar',302,'foo.bar'). POST fuzz of every route with next=<26 digits> also hit core:table_views, listings:bulk, jobs:company_bulk, applications:suggestion_action, applications:interview_outcome and resume:link_check_all. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep low. Precondition: an authenticated member forging their own POST (nothing a third party can cause). Effect: a self-inflicted 500 plus an ERROR traceback in the log. The list of affected endpoints is incomplete: applications:status (applications/views.py:721), :1072, the logo endpoints (jobs/views.py:532 and :546) and documents/views.py:835-866 use the same redirect(safe_next()) pattern. The listed lines 1217 and 1479-1497 and the nine-endpoint count are fine. Suggested fix: make safe_next return the fallback unless the value starts with '/' (and not '//'), or use HttpResponseRedirect, which does no name resolution. Add a bare-word case to tests/security/test_redirects.py. **Suggested remediation.** Make safe_next return the fallback unless the target starts with a single '/' (and not '//' or '/\\'), or run it through urlsplit and rebuild path+query; at minimum prefix a relative target with '/'. Extend test_redirects.py to try bare words, digits and dotted names. **Also covered by this issue.** - **safe_next() returns bare words that redirect() then tries to reverse as view names: next=foo, next=?x=1 or next=#frag gives a 500 on about 25 endpoints** (`src/postulo/core/redirects.py` (redirects.py:18-25); Informational). Unhandled exception for any POST whose next has this shape; the promise that every next goes through safe_next() holds, but the helper does not return something safe to hand to redirect(). …
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:44 +00:00
Author
Owner

Fixed in 9b2b85084 on main.

Fixed in 9b2b85084 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#768
No description provided.