Security (API): bounded rename oracle, narrowed rendering answer, wrong-token counting, no-store, text bounds #1000

Merged
tiagoagueda merged 7 commits from sec2-c-api into main 2026-10-18 07:17:25 +00:00
Owner

PR C (API) of the SEC2 series.

Closes #949, Closes #950, Closes #951, Closes #952, Closes #953, Closes #954

  • #949 The rename clash check stays. A caller who does not own the company is told "you already have a company with that name" at most POSTULO_COMPANY_NAME_RATE (20/h) times, asked before the lookup, and once it is spent a free and a held name are refused alike with a 429 (the #758 pattern, core/throttle). Same helper (jobs/company_names.py) for the API and the web form. Threat model amended. Owners are never bounded.
  • #950 PUT /captures/{id}/rendering returns _as_listed, so a captures-only token gets the narrowed row in both the 201 and the 200.
  • #951 New auth.live_token: bar check, lookup and wrong-token count for openapi.json, the unknown-address answer and the 405 wrapper; a barred address gets the same 429 problem document and Retry-After. Not changed: the schema view still spends no allowance for a live token (not in the issue's fix).
  • #952 urls() wraps every address (schema, 401/404 of unknown addresses included) so an answer with no Cache-Control of its own gets private, max-age=0, no-store.
  • #953 max_length=BODY_MAX_CHARS (40,000) on the ten write-side free-text fields (API refuses with 422, maxLength in OpenAPI); record_event cuts a longer body from a page or import with the history marker.
  • #954 No code change: the behaviour stays and the trade-off is written in docs/THREAT-MODEL.md. The user still has to confirm this at review.

Tests: each issue has tests that fail without the change (tests/security/test_rate_limits.py, tests/security/test_captured_pages.py, tests/test_hardening_799.py, tests/security/test_downloads_not_cached.py, tests/test_api.py). Full unit suite: 18,898 passed; 25 failures on the first run were uncompiled catalogues in the fresh worktree and passed after messages.py compile. No browser tests run locally; no JS or templates touched. No new translatable strings (extract --check clean).

Wiki commit 28573ba (not pushed): Configuration.md and The-capture-API.md.

🤖 Generated with Claude Code

PR C (API) of the SEC2 series. Closes #949, Closes #950, Closes #951, Closes #952, Closes #953, Closes #954 - **#949** The rename clash check stays. A caller who does not own the company is told "you already have a company with that name" at most `POSTULO_COMPANY_NAME_RATE` (20/h) times, asked before the lookup, and once it is spent a free and a held name are refused alike with a 429 (the #758 pattern, `core/throttle`). Same helper (`jobs/company_names.py`) for the API and the web form. Threat model amended. Owners are never bounded. - **#950** `PUT /captures/{id}/rendering` returns `_as_listed`, so a captures-only token gets the narrowed row in both the 201 and the 200. - **#951** New `auth.live_token`: bar check, lookup and wrong-token count for `openapi.json`, the unknown-address answer and the 405 wrapper; a barred address gets the same 429 problem document and `Retry-After`. Not changed: the schema view still spends no allowance for a live token (not in the issue's fix). - **#952** `urls()` wraps every address (schema, 401/404 of unknown addresses included) so an answer with no `Cache-Control` of its own gets `private, max-age=0, no-store`. - **#953** `max_length=BODY_MAX_CHARS` (40,000) on the ten write-side free-text fields (API refuses with 422, `maxLength` in OpenAPI); `record_event` cuts a longer body from a page or import with the history marker. - **#954** No code change: the behaviour stays and the trade-off is written in `docs/THREAT-MODEL.md`. **The user still has to confirm this at review.** Tests: each issue has tests that fail without the change (`tests/security/test_rate_limits.py`, `tests/security/test_captured_pages.py`, `tests/test_hardening_799.py`, `tests/security/test_downloads_not_cached.py`, `tests/test_api.py`). Full unit suite: 18,898 passed; 25 failures on the first run were uncompiled catalogues in the fresh worktree and passed after `messages.py compile`. No browser tests run locally; no JS or templates touched. No new translatable strings (`extract --check` clean). Wiki commit 28573ba (not pushed): Configuration.md and The-capture-API.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A read/write grantee may rename a shared company, and the clash check looks at all of the
owner's companies, so a PATCH or the page form told the grantee which other employers the
owner tracks, two calls per guess. The check stays (the per-owner uniqueness needs it); a
caller who does not own the company is now told at most POSTULO_COMPANY_NAME_RATE (20/h)
times, asked before the lookup, and once spent a free and a held name are refused alike with
a 429 (the way #758 bounds telephone numbers). The threat model records the choice.

Closes #949

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#761 blanked the address, company, location and source of a capture for a token that
only holds `captures`, on GET /captures. The rendering call answered both its 201 and its
200 with the whole row, so such a token could read them from each pending capture whose id
it knew. It now returns the same narrowed row as the list.

Closes #950

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#799 counted wrong tokens and barred an address in the two HttpBearer classes. The schema view,
the unknown-address answer and the wrong-method answer read the Authorization header on their
own and called lookup() bare, so an address barred everywhere else could still guess there.
One helper, auth.live_token, runs the bar, the lookup and the count for all three; a barred
address is answered with the same 429 problem document and Retry-After.

Closes #951

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#788 gave the header to attachments only. The two iCalendar feeds and every JSON answer of the
API are plain bodies and a token call has no session user, so none of the existing rules reached
them. urls() already rewrites each address; it now wraps each (the schema, the 401 and the 404 of
an unknown address included) so that an answer with no Cache-Control of its own gets
private, max-age=0, no-store.

Closes #952

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#218 and #270 settled that foreign text is held to 40,000 characters in a listing history.
The timeline body, status and outcome notes, company, contact and interview notes, a listing
description and a letter body were bare strings, bounded only by the 2.5 MB request ceiling.
They now carry max_length (a 422 naming the field, a maxLength in the OpenAPI document), and
record_event cuts a longer body from a page or an import with the same marker.

Closes #953

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Record in the threat model that a capture refusal says why
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 12m58s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m27s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m10s
CI / Security audit (pull_request) Successful in 1m18s
CI / Browser tests (Chromium) (pull_request) Successful in 29m8s
82f4485401
A refused capture names the reason, so a caller can tell a name the resolver does not know from
one that lives on a private network, one hostname at a time, bounded by the capture allowance.
The project chose to tell the person why, so the behaviour stays and the trade-off is written
down beside the other accepted residues, with the operator lever. Awaiting the owner's
confirmation at review.

Closes #954

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tiagoagueda force-pushed sec2-c-api from 82f4485401
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 12m58s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m27s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m10s
CI / Security audit (pull_request) Successful in 1m18s
CI / Browser tests (Chromium) (pull_request) Successful in 29m8s
to 0dc9433dc2
All checks were successful
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 12m30s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m19s
CI / Database tests on PostgreSQL (pull_request) Successful in 3m22s
CI / Security audit (pull_request) Successful in 1m2s
CI / Browser tests (Chromium) (pull_request) Successful in 26m55s
2026-10-18 06:48:48 +00:00
Compare
Sign in to join this conversation.
No reviewers
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!1000
No description provided.