API tokens: a new token's secret is written in clear to the session table on its way to the page #441

Closed
opened 2026-10-01 10:46:43 +00:00 by tiagoagueda · 0 comments
Owner

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 main at 0a5cad587: 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_ENGINE is set, so sessions are rows in django_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.

  • Where:
    • 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: no SESSION_ENGINE, so Django's database backend applies. Nothing in src runs clearsessions or Session.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") and postulo.wiki/The-capture-API.md:15-17 ("Only a hash is kept: a copy of the database is not a set of working credentials").
  • What happens: a person creates a token, and the redirect after the POST is never rendered: the connection drops, or they close the tab. They do not sign out, so the session is not flushed. The raw token stays in that session's session_data. Checked with the test client: after the POST, the stored session decodes to a dictionary holding postulo_new_capture_token with the raw token in it. The backup in core/backup.py (SQLite .backup() or pg_dump) copies django_session like every other table.
  • Who can trigger it: nobody can cause it. The exposure is to whoever later obtains a backup or a copy of the database, the threat model's own actor, and only for a token whose creation page was never rendered.
  • Why it matters: a session key found in the same copy lapses when its session expires, two weeks by default. The token keeps working until it expires or is revoked, possibly for good, and may carry write or documents:read. The one credential the threat model says a copy does not give is given in this case.
  • Fix: do not route the secret through server-side storage. Either render the token list directly in the POST response with the secret in its context, sending Cache-Control: no-store and 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 with POSTULO_FIELD_KEY and give it an expiry of a few minutes. Correct the comment at api/views.py:18-19 either way.

Done when

  • A test in tests/test_capture_api.py creates a token through api:token_create and asserts that no row of django_session decodes to a value containing the raw token. It fails today.
  • test_a_token_is_shown_once_and_then_never_again still passes.
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 `main` at `0a5cad587`: 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_ENGINE` is set, so sessions are rows in `django_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. - **Where:** - `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`: no `SESSION_ENGINE`, so Django's database backend applies. Nothing in `src` runs `clearsessions` or `Session.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") and `postulo.wiki/The-capture-API.md:15-17` ("Only a hash is kept: a copy of the database is not a set of working credentials"). - **What happens:** a person creates a token, and the redirect after the POST is never rendered: the connection drops, or they close the tab. They do not sign out, so the session is not flushed. The raw token stays in that session's `session_data`. Checked with the test client: after the POST, the stored session decodes to a dictionary holding `postulo_new_capture_token` with the raw token in it. The backup in `core/backup.py` (SQLite `.backup()` or `pg_dump`) copies `django_session` like every other table. - **Who can trigger it:** nobody can cause it. The exposure is to whoever later obtains a backup or a copy of the database, the threat model's own actor, and only for a token whose creation page was never rendered. - **Why it matters:** a session key found in the same copy lapses when its session expires, two weeks by default. The token keeps working until it expires or is revoked, possibly for good, and may carry `write` or `documents:read`. The one credential the threat model says a copy does not give is given in this case. - **Fix:** do not route the secret through server-side storage. Either render the token list directly in the POST response with the secret in its context, sending `Cache-Control: no-store` and 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 with `POSTULO_FIELD_KEY` and give it an expiry of a few minutes. Correct the comment at `api/views.py:18-19` either way. ## Done when - A test in `tests/test_capture_api.py` creates a token through `api:token_create` and asserts that no row of `django_session` decodes to a value containing the raw token. It fails today. - `test_a_token_is_shown_once_and_then_never_again` still passes.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:43 +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#441
No description provided.