SEC2-011: Free text written through the API has no length bound where the listing history has one #953

Closed
opened 2026-10-17 10:38:04 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Informational · Category: Input Validation · Reference: CWE-770 (Allocation of Resources Without Limits or Throttling); OWASP A04:2021 (Insecure Design)

Location:

  • src/postulo/api/schemas.py:849 (EventIn.body), :835 (StatusIn.note), :760 (InterviewOutcomeIn.note), :743 and :755 (InterviewIn.notes, InterviewPatch.notes), :1072 (LetterIn.body), :362, :386, :462 (company and contact notes), :619 (ListingIn.description)
  • src/postulo/api/schemas.py:564-568 (ListingEventIn.body, bounded to BODY_MAX_CHARS)
  • src/postulo/applications/services.py:61-68 (record_event clips summary and actor, not body)
  • src/postulo/jobs/history.py:93-98, 155-156 (bounded, and the rule the listing history follows)

Issue

#218 and #270 settled that text arriving through the API is held to a length: a listing's
history entry refuses a body over 40,000 characters rather than cutting it. The other
free-text fields are bare str:

class EventIn(Schema):
    kind: str = "note"
    summary: str = Field(default="", max_length=250)
    body: str = ""
    occurred_at: Moment | None = None

and the service behind them clips only the columns PostgreSQL would refuse:

summary = summary[: ApplicationEvent._meta.get_field("summary").max_length]
actor = actor[: ApplicationEvent._meta.get_field("actor").max_length]
return ApplicationEvent.objects.create(..., body=body, ...)

The only ceiling is Django's DATA_UPLOAD_MAX_MEMORY_SIZE (2.5 MB by default; the API
documents it as too-large), so each of these calls stores up to that per request, 600 times an
hour per token. The same fields on the pages are TextField textareas, so the page is no
stricter; but a mail client or an agent is the client that pastes whole messages, and the
timeline body is then drawn on every page, export and theme that shows the application.

Impact

A token with write fills the database and backups at megabytes per call with no refusal; a
filed message of arbitrary size lands on pages and in exports that were written for a
paragraph. Denial of service against a personal instance is out of the threat model's
scope, which is what keeps this Informational; the inconsistency with the rule the project set
for the listing history is the finding.

Fix

Mirror what the listing history does today (history.py:93-98 and :155-156): refuse at the
API and cut-with-marker on the page path. Field(max_length=BODY_MAX_CHARS) on each
body/notes/note/description above (reusing postulo.jobs.history.BODY_MAX_CHARS,
already imported in schemas.py) makes the API refuse with a 422; bounded(body) in
record_event, as record_listing_event does, cuts the page path at the same number with the
marker. Done when tests/test_api.py posts a 40,001-character timeline body and gets a 422
naming body, the OpenAPI description shows the maxLength, and a page test sees a longer
body stored cut with the marker.

#218 and #463 (closed) - set the two rules quoted here. #766 (closed) - numeric ranges, the same idea for numbers.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Informational · **Category:** Input Validation · **Reference:** CWE-770 (Allocation of Resources Without Limits or Throttling); OWASP A04:2021 (Insecure Design) **Location:** - `src/postulo/api/schemas.py:849` (`EventIn.body`), `:835` (`StatusIn.note`), `:760` (`InterviewOutcomeIn.note`), `:743` and `:755` (`InterviewIn.notes`, `InterviewPatch.notes`), `:1072` (`LetterIn.body`), `:362`, `:386`, `:462` (company and contact `notes`), `:619` (`ListingIn.description`) - `src/postulo/api/schemas.py:564-568` (`ListingEventIn.body`, bounded to `BODY_MAX_CHARS`) - `src/postulo/applications/services.py:61-68` (`record_event` clips `summary` and `actor`, not `body`) - `src/postulo/jobs/history.py:93-98, 155-156` (`bounded`, and the rule the listing history follows) ## Issue #218 and #270 settled that text arriving through the API is held to a length: a listing's history entry refuses a `body` over 40,000 characters rather than cutting it. The other free-text fields are bare `str`: ```python class EventIn(Schema): kind: str = "note" summary: str = Field(default="", max_length=250) body: str = "" occurred_at: Moment | None = None ``` and the service behind them clips only the columns PostgreSQL would refuse: ```python summary = summary[: ApplicationEvent._meta.get_field("summary").max_length] actor = actor[: ApplicationEvent._meta.get_field("actor").max_length] return ApplicationEvent.objects.create(..., body=body, ...) ``` The only ceiling is Django's `DATA_UPLOAD_MAX_MEMORY_SIZE` (2.5 MB by default; the API documents it as `too-large`), so each of these calls stores up to that per request, 600 times an hour per token. The same fields on the pages are `TextField` textareas, so the page is no stricter; but a mail client or an agent is the client that pastes whole messages, and the timeline body is then drawn on every page, export and theme that shows the application. ## Impact A token with `write` fills the database and backups at megabytes per call with no refusal; a filed message of arbitrary size lands on pages and in exports that were written for a paragraph. Denial of service against a personal instance is out of the threat model's scope, which is what keeps this Informational; the inconsistency with the rule the project set for the listing history is the finding. ## Fix Mirror what the listing history does today (`history.py:93-98` and `:155-156`): refuse at the API and cut-with-marker on the page path. `Field(max_length=BODY_MAX_CHARS)` on each `body`/`notes`/`note`/`description` above (reusing `postulo.jobs.history.BODY_MAX_CHARS`, already imported in `schemas.py`) makes the API refuse with a 422; `bounded(body)` in `record_event`, as `record_listing_event` does, cuts the page path at the same number with the marker. Done when `tests/test_api.py` posts a 40,001-character timeline body and gets a 422 naming `body`, the OpenAPI description shows the `maxLength`, and a page test sees a longer body stored cut with the marker. ## Related #218 and #463 (closed) - set the two rules quoted here. #766 (closed) - numeric ranges, the same idea for numbers.
tiagoagueda added this to the 1.0.0 milestone 2026-10-17 10:38:08 +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#953
No description provided.