PST-037: Out-of-range numbers and datetimes in the API reach the database layer and answer 500 #766

Closed
opened 2026-10-10 10:56:11 +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-037. 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: input-validation / availability
  • Location: src/postulo/api/paging.py (63)
  • Found by: completeness review: tests against claims

Who can do it, and what they need. Holder of an API token (read scope for the list calls, write scope for the POST/PATCH calls). In practice the person's own script or agent, or whoever steals such a token. No privilege is crossed; the only victim is the token's owner. Needs no other account.

How it works.

Reproduced with the Django test client against the real routes. GET /api/v1/{applications,captures,companies,cvs,documents,interviews,letters,listings,listings/choices,offers,reminders}?offset=9223372036854775808 -> IntegrityError('datatype mismatch') (500). GET /api/v1/{applications,listings}?company=<263> and /api/v1/{interviews,offers}?application=<263> -> OverflowError('Python int too large to convert to SQLite INTEGER'). updated_since=0001-01-01T00:00:00+14:00 on every list, and since= on /interviews -> OverflowError('date value out of range'). POST /api/v1/listings or /applications with salary_min/salary_max 1e30 or 1e400 -> decimal.InvalidOperation. POST /api/v1/interviews or PATCH /interviews/<pk> with contact_ids [2**63+] -> OverflowError; starts_at/ends_at 9999-12-31T23:59:59Z -> OverflowError; POST /applications/<pk>/events, POST /listings/<pk>/events, POST /reminders, PATCH /reminders/<pk> with 0001-01-01T00:00:00+14:00 -> OverflowError. Persistent variant: POST /api/v1/reminders {"due_at": "9999-12-31T23:59:59Z"} is accepted (201) and stored; with the default TIME_ZONE (Europe/Paris) the web Reminders page (applications:reminder_list) and the calendar agenda view raise OverflowError, and global search (web and /api/v1/search) raises it whenever that reminder is among the hits. The reminder's delete page still renders, so the owner can recover.

Impact. Authenticated, self-inflicted unhandled exceptions (500 plus a logged traceback) on a surface whose changelog (#434) says list calls are bounded, and a way to leave stored data that makes several of the owner's pages fail. Siblings show partial hardening: after_id is handled (200 for 2**63), the capture source bounds amounts (plugins/builtin/init.py:134-141 0 < amount < patterns.LARGEST), table number filters were fixed in #499, but the API schemas bound none of these. On PostgreSQL the same values raise DataError (integer/numeric out of range). Not a confidentiality or integrity issue.

Evidence.

api/paging.py:63 `offset: int = Field(0, ge=0)`; api/routers/listings.py:76 `company: int | None = Query(None)` then :92 `listings.filter(company_id=company)`; api/schemas.py:493-494 `salary_min: Decimal | None = None` / `salary_max: Decimal | None = None`; applications/services.py:258 `return JobPosting.objects.create(owner=owner, company=company, **posting_data)` (no full_clean); schemas.py:615-621 `class InterviewIn(Schema): ... starts_at: AwareDatetime ... contact_ids: list[int] = Field(default_factory=list)`; schemas.py:734-737 `class ReminderIn(Schema): ... due_at: AwareDatetime`. A schema-driven fuzz of /api/v1/openapi.json (402 write requests, 160 query-parameter probes) produced exa
…

Verification (verdict: confirmed; severity after verification: Low). Keep it low. Two additions to the candidate. (1) The persistent variant is not limited to reminders. Any timeline or listing-history occurred_at of year 9999 is accepted with a write token, or with the narrowest listings:bind token. It makes the application detail page (applications:detail) or the listing page (jobs:posting_detail) return 500 for the owner, and there is no web route to delete a single event. This contradicts the threat-model sentence that a mail-client token's worst case is 'add entries the person will read'. Root cause: AwareDatetime fields and Decimal fields have no range bound. Suggested bound: reject datetimes outside a sane window (for example 1900 to 2200) and clamp offset, id filters and contact_ids to the signed 64-bit range, or catch OverflowError, IntegrityError and InvalidOperation and return 422. (2) Minor correction to the finding's salary note: 1e400 as a bare JSON number gives a 422. The string "1e400" gives InvalidOperation. No cross-account or confidentiality impact, and the attacker is only the token's own holder.

Suggested remediation. Add bounds in the schemas: offset le=2**31-1, id filters and body ids Field(ge=1, le=2**31-1), salary Decimals with max_digits=12/decimal_places=2 and the same 0 < x < LARGEST rule the capture source uses, datetimes limited to a sane window (for example years 1970-2100) for updated_since, since, due_at, starts_at, ends_at and occurred_at, and cap list lengths (contact_ids, tags). Add a regression test that feeds each parameter its extreme value and asserts 422 rather than 500.

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-037**. 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:** input-validation / availability - **Location:** `src/postulo/api/paging.py` (63) - **Found by:** completeness review: tests against claims **Who can do it, and what they need.** Holder of an API token (read scope for the list calls, write scope for the POST/PATCH calls). In practice the person's own script or agent, or whoever steals such a token. No privilege is crossed; the only victim is the token's owner. Needs no other account. **How it works.** Reproduced with the Django test client against the real routes. GET /api/v1/{applications,captures,companies,cvs,documents,interviews,letters,listings,listings/choices,offers,reminders}?offset=9223372036854775808 -> IntegrityError('datatype mismatch') (500). GET /api/v1/{applications,listings}?company=&lt;2**63> and /api/v1/{interviews,offers}?application=&lt;2**63> -> OverflowError('Python int too large to convert to SQLite INTEGER'). `updated_since=0001-01-01T00:00:00+14:00` on every list, and `since=` on /interviews -> OverflowError('date value out of range'). POST /api/v1/listings or /applications with salary_min/salary_max `1e30` or `1e400` -> decimal.InvalidOperation. POST /api/v1/interviews or PATCH /interviews/&lt;pk> with contact_ids [2**63+] -> OverflowError; starts_at/ends_at `9999-12-31T23:59:59Z` -> OverflowError; POST /applications/&lt;pk>/events, POST /listings/&lt;pk>/events, POST /reminders, PATCH /reminders/&lt;pk> with `0001-01-01T00:00:00+14:00` -> OverflowError. Persistent variant: POST /api/v1/reminders {"due_at": "9999-12-31T23:59:59Z"} is accepted (201) and stored; with the default TIME_ZONE (Europe/Paris) the web Reminders page (applications:reminder_list) and the calendar agenda view raise OverflowError, and global search (web and /api/v1/search) raises it whenever that reminder is among the hits. The reminder's delete page still renders, so the owner can recover. **Impact.** Authenticated, self-inflicted unhandled exceptions (500 plus a logged traceback) on a surface whose changelog (#434) says list calls are bounded, and a way to leave stored data that makes several of the owner's pages fail. Siblings show partial hardening: after_id is handled (200 for 2**63), the capture source bounds amounts (plugins/builtin/__init__.py:134-141 `0 < amount < patterns.LARGEST`), table number filters were fixed in #499, but the API schemas bound none of these. On PostgreSQL the same values raise DataError (integer/numeric out of range). Not a confidentiality or integrity issue. **Evidence.** ```text api/paging.py:63 `offset: int = Field(0, ge=0)`; api/routers/listings.py:76 `company: int | None = Query(None)` then :92 `listings.filter(company_id=company)`; api/schemas.py:493-494 `salary_min: Decimal | None = None` / `salary_max: Decimal | None = None`; applications/services.py:258 `return JobPosting.objects.create(owner=owner, company=company, **posting_data)` (no full_clean); schemas.py:615-621 `class InterviewIn(Schema): ... starts_at: AwareDatetime ... contact_ids: list[int] = Field(default_factory=list)`; schemas.py:734-737 `class ReminderIn(Schema): ... due_at: AwareDatetime`. A schema-driven fuzz of /api/v1/openapi.json (402 write requests, 160 query-parameter probes) produced exa … ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep it low. Two additions to the candidate. (1) The persistent variant is not limited to reminders. Any timeline or listing-history `occurred_at` of year 9999 is accepted with a `write` token, or with the narrowest `listings:bind` token. It makes the application detail page (`applications:detail`) or the listing page (`jobs:posting_detail`) return 500 for the owner, and there is no web route to delete a single event. This contradicts the threat-model sentence that a mail-client token's worst case is 'add entries the person will read'. Root cause: AwareDatetime fields and Decimal fields have no range bound. Suggested bound: reject datetimes outside a sane window (for example 1900 to 2200) and clamp offset, id filters and contact_ids to the signed 64-bit range, or catch OverflowError, IntegrityError and InvalidOperation and return 422. (2) Minor correction to the finding's salary note: `1e400` as a bare JSON number gives a 422. The string `"1e400"` gives `InvalidOperation`. No cross-account or confidentiality impact, and the attacker is only the token's own holder. **Suggested remediation.** Add bounds in the schemas: `offset` le=2**31-1, id filters and body ids `Field(ge=1, le=2**31-1)`, salary Decimals with max_digits=12/decimal_places=2 and the same `0 < x < LARGEST` rule the capture source uses, datetimes limited to a sane window (for example years 1970-2100) for updated_since, since, due_at, starts_at, ends_at and occurred_at, and cap list lengths (contact_ids, tags). Add a regression test that feeds each parameter its extreme value and asserts 422 rather than 500.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:42 +00:00
Author
Owner

Fixed in d65a01c4a on main.

Fixed in d65a01c4a 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#766
No description provided.