PST-039: safe_next() accepts a bare word, which redirect() then tries to reverse as a view name and answers 500 #768
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#768
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 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.
src/postulo/core/redirects.py(17-24; redirects.py:18-25)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.
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.
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(). …Fixed in
9b2b85084on main.