API tokens: a new token's secret is written in clear to the session table on its way to the page #441
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#441
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 on 2026-09-29 in a read-only review of postulo at
95348908d, following a new token from the form to the page that shows it, and confirmed with the test client.Checked again on 2026-10-01 against
mainat0a5cad587: still present.Postulo promises that a copy of the database is not a set of working credentials, because API tokens are kept only as hashes. But the raw token passes through the session on its way to the page that shows it once. No
SESSION_ENGINEis set, so sessions are rows indjango_session. The raw token therefore sits in the database, signed but not encrypted, until the token list is next rendered in that session. If that render never happens, it stays for as long as the session row does. The comment beside the key says the opposite.api/views.py:51,ApiTokenCreateView.post:request.session[NEW_TOKEN_SESSION_KEY] = raw, then a redirect to the list.api/views.py:33,ApiTokenListView.get_context_data: pops it on the next render.api/views.py:18-20: "A session key, not a secret: the secret it briefly points at never touches the database."config/settings/base.py: noSESSION_ENGINE, so Django's database backend applies. Nothing insrcrunsclearsessionsorSession.objects.clear_expired(), so expired rows are never removed.docs/THREAT-MODEL.md:19("API tokens are stored as hashes, so a copy is not a set of working credentials") andpostulo.wiki/The-capture-API.md:15-17("Only a hash is kept: a copy of the database is not a set of working credentials").session_data. Checked with the test client: after the POST, the stored session decodes to a dictionary holdingpostulo_new_capture_tokenwith the raw token in it. The backup incore/backup.py(SQLite.backup()orpg_dump) copiesdjango_sessionlike every other table.writeordocuments:read. The one credential the threat model says a copy does not give is given in this case.Cache-Control: no-storeand not redirecting, or keep only the new token's id in the session and show that the token was created without its secret, telling the person to revoke it and make another. If the redirect has to stay, encrypt the value withPOSTULO_FIELD_KEYand give it an expiry of a few minutes. Correct the comment atapi/views.py:18-19either way.Done when
tests/test_capture_api.pycreates a token throughapi:token_createand asserts that no row ofdjango_sessiondecodes to a value containing the raw token. It fails today.test_a_token_is_shown_once_and_then_never_againstill passes.