Security audit of 2026-10-10: index of PST-001 to PST-098 (#730–#827) #828

Closed
opened 2026-10-10 10:58:38 +00:00 by tiagoagueda · 27 comments
Owner

Index of the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md, main at c30851b). Its 98 issues are filed one per finding as #730–#827, each titled with its PST number and labelled security; High and Medium are in 0.5.0, Low in 0.6.0, Informational and unsettled in 1.0.0. What follows is the report minus the individual entries: method, summary, themes, the suggested order of work, the refuted candidates, what held up and the audit's limits. The reviewers' coverage notes follow as comments, one per reviewer.


  • Repository: tiagoagueda/postulo, commit c30851b (the tip of main when the audit started), audited 8 to 10 October 2026
  • Scope: everything under src/postulo/ (about 103,000 lines of Python outside migrations, 264 templates, static/js/app.js and the vendored scripts), the settings and URL configuration, docker/, the CI workflows, pyproject.toml and uv.lock
  • Out of scope by the project's own threat model (docs/THREAT-MODEL.md): the operator's host, denial of service against a personal instance, and a compromised browser. Where a finding's only attacker is one the threat model already trusts (an administrator, a plugin author), it is kept only if it gains something beyond that trust, and it is marked as such.
  • This document changes nothing. No code was modified and no issue was acted on. Every suggested remediation is described in words for the maintainer to weigh.
  • Handle with care. This report describes unfixed vulnerabilities, including one rated high. SECURITY.md asks for private disclosure, so keep the file out of any public branch, mirror or issue until the relevant issues are fixed.

How the audit was done

The audit read the code against the promises the project makes in SECURITY.md and docs/THREAT-MODEL.md: every query owner-scoped, every file served through a permission check, a content security policy with no inline script, every next redirect checked, and every outbound request through the guarded client. It ran as a multi-agent review in four stages.

  1. Twenty-one finders read the code in parallel, each with one lens. Eight walked one app each for authorization and ownership (core, accounts, jobs, applications, documents, resume, plugins, api), tracing every URL pattern to its view, queryset, form and template. Thirteen covered one dimension across the whole tree: authentication and sessions; file handling; server-side request forgery and outbound traffic; cross-site scripting and the content security policy; injection and parser safety; secrets and cryptography; cross-site request forgery, methods and redirects; the administrator surface and the plugin boundary; information disclosure and privacy; configuration, deployment and dependencies; client-side code; business logic and imports; and the REST API.
  2. Every candidate was verified by a second agent that tried both to reproduce it and to refute it. Reproduction meant a scratch test against the real application with the Django test client, written outside the repository and not committed. Refutation meant looking for the mitigation the finder missed. The one candidate that survived verification at high severity then went to a third, refutation-only reviewer, which could not break it. Verifiers lowered the severity of 21 candidates, raised it for 9 and refuted 13.
  3. Five completeness critics then looked for what the first round missed: addresses nobody examined, security promises that the tests pin more weakly than the documents state, the last 90 days of commits, the shipped plugins, and chains of lower findings into higher ones. Their 29 new candidates went through the same verification.
  4. Merging. Many finders reported the same root cause from different angles, so the 169 findings that were confirmed or left unsettled are merged here into 98 issues. Each issue lists every location and every variant it covers.

In total 182 candidates were examined: 167 confirmed, 2 left unsettled, 13 refuted. The existing suite under tests/security/ was run first and passed, apart from one test that needs the HarfBuzz-Subset system library, which the audit container lacks. That failure is environmental and is not a finding. pip-audit was run against the lock file and reported one advisory, in oauthlib, which the project's CI already ignores by name with a test that fails once the lock can take the fixed version.

Severity scale

  • High: cross-account access that needs a precondition, SSRF into the internal network, stored cross-site scripting, or escalation to administrator.
  • Medium: a meaningful weakness with a real but bounded consequence, such as a security control that does not hold as documented or personal data reaching a place it should not.
  • Low: a hardening gap, a defence-in-depth hole, or a weakness with a narrow precondition.
  • Informational: an observation worth recording, with no realistic attack path beyond what the threat model already accepts. Several are deliberate trade-offs the project documents.
  • No issue was rated critical.

Severity is the verifier's corrected severity, not the finder's first claim. Where the two differ the entry says so. A merged issue takes the highest severity among its parts.

Summary

Postulo's central promises held up. This audit found no way for an ordinary member to read or change records another member already holds: CVs, applications, documents, addresses and files stayed behind the owner scope, the file-serving path, the content security policy, the CSRF checks and the outbound-request guard in every attempt to break them. The issues below are weaker spots at the edges of those promises.

The most serious findings are of a different kind. They let a member arrange for a person who has not yet signed up to land inside the member's own account, because an email address the member never proved they hold is treated as a credential.

Severity Issues
High 1
Medium 5
Low 56
Informational 34
Unsettled 2
Total 98

The 98 issues merge 169 individual findings. A further 13 candidates were examined and refuted; they are listed near the end with the reason.

Fix first

PST-001 (#730) is the only high-severity issue and it needs no clever attack. A member adds an email address that nobody holds to their own account, never confirms it, and waits. When the owner of that address later signs in through the identity provider, Postulo links the provider identity to the member's account and signs the newcomer in as the member. The member later regains the account through a password reset to their own verified address. It applies only to instances with an OpenID Connect provider configured and POSTULO_OIDC_LINK_BY_EMAIL left on, which is its default. It was reproduced by two independent verifiers and again when the report was assembled.

Themes

  1. An unverified email address is treated as proof of identity (PST-001 (#730), PST-002 (#731), PST-003 (#732), PST-007 (#736)). Members can park an address on their account without confirming it, and nothing expires the claim. allauth then falls back to that unverified row when matching a single sign-on login, a password reset or a sign-in code, and a bound invitation marks its address verified although Postulo never mails it. One policy closes all four: only a verified address is a credential, an unverified claim expires or yields to the real holder, and an invitation proves nothing unless Postulo delivered it.
  2. Rate limits are weaker than documented (PST-005 (#734), PST-006 (#735), PST-026 (#755), PST-027 (#756), PST-028 (#757), PST-029 (#758)). On the default database cache every limit longer than five minutes lapses after five idle minutes. The cache is small enough for anyone to evict counters. A charge made in a request that later fails is rolled back. The candidate-file review answers the telephone-number question that the allowance exists to bound.
  3. Personal data reaches logs and lingers after deletion (PST-004 (#733), PST-025 (#754), PST-068 (#797), PST-022 (#751), PST-021 (#750), PST-023 (#752)). Query strings (what members typed), tokens carried in URL paths, and hosts reach the log file, the administrators' Logs page, the log collector and the container's access log. Expired sessions holding parsed CVs and spreadsheets are never purged. Deleting an account leaves some rows behind.
  4. Second factors and tokens are not tied to the password (PST-013 (#742), PST-014 (#743), PST-010 (#739), PST-011 (#740), PST-012 (#741)). API tokens and any authenticator added by an attacker survive a password change or recovery. Authenticator secrets are stored unencrypted. A recovery link outlives the password change it was meant for.
  5. Outbound reads are guarded but not bounded (PST-052 (#781), PST-051 (#780), PST-053 (#782), PST-050 (#779)). The guard that refuses private addresses held up. What is missing is a cap on response size, a total deadline and a port restriction.
  6. Trust placed in plugins, the catalogue and backups (PST-043 (#772), PST-054 (#783), PST-055 (#784), PST-091 (#820)). The threat model accepts that administrators can run plugin code. These issues are about making that trust harder to abuse: an unauthenticated backup that can write the plugins directory, a replayable catalogue index, and plugin code that still runs when switched off.
  7. Owner scoping rests on each write path (PST-045 (#774), PST-044 (#773), PST-030 (#759)). No exploitable path was found. Several read helpers and generic relations take ids without an owner filter and depend on every writer having been careful.
  8. Crafted input answers 500 (PST-038 (#767), PST-039 (#768), PST-040 (#769)). Oversized integers, non-ASCII digits and bare-word next values raise unhandled exceptions. Each affects only the request that sent it.
  9. Build and deployment (PST-061 (#790), PST-095 (#824), PST-062 (#791)). Mutable references in CI, unsigned release artefacts, container hardening and the whole .env reaching the database container.

Suggested order of work

  1. Close the unverified-address cluster: PST-001 (#730), PST-002 (#731), PST-003 (#732) and PST-007 (#736).
  2. Stop the log leak: PST-004 (#733) and PST-025 (#754). Old log files keep the lines already written until they rotate away.
  3. Repair rate limiting: PST-005 (#734), then PST-026 (#755), PST-027 (#756) and PST-028 (#757), then PST-006 (#735).
  4. Take the cheap hardening: PST-013 (#742), PST-014 (#743), PST-022 (#751), PST-058 (#787), PST-059 (#788) and PST-021 (#750).
  5. Work through the remaining low issues by theme, and treat the informational ones as a backlog.

Every entry below says who the attacker is, what they need, how the attack runs, what it costs the victim, the evidence quoted from the code, what verification found, and a suggested remediation. The findings were produced by automated review and checked by further automated review, with reproduction scripts where a script could settle the question. A human should re-check an entry before acting on it, particularly the lower-severity ones.

Issues at a glance

ID Severity Issue Where
PST-001 (#730) (#730) High Single sign-on links a provider-verified address to a local account that holds it only unverified src/postulo/accounts/social_adapter.py
PST-002 (#731) (#731) Medium A member can pre-claim an unused email address; a password reset or sign-in code then puts its real holder inside the claimant's account src/postulo/config/settings/base.py
PST-003 (#732) (#732) Medium An invitation bound to an address marks that address verified at sign-up although Postulo never delivered the invitation src/postulo/accounts/signals.py
PST-004 (#733) (#733) Medium The JSON log records the full request line, so query strings and URL-path tokens reach the log file, the Logs page and the log collector src/postulo/core/logs.py
PST-005 (#734) (#734) Medium Rate limits longer than five minutes collapse to five minutes on the default database cache src/postulo/core/throttle.py
PST-006 (#735) (#735) Medium The candidate-file review keeps answering whether a telephone number is held by another account after the allowance is spent src/postulo/resume/candidate.py
PST-007 (#736) (#736) Low Single sign-on auto-signup redeems an invitation that is bound to a different email address src/postulo/accounts/social_adapter.py
PST-008 (#737) (#737) Low The single sign-on handshake can be started by a cross-site GET (SOCIALACCOUNT_LOGIN_ON_GET) src/postulo/config/settings/base.py
PST-009 (#738) (#738) Low On a fresh instance, sign-up is open and the first account becomes superuser, so whoever arrives first owns the instance src/postulo/accounts/signals.py
PST-010 (#739) (#739) Low A recovery link stays usable for its full hour after the person has changed their password or signed in src/postulo/accounts/recovery.py
PST-011 (#740) (#740) Low Administrator recovery links, demotion and account deletion need no fresh proof, and taking over a member's account is silent src/postulo/core/server_views.py
PST-012 (#741) (#741) Low Accounts with no password or second factor pass reauthentication automatically, so a hijacked SSO-only session can delete the account src/postulo/accounts/views.py
PST-013 (#742) (#742) Low API tokens, and any passkey or authenticator an attacker added, survive a password change, reset or recovery src/postulo/api/auth.py
PST-014 (#743) (#743) Low Authenticator secrets and recovery-code seeds are stored unencrypted in the database src/postulo/accounts/mfa_adapter.py
PST-015 (#744) (#744) Low The per-account sign-in lockout reveals which username belongs to which address, and lets a stranger lock anyone out for five minutes src/postulo/accounts/adapter.py
PST-016 (#745) (#745) Low Username existence is confirmed by the sign-up form and by Settings, Account src/postulo/accounts/forms.py
PST-017 (#746) (#746) Low Single sign-on, login, logout and sign-up accept a next address with an http scheme on an https instance third-party: allauth/account/adapter.py
PST-018 (#747) (#747) Low A wildcard POSTULO_ALLOWED_HOSTS is not refused, and absolute links in mail and API answers come from the Host header src/postulo/config/settings/base.py
PST-019 (#748) (#748) Low The OAuth consent state is a signed bearer value: not bound to the browser session and not single-use src/postulo/plugins/consent.py
PST-020 (#749) (#749) Low The Django admin's profile inline exposes and edits every account's birth data, nationalities and gender src/postulo/accounts/admin.py
PST-021 (#750) (#750) Low Account deletion leaves the person's email address, notes and held import data behind src/postulo/accounts/deletion.py
PST-022 (#751) (#751) Low Expired and orphaned sessions are never purged, so held imports (parsed CVs, spreadsheets, vCards) persist and survive account deletion src/postulo/core/csv_import.py
PST-023 (#752) (#752) Low Webhook delivery rows keep the full notification body forever and escape export and erasure src/postulo/notifications/webhooks.py
PST-024 (#753) (#753) Low A Web Push subscription outlives sign-out, so reminders keep arriving on a shared browser src/postulo/static/js/app.js
PST-025 (#754) (#754) Low The shipped gunicorn access log writes one-time bearer tokens that travel in URL paths docker/Dockerfile
PST-026 (#755) (#755) Low Rate-limit and lockout counters share a small database cache that culls by key order, so anyone can evict them src/postulo/config/settings/base.py
PST-027 (#756) (#756) Low Rate-limit counters live inside the request transaction, so a charge made before an exception is rolled back src/postulo/core/throttle.py
PST-028 (#757) (#757) Low Per-address limits are keyed on the peer address, which is the proxy for everyone until the operator names the proxy src/postulo/core/throttle.py
PST-029 (#758) (#758) Low Instance-wide telephone-number uniqueness is a bounded cross-account existence oracle src/postulo/accounts/forms.py
PST-030 (#759) (#759) Low The company identifier formset discloses whether another account's identifier row id exists src/postulo/jobs/forms.py
PST-031 (#760) (#760) Low The API route table and allowed methods are disclosed to unauthenticated callers through 405 answers src/postulo/api/api.py
PST-032 (#761) (#761) Low A captures-only token reads the whole pending review queue, including company, location and URL of every capture src/postulo/api/api.py
PST-033 (#762) (#762) Low Any API token, including a captures-only one, learns the account's email address from GET /me src/postulo/api/api.py
PST-034 (#763) (#763) Low A listings:bind token can read back an existing history entry by replaying its external_id src/postulo/api/routers/listings.py
PST-035 (#764) (#764) Low API token creation needs no re-authentication, defaults to never expiring and is not rate-limited src/postulo/api/views.py
PST-036 (#765) (#765) Low A write-only token reads whole records through no-op writes src/postulo/api/routers/applications.py
PST-037 (#766) (#766) Low Out-of-range numbers and datetimes in the API reach the database layer and answer 500 src/postulo/api/paging.py
PST-038 (#767) (#767) Low Oversized integers and non-ASCII digits in ids raise unhandled exceptions (HTTP 500) on many endpoints src/postulo/core/bulk.py
PST-039 (#768) (#768) Low safe_next() accepts a bare word, which redirect() then tries to reverse as a view name and answers 500 src/postulo/core/redirects.py
PST-040 (#769) (#769) Low Other crafted inputs that crash with an unhandled exception src/postulo/core/kind_files.py
PST-041 (#770) (#770) Low Imports and remote metadata store URLs without a scheme check, and pages render them as href src/postulo/core/importer.py
PST-042 (#771) (#771) Low An inline document's Content-Type is steered by a user-typed title src/postulo/core/files.py
PST-043 (#772) (#772) Low Backup archives are only self-consistent: nothing authenticates an archive, and a restore writes the plugins directory src/postulo/core/backup.py
PST-044 (#773) (#773) Low The owner-folder guard is missing on rendered-document and export downloads, and is a raw prefix test elsewhere src/postulo/documents/views.py
PST-045 (#774) (#774) Low Owner scoping rests on each write path: several read helpers and generic relations take ids without an owner filter src/postulo/documents/models.py
PST-046 (#775) (#775) Low The unattended media sweep has no floor: a pass that sees an empty or wrong database deletes every user's files src/postulo/documents/scrub.py
PST-047 (#776) (#776) Low The kept-page account quota is a check-then-write with no lock, so concurrent uploads exceed it src/postulo/jobs/pages.py
PST-048 (#777) (#777) Low Company-logo and plugin-logo decoding accepts every Pillow format from bytes a hostile website chooses src/postulo/core/pictures.py
PST-049 (#778) (#778) Low The capture parser does quadratic work on a hostile page, with no time bound in the errand worker src/postulo/plugins/builtin/htmlutil.py
PST-050 (#779) (#779) Low POSTULO_CONNECTIONS_ALLOW_PRIVATE is one global switch that also disables address pinning and exposes loopback and link-local to every member src/postulo/plugins/http.py
PST-051 (#780) (#780) Low No port or self-address restriction: members can make the server open TCP connections to any port of any public address src/postulo/plugins/public_addresses.py
PST-052 (#781) (#781) Low Outbound responses are read with no size cap or total deadline, and name resolution has no timeout src/postulo/plugins/webhook/__init__.py
PST-053 (#782) (#782) Low The GeoNames download and the update check are read without a size bound, and GeoNames has no integrity check src/postulo/jobs/management/commands/fetch_geonames.py
PST-054 (#783) (#783) Low The signed plugin catalogue index has no freshness or version counter, so an old signed index can be replayed src/postulo/plugins/catalogue.py
PST-055 (#784) (#784) Low Switching a plugin off does not stop its code: .pth files in the plugins directory run at every process start src/postulo/plugins/record.py
PST-056 (#785) (#785) Low connections:create reaches the built-in local store that the pick page hides src/postulo/plugins/views.py
PST-057 (#786) (#786) Low Sync now and Send everything have no per-account rate limit, while Test is throttled src/postulo/plugins/views.py
PST-058 (#787) (#787) Low Authenticated HTML pages carry no Cache-Control: no-store, so Back after sign-out can restore personal data on a shared browser src/postulo/config/settings/base.py
PST-059 (#788) (#788) Low Generated downloads (calendar, report CSV and PDF, contact export) are sent without Cache-Control: no-store src/postulo/applications/views.py
PST-060 (#789) (#789) Low Database, media and log files are created world-readable on a bare-metal install src/postulo/config/settings/base.py
PST-061 (#790) (#790) Low CI and release workflows trust mutable references and unreviewed packages, and publish unsigned artefacts .forgejo/workflows/ci.yml
PST-062 (#791) (#791) Low The whole .env, including the secret keys, is handed to the PostgreSQL container docker/compose.postgres.yml
PST-063 (#792) (#792) Informational Any administrator can strip the operator's superuser flag through People src/postulo/core/server_views.py
PST-064 (#793) (#793) Informational No notification is sent when a password, address or second factor changes src/postulo/config/settings/base.py
PST-065 (#794) (#794) Informational The OAuth token-exchange client follows redirects while carrying the client secret and the code src/postulo/plugins/consent.py
PST-066 (#795) (#795) Informational Any administrator may fetch any account's profile picture src/postulo/accounts/views.py
PST-067 (#796) (#796) Informational Gravatar refresh is an unthrottled server-side fetch any account can trigger src/postulo/accounts/views.py
PST-068 (#797) (#797) Informational Administrators and the log collector can read members' activity (hosts, captured addresses, file names) in the logs src/postulo/plugins/registry.py
PST-069 (#798) (#798) Informational A client-supplied X-Request-ID is accepted verbatim and echoed, so a stranger can borrow another request's id in the logs src/postulo/core/middleware.py
PST-070 (#799) (#799) Informational API throttling gaps: invalid tokens are not throttled, scope is checked before the allowance is charged, and tokens multiply the allowance src/postulo/api/auth.py
PST-071 (#800) (#800) Informational /healthz tells unauthenticated callers the exact Postulo version and database state src/postulo/core/views.py
PST-072 (#801) (#801) Informational /metrics is served to anyone when enabled without a token src/postulo/core/views_metrics.py
PST-073 (#802) (#802) Informational API validation gaps: capture corrections echo the submitted value, and enumerated fields are stored unvalidated src/postulo/api/api.py
PST-074 (#803) (#803) Informational captures/known lets a captures-only token test whether the owner holds a listing with a given title at a company src/postulo/api/api.py
PST-075 (#804) (#804) Informational The messages partial decides a message is HTML by a substring test on extra_tags src/postulo/templates/partials/messages.html
PST-076 (#805) (#805) Informational A template helper builds markup with an f-string and interpolates the picture address unescaped src/postulo/core/templatetags/postulo.py
PST-077 (#806) (#806) Informational format_html is called with translated catalogue strings as the format string src/postulo/jobs/capture_views.py
PST-078 (#807) (#807) Informational The editable-cell POST accepts the whole company form's fields beside the one cell src/postulo/core/cells.py
PST-079 (#808) (#808) Informational Kind-file application import writes status, dates and timeline entries directly, bypassing the event log src/postulo/core/kind_files.py
PST-080 (#809) (#809) Informational Hard-linked media files are silently left out of backups src/postulo/core/backup.py
PST-081 (#810) (#810) Informational Any administrator can download a full backup holding every account's rows and files src/postulo/core/views_backups.py
PST-082 (#811) (#811) Informational The backup manifest publishes an unsalted SHA-256 of the secret key, and the field-encryption key is a single unsalted hash src/postulo/plugins/secrets.py
PST-083 (#812) (#812) Informational No server-side ceiling on a multipart upload body before it is spooled to disk src/postulo/config/settings/base.py
PST-084 (#813) (#813) Informational Proxy hand-off (X-Accel-Redirect, X-Sendfile) relies on the operator to re-add the file policy and nosniff src/postulo/core/files.py
PST-085 (#814) (#814) Informational The page-rendering child process inherits the full environment, including the secret key and database credentials src/postulo/jobs/rendering.py
PST-086 (#815) (#815) Informational OpenID Connect discovery, token and userinfo requests bypass the guarded client and its destination policy src/postulo/config/settings/base.py
PST-087 (#816) (#816) Informational Multicast ranges are classified as public by the destination guard src/postulo/core/destinations.py
PST-088 (#817) (#817) Informational Behind an HTTP(S) proxy, pinned HTTPS requests verify the certificate against the pinned address, not the typed name src/postulo/plugins/http.py
PST-089 (#818) (#818) Informational Board-recipe selection reads a different host from the one fetched when the URL carries userinfo src/postulo/plugins/builtin/boards/__init__.py
PST-090 (#819) (#819) Informational The catalogue wheel download names its temporary file from the release URL's last path segment src/postulo/plugins/catalogue.py
PST-091 (#820) (#820) Informational Plugins run in-process with the whole ORM and every secret within reach; plugin themes are handed full model instances src/postulo/plugins/api.py
PST-092 (#821) (#821) Informational The VAPID public key is derived from the secret key and handed to every signed-in member src/postulo/plugins/browser/webpush.py
PST-093 (#822) (#822) Informational Content security policy hardening notes: data: images, base-uri, no violation reporting src/postulo/config/settings/base.py
PST-094 (#823) (#823) Informational htmx script-tag and eval surfaces are left at their permissive defaults src/postulo/templates/base.html
PST-095 (#824) (#824) Informational Container hardening: gunicorn keeps its own trusted-proxy list, and services run without read-only root, dropped capabilities or memory limits docker/Dockerfile
PST-096 (#825) (#825) Informational Some security tests look like proof but do not discriminate, and some promises have no mechanical check tests/security/test_isolation_sweep.py
PST-097 (#826) (#826) Informational (unsettled) The first-account-becomes-administrator check may be racy on PostgreSQL src/postulo/accounts/signals.py
PST-098 (#827) (#827) Informational (unsettled) Pull-request workflow runs share the runner cache with main through restore-keys prefixes .forgejo/workflows/ci.yml

Candidates that were examined and refuted

These were reported by a finder and then shown not to be vulnerabilities. They are listed so the maintainer can see what was considered and why it was dropped.

Candidate Where Why it is not a vulnerability
Whole-account archive restore checks telephone-number collisions with no allowance at all (related to the candidate-file number oracle) src/postulo/core/importer.py The precondition is false. The whole-account archive restore (importer.load, which calls _restore_phone_numbers(profile, user, numbers, report, ...) at importer.py:1040) is reached only by the management command manage.py import_data (core/management/commands/import_data.py:8,34); its report lines go to the command's stdout (:40-41), i.e. to the operator at the console, whom the threat model trusts (root on the host is out of scope). A scratch test walked every URL pattern's callback module and found none that imports postulo.core.importer. core/views_import.py, which the finding cites, is the CSV spreadsheet import; csv_import.py has no telephone handling at all (its only 'phone' is the interview kind 'phone screen'), and the /import page mentions neither 'archive' nor 'zip'. …
Any appointed administrator can take over the operator's superuser account with a recovery link and strip its superuser flag src/postulo/core/server_views.py The steps reproduce exactly as described (a staff, non-superuser account issues a recovery link for the superuser, sets a new password with it, and separately strips the superuser's is_staff/is_superuser), but the finding names no attacker the threat model does not already trust and no gain beyond that trust. docs/THREAT-MODEL.md says an administrator 'is still trusted with the instance, since they install plugins ...: appoint only people you would trust with the database' and 'every administrator can do it (#368)'; a plugin runs in the process, so a staff administrator can already read every row and set any flag. …
CSV import keeps the whole upload (up to 2 MB, base64) in the session row and copies it into an Errand payload per import src/postulo/core/csv_import.py The storage facts are half right and the impact claim is wrong. True: stash() (csv_import.py:1267-1268) writes the base64 of the whole upload (bounded by MAX_BYTES = 2 MiB, csv_import.py:46, enforced at views_import.py:27-29 and read_sheet:470) into the database-backed session, and unstash() re-decodes and re-parses it on every GET/POST of the mapping page; the view then copies the same base64 into errands.send(..., data=...) (views_import.py:66-74) so Errand.payload holds it. False: 'nothing ... prunes payloads after completion'. The csv_import handler drops the payload the moment it is read, before the import even runs: src/postulo/core/slow.py:86 Errand.objects.filter(pk=errand.pk).update(payload={}), with the docstring saying exactly that ('it is dropped from the row as soon as it has been read, so a finished errand holds only the report'). …
Any appointed administrator (is_staff) can download the whole-instance backup: every person's rows, files and encrypted secrets src/postulo/core/views_backups.py The behaviour reproduces (a non-superuser is_staff account downloads the whole archive after a recent authentication), but the only attacker is one the threat model explicitly trusts, and the download gives them nothing beyond that trust. The same is_staff gate (StaffRequiredMixin.test_func returns is_staff) admits that account to Server settings -> Plugins, where PluginActionView lets them upload and install a wheel that runs in Postulo's process (server_views.py:1508 is StaffRequiredMixin only; the comment at 252 says 'Nothing in Postulo reads is_superuser'); that is already full read of the database and every file. docs/THREAT-MODEL.md says it in so many words: 'An administrator is still trusted with the instance, since they install plugins (below): appoint only people you would trust with the database.' So this is not a weakness in the code; it is the trust model working as written. …
Administrators are a flat tier: any staff administrator can take over the operator's superuser account (and any member's) through a recovery link, and can demote, deactivate or delete the superuser src/postulo/core/server_views.py Every step the auditor describes works mechanically, and I reproduced it through the real sign-in form rather than client.login(): a staff-only administrator issued a recovery link for the superuser (PersonRecoveryView.post has no check on person.is_superuser), opened it in a fresh client, set a new password, posted it to the sign-in form and landed signed in as the operator with Server settings open; the same staff account also stripped is_staff/is_superuser, deactivated and deleted the operator. TOTP on the operator stops the sign-in at /accounts/2fa/authenticate/, as the auditor said. The finding is nevertheless refuted as a privilege escalation because its only attacker is one the threat model trusts in so many words, and the design intends this route. …
Private files handed to nginx/Apache by X-Accel-Redirect / X-Sendfile lose the sandboxing policy and nosniff unless the operator re-adds them src/postulo/core/files.py The attack does not work end to end. Step 2 (upload a .html so it is typed text/html and shown inline) is stopped twice: the model field carries FileExtensionValidator(integrity.EXTENSIONS) at documents/models.py:657-661 with EXTENSIONS = pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg (integrity.py:30), and the upload form's clean_file (documents/forms.py:690-704) additionally checks the bytes against the extension; my scratch POST of evil.html to documents:upload_create was redisplayed with 0 rows created. There is no API upload endpoint (no UploadedFile/request.FILES anywhere under src/postulo/api), the finding and kind files carry no uploads, and the archive importer re-checks the extension (importer.py:943) and is an operator action. …
Administrators can fetch any member's profile picture by primary key (AvatarView), outside the owner-scoping rule src/postulo/accounts/views.py The behaviour exists (any is_staff account gets 200 for another member's avatar), but it is a documented design, pinned by a test, and gives an administrator nothing they do not already have. AvatarView's docstring says 'their own, or anyone's to an administrator'. tests/security/test_feeds_and_pictures.py::test_a_picture_is_ones_own_or_an_administrators_to_see asserts exactly this: a member gets 404 for another's picture and staff get 200. The staff-only People page (server/people.html:47, {% avatar person %}, StaffRequiredMixin) draws every member's avatar through this same URL, and that page also lists every member's username, full name, email and last sign-in. Staff are also allowed to issue a single-use recovery link for any person (PersonRecoveryView, StaffRequiredMixin), so a profile picture adds nothing to what administrators can reach. …
Fernet ciphertexts carry no associated data binding them to a row or owner, so blobs can be transplanted between connections by anyone with database write access src/postulo/plugins/secrets.py The mechanical claim is true, and I reproduced it. Fernet has no associated data, so secrets_encrypted copied from one Connection row to another decrypts there. But it is not a vulnerability. (1) No application path lets any user write secrets_encrypted. The column is editable=False. It is written only by Connection.secrets (setter), consent._keep/forget and the retire/clear paths, and by an administrator's whole-database restore. The attacker therefore needs raw database write access, which is full compromise. (2) With database write access the attacker has a strictly stronger primitive that binding the ciphertext to a row would not stop. config (including the destination URL) is plaintext JSON with no integrity protection, and Connection.full_config merges config and decrypted secrets. …
Plugin exceptions are written with full tracebacks to the admin-readable log and the /logs export; an exception message that embeds a credential-bearing URL would carry a user's secret there src/postulo/plugins/views.py The logging mechanism is real. Plugin exceptions are logged with logger.exception, and JSONFormatter puts the full traceback text, including the exception message, into the file and the /logs export. I reproduced that with a hypothetical plugin that raises an exception embedding the credential-bearing URL. As a vulnerability it fails on both ends. (a) No shipped plugin does this. The only credential-taking shipped notifier, webhook, keeps its HMAC secret out of any URL and never calls raise_for_status. Its failure strings for a URL with userinfo are 'That hostname could not be resolved.' and 'That address is on a private or local network...', neither of which contains the URL or the password. webhooks.deliver logs only type(error).__name__: error from httpx transport errors, which do not carry the URL. The auditor also admits that no shipped plugin was confirmed to do it. …
Free-text bodies accepted without a length bound through the API where other doors cap the same kind of text src/postulo/api/schemas.py The unbounded fields exist, and the test shows a 2.4 MB body is accepted on POST /applications/{pk}/events (201), on company notes (201) and on listing description (201). Over 2.5 MB is refused with 413 by Django's DATA_UPLOAD_MAX_MEMORY_SIZE (2,621,440 bytes). The finding's central premise is wrong, though: that the API is a door that caps less than 'other doors cap the same kind of text'. The #218 caps (40,000 characters) apply to foreign text, meaning captured pages and listing history written by mail clients. The fields listed are the owner's own text, and the browser door is equally unbounded: EventForm (a ModelForm on a TextField body) accepted the same 2.4 MB body (web EventForm valid: True), and applications/views.py:806 binds it straight from request.POST. So the API opens no door that the account holder's own session does not already have. …
/metrics bearer token may travel in clear: the path is exempt from the HTTPS redirect src/postulo/config/settings/prod.py The causal claim is wrong. A bearer token sent by a scraper over plain http:// has already crossed the wire when the request reaches the server, so answering 301 instead of 200 protects nothing. I confirmed this with a spy middleware placed ahead of SecurityMiddleware. With the exemption removed the response is a 301, yet the spy had already recorded 'Bearer <token>'. What exposes the token is a scraper configured with an http:// URL, which is an operator choice, not the exemption. The exemption is deliberate and documented at prod.py:43-49, pinned by tests/security/test_health_redirect.py, and kept narrow (anchored to exactly /healthz and /metrics). In the shipped compose files the plain-HTTP hop is 127.0.0.1:8000 or the container network. An observer there is root on the host or the container network, which docs/THREAT-MODEL.md puts out of scope. …
Metrics endpoint: open when enabled without a token, and exempt from the HTTPS redirect so a token travels in clear if scraped over http:// src/postulo/config/settings/prod.py It has two halves, and neither is a defect. (1) Metrics open when enabled with no token. This is an opt-in, documented design, not a missing check. It is off by default and answers 404 when off (tests confirm this). When on without a token, the behaviour is stated in the views_metrics.py docstring, the base.py:360-364 comment, .env.example, and the administration Logs page, which shows 'with no token, so anybody who can reach this instance can read them'. It is pinned by tests. What it serves is counts, not personal data. The version is already public: /healthz is unauthenticated and returns {'status','database','version'}. The finding's claim that it lists 'installed plugins with versions' is false: postulo_plugins only reports enabled/disabled counts, with no names or versions. …
dev-image.yml runs automatically on every push to main on a runner whose Docker daemon is root on the host .forgejo/workflows/dev-image.yml The facts are accurate, but they describe a deliberate, documented trust boundary and not a weakness in Postulo. The only attacker is someone who can already push to main, which is the project's most trusted role. The automatic trigger gives that attacker no reach they lacked, and no project claim is violated. (1) The facts hold. dev-image.yml:47-49 triggers on push to main and on workflow_dispatch. dev-image.yml:68 has runs-on: docker. The comments at dev-image.yml:8-15 and CONTRIBUTING.md:719-755 say plainly that the docker runner uses container.docker_host: automount, so the host Docker socket is mounted and Docker is root on that machine. (2) The control the project relies on is stated, not hidden: the runner is a second instance registered to this repository alone (--scope owner/repo), so Forgejo schedules no other repository's jobs onto it. …

What held up

These are the controls that the reviewers tried to break and could not. They are listed so the maintainer knows what was covered, and as a record of what the code gets right.

Ownership and isolation

  • Every record-bearing route in all eight apps narrows its queryset with for_user() before any lookup, and a foreign id answers 404. The reviewers traced every URL pattern, checked every ModelChoiceField and ModelMultipleChoiceField on every form, and posted another account's row id into every generic inline formset. In a direct test, a second account was given one of nearly every kind of record, then the first account requested every parameterless page, every id route, every API list, both calendar feeds, the export archive and the downloads: 825 responses, none containing the second account's data.
  • Only 11 models under postulo.* are not owner-scoped, and each is deliberately instance-wide. Bulk actions, merges, imports and background tasks re-scope every id they are handed. Archive and candidate-file importers drop id and every *_id key.
  • Anonymous GET and POST against all 335 resolver patterns reached nothing that required sign-in. Every state-changing endpoint is POST-only and under CSRF protection. No csrf_exempt exists outside the API, which authenticates by bearer token alone and ignores the session.
  • Administrator pages show account metadata only. No form on the user or profile models can set is_staff, is_superuser or is_active.

Files and uploads

  • MEDIA_URL is not routed anywhere. Every file goes through an ownership-checked view with no-store, nosniff and a sandboxing policy. Kept page sources are text, shown escaped and downloaded as attachments.
  • Uploads are checked by extension allow-list and magic bytes, with zip directories inspected and never unpacked. Pictures are re-encoded and SVG passes a defusedxml allow-list sanitiser, which survived 19 attack vectors.
  • Backup restore checks every archive member for traversal, absolute paths, links and non-regular files before writing anything. Slug-driven lookups never build a filesystem path.
  • PDF rendering uses WeasyPrint with a fetcher that opens nothing, in a child process with memory and CPU ceilings.

Outbound requests

  • The guard classifies loopback, private, link-local, carrier-grade NAT, metadata and IPv4-mapped IPv6 addresses correctly, resolves every hop, approves every answer and connects to the approved address, so DNS rebinding does not work. Redirect targets with other schemes are refused. Header injection is refused by the HTTP library.
  • Every outbound caller in the codebase was enumerated. Apart from OpenID Connect discovery (PST-086 (#815)), each goes through the guarded client or the equivalent for SMTP.

Web layer

  • 264 templates contain no |safe, autoescape off or inline script. The content security policy has no unsafe-inline or unsafe-eval for scripts. Markdown is rendered with raw HTML off and then an nh3 allow-list. app.js has one HTML sink, fed only by a server-built fragment, and no eval, postMessage, cookie reads or localStorage.
  • safe_next() held against //evil, backslash variants, javascript: and control characters. Host handling, forwarded headers, cookie flags, HSTS and framing headers are set correctly in production. The proxy middleware strips forwarding headers from untrusted peers.

Secrets and tokens

  • API tokens, invitation tokens and recovery tokens are 32 random bytes, stored only as SHA-256 fingerprints, shown once. Plugin connection secrets use Fernet. A weak or placeholder secret key stops start-up. /logs and /metrics compare tokens in constant time and refuse to serve without one.
  • Web push uses RFC 8291 and RFC 8292 with a fresh key and salt per message. Webhook signatures are HMAC-SHA256 over timestamp and body.

Parsers and dependencies

  • No raw SQL beyond constant statements. No pickle, yaml, eval or exec. XML parsing uses defusedxml with DTDs forbidden. CSV output neutralises formula prefixes. Imports cap size, rows and nesting depth. 16,655 hostile form values and a battery of decompression-bomb and deep-nesting uploads produced no 5xx beyond the issues in this report.
  • pip-audit reported one advisory, in oauthlib (provider-side PKCE timing; Postulo is only a client), which CI already ignores by name with a test that expires the ignore. Vendored htmx 2.0.10 matched its lock entry and no advisory was found against it.

Accounts

  • Sign-in is rate-limited per account and per address. Account enumeration through reset and login-by-code is prevented. Account deletion needs reauthentication for any account that has a password or a second factor (PST-012 (#741) covers the rest). The Django admin is unmounted unless the operator sets a path, and then sends sign-in through allauth with its limits and MFA.

Limits of this audit

  • Method. The audit was a static read of the code backed by scratch tests against an in-memory SQLite database with the Django test client. It was not a penetration test of a running deployment. It did not exercise PostgreSQL, a real reverse proxy, a real browser enforcing the content security policy, a real identity provider or a real mail server. Findings that depend on those, for example the single sign-on issues, were reproduced at the level of allauth's own functions with Postulo's real settings.
  • Reviewers. The reviewers were automated agents. Each candidate got one reproduction-and-refutation pass, and only the high-severity issue got a further independent review. A false negative is possible anywhere, and a lower-severity entry may be wrong in detail. The entries quote code so that each can be checked in minutes.
  • The one environmental failure. tests/security/test_pdf_fetching.py fails in the audit container because the HarfBuzz-Subset library is missing. It is not a finding.
  • Not attempted. Fuzzing a running server, load testing, social engineering, physical access, and a review of the upstream libraries' own code beyond how Postulo uses them.
  • Disagreement among reviewers. One coverage note from the information-disclosure review states that authenticator secrets are encrypted by allauth. A later verifier showed the project's adapter does not override allauth's encrypt and decrypt hooks, so they are stored in clear (PST-014 (#743)). The issue entry, not that note, is the record.

Companion file

The raw per-reviewer notes of what each reviewer examined and found sound are kept in SECURITY-AUDIT-2026-10-10-coverage.md beside this report.

Index of the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`, `main` at `c30851b`). Its 98 issues are filed one per finding as #730–#827, each titled with its PST number and labelled `security`; High and Medium are in 0.5.0, Low in 0.6.0, Informational and unsettled in 1.0.0. What follows is the report minus the individual entries: method, summary, themes, the suggested order of work, the refuted candidates, what held up and the audit's limits. The reviewers' coverage notes follow as comments, one per reviewer. --- - **Repository:** `tiagoagueda/postulo`, commit `c30851b` (the tip of `main` when the audit started), audited 8 to 10 October 2026 - **Scope:** everything under `src/postulo/` (about 103,000 lines of Python outside migrations, 264 templates, `static/js/app.js` and the vendored scripts), the settings and URL configuration, `docker/`, the CI workflows, `pyproject.toml` and `uv.lock` - **Out of scope by the project's own threat model (`docs/THREAT-MODEL.md`):** the operator's host, denial of service against a personal instance, and a compromised browser. Where a finding's only attacker is one the threat model already trusts (an administrator, a plugin author), it is kept only if it gains something beyond that trust, and it is marked as such. - **This document changes nothing.** No code was modified and no issue was acted on. Every suggested remediation is described in words for the maintainer to weigh. - **Handle with care.** This report describes unfixed vulnerabilities, including one rated high. `SECURITY.md` asks for private disclosure, so keep the file out of any public branch, mirror or issue until the relevant issues are fixed. ## How the audit was done The audit read the code against the promises the project makes in `SECURITY.md` and `docs/THREAT-MODEL.md`: every query owner-scoped, every file served through a permission check, a content security policy with no inline script, every `next` redirect checked, and every outbound request through the guarded client. It ran as a multi-agent review in four stages. 1. **Twenty-one finders** read the code in parallel, each with one lens. Eight walked one app each for authorization and ownership (core, accounts, jobs, applications, documents, resume, plugins, api), tracing every URL pattern to its view, queryset, form and template. Thirteen covered one dimension across the whole tree: authentication and sessions; file handling; server-side request forgery and outbound traffic; cross-site scripting and the content security policy; injection and parser safety; secrets and cryptography; cross-site request forgery, methods and redirects; the administrator surface and the plugin boundary; information disclosure and privacy; configuration, deployment and dependencies; client-side code; business logic and imports; and the REST API. 2. **Every candidate was verified** by a second agent that tried both to reproduce it and to refute it. Reproduction meant a scratch test against the real application with the Django test client, written outside the repository and not committed. Refutation meant looking for the mitigation the finder missed. The one candidate that survived verification at high severity then went to a third, refutation-only reviewer, which could not break it. Verifiers lowered the severity of 21 candidates, raised it for 9 and refuted 13. 3. **Five completeness critics** then looked for what the first round missed: addresses nobody examined, security promises that the tests pin more weakly than the documents state, the last 90 days of commits, the shipped plugins, and chains of lower findings into higher ones. Their 29 new candidates went through the same verification. 4. **Merging.** Many finders reported the same root cause from different angles, so the 169 findings that were confirmed or left unsettled are merged here into 98 issues. Each issue lists every location and every variant it covers. In total 182 candidates were examined: 167 confirmed, 2 left unsettled, 13 refuted. The existing suite under `tests/security/` was run first and passed, apart from one test that needs the HarfBuzz-Subset system library, which the audit container lacks. That failure is environmental and is not a finding. `pip-audit` was run against the lock file and reported one advisory, in `oauthlib`, which the project's CI already ignores by name with a test that fails once the lock can take the fixed version. ## Severity scale - **High:** cross-account access that needs a precondition, SSRF into the internal network, stored cross-site scripting, or escalation to administrator. - **Medium:** a meaningful weakness with a real but bounded consequence, such as a security control that does not hold as documented or personal data reaching a place it should not. - **Low:** a hardening gap, a defence-in-depth hole, or a weakness with a narrow precondition. - **Informational:** an observation worth recording, with no realistic attack path beyond what the threat model already accepts. Several are deliberate trade-offs the project documents. - No issue was rated critical. Severity is the verifier's corrected severity, not the finder's first claim. Where the two differ the entry says so. A merged issue takes the highest severity among its parts. ## Summary Postulo's central promises held up. This audit found no way for an ordinary member to read or change records another member already holds: CVs, applications, documents, addresses and files stayed behind the owner scope, the file-serving path, the content security policy, the CSRF checks and the outbound-request guard in every attempt to break them. The issues below are weaker spots at the edges of those promises. The most serious findings are of a different kind. They let a member arrange for a person who has *not yet signed up* to land inside the member's own account, because an email address the member never proved they hold is treated as a credential. | Severity | Issues | | --- | --- | | High | 1 | | Medium | 5 | | Low | 56 | | Informational | 34 | | Unsettled | 2 | | **Total** | **98** | The 98 issues merge 169 individual findings. A further 13 candidates were examined and refuted; they are listed near the end with the reason. ### Fix first PST-001 (#730) is the only high-severity issue and it needs no clever attack. A member adds an email address that nobody holds to their own account, never confirms it, and waits. When the owner of that address later signs in through the identity provider, Postulo links the provider identity to the member's account and signs the newcomer in as the member. The member later regains the account through a password reset to their own verified address. It applies only to instances with an OpenID Connect provider configured and `POSTULO_OIDC_LINK_BY_EMAIL` left on, which is its default. It was reproduced by two independent verifiers and again when the report was assembled. ### Themes 1. **An unverified email address is treated as proof of identity** (PST-001 (#730), PST-002 (#731), PST-003 (#732), PST-007 (#736)). Members can park an address on their account without confirming it, and nothing expires the claim. allauth then falls back to that unverified row when matching a single sign-on login, a password reset or a sign-in code, and a bound invitation marks its address verified although Postulo never mails it. One policy closes all four: only a verified address is a credential, an unverified claim expires or yields to the real holder, and an invitation proves nothing unless Postulo delivered it. 2. **Rate limits are weaker than documented** (PST-005 (#734), PST-006 (#735), PST-026 (#755), PST-027 (#756), PST-028 (#757), PST-029 (#758)). On the default database cache every limit longer than five minutes lapses after five idle minutes. The cache is small enough for anyone to evict counters. A charge made in a request that later fails is rolled back. The candidate-file review answers the telephone-number question that the allowance exists to bound. 3. **Personal data reaches logs and lingers after deletion** (PST-004 (#733), PST-025 (#754), PST-068 (#797), PST-022 (#751), PST-021 (#750), PST-023 (#752)). Query strings (what members typed), tokens carried in URL paths, and hosts reach the log file, the administrators' Logs page, the log collector and the container's access log. Expired sessions holding parsed CVs and spreadsheets are never purged. Deleting an account leaves some rows behind. 4. **Second factors and tokens are not tied to the password** (PST-013 (#742), PST-014 (#743), PST-010 (#739), PST-011 (#740), PST-012 (#741)). API tokens and any authenticator added by an attacker survive a password change or recovery. Authenticator secrets are stored unencrypted. A recovery link outlives the password change it was meant for. 5. **Outbound reads are guarded but not bounded** (PST-052 (#781), PST-051 (#780), PST-053 (#782), PST-050 (#779)). The guard that refuses private addresses held up. What is missing is a cap on response size, a total deadline and a port restriction. 6. **Trust placed in plugins, the catalogue and backups** (PST-043 (#772), PST-054 (#783), PST-055 (#784), PST-091 (#820)). The threat model accepts that administrators can run plugin code. These issues are about making that trust harder to abuse: an unauthenticated backup that can write the plugins directory, a replayable catalogue index, and plugin code that still runs when switched off. 7. **Owner scoping rests on each write path** (PST-045 (#774), PST-044 (#773), PST-030 (#759)). No exploitable path was found. Several read helpers and generic relations take ids without an owner filter and depend on every writer having been careful. 8. **Crafted input answers 500** (PST-038 (#767), PST-039 (#768), PST-040 (#769)). Oversized integers, non-ASCII digits and bare-word `next` values raise unhandled exceptions. Each affects only the request that sent it. 9. **Build and deployment** (PST-061 (#790), PST-095 (#824), PST-062 (#791)). Mutable references in CI, unsigned release artefacts, container hardening and the whole `.env` reaching the database container. ### Suggested order of work 1. Close the unverified-address cluster: PST-001 (#730), PST-002 (#731), PST-003 (#732) and PST-007 (#736). 2. Stop the log leak: PST-004 (#733) and PST-025 (#754). Old log files keep the lines already written until they rotate away. 3. Repair rate limiting: PST-005 (#734), then PST-026 (#755), PST-027 (#756) and PST-028 (#757), then PST-006 (#735). 4. Take the cheap hardening: PST-013 (#742), PST-014 (#743), PST-022 (#751), PST-058 (#787), PST-059 (#788) and PST-021 (#750). 5. Work through the remaining low issues by theme, and treat the informational ones as a backlog. Every entry below says who the attacker is, what they need, how the attack runs, what it costs the victim, the evidence quoted from the code, what verification found, and a suggested remediation. The findings were produced by automated review and checked by further automated review, with reproduction scripts where a script could settle the question. A human should re-check an entry before acting on it, particularly the lower-severity ones. ### Issues at a glance | ID | Severity | Issue | Where | | --- | --- | --- | --- | | PST-001 (#730) (#730) | High | Single sign-on links a provider-verified address to a local account that holds it only unverified | `src/postulo/accounts/social_adapter.py` | | PST-002 (#731) (#731) | Medium | A member can pre-claim an unused email address; a password reset or sign-in code then puts its real holder inside the claimant's account | `src/postulo/config/settings/base.py` | | PST-003 (#732) (#732) | Medium | An invitation bound to an address marks that address verified at sign-up although Postulo never delivered the invitation | `src/postulo/accounts/signals.py` | | PST-004 (#733) (#733) | Medium | The JSON log records the full request line, so query strings and URL-path tokens reach the log file, the Logs page and the log collector | `src/postulo/core/logs.py` | | PST-005 (#734) (#734) | Medium | Rate limits longer than five minutes collapse to five minutes on the default database cache | `src/postulo/core/throttle.py` | | PST-006 (#735) (#735) | Medium | The candidate-file review keeps answering whether a telephone number is held by another account after the allowance is spent | `src/postulo/resume/candidate.py` | | PST-007 (#736) (#736) | Low | Single sign-on auto-signup redeems an invitation that is bound to a different email address | `src/postulo/accounts/social_adapter.py` | | PST-008 (#737) (#737) | Low | The single sign-on handshake can be started by a cross-site GET (SOCIALACCOUNT_LOGIN_ON_GET) | `src/postulo/config/settings/base.py` | | PST-009 (#738) (#738) | Low | On a fresh instance, sign-up is open and the first account becomes superuser, so whoever arrives first owns the instance | `src/postulo/accounts/signals.py` | | PST-010 (#739) (#739) | Low | A recovery link stays usable for its full hour after the person has changed their password or signed in | `src/postulo/accounts/recovery.py` | | PST-011 (#740) (#740) | Low | Administrator recovery links, demotion and account deletion need no fresh proof, and taking over a member's account is silent | `src/postulo/core/server_views.py` | | PST-012 (#741) (#741) | Low | Accounts with no password or second factor pass reauthentication automatically, so a hijacked SSO-only session can delete the account | `src/postulo/accounts/views.py` | | PST-013 (#742) (#742) | Low | API tokens, and any passkey or authenticator an attacker added, survive a password change, reset or recovery | `src/postulo/api/auth.py` | | PST-014 (#743) (#743) | Low | Authenticator secrets and recovery-code seeds are stored unencrypted in the database | `src/postulo/accounts/mfa_adapter.py` | | PST-015 (#744) (#744) | Low | The per-account sign-in lockout reveals which username belongs to which address, and lets a stranger lock anyone out for five minutes | `src/postulo/accounts/adapter.py` | | PST-016 (#745) (#745) | Low | Username existence is confirmed by the sign-up form and by Settings, Account | `src/postulo/accounts/forms.py` | | PST-017 (#746) (#746) | Low | Single sign-on, login, logout and sign-up accept a next address with an http scheme on an https instance | `third-party: allauth/account/adapter.py` | | PST-018 (#747) (#747) | Low | A wildcard POSTULO_ALLOWED_HOSTS is not refused, and absolute links in mail and API answers come from the Host header | `src/postulo/config/settings/base.py` | | PST-019 (#748) (#748) | Low | The OAuth consent state is a signed bearer value: not bound to the browser session and not single-use | `src/postulo/plugins/consent.py` | | PST-020 (#749) (#749) | Low | The Django admin's profile inline exposes and edits every account's birth data, nationalities and gender | `src/postulo/accounts/admin.py` | | PST-021 (#750) (#750) | Low | Account deletion leaves the person's email address, notes and held import data behind | `src/postulo/accounts/deletion.py` | | PST-022 (#751) (#751) | Low | Expired and orphaned sessions are never purged, so held imports (parsed CVs, spreadsheets, vCards) persist and survive account deletion | `src/postulo/core/csv_import.py` | | PST-023 (#752) (#752) | Low | Webhook delivery rows keep the full notification body forever and escape export and erasure | `src/postulo/notifications/webhooks.py` | | PST-024 (#753) (#753) | Low | A Web Push subscription outlives sign-out, so reminders keep arriving on a shared browser | `src/postulo/static/js/app.js` | | PST-025 (#754) (#754) | Low | The shipped gunicorn access log writes one-time bearer tokens that travel in URL paths | `docker/Dockerfile` | | PST-026 (#755) (#755) | Low | Rate-limit and lockout counters share a small database cache that culls by key order, so anyone can evict them | `src/postulo/config/settings/base.py` | | PST-027 (#756) (#756) | Low | Rate-limit counters live inside the request transaction, so a charge made before an exception is rolled back | `src/postulo/core/throttle.py` | | PST-028 (#757) (#757) | Low | Per-address limits are keyed on the peer address, which is the proxy for everyone until the operator names the proxy | `src/postulo/core/throttle.py` | | PST-029 (#758) (#758) | Low | Instance-wide telephone-number uniqueness is a bounded cross-account existence oracle | `src/postulo/accounts/forms.py` | | PST-030 (#759) (#759) | Low | The company identifier formset discloses whether another account's identifier row id exists | `src/postulo/jobs/forms.py` | | PST-031 (#760) (#760) | Low | The API route table and allowed methods are disclosed to unauthenticated callers through 405 answers | `src/postulo/api/api.py` | | PST-032 (#761) (#761) | Low | A captures-only token reads the whole pending review queue, including company, location and URL of every capture | `src/postulo/api/api.py` | | PST-033 (#762) (#762) | Low | Any API token, including a captures-only one, learns the account's email address from GET /me | `src/postulo/api/api.py` | | PST-034 (#763) (#763) | Low | A listings:bind token can read back an existing history entry by replaying its external_id | `src/postulo/api/routers/listings.py` | | PST-035 (#764) (#764) | Low | API token creation needs no re-authentication, defaults to never expiring and is not rate-limited | `src/postulo/api/views.py` | | PST-036 (#765) (#765) | Low | A write-only token reads whole records through no-op writes | `src/postulo/api/routers/applications.py` | | PST-037 (#766) (#766) | Low | Out-of-range numbers and datetimes in the API reach the database layer and answer 500 | `src/postulo/api/paging.py` | | PST-038 (#767) (#767) | Low | Oversized integers and non-ASCII digits in ids raise unhandled exceptions (HTTP 500) on many endpoints | `src/postulo/core/bulk.py` | | PST-039 (#768) (#768) | Low | safe_next() accepts a bare word, which redirect() then tries to reverse as a view name and answers 500 | `src/postulo/core/redirects.py` | | PST-040 (#769) (#769) | Low | Other crafted inputs that crash with an unhandled exception | `src/postulo/core/kind_files.py` | | PST-041 (#770) (#770) | Low | Imports and remote metadata store URLs without a scheme check, and pages render them as href | `src/postulo/core/importer.py` | | PST-042 (#771) (#771) | Low | An inline document's Content-Type is steered by a user-typed title | `src/postulo/core/files.py` | | PST-043 (#772) (#772) | Low | Backup archives are only self-consistent: nothing authenticates an archive, and a restore writes the plugins directory | `src/postulo/core/backup.py` | | PST-044 (#773) (#773) | Low | The owner-folder guard is missing on rendered-document and export downloads, and is a raw prefix test elsewhere | `src/postulo/documents/views.py` | | PST-045 (#774) (#774) | Low | Owner scoping rests on each write path: several read helpers and generic relations take ids without an owner filter | `src/postulo/documents/models.py` | | PST-046 (#775) (#775) | Low | The unattended media sweep has no floor: a pass that sees an empty or wrong database deletes every user's files | `src/postulo/documents/scrub.py` | | PST-047 (#776) (#776) | Low | The kept-page account quota is a check-then-write with no lock, so concurrent uploads exceed it | `src/postulo/jobs/pages.py` | | PST-048 (#777) (#777) | Low | Company-logo and plugin-logo decoding accepts every Pillow format from bytes a hostile website chooses | `src/postulo/core/pictures.py` | | PST-049 (#778) (#778) | Low | The capture parser does quadratic work on a hostile page, with no time bound in the errand worker | `src/postulo/plugins/builtin/htmlutil.py` | | PST-050 (#779) (#779) | Low | POSTULO_CONNECTIONS_ALLOW_PRIVATE is one global switch that also disables address pinning and exposes loopback and link-local to every member | `src/postulo/plugins/http.py` | | PST-051 (#780) (#780) | Low | No port or self-address restriction: members can make the server open TCP connections to any port of any public address | `src/postulo/plugins/public_addresses.py` | | PST-052 (#781) (#781) | Low | Outbound responses are read with no size cap or total deadline, and name resolution has no timeout | `src/postulo/plugins/webhook/__init__.py` | | PST-053 (#782) (#782) | Low | The GeoNames download and the update check are read without a size bound, and GeoNames has no integrity check | `src/postulo/jobs/management/commands/fetch_geonames.py` | | PST-054 (#783) (#783) | Low | The signed plugin catalogue index has no freshness or version counter, so an old signed index can be replayed | `src/postulo/plugins/catalogue.py` | | PST-055 (#784) (#784) | Low | Switching a plugin off does not stop its code: .pth files in the plugins directory run at every process start | `src/postulo/plugins/record.py` | | PST-056 (#785) (#785) | Low | connections:create reaches the built-in local store that the pick page hides | `src/postulo/plugins/views.py` | | PST-057 (#786) (#786) | Low | Sync now and Send everything have no per-account rate limit, while Test is throttled | `src/postulo/plugins/views.py` | | PST-058 (#787) (#787) | Low | Authenticated HTML pages carry no Cache-Control: no-store, so Back after sign-out can restore personal data on a shared browser | `src/postulo/config/settings/base.py` | | PST-059 (#788) (#788) | Low | Generated downloads (calendar, report CSV and PDF, contact export) are sent without Cache-Control: no-store | `src/postulo/applications/views.py` | | PST-060 (#789) (#789) | Low | Database, media and log files are created world-readable on a bare-metal install | `src/postulo/config/settings/base.py` | | PST-061 (#790) (#790) | Low | CI and release workflows trust mutable references and unreviewed packages, and publish unsigned artefacts | `.forgejo/workflows/ci.yml` | | PST-062 (#791) (#791) | Low | The whole .env, including the secret keys, is handed to the PostgreSQL container | `docker/compose.postgres.yml` | | PST-063 (#792) (#792) | Informational | Any administrator can strip the operator's superuser flag through People | `src/postulo/core/server_views.py` | | PST-064 (#793) (#793) | Informational | No notification is sent when a password, address or second factor changes | `src/postulo/config/settings/base.py` | | PST-065 (#794) (#794) | Informational | The OAuth token-exchange client follows redirects while carrying the client secret and the code | `src/postulo/plugins/consent.py` | | PST-066 (#795) (#795) | Informational | Any administrator may fetch any account's profile picture | `src/postulo/accounts/views.py` | | PST-067 (#796) (#796) | Informational | Gravatar refresh is an unthrottled server-side fetch any account can trigger | `src/postulo/accounts/views.py` | | PST-068 (#797) (#797) | Informational | Administrators and the log collector can read members' activity (hosts, captured addresses, file names) in the logs | `src/postulo/plugins/registry.py` | | PST-069 (#798) (#798) | Informational | A client-supplied X-Request-ID is accepted verbatim and echoed, so a stranger can borrow another request's id in the logs | `src/postulo/core/middleware.py` | | PST-070 (#799) (#799) | Informational | API throttling gaps: invalid tokens are not throttled, scope is checked before the allowance is charged, and tokens multiply the allowance | `src/postulo/api/auth.py` | | PST-071 (#800) (#800) | Informational | /healthz tells unauthenticated callers the exact Postulo version and database state | `src/postulo/core/views.py` | | PST-072 (#801) (#801) | Informational | /metrics is served to anyone when enabled without a token | `src/postulo/core/views_metrics.py` | | PST-073 (#802) (#802) | Informational | API validation gaps: capture corrections echo the submitted value, and enumerated fields are stored unvalidated | `src/postulo/api/api.py` | | PST-074 (#803) (#803) | Informational | captures/known lets a captures-only token test whether the owner holds a listing with a given title at a company | `src/postulo/api/api.py` | | PST-075 (#804) (#804) | Informational | The messages partial decides a message is HTML by a substring test on extra_tags | `src/postulo/templates/partials/messages.html` | | PST-076 (#805) (#805) | Informational | A template helper builds markup with an f-string and interpolates the picture address unescaped | `src/postulo/core/templatetags/postulo.py` | | PST-077 (#806) (#806) | Informational | format_html is called with translated catalogue strings as the format string | `src/postulo/jobs/capture_views.py` | | PST-078 (#807) (#807) | Informational | The editable-cell POST accepts the whole company form's fields beside the one cell | `src/postulo/core/cells.py` | | PST-079 (#808) (#808) | Informational | Kind-file application import writes status, dates and timeline entries directly, bypassing the event log | `src/postulo/core/kind_files.py` | | PST-080 (#809) (#809) | Informational | Hard-linked media files are silently left out of backups | `src/postulo/core/backup.py` | | PST-081 (#810) (#810) | Informational | Any administrator can download a full backup holding every account's rows and files | `src/postulo/core/views_backups.py` | | PST-082 (#811) (#811) | Informational | The backup manifest publishes an unsalted SHA-256 of the secret key, and the field-encryption key is a single unsalted hash | `src/postulo/plugins/secrets.py` | | PST-083 (#812) (#812) | Informational | No server-side ceiling on a multipart upload body before it is spooled to disk | `src/postulo/config/settings/base.py` | | PST-084 (#813) (#813) | Informational | Proxy hand-off (X-Accel-Redirect, X-Sendfile) relies on the operator to re-add the file policy and nosniff | `src/postulo/core/files.py` | | PST-085 (#814) (#814) | Informational | The page-rendering child process inherits the full environment, including the secret key and database credentials | `src/postulo/jobs/rendering.py` | | PST-086 (#815) (#815) | Informational | OpenID Connect discovery, token and userinfo requests bypass the guarded client and its destination policy | `src/postulo/config/settings/base.py` | | PST-087 (#816) (#816) | Informational | Multicast ranges are classified as public by the destination guard | `src/postulo/core/destinations.py` | | PST-088 (#817) (#817) | Informational | Behind an HTTP(S) proxy, pinned HTTPS requests verify the certificate against the pinned address, not the typed name | `src/postulo/plugins/http.py` | | PST-089 (#818) (#818) | Informational | Board-recipe selection reads a different host from the one fetched when the URL carries userinfo | `src/postulo/plugins/builtin/boards/__init__.py` | | PST-090 (#819) (#819) | Informational | The catalogue wheel download names its temporary file from the release URL's last path segment | `src/postulo/plugins/catalogue.py` | | PST-091 (#820) (#820) | Informational | Plugins run in-process with the whole ORM and every secret within reach; plugin themes are handed full model instances | `src/postulo/plugins/api.py` | | PST-092 (#821) (#821) | Informational | The VAPID public key is derived from the secret key and handed to every signed-in member | `src/postulo/plugins/browser/webpush.py` | | PST-093 (#822) (#822) | Informational | Content security policy hardening notes: data: images, base-uri, no violation reporting | `src/postulo/config/settings/base.py` | | PST-094 (#823) (#823) | Informational | htmx script-tag and eval surfaces are left at their permissive defaults | `src/postulo/templates/base.html` | | PST-095 (#824) (#824) | Informational | Container hardening: gunicorn keeps its own trusted-proxy list, and services run without read-only root, dropped capabilities or memory limits | `docker/Dockerfile` | | PST-096 (#825) (#825) | Informational | Some security tests look like proof but do not discriminate, and some promises have no mechanical check | `tests/security/test_isolation_sweep.py` | | PST-097 (#826) (#826) | Informational (unsettled) | The first-account-becomes-administrator check may be racy on PostgreSQL | `src/postulo/accounts/signals.py` | | PST-098 (#827) (#827) | Informational (unsettled) | Pull-request workflow runs share the runner cache with main through restore-keys prefixes | `.forgejo/workflows/ci.yml` | ## Candidates that were examined and refuted These were reported by a finder and then shown not to be vulnerabilities. They are listed so the maintainer can see what was considered and why it was dropped. | Candidate | Where | Why it is not a vulnerability | | --- | --- | --- | | Whole-account archive restore checks telephone-number collisions with no allowance at all (related to the candidate-file number oracle) | `src/postulo/core/importer.py` | The precondition is false. The whole-account archive restore (`importer.load`, which calls `_restore_phone_numbers(profile, user, numbers, report, ...)` at importer.py:1040) is reached only by the management command `manage.py import_data` (core/management/commands/import_data.py:8,34); its report lines go to the command's stdout (:40-41), i.e. to the operator at the console, whom the threat model trusts (root on the host is out of scope). A scratch test walked every URL pattern's callback module and found none that imports `postulo.core.importer`. `core/views_import.py`, which the finding cites, is the CSV spreadsheet import; `csv_import.py` has no telephone handling at all (its only 'phone' is the interview kind 'phone screen'), and the /import page mentions neither 'archive' nor 'zip'. … | | Any appointed administrator can take over the operator's superuser account with a recovery link and strip its superuser flag | `src/postulo/core/server_views.py` | The steps reproduce exactly as described (a staff, non-superuser account issues a recovery link for the superuser, sets a new password with it, and separately strips the superuser's is_staff/is_superuser), but the finding names no attacker the threat model does not already trust and no gain beyond that trust. docs/THREAT-MODEL.md says an administrator 'is still trusted with the instance, since they install plugins ...: appoint only people you would trust with the database' and 'every administrator can do it (#368)'; a plugin runs in the process, so a staff administrator can already read every row and set any flag. … | | CSV import keeps the whole upload (up to 2 MB, base64) in the session row and copies it into an Errand payload per import | `src/postulo/core/csv_import.py` | The storage facts are half right and the impact claim is wrong. True: `stash()` (csv_import.py:1267-1268) writes the base64 of the whole upload (bounded by MAX_BYTES = 2 MiB, csv_import.py:46, enforced at views_import.py:27-29 and read_sheet:470) into the database-backed session, and `unstash()` re-decodes and re-parses it on every GET/POST of the mapping page; the view then copies the same base64 into `errands.send(..., data=...)` (views_import.py:66-74) so `Errand.payload` holds it. False: 'nothing ... prunes payloads after completion'. The csv_import handler drops the payload the moment it is read, before the import even runs: src/postulo/core/slow.py:86 `Errand.objects.filter(pk=errand.pk).update(payload={})`, with the docstring saying exactly that ('it is dropped from the row as soon as it has been read, so a finished errand holds only the report'). … | | Any appointed administrator (is_staff) can download the whole-instance backup: every person's rows, files and encrypted secrets | `src/postulo/core/views_backups.py` | The behaviour reproduces (a non-superuser is_staff account downloads the whole archive after a recent authentication), but the only attacker is one the threat model explicitly trusts, and the download gives them nothing beyond that trust. The same is_staff gate (StaffRequiredMixin.test_func returns is_staff) admits that account to Server settings -> Plugins, where PluginActionView lets them upload and install a wheel that runs in Postulo's process (server_views.py:1508 is StaffRequiredMixin only; the comment at 252 says 'Nothing in Postulo reads is_superuser'); that is already full read of the database and every file. docs/THREAT-MODEL.md says it in so many words: 'An administrator is still trusted with the instance, since they install plugins (below): appoint only people you would trust with the database.' So this is not a weakness in the code; it is the trust model working as written. … | | Administrators are a flat tier: any staff administrator can take over the operator's superuser account (and any member's) through a recovery link, and can demote, deactivate or delete the superuser | `src/postulo/core/server_views.py` | Every step the auditor describes works mechanically, and I reproduced it through the real sign-in form rather than client.login(): a staff-only administrator issued a recovery link for the superuser (PersonRecoveryView.post has no check on person.is_superuser), opened it in a fresh client, set a new password, posted it to the sign-in form and landed signed in as the operator with Server settings open; the same staff account also stripped is_staff/is_superuser, deactivated and deleted the operator. TOTP on the operator stops the sign-in at /accounts/2fa/authenticate/, as the auditor said. The finding is nevertheless refuted as a privilege escalation because its only attacker is one the threat model trusts in so many words, and the design intends this route. … | | Private files handed to nginx/Apache by X-Accel-Redirect / X-Sendfile lose the sandboxing policy and nosniff unless the operator re-adds them | `src/postulo/core/files.py` | The attack does not work end to end. Step 2 (upload a `.html` so it is typed text/html and shown inline) is stopped twice: the model field carries `FileExtensionValidator(integrity.EXTENSIONS)` at documents/models.py:657-661 with EXTENSIONS = pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg (integrity.py:30), and the upload form's clean_file (documents/forms.py:690-704) additionally checks the bytes against the extension; my scratch POST of `evil.html` to documents:upload_create was redisplayed with 0 rows created. There is no API upload endpoint (no UploadedFile/request.FILES anywhere under src/postulo/api), the finding and kind files carry no uploads, and the archive importer re-checks the extension (importer.py:943) and is an operator action. … | | Administrators can fetch any member's profile picture by primary key (AvatarView), outside the owner-scoping rule | `src/postulo/accounts/views.py` | The behaviour exists (any is_staff account gets 200 for another member's avatar), but it is a documented design, pinned by a test, and gives an administrator nothing they do not already have. AvatarView's docstring says 'their own, or anyone's to an administrator'. tests/security/test_feeds_and_pictures.py::test_a_picture_is_ones_own_or_an_administrators_to_see asserts exactly this: a member gets 404 for another's picture and staff get 200. The staff-only People page (server/people.html:47, `{% avatar person %}`, StaffRequiredMixin) draws every member's avatar through this same URL, and that page also lists every member's username, full name, email and last sign-in. Staff are also allowed to issue a single-use recovery link for any person (PersonRecoveryView, StaffRequiredMixin), so a profile picture adds nothing to what administrators can reach. … | | Fernet ciphertexts carry no associated data binding them to a row or owner, so blobs can be transplanted between connections by anyone with database write access | `src/postulo/plugins/secrets.py` | The mechanical claim is true, and I reproduced it. Fernet has no associated data, so secrets_encrypted copied from one Connection row to another decrypts there. But it is not a vulnerability. (1) No application path lets any user write secrets_encrypted. The column is editable=False. It is written only by Connection.secrets (setter), consent._keep/forget and the retire/clear paths, and by an administrator's whole-database restore. The attacker therefore needs raw database write access, which is full compromise. (2) With database write access the attacker has a strictly stronger primitive that binding the ciphertext to a row would not stop. `config` (including the destination URL) is plaintext JSON with no integrity protection, and Connection.full_config merges config and decrypted secrets. … | | Plugin exceptions are written with full tracebacks to the admin-readable log and the /logs export; an exception message that embeds a credential-bearing URL would carry a user's secret there | `src/postulo/plugins/views.py` | The logging mechanism is real. Plugin exceptions are logged with logger.exception, and JSONFormatter puts the full traceback text, including the exception message, into the file and the /logs export. I reproduced that with a hypothetical plugin that raises an exception embedding the credential-bearing URL. As a vulnerability it fails on both ends. (a) No shipped plugin does this. The only credential-taking shipped notifier, webhook, keeps its HMAC secret out of any URL and never calls raise_for_status. Its failure strings for a URL with userinfo are 'That hostname could not be resolved.' and 'That address is on a private or local network...', neither of which contains the URL or the password. webhooks.deliver logs only `type(error).__name__: error` from httpx transport errors, which do not carry the URL. The auditor also admits that no shipped plugin was confirmed to do it. … | | Free-text bodies accepted without a length bound through the API where other doors cap the same kind of text | `src/postulo/api/schemas.py` | The unbounded fields exist, and the test shows a 2.4 MB body is accepted on POST /applications/{pk}/events (201), on company notes (201) and on listing description (201). Over 2.5 MB is refused with 413 by Django's DATA_UPLOAD_MAX_MEMORY_SIZE (2,621,440 bytes). The finding's central premise is wrong, though: that the API is a door that caps less than 'other doors cap the same kind of text'. The #218 caps (40,000 characters) apply to foreign text, meaning captured pages and listing history written by mail clients. The fields listed are the owner's own text, and the browser door is equally unbounded: EventForm (a ModelForm on a TextField body) accepted the same 2.4 MB body (`web EventForm valid: True`), and applications/views.py:806 binds it straight from request.POST. So the API opens no door that the account holder's own session does not already have. … | | /metrics bearer token may travel in clear: the path is exempt from the HTTPS redirect | `src/postulo/config/settings/prod.py` | The causal claim is wrong. A bearer token sent by a scraper over plain http:// has already crossed the wire when the request reaches the server, so answering 301 instead of 200 protects nothing. I confirmed this with a spy middleware placed ahead of SecurityMiddleware. With the exemption removed the response is a 301, yet the spy had already recorded 'Bearer &lt;token>'. What exposes the token is a scraper configured with an http:// URL, which is an operator choice, not the exemption. The exemption is deliberate and documented at prod.py:43-49, pinned by tests/security/test_health_redirect.py, and kept narrow (anchored to exactly /healthz and /metrics). In the shipped compose files the plain-HTTP hop is 127.0.0.1:8000 or the container network. An observer there is root on the host or the container network, which docs/THREAT-MODEL.md puts out of scope. … | | Metrics endpoint: open when enabled without a token, and exempt from the HTTPS redirect so a token travels in clear if scraped over http:// | `src/postulo/config/settings/prod.py` | It has two halves, and neither is a defect. (1) Metrics open when enabled with no token. This is an opt-in, documented design, not a missing check. It is off by default and answers 404 when off (tests confirm this). When on without a token, the behaviour is stated in the views_metrics.py docstring, the base.py:360-364 comment, .env.example, and the administration Logs page, which shows 'with no token, so anybody who can reach this instance can read them'. It is pinned by tests. What it serves is counts, not personal data. The version is already public: /healthz is unauthenticated and returns {'status','database','version'}. The finding's claim that it lists 'installed plugins with versions' is false: postulo_plugins only reports enabled/disabled counts, with no names or versions. … | | dev-image.yml runs automatically on every push to main on a runner whose Docker daemon is root on the host | `.forgejo/workflows/dev-image.yml` | The facts are accurate, but they describe a deliberate, documented trust boundary and not a weakness in Postulo. The only attacker is someone who can already push to main, which is the project's most trusted role. The automatic trigger gives that attacker no reach they lacked, and no project claim is violated. (1) The facts hold. dev-image.yml:47-49 triggers on push to main and on workflow_dispatch. dev-image.yml:68 has `runs-on: docker`. The comments at dev-image.yml:8-15 and CONTRIBUTING.md:719-755 say plainly that the `docker` runner uses `container.docker_host: automount`, so the host Docker socket is mounted and Docker is root on that machine. (2) The control the project relies on is stated, not hidden: the runner is a second instance registered to this repository alone (`--scope owner/repo`), so Forgejo schedules no other repository's jobs onto it. … | ## What held up These are the controls that the reviewers tried to break and could not. They are listed so the maintainer knows what was covered, and as a record of what the code gets right. **Ownership and isolation** - Every record-bearing route in all eight apps narrows its queryset with `for_user()` before any lookup, and a foreign id answers 404. The reviewers traced every URL pattern, checked every `ModelChoiceField` and `ModelMultipleChoiceField` on every form, and posted another account's row id into every generic inline formset. In a direct test, a second account was given one of nearly every kind of record, then the first account requested every parameterless page, every id route, every API list, both calendar feeds, the export archive and the downloads: 825 responses, none containing the second account's data. - Only 11 models under `postulo.*` are not owner-scoped, and each is deliberately instance-wide. Bulk actions, merges, imports and background tasks re-scope every id they are handed. Archive and candidate-file importers drop `id` and every `*_id` key. - Anonymous GET and POST against all 335 resolver patterns reached nothing that required sign-in. Every state-changing endpoint is POST-only and under CSRF protection. No `csrf_exempt` exists outside the API, which authenticates by bearer token alone and ignores the session. - Administrator pages show account metadata only. No form on the user or profile models can set `is_staff`, `is_superuser` or `is_active`. **Files and uploads** - `MEDIA_URL` is not routed anywhere. Every file goes through an ownership-checked view with `no-store`, `nosniff` and a sandboxing policy. Kept page sources are text, shown escaped and downloaded as attachments. - Uploads are checked by extension allow-list and magic bytes, with zip directories inspected and never unpacked. Pictures are re-encoded and SVG passes a `defusedxml` allow-list sanitiser, which survived 19 attack vectors. - Backup restore checks every archive member for traversal, absolute paths, links and non-regular files before writing anything. Slug-driven lookups never build a filesystem path. - PDF rendering uses WeasyPrint with a fetcher that opens nothing, in a child process with memory and CPU ceilings. **Outbound requests** - The guard classifies loopback, private, link-local, carrier-grade NAT, metadata and IPv4-mapped IPv6 addresses correctly, resolves every hop, approves every answer and connects to the approved address, so DNS rebinding does not work. Redirect targets with other schemes are refused. Header injection is refused by the HTTP library. - Every outbound caller in the codebase was enumerated. Apart from OpenID Connect discovery (PST-086 (#815)), each goes through the guarded client or the equivalent for SMTP. **Web layer** - 264 templates contain no `|safe`, `autoescape off` or inline script. The content security policy has no `unsafe-inline` or `unsafe-eval` for scripts. Markdown is rendered with raw HTML off and then an `nh3` allow-list. `app.js` has one HTML sink, fed only by a server-built fragment, and no `eval`, `postMessage`, cookie reads or `localStorage`. - `safe_next()` held against `//evil`, backslash variants, `javascript:` and control characters. Host handling, forwarded headers, cookie flags, HSTS and framing headers are set correctly in production. The proxy middleware strips forwarding headers from untrusted peers. **Secrets and tokens** - API tokens, invitation tokens and recovery tokens are 32 random bytes, stored only as SHA-256 fingerprints, shown once. Plugin connection secrets use Fernet. A weak or placeholder secret key stops start-up. `/logs` and `/metrics` compare tokens in constant time and refuse to serve without one. - Web push uses RFC 8291 and RFC 8292 with a fresh key and salt per message. Webhook signatures are HMAC-SHA256 over timestamp and body. **Parsers and dependencies** - No raw SQL beyond constant statements. No `pickle`, `yaml`, `eval` or `exec`. XML parsing uses `defusedxml` with DTDs forbidden. CSV output neutralises formula prefixes. Imports cap size, rows and nesting depth. 16,655 hostile form values and a battery of decompression-bomb and deep-nesting uploads produced no 5xx beyond the issues in this report. - `pip-audit` reported one advisory, in `oauthlib` (provider-side PKCE timing; Postulo is only a client), which CI already ignores by name with a test that expires the ignore. Vendored htmx 2.0.10 matched its lock entry and no advisory was found against it. **Accounts** - Sign-in is rate-limited per account and per address. Account enumeration through reset and login-by-code is prevented. Account deletion needs reauthentication for any account that has a password or a second factor (PST-012 (#741) covers the rest). The Django admin is unmounted unless the operator sets a path, and then sends sign-in through allauth with its limits and MFA. ## Limits of this audit - **Method.** The audit was a static read of the code backed by scratch tests against an in-memory SQLite database with the Django test client. It was not a penetration test of a running deployment. It did not exercise PostgreSQL, a real reverse proxy, a real browser enforcing the content security policy, a real identity provider or a real mail server. Findings that depend on those, for example the single sign-on issues, were reproduced at the level of allauth's own functions with Postulo's real settings. - **Reviewers.** The reviewers were automated agents. Each candidate got one reproduction-and-refutation pass, and only the high-severity issue got a further independent review. A false negative is possible anywhere, and a lower-severity entry may be wrong in detail. The entries quote code so that each can be checked in minutes. - **The one environmental failure.** `tests/security/test_pdf_fetching.py` fails in the audit container because the HarfBuzz-Subset library is missing. It is not a finding. - **Not attempted.** Fuzzing a running server, load testing, social engineering, physical access, and a review of the upstream libraries' own code beyond how Postulo uses them. - **Disagreement among reviewers.** One coverage note from the information-disclosure review states that authenticator secrets are encrypted by allauth. A later verifier showed the project's adapter does not override allauth's encrypt and decrypt hooks, so they are stored in clear (PST-014 (#743)). The issue entry, not that note, is the record. ## Companion file The raw per-reviewer notes of what each reviewer examined and found sound are kept in `SECURITY-AUDIT-2026-10-10-coverage.md` beside this report.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of core

  • Every core route that names a record (core:errand, core:errand_state, core:export_archive, core:import_csv_done) narrows with Errand/ExportArchive.objects.for_user(request.user) before get_object_or_404; all four are in tests/security/test_isolation_sweep.py FACTORIES and answer 404 for another account's row.
  • OwnedQuerySet.for_user fails closed (returns none()) for anonymous or missing users; OwnedObjectMixin narrows get_queryset before lookup; OwnerFormMixin stamps the owner on create.
  • Dashboard arranging (POST core:home) writes only the signed-in user's own Profile (arranging.profile_for(request.user)); widget keys are validated against widgets.REGISTRY, actions outside the known set are ignored, drop positions are clamped.
  • Table settings and saved views (core:table_settings, core:table_views) validate the table name against tables.TABLES, write only the caller's profile.table_settings, pass next through safe_next(), and re-parse the stored query with QueryDict so a view can only hold what the page showed.
  • Search (core:search) runs every group through for_user(); excerpts and highlights are escaped in the template filter; the page redirects away any parameter but q.
  • Whole-account export (core:export, core:export_download, core:export_archive) and kind files (core:file_<kind>, core:file_<kind>_download) build from request.user only; kind_files.plan/apply look companies, contacts, listings, departments up with for_user() or owner=user and never read an id from the file (#659); tests/security/test_kind_files.py covers forged files and other accounts' rows.
  • CSV import holds the sheet in the session, sends it as an errand owned by request.user, and csv_import.perform looks up companies/postings/applications through for_user(); errand handlers in slow.py act as errand.owner and the errand id comes from the queue, never a request.
  • Inline formset rows for telephone numbers, postal addresses, web links, messaging handles and identifiers are generic inline formsets bound to the holder: a posted foreign row id is not in the holder's queryset, becomes a blank instance flagged already_gone (RowsAlreadyGone) and is skipped on save; new rows take owner from owner_of(holder); the recovery flag can only land on the person's own profile numbers (is_their_own); tests/security/test_formset_rows.py exercises this for the profile page.
  • Every server: route is behind StaffRequiredMixin (backups behind the 404 variant); tests/security/test_staff_only.py walks the resolver and asserts 403 for members and a sign-in redirect for visitors; PersonPluginsView looks the person up only after the staff test passes.
  • Administrator pages expose only account metadata: People lists username, name, email, last sign-in and role; Overview, Data protection dry run and Record of processing are counts; no member's applications, contacts or documents are rendered on any server: template.
  • serve_private_file resolves names inside MEDIA_ROOT, refuses missing files with 404, sets Cache-Control no-store, nosniff and the file CSP; serve_private_text is fixed to text/plain attachment; no core view hands a file to the web server directly.
  • Backup archives are addressed only by a name that fullmatches backups.NAME, must be a regular non-symlink file whose real parent is the backup directory; download and delete require allauth reauthentication and log who; upload verifies into a temp name before renaming; tests/security/test_backups_page.py covers non-administrators (404) and bad names.
  • Plugin upload/confirm binds the pending wheel to a random session token and refuses a mismatched or missing one; catalogue install, enable/disable and remove are administrator-only and logged.
  • Mail OAuth consent (EmailConsentView, mail_auth.finish_consent) signs the state with a dedicated salt and refuses a callback unless the signed-in user is the staff administrator who started it; tokens are kept in the encrypted field.
  • /logs refuses to serve when enabled without a token (503 plus log line), compares the bearer in constant time, throttles wrong tokens per address and the endpoint itself; /metrics carries no person, company or URL in any label.
  • safe_next() is used for every next in core (table settings, table views, help topic back link); HtmxLoginRedirectMiddleware only rewrites relative redirects to the sign-in paths; HealthProbeMiddleware bypasses the host check only for a loopback peer with no forwarding header.
  • No csrf_exempt or login_not_required in core views (only the admin signpost); state-changing core views are POST-only (require_POST or View.post) and CSRF-protected; messages|safe is used only with format_html-built links in the jobs app.
  • ExportArchive rows cascade with the owner and their files are removed on post_delete (core/signals.py) and by the expiry reaper; the admin site (admin_site.py) registers no member records and routes its login through allauth.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of core - Every core route that names a record (core:errand, core:errand_state, core:export_archive, core:import_csv_done) narrows with Errand/ExportArchive.objects.for_user(request.user) before get_object_or_404; all four are in tests/security/test_isolation_sweep.py FACTORIES and answer 404 for another account's row. - OwnedQuerySet.for_user fails closed (returns none()) for anonymous or missing users; OwnedObjectMixin narrows get_queryset before lookup; OwnerFormMixin stamps the owner on create. - Dashboard arranging (POST core:home) writes only the signed-in user's own Profile (arranging.profile_for(request.user)); widget keys are validated against widgets.REGISTRY, actions outside the known set are ignored, drop positions are clamped. - Table settings and saved views (core:table_settings, core:table_views) validate the table name against tables.TABLES, write only the caller's profile.table_settings, pass next through safe_next(), and re-parse the stored query with QueryDict so a view can only hold what the page showed. - Search (core:search) runs every group through for_user(); excerpts and highlights are escaped in the template filter; the page redirects away any parameter but q. - Whole-account export (core:export, core:export_download, core:export_archive) and kind files (core:file_&lt;kind>, core:file_&lt;kind>_download) build from request.user only; kind_files.plan/apply look companies, contacts, listings, departments up with for_user() or owner=user and never read an id from the file (#659); tests/security/test_kind_files.py covers forged files and other accounts' rows. - CSV import holds the sheet in the session, sends it as an errand owned by request.user, and csv_import.perform looks up companies/postings/applications through for_user(); errand handlers in slow.py act as errand.owner and the errand id comes from the queue, never a request. - Inline formset rows for telephone numbers, postal addresses, web links, messaging handles and identifiers are generic inline formsets bound to the holder: a posted foreign row id is not in the holder's queryset, becomes a blank instance flagged already_gone (RowsAlreadyGone) and is skipped on save; new rows take owner from owner_of(holder); the recovery flag can only land on the person's own profile numbers (is_their_own); tests/security/test_formset_rows.py exercises this for the profile page. - Every server: route is behind StaffRequiredMixin (backups behind the 404 variant); tests/security/test_staff_only.py walks the resolver and asserts 403 for members and a sign-in redirect for visitors; PersonPluginsView looks the person up only after the staff test passes. - Administrator pages expose only account metadata: People lists username, name, email, last sign-in and role; Overview, Data protection dry run and Record of processing are counts; no member's applications, contacts or documents are rendered on any server: template. - serve_private_file resolves names inside MEDIA_ROOT, refuses missing files with 404, sets Cache-Control no-store, nosniff and the file CSP; serve_private_text is fixed to text/plain attachment; no core view hands a file to the web server directly. - Backup archives are addressed only by a name that fullmatches backups.NAME, must be a regular non-symlink file whose real parent is the backup directory; download and delete require allauth reauthentication and log who; upload verifies into a temp name before renaming; tests/security/test_backups_page.py covers non-administrators (404) and bad names. - Plugin upload/confirm binds the pending wheel to a random session token and refuses a mismatched or missing one; catalogue install, enable/disable and remove are administrator-only and logged. - Mail OAuth consent (EmailConsentView, mail_auth.finish_consent) signs the state with a dedicated salt and refuses a callback unless the signed-in user is the staff administrator who started it; tokens are kept in the encrypted field. - /logs refuses to serve when enabled without a token (503 plus log line), compares the bearer in constant time, throttles wrong tokens per address and the endpoint itself; /metrics carries no person, company or URL in any label. - safe_next() is used for every next in core (table settings, table views, help topic back link); HtmxLoginRedirectMiddleware only rewrites relative redirects to the sign-in paths; HealthProbeMiddleware bypasses the host check only for a loopback peer with no forwarding header. - No csrf_exempt or login_not_required in core views (only the admin signpost); state-changing core views are POST-only (require_POST or View.post) and CSRF-protected; messages|safe is used only with format_html-built links in the jobs app. - ExportArchive rows cascade with the owner and their files are removed on post_delete (core/signals.py) and by the expiry reaper; the admin site (admin_site.py) registers no member records and routes its login through allauth.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of accounts

  • Every accounts: and settings: URL traced view by view: ProfileView, RemoveRowView (x5), ThemeView, AvatarView (own pk), GravatarRefreshView, DeleteAccountView, InviteListView/CreateView/RevokeView, InviteAcceptView, RecoveryLinkView, RecoverySetPasswordView, SignInView, RequestLoginCodeView, SettingsIndexView, AppearanceView, AccessibilityView, SaveFieldView, LocaleView, AccountView, CaptureView, ForgetRememberedPlacesView, PluginsView: each requires login via LoginRequiredMixin / SettingsSectionMixin or is deliberately public (sign-in, token-bearing invitation/recovery links); invitation management is StaffRequiredMixin and covered by tests/security/test_staff_only.py.
  • Object lookups: every profile lookup is Profile.objects.get_or_create(user=request.user); RemoveRowView narrows with removals.rows() (owner + content_type + object_id of the caller's own profile, or PersonIdentifier.objects.filter(profile=...)) before get_object_or_404 and re-checks removals.offered(); RecoverySetPasswordView reads the link pk only from the server-side session; pending_invite reads the fingerprint only from the session.
  • Formset-row attack (posting another account's row id as <prefix>-<n>-id): all four generic inline formsets (phone_numbers, postal, web_links, messaging_handles) are built with instance=the caller's profile so BaseGenericInlineFormSet filters by content_type/object_id, and PersonIdentifierFormSet is an inline formset on the profile FK; a foreign id resolves to no instance, RowsAlreadyGone marks it deleted, and save_existing_objects / remove_first skip rows whose instance.pk is None. tests/security/test_formset_rows.py covers change and remove for every kind.
  • ModelChoiceField / queryset scoping in accounts forms: ProfileForm, AppearanceForm, AccessibilityForm, CaptureKeepingForm, LocaleForm, AccountForm, InviteForm, PersonIdentifierForm declare no model-choice fields; the only model-bound choices are static country/language/time-zone lists. web_links.add_single_boxes/save_single_boxes and phone_numbers.save_only_number act on the holder passed in (the caller's own profile).
  • SaveFieldView: section and field names are looked up only in SAVE_AS_YOU_GO and the form's save_as_you_go_fields; the record is always the caller's profile; AppearanceForm.save short-circuits when 'navigation' is absent so a one-field save cannot blank the navigation; tests/security/test_save_as_you_go.py covers posting another account's ids.
  • Administrator pages (dimension 8): server/people.html prints username, name, email, last sign-in, role and the avatar tile only; PersonPluginsView, PersonRecoveryView, PersonUsernameView, PersonAdminView, PersonActiveView, PersonDeleteView look up accounts (not owned records) behind StaffRequiredMixin and show policy rows, recovery-link metadata and the username form; no application, document or address of another person is rendered.
  • Session-held tokens: invitation and recovery tokens travel in the address once, are stored only as SHA-256 fingerprints (accounts/tokens.py), and are moved into the session before the form so they do not reach Referer; Invite.accept is a single conditional UPDATE so a race spends it once; recovery links are single-use, one-hour, revoked on reissue, and consumed only on POST.
  • Method and CSRF discipline: ThemeView, GravatarRefreshView, InviteRevokeView, ForgetRememberedPlacesView, SaveFieldView and RemoveRowView.post are POST-only, no csrf_exempt in the app, CsrfViewMiddleware is in MIDDLEWARE, ThemeView's redirect goes through safe_next(). DeleteAccountView is behind allauth reauthentication plus the typed address and refuses the last administrator.
  • Signals and services: create_profile, consume_invite, gravatar_follows_the_primary_address and deletion.delete_account act on the user from the signal/request, never on an id from the body; delete_account raises LastAdministrator and removes files only on commit; management commands (delete_account, mfa_reset) are console-only.
  • Login rate limiting: AccountAdapter._get_login_attempts_cache_key resolves username/e-mail to one account:<pk> key so guesses are counted per account, with a distinct name: prefix for unknown names (tests/security/test_admin_exposure.py).
  • Isolation sweep coverage: accounts:avatar and all five accounts:remove_* routes are in FACTORIES; invite_accept, recovery_open, invite_revoke and settings:save_field are EXCUSED with named tests that exist and match the stated reasons.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of accounts - Every accounts: and settings: URL traced view by view: ProfileView, RemoveRowView (x5), ThemeView, AvatarView (own pk), GravatarRefreshView, DeleteAccountView, InviteListView/CreateView/RevokeView, InviteAcceptView, RecoveryLinkView, RecoverySetPasswordView, SignInView, RequestLoginCodeView, SettingsIndexView, AppearanceView, AccessibilityView, SaveFieldView, LocaleView, AccountView, CaptureView, ForgetRememberedPlacesView, PluginsView: each requires login via LoginRequiredMixin / SettingsSectionMixin or is deliberately public (sign-in, token-bearing invitation/recovery links); invitation management is StaffRequiredMixin and covered by tests/security/test_staff_only.py. - Object lookups: every profile lookup is Profile.objects.get_or_create(user=request.user); RemoveRowView narrows with removals.rows() (owner + content_type + object_id of the caller's own profile, or PersonIdentifier.objects.filter(profile=...)) before get_object_or_404 and re-checks removals.offered(); RecoverySetPasswordView reads the link pk only from the server-side session; pending_invite reads the fingerprint only from the session. - Formset-row attack (posting another account's row id as &lt;prefix>-&lt;n>-id): all four generic inline formsets (phone_numbers, postal, web_links, messaging_handles) are built with instance=the caller's profile so BaseGenericInlineFormSet filters by content_type/object_id, and PersonIdentifierFormSet is an inline formset on the profile FK; a foreign id resolves to no instance, RowsAlreadyGone marks it deleted, and save_existing_objects / remove_first skip rows whose instance.pk is None. tests/security/test_formset_rows.py covers change and remove for every kind. - ModelChoiceField / queryset scoping in accounts forms: ProfileForm, AppearanceForm, AccessibilityForm, CaptureKeepingForm, LocaleForm, AccountForm, InviteForm, PersonIdentifierForm declare no model-choice fields; the only model-bound choices are static country/language/time-zone lists. web_links.add_single_boxes/save_single_boxes and phone_numbers.save_only_number act on the holder passed in (the caller's own profile). - SaveFieldView: section and field names are looked up only in SAVE_AS_YOU_GO and the form's save_as_you_go_fields; the record is always the caller's profile; AppearanceForm.save short-circuits when 'navigation' is absent so a one-field save cannot blank the navigation; tests/security/test_save_as_you_go.py covers posting another account's ids. - Administrator pages (dimension 8): server/people.html prints username, name, email, last sign-in, role and the avatar tile only; PersonPluginsView, PersonRecoveryView, PersonUsernameView, PersonAdminView, PersonActiveView, PersonDeleteView look up accounts (not owned records) behind StaffRequiredMixin and show policy rows, recovery-link metadata and the username form; no application, document or address of another person is rendered. - Session-held tokens: invitation and recovery tokens travel in the address once, are stored only as SHA-256 fingerprints (accounts/tokens.py), and are moved into the session before the form so they do not reach Referer; Invite.accept is a single conditional UPDATE so a race spends it once; recovery links are single-use, one-hour, revoked on reissue, and consumed only on POST. - Method and CSRF discipline: ThemeView, GravatarRefreshView, InviteRevokeView, ForgetRememberedPlacesView, SaveFieldView and RemoveRowView.post are POST-only, no csrf_exempt in the app, CsrfViewMiddleware is in MIDDLEWARE, ThemeView's redirect goes through safe_next(). DeleteAccountView is behind allauth reauthentication plus the typed address and refuses the last administrator. - Signals and services: create_profile, consume_invite, gravatar_follows_the_primary_address and deletion.delete_account act on the user from the signal/request, never on an id from the body; delete_account raises LastAdministrator and removes files only on commit; management commands (delete_account, mfa_reset) are console-only. - Login rate limiting: AccountAdapter._get_login_attempts_cache_key resolves username/e-mail to one account:&lt;pk> key so guesses are counted per account, with a distinct name: prefix for unknown names (tests/security/test_admin_exposure.py). - Isolation sweep coverage: accounts:avatar and all five accounts:remove_* routes are in FACTORIES; invite_accept, recovery_open, invite_revoke and settings:save_field are EXCUSED with named tests that exist and match the stated reasons.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of jobs

  • Login: every view in jobs/urls.py and jobs/listing_urls.py descends from OwnedObjectMixin (LoginRequiredMixin) or LoginRequiredMixin directly (CompanyMapView, CompanyCellView, CompanyBulkView, ListingCellView, ListingBulkView, all vcard views). No administrative action lives in the app.
  • Object lookup: every pk pattern narrows with for_user() before get_object_or_404/get() -- company_, industry_, contact_, capture_ (incl. page views via CapturedPageMixin), posting_, listings: -- and the EditableCellView used by the cell routes does self.model.objects.for_user(self.request.user). All of them are in tests/security/test_isolation_sweep.py FACTORIES.
  • Body ids: CaptureBindView listing (for_user + bind_capture owner check), MergeView with on GET and POST (records().exclude(kept)), CompanyBulkView chosen and industry (bulk.chosen_rows + Industry.for_user), ListingBulkView chosen (chosen_rows), CaptureDiscardSelectedView selected (for_user + PENDING), ContactCreateView ?company (for_user .exists()), CompanyListView ?group (for_user name_key). Functional tests cover bulk/discard-selected with a stranger's row (test_listings_table.py:259, test_capture_triage.py:126); merge/bind foreign-body cases are not in the security sweep's BODIES but are scoped in code.
  • Forms: CompanyForm.parent/industries, ContactForm.company (offered(including=) stays inside the for_user set), JobPostingForm.company, IndustryForm.merge_into, ListingEventForm.contact/document (UploadedDocument.for_user) and ApplicationDetailsForm.tags (used by listings:apply) all narrow to the user; CompanyIdentifier inline rows are bound to the company instance so a foreign row is never written or deleted (verified) -- only the existence oracle above remains.
  • Generic-inline formsets on the contact form (phone numbers, web links, messaging handles, postal addresses) carry RowsAlreadyGone and filter by holder content_type/object_id; owner stamped via core.formsets.owner_of(holder).
  • History: record_listing_event checks contact.owner_id and artefact.owner_id against the listing's owner, restricts artefacts to the ARTEFACTS allowlist, and ListingEvent.points_at re-checks the owner at render; templates/cotton/listing_history.html reads only bound_capture/bound_document, never entry.artefact.
  • Merging: both records looked up with for_user, _same_owner() asserted, every move filters owner_id, rows re-read under select_for_update, and _refuse_what_is_left aborts the transaction if the cascade would touch anything unexpected.
  • Duplicates and remembered places: duplicates.for_company/for_contact/_contacts_alike filter by owner_id; FieldHint reads and writes go through for_user(owner); known() uses for_user.
  • Errands: core:errand page uses Errand.objects.for_user; find_a_logo re-checks company.owner_id == errand.owner_id, draw_a_page filters owner=errand.owner, fetch_a_capture creates with owner=errand.owner.
  • Services called from jobs views: create_listing/apply_to_listing/get_or_create_company stamp owner from the request user or posting.owner; vcards.apply creates with owner=user and matches companies through get_or_create_company (for_user).
  • Tables: CompaniesTable/ListingsTable filters are text/choice/date/number on the owner-scoped queryset; choice values are validated against declared choices; no filter takes a record id; settings are read from the caller's own profile.
  • Method and CSRF: state-changing views are POST-only (View subclasses without get() answer 405), django.middleware.csrf.CsrfViewMiddleware is in MIDDLEWARE, no csrf_exempt in jobs; every next goes through safe_next (url_has_allowed_host_and_scheme with the request host).
  • Files: company logo via serve_stored_picture after a for_user lookup; captured source via serve_private_text (text/plain, attachment); rendering via serve_private_file with a type from RenderingKind; logo download uses http.public_only_client (SSRF-guarded).
  • Admin surfaces: none in the jobs app; core/metrics.py reads only a count of pending captures across accounts.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of jobs - Login: every view in jobs/urls.py and jobs/listing_urls.py descends from OwnedObjectMixin (LoginRequiredMixin) or LoginRequiredMixin directly (CompanyMapView, CompanyCellView, CompanyBulkView, ListingCellView, ListingBulkView, all vcard views). No administrative action lives in the app. - Object lookup: every pk pattern narrows with for_user() before get_object_or_404/get() -- company_*, industry_*, contact_*, capture_* (incl. page views via CapturedPageMixin), posting_*, listings:* -- and the EditableCellView used by the cell routes does `self.model.objects.for_user(self.request.user)`. All of them are in tests/security/test_isolation_sweep.py FACTORIES. - Body ids: CaptureBindView `listing` (for_user + bind_capture owner check), MergeView `with` on GET and POST (records().exclude(kept)), CompanyBulkView `chosen` and `industry` (bulk.chosen_rows + Industry.for_user), ListingBulkView `chosen` (chosen_rows), CaptureDiscardSelectedView `selected` (for_user + PENDING), ContactCreateView ?company (for_user .exists()), CompanyListView ?group (for_user name_key). Functional tests cover bulk/discard-selected with a stranger's row (test_listings_table.py:259, test_capture_triage.py:126); merge/bind foreign-body cases are not in the security sweep's BODIES but are scoped in code. - Forms: CompanyForm.parent/industries, ContactForm.company (offered(including=) stays inside the for_user set), JobPostingForm.company, IndustryForm.merge_into, ListingEventForm.contact/document (UploadedDocument.for_user) and ApplicationDetailsForm.tags (used by listings:apply) all narrow to the user; CompanyIdentifier inline rows are bound to the company instance so a foreign row is never written or deleted (verified) -- only the existence oracle above remains. - Generic-inline formsets on the contact form (phone numbers, web links, messaging handles, postal addresses) carry RowsAlreadyGone and filter by holder content_type/object_id; owner stamped via core.formsets.owner_of(holder). - History: record_listing_event checks contact.owner_id and artefact.owner_id against the listing's owner, restricts artefacts to the ARTEFACTS allowlist, and ListingEvent.points_at re-checks the owner at render; templates/cotton/listing_history.html reads only bound_capture/bound_document, never entry.artefact. - Merging: both records looked up with for_user, _same_owner() asserted, every move filters owner_id, rows re-read under select_for_update, and _refuse_what_is_left aborts the transaction if the cascade would touch anything unexpected. - Duplicates and remembered places: duplicates.for_company/for_contact/_contacts_alike filter by owner_id; FieldHint reads and writes go through for_user(owner); known() uses for_user. - Errands: core:errand page uses Errand.objects.for_user; find_a_logo re-checks company.owner_id == errand.owner_id, draw_a_page filters owner=errand.owner, fetch_a_capture creates with owner=errand.owner. - Services called from jobs views: create_listing/apply_to_listing/get_or_create_company stamp owner from the request user or posting.owner; vcards.apply creates with owner=user and matches companies through get_or_create_company (for_user). - Tables: CompaniesTable/ListingsTable filters are text/choice/date/number on the owner-scoped queryset; choice values are validated against declared choices; no filter takes a record id; settings are read from the caller's own profile. - Method and CSRF: state-changing views are POST-only (View subclasses without get() answer 405), django.middleware.csrf.CsrfViewMiddleware is in MIDDLEWARE, no csrf_exempt in jobs; every `next` goes through safe_next (url_has_allowed_host_and_scheme with the request host). - Files: company logo via serve_stored_picture after a for_user lookup; captured source via serve_private_text (text/plain, attachment); rendering via serve_private_file with a type from RenderingKind; logo download uses http.public_only_client (SSRF-guarded). - Admin surfaces: none in the jobs app; core/metrics.py reads only a count of pending captures across accounts.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of applications

  • All 52 URL patterns in applications/urls.py traced: every view requires login (OwnedObjectMixin/LoginRequiredMixin); the three RedirectViews (board, reminders, insights) carry no data and redirect only to same-host paths built from reverse() plus urlencode.
  • Every pk lookup is narrowed before get_object_or_404: all get_queryset overrides use Model.objects.for_user(request.user); generic views inherit OwnedObjectMixin.get_queryset; ApplicationListView.matching/column_counts go through super().get_queryset() (scoped).
  • Forms: ApplicationForm scopes tags, contact, referred_by, through_agency (AgencyChoiceField) and department (Department.objects.for_user) ; ReminderForm.application, InterviewForm.contacts, ApplicationDetailsForm.tags, ical_import.RowForm.application are all for_user; Tag.named/slugs.named are owner-scoped. Verified empirically with a scratch test posting foreign ids to update, interview_create, interview_update, reminder_create, reminder_update, suggestion_action accept and ical_import confirm: nothing foreign was saved (9/9 passed).
  • Bulk action: core/bulk.chosen_rows re-scopes every posted id with for_user, bounds the list at 1000, and _tag re-scopes the tag id; tests/security/test_bulk_actions.py covers it.
  • List/board filters: ?tag= filters the owner's applications by slug (per-owner unique; a foreign slug returns the same empty page as a missing one, verified); ?status, ?state, ?quiet, ?q (clean_query capped) touch no foreign row; core/tables filters use fixed column lookups.
  • htmx partial endpoints (status, event_create, reminder done/later with HX-Target) are POST-only, CSRF-protected (CsrfViewMiddleware in config/settings/base.py:111, no csrf_exempt anywhere in src), and detail_fragments picks its template from a fixed dict.
  • Every redirect-with-next goes through safe_next (url_has_allowed_host_and_scheme with the request host and require_https); moved_on_the_board parses safe_next's result.
  • Services derive owner from the application (schedule_interview, record_offer, _remind_to_answer, Reminder in quiet snooze) and change_status re-reads the locked row by the already-scoped pk; errands.send takes the request user and core:errand/errand_state are for_user; slow.render_a_report uses errand.owner.
  • Background task quiet.announce_quiet_applications iterates per owner and notifies each about only their own rows; no request-supplied id reaches it.
  • API routers for reminders, interviews and offers scope application_id and contact_ids with owned(); CSV/archive/candidate-file importers attach child rows only to applications they created for the same user.
  • Suggestion accept checks target.owner_id == suggestion.owner_id; SuggestionActionView restricts action to accept/decline (404 otherwise) and re-scopes a posted application id (404 for a foreign one, verified).
  • Report CSV goes through core.spreadsheets.row (formula-prefix neutralisation); report/calendar filenames are built from dates or an int pk, and ical responses use content_disposition_header; draft_pdf's in-process cache is keyed by SHA-256 of the full HTML, which carries the person's name and period.
  • iCalendar output uses the icalendar library with control characters stripped (ical.without_controls/clean), so a captured title cannot inject properties into a feed.
  • Templates under templates/applications use no |safe, autoescape off or mark_safe; plugin-supplied Suggestion.context and body are rendered escaped.
  • No admin pages, management views or Django admin registrations exist in the applications app; no GenericForeignKey in its models (history_of's generic artefact prefetch is checked for owner in jobs/history.py:100-121, 163).
  • Sweep coverage: all 20 pk-bearing applications routes are in tests/security/test_isolation_sweep.py FACTORIES (lines 398-417); the id-bearing parameters the sweep cannot reach (bulk, reminder_create?application=, suggestion accept POST application, list?tag=, update's related ids, interview contacts, ical_import confirm) were checked by reading and by the scratch test; tests/test_query_parameters.py covers only non-numeric values for them.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of applications - All 52 URL patterns in applications/urls.py traced: every view requires login (OwnedObjectMixin/LoginRequiredMixin); the three RedirectViews (board, reminders, insights) carry no data and redirect only to same-host paths built from reverse() plus urlencode. - Every pk lookup is narrowed before get_object_or_404: all get_queryset overrides use Model.objects.for_user(request.user); generic views inherit OwnedObjectMixin.get_queryset; ApplicationListView.matching/column_counts go through super().get_queryset() (scoped). - Forms: ApplicationForm scopes tags, contact, referred_by, through_agency (AgencyChoiceField) and department (Department.objects.for_user) ; ReminderForm.application, InterviewForm.contacts, ApplicationDetailsForm.tags, ical_import.RowForm.application are all for_user; Tag.named/slugs.named are owner-scoped. Verified empirically with a scratch test posting foreign ids to update, interview_create, interview_update, reminder_create, reminder_update, suggestion_action accept and ical_import confirm: nothing foreign was saved (9/9 passed). - Bulk action: core/bulk.chosen_rows re-scopes every posted id with for_user, bounds the list at 1000, and _tag re-scopes the tag id; tests/security/test_bulk_actions.py covers it. - List/board filters: ?tag= filters the owner's applications by slug (per-owner unique; a foreign slug returns the same empty page as a missing one, verified); ?status, ?state, ?quiet, ?q (clean_query capped) touch no foreign row; core/tables filters use fixed column lookups. - htmx partial endpoints (status, event_create, reminder done/later with HX-Target) are POST-only, CSRF-protected (CsrfViewMiddleware in config/settings/base.py:111, no csrf_exempt anywhere in src), and detail_fragments picks its template from a fixed dict. - Every redirect-with-next goes through safe_next (url_has_allowed_host_and_scheme with the request host and require_https); moved_on_the_board parses safe_next's result. - Services derive owner from the application (schedule_interview, record_offer, _remind_to_answer, Reminder in quiet snooze) and change_status re-reads the locked row by the already-scoped pk; errands.send takes the request user and core:errand/errand_state are for_user; slow.render_a_report uses errand.owner. - Background task quiet.announce_quiet_applications iterates per owner and notifies each about only their own rows; no request-supplied id reaches it. - API routers for reminders, interviews and offers scope application_id and contact_ids with owned(); CSV/archive/candidate-file importers attach child rows only to applications they created for the same user. - Suggestion accept checks target.owner_id == suggestion.owner_id; SuggestionActionView restricts action to accept/decline (404 otherwise) and re-scopes a posted application id (404 for a foreign one, verified). - Report CSV goes through core.spreadsheets.row (formula-prefix neutralisation); report/calendar filenames are built from dates or an int pk, and ical responses use content_disposition_header; draft_pdf's in-process cache is keyed by SHA-256 of the full HTML, which carries the person's name and period. - iCalendar output uses the icalendar library with control characters stripped (ical.without_controls/clean), so a captured title cannot inject properties into a feed. - Templates under templates/applications use no |safe, autoescape off or mark_safe; plugin-supplied Suggestion.context and body are rendered escaped. - No admin pages, management views or Django admin registrations exist in the applications app; no GenericForeignKey in its models (history_of's generic artefact prefetch is checked for owner in jobs/history.py:100-121, 163). - Sweep coverage: all 20 pk-bearing applications routes are in tests/security/test_isolation_sweep.py FACTORIES (lines 398-417); the id-bearing parameters the sweep cannot reach (bulk, reminder_create?application=, suggestion accept POST application, list?tag=, update's related ids, interview contacts, ical_import confirm) were checked by reading and by the scratch test; tests/test_query_parameters.py covers only non-numeric values for them.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of documents

  • Every URL pattern in documents/urls.py (29 routes) traced to its view: all views inherit OwnedObjectMixin (LoginRequiredMixin) and narrow with for_user() before get_object_or_404/get(); non-pk list/create routes are login-only and scope their querysets. No administrative actions exist in this app; no documents model is registered in Django admin.
  • tests/security/test_isolation_sweep.py covers every pk-bearing documents route (cv_, cv_item_, letter_, upload_, rendered_*, send, application_documents) for a foreign id (404 both methods); the form sweep covers CVForm, CVItemForm, CoverLetterForm, UploadedDocumentForm querysets.
  • SendDocumentsForm: cv / cover_letter / uploads / links are ModelChoice/ModelMultipleChoice fields built from for_user(user); _posted_pks only widens within that scope. Scratch test: posting another account's cv, letter, upload and link ids yields a 200 with errors, no Errand created, no M2M rows added, and none of the foreign titles appear on the page.
  • AddCVItemsForm / CVAddItemsView: choices are built from cv.owner's rows per section; CVItem.get_or_create stamps owner=request.user. Scratch test: foreign experience, education and project ids are refused (nothing added); the sweep already covers a foreign reference.
  • CVForm prints_* choosers, identifier_rows and printing.choose/choose_identifiers: offered() is built from the owner's own profile (_profile refuses a profile whose user_id differs), the form's ChoiceField validates against those choices, and choose() re-checks the id against offered() -- covered end to end by tests/security/test_cv_prints.py (page, API, archive, direct column writes).
  • UploadedDocumentForm: replaces and referee querysets are for_user(user); proves is a ChoiceField of the owner's own entries, re-resolved through proofs.find(user, ...) and enforced again by UploadedDocument.check_proves() on every save; file is removed from the edit form (#217). Scratch test: foreign replaces and referee ids are refused.
  • chosen_application() (letter preview/draft/download ?application=) looks the id up with Application.objects.for_user(request.user) and as_pk() tolerates non-numbers; UploadListView ?proves= goes through proofs.find(user, ...) and ?kind= is checked against the registry; CoverLetterListView ?kind= is checked against LetterKind.
  • Errand handlers in documents/slow.py (cv_pdf, sent_documents) re-scope every id in the payload to errand.owner (_one, for_user) before rendering/freezing; freeze() adds M2M rows from those scoped lists; core:errand page/state views are owner-scoped.
  • RenderedCompareView: the earlier version is found by RenderedDocument.previous(), which filters owner_id and source; no second id in the address. ApplicationDocumentsView reads rendered_documents / sent_uploads / sent_links of an owner-scoped application whose rows are only ever written by the scoped freeze() or the importer's same-user maps.
  • SendCopiesNowView: POST-only, origin fixed by the URL kwargs dict, document owner-scoped, store_connections(request.user) and schedule_copies(document.owner) keep copies on the owner's own connections; redirects go through safe_next().
  • State-changing views (cv_export, cv_add_items, cv_item_move, *_archive, send, deletes) answer POST only; draft/download views that accept POST as GET change no state. CSRF middleware applies to all of them.
  • File delivery: UploadDownloadView passes within=document.storage_prefix; serve_private_file resolves the stored name inside MEDIA_ROOT and sets no-store, nosniff and FILE_POLICY; handed_over() (drafts and format downloads) sets the same headers with a fixed content type per format. Scratch test: a CRLF/quote-laden ?title= is escaped by Django's content_disposition_header and the response stays text/plain.
  • Importer (core/importer.py) builds CV/CVItem/Upload/Render rows only from an allow-list (_carried drops id/*_id keys), resolves CV entries, sent-document sources and proofs through maps of rows it created for the same user, and restores print choices by content via printing.restore(); DocumentCopy rows are restored with connection=None.
  • Rendering surface: themes resolve templates only through the registry (themes.template_for; the model validator SetsThisKind and CV.clean refuse unknown or mismatched names); WeasyPrint's fetcher allows only data: and Chromium aborts every request; theme templates use only autoescaped output (linebreaks/linebreaksbr); letter placeholders are substituted by a fixed regex, not a template engine; ODT/DOCX writers escape and clean every string; the draft cache is keyed by SHA-256 of the full HTML.
  • Signals: post_delete receivers null the generic source/proves links rather than cascading, and the file-removal receiver checks no other row still names the file; schedule_copies refuses documents that goes_to_stores says stay local.
  • Management commands (check_fonts, prune_media, verify_media) and scrub.py are operator/scheduler code with no request input and no staff page in this app.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of documents - Every URL pattern in documents/urls.py (29 routes) traced to its view: all views inherit OwnedObjectMixin (LoginRequiredMixin) and narrow with for_user() before get_object_or_404/get(); non-pk list/create routes are login-only and scope their querysets. No administrative actions exist in this app; no documents model is registered in Django admin. - tests/security/test_isolation_sweep.py covers every pk-bearing documents route (cv_*, cv_item_*, letter_*, upload_*, rendered_*, send, application_documents) for a foreign id (404 both methods); the form sweep covers CVForm, CVItemForm, CoverLetterForm, UploadedDocumentForm querysets. - SendDocumentsForm: cv / cover_letter / uploads / links are ModelChoice/ModelMultipleChoice fields built from for_user(user); _posted_pks only widens within that scope. Scratch test: posting another account's cv, letter, upload and link ids yields a 200 with errors, no Errand created, no M2M rows added, and none of the foreign titles appear on the page. - AddCVItemsForm / CVAddItemsView: choices are built from cv.owner's rows per section; CVItem.get_or_create stamps owner=request.user. Scratch test: foreign experience, education and project ids are refused (nothing added); the sweep already covers a foreign reference. - CVForm prints_* choosers, identifier_rows and printing.choose/choose_identifiers: offered() is built from the owner's own profile (_profile refuses a profile whose user_id differs), the form's ChoiceField validates against those choices, and choose() re-checks the id against offered() -- covered end to end by tests/security/test_cv_prints.py (page, API, archive, direct column writes). - UploadedDocumentForm: `replaces` and `referee` querysets are for_user(user); `proves` is a ChoiceField of the owner's own entries, re-resolved through proofs.find(user, ...) and enforced again by UploadedDocument.check_proves() on every save; `file` is removed from the edit form (#217). Scratch test: foreign replaces and referee ids are refused. - chosen_application() (letter preview/draft/download ?application=) looks the id up with Application.objects.for_user(request.user) and as_pk() tolerates non-numbers; UploadListView ?proves= goes through proofs.find(user, ...) and ?kind= is checked against the registry; CoverLetterListView ?kind= is checked against LetterKind. - Errand handlers in documents/slow.py (cv_pdf, sent_documents) re-scope every id in the payload to errand.owner (`_one`, for_user) before rendering/freezing; freeze() adds M2M rows from those scoped lists; core:errand page/state views are owner-scoped. - RenderedCompareView: the earlier version is found by RenderedDocument.previous(), which filters owner_id and source; no second id in the address. ApplicationDocumentsView reads rendered_documents / sent_uploads / sent_links of an owner-scoped application whose rows are only ever written by the scoped freeze() or the importer's same-user maps. - SendCopiesNowView: POST-only, origin fixed by the URL kwargs dict, document owner-scoped, store_connections(request.user) and schedule_copies(document.owner) keep copies on the owner's own connections; redirects go through safe_next(). - State-changing views (cv_export, cv_add_items, cv_item_move, *_archive, send, deletes) answer POST only; draft/download views that accept POST as GET change no state. CSRF middleware applies to all of them. - File delivery: UploadDownloadView passes within=document.storage_prefix; serve_private_file resolves the stored name inside MEDIA_ROOT and sets no-store, nosniff and FILE_POLICY; handed_over() (drafts and format downloads) sets the same headers with a fixed content type per format. Scratch test: a CRLF/quote-laden ?title= is escaped by Django's content_disposition_header and the response stays text/plain. - Importer (core/importer.py) builds CV/CVItem/Upload/Render rows only from an allow-list (`_carried` drops `id`/`*_id` keys), resolves CV entries, sent-document sources and proofs through maps of rows it created for the same user, and restores print choices by content via printing.restore(); DocumentCopy rows are restored with connection=None. - Rendering surface: themes resolve templates only through the registry (themes.template_for; the model validator SetsThisKind and CV.clean refuse unknown or mismatched names); WeasyPrint's fetcher allows only data: and Chromium aborts every request; theme templates use only autoescaped output (linebreaks/linebreaksbr); letter placeholders are substituted by a fixed regex, not a template engine; ODT/DOCX writers escape and clean every string; the draft cache is keyed by SHA-256 of the full HTML. - Signals: post_delete receivers null the generic source/proves links rather than cascading, and the file-removal receiver checks no other row still names the file; schedule_copies refuses documents that goes_to_stores says stay local. - Management commands (check_fonts, prune_media, verify_media) and scrub.py are operator/scheduler code with no request input and no staff page in this app.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of resume

  • All 12 URL patterns in src/postulo/resume/urls.py traced: every view carries LoginRequiredMixin directly or via OwnedObjectMixin (overview, preview, europass_import, candidate_file, candidate_download, skill_suggestions, link_check(_all), item_create/update/delete/languages/move); none is administrative, so no staff check is needed and none is missing.
  • Every object lookup is narrowed before get: ResumeItem{Update,Delete,Move,Translations}View and LinkCheckView all define get_queryset() as <section model>.objects.for_user(request.user) and use get_object_or_404 on that; get_section() 404s on an unknown section slug so the registry cannot be used to reach an arbitrary model.
  • State changes are POST-only with CSRF: ResumeItemMoveView and LinkCheckView subclass View with only post(); the delete view is Django's DeleteView (GET confirms, POST deletes); the candidate and Europass pages act only on POST with an action value; next on the move and link-check views goes through safe_next().
  • Forms: the only related-object fields in resume/forms.py are SkillForm.group (scoped to SkillGroup.objects.for_user(user), required) and ReferenceForm.contact (scoped to Contact.objects.for_user(user), plus Reference.clean()/save() refuse a contact with another owner_id). Verified by a scratch test: posting another account's group id to item_create/skill and item_update/skill is rejected (200 with errors, nothing saved) and ?group=<their id> renders no foreign option. The sweep already covers a foreign contact on reference create (test_isolation_sweep.py:1116-1135). No form lists owner or company (company is editable=False and set only via companies.find_or_add(owner, name), which is for_user/get_or_create_company scoped).
  • No formsets exist in the resume app (TranslationForm is a plain Form keyed to the narrowed entry; Translation rows are written with owner=entry.owner), so the formset-row attack class does not apply; tests/security/test_formset_rows.py correctly has no resume entry.
  • Candidate file (resume/candidate.py): ids in the file (id, group_id, ref) are resolved only against rows of the same file (self.named) and never against the database; existing entries are matched by content against kind.model.objects.for_user(user); translations are written at entry_row.target which is either the user's existing row or the unsaved new one; company links come from companies.existing(user, name) (for_user) and a file never adds a company; addresses/numbers/links/identifiers are hung on the importer's own Profile; details touch only first_name/last_name and CANDIDATE_PROFILE_FIELDS of the importer's own profile. Session storage is Django's default database-backed session, so held files never reach a cookie.
  • Europass import (resume/importing.py, EuropassImportView): every write is owner=request.user; SkillGroup reuse is filtered by owner; references create Contacts owned by the importer; the profile phone number and reference numbers go through phone_numbers.refusal(), which checks the allowance before the lookup (the correct order).
  • Candidate download: CandidateDownloadView serves export.build_candidate_document(request.user) only, with Content-Disposition attachment and Cache-Control no-store; no id in the address.
  • Indirect reach: Experience/Education/Certification/Honour/Membership/Course.company and Reference.contact FKs are only ever set to owner-scoped rows; Skill.group is scoped; the CV picker in documents/forms.py (AddCVItemsForm) builds MultipleChoiceField choices from spec.model.objects.for_user(cv.owner) so a foreign entry pk posted as add_<section> fails validation (also covered by the sweep); documents/proofs.find/entries_for are for_user and UploadedDocument.proves is read-only in the API.
  • Administrator pages and Django admin hold no career records: no admin.py in resume, and no server/people/accounts view imports a resume model (only a docstring mention in core/views_kind_files.py).
  • Signals, errands and background tasks: the resume app registers no signal receivers and no errands; apps.ready() only registers the built-in Europass importer. Management commands (import_europass, match_languages) are operator-only and take the account from the shell, not from a request.
  • Model-level invariants: Publication.free_cite_key and the cite-key uniqueness check use for_user; ordering.siblings/place_many operate on type(item).objects.for_user(owner), so a move can only swap with the owner's own entries; Translation language from GET/POST is held to languages.well_formed (RFC 5646 shape, <= 35 chars) before it is stored.
  • Skill suggestions (SkillSuggestionsView) read only the ESCO classification, are throttled per account, and are answered with Cache-Control: private, no-store; the ESCO lookup is an in-memory prefix search, not SQL.
  • Link checks (LinkCheckView, resume/links.py) act only on Link.objects.for_user(user), spend the per-account fetch allowance before any request, and validate the address and every redirect through the guarded public_only_client (SSRF belongs to another dimension and was not re-audited here).
  • Templates (overview, item_form, translations, candidate_file, europass_import, preview, skill_suggestions) contain no |safe and render only the context the scoped views built; reference rows print permission state in words and never a referee's contact details.
  • Out of scope by the threat model and not reported: the candidate review page re-validates up to 200 rows per kind and 2000 translations through Django forms on every GET while a file is held and the upload POST is not throttled (a CPU cost per request); denial of service against a personal instance is explicitly out of scope.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of resume - All 12 URL patterns in src/postulo/resume/urls.py traced: every view carries LoginRequiredMixin directly or via OwnedObjectMixin (overview, preview, europass_import, candidate_file, candidate_download, skill_suggestions, link_check(_all), item_create/update/delete/languages/move); none is administrative, so no staff check is needed and none is missing. - Every object lookup is narrowed before get: ResumeItem{Update,Delete,Move,Translations}View and LinkCheckView all define get_queryset() as &lt;section model>.objects.for_user(request.user) and use get_object_or_404 on that; get_section() 404s on an unknown section slug so the registry cannot be used to reach an arbitrary model. - State changes are POST-only with CSRF: ResumeItemMoveView and LinkCheckView subclass View with only post(); the delete view is Django's DeleteView (GET confirms, POST deletes); the candidate and Europass pages act only on POST with an `action` value; `next` on the move and link-check views goes through safe_next(). - Forms: the only related-object fields in resume/forms.py are SkillForm.group (scoped to SkillGroup.objects.for_user(user), required) and ReferenceForm.contact (scoped to Contact.objects.for_user(user), plus Reference.clean()/save() refuse a contact with another owner_id). Verified by a scratch test: posting another account's group id to item_create/skill and item_update/skill is rejected (200 with errors, nothing saved) and ?group=&lt;their id> renders no foreign option. The sweep already covers a foreign contact on reference create (test_isolation_sweep.py:1116-1135). No form lists `owner` or `company` (company is editable=False and set only via companies.find_or_add(owner, name), which is for_user/get_or_create_company scoped). - No formsets exist in the resume app (TranslationForm is a plain Form keyed to the narrowed entry; Translation rows are written with owner=entry.owner), so the formset-row attack class does not apply; tests/security/test_formset_rows.py correctly has no resume entry. - Candidate file (resume/candidate.py): ids in the file (`id`, `group_id`, `ref`) are resolved only against rows of the same file (self.named) and never against the database; existing entries are matched by content against kind.model.objects.for_user(user); translations are written at entry_row.target which is either the user's existing row or the unsaved new one; company links come from companies.existing(user, name) (for_user) and a file never adds a company; addresses/numbers/links/identifiers are hung on the importer's own Profile; details touch only first_name/last_name and CANDIDATE_PROFILE_FIELDS of the importer's own profile. Session storage is Django's default database-backed session, so held files never reach a cookie. - Europass import (resume/importing.py, EuropassImportView): every write is owner=request.user; SkillGroup reuse is filtered by owner; references create Contacts owned by the importer; the profile phone number and reference numbers go through phone_numbers.refusal(), which checks the allowance before the lookup (the correct order). - Candidate download: CandidateDownloadView serves export.build_candidate_document(request.user) only, with Content-Disposition attachment and Cache-Control no-store; no id in the address. - Indirect reach: Experience/Education/Certification/Honour/Membership/Course.company and Reference.contact FKs are only ever set to owner-scoped rows; Skill.group is scoped; the CV picker in documents/forms.py (AddCVItemsForm) builds MultipleChoiceField choices from spec.model.objects.for_user(cv.owner) so a foreign entry pk posted as add_&lt;section> fails validation (also covered by the sweep); documents/proofs.find/entries_for are for_user and UploadedDocument.proves is read-only in the API. - Administrator pages and Django admin hold no career records: no admin.py in resume, and no server/people/accounts view imports a resume model (only a docstring mention in core/views_kind_files.py). - Signals, errands and background tasks: the resume app registers no signal receivers and no errands; apps.ready() only registers the built-in Europass importer. Management commands (import_europass, match_languages) are operator-only and take the account from the shell, not from a request. - Model-level invariants: Publication.free_cite_key and the cite-key uniqueness check use for_user; ordering.siblings/place_many operate on type(item).objects.for_user(owner), so a move can only swap with the owner's own entries; Translation language from GET/POST is held to languages.well_formed (RFC 5646 shape, &lt;= 35 chars) before it is stored. - Skill suggestions (SkillSuggestionsView) read only the ESCO classification, are throttled per account, and are answered with Cache-Control: private, no-store; the ESCO lookup is an in-memory prefix search, not SQL. - Link checks (LinkCheckView, resume/links.py) act only on Link.objects.for_user(user), spend the per-account fetch allowance before any request, and validate the address and every redirect through the guarded public_only_client (SSRF belongs to another dimension and was not re-audited here). - Templates (overview, item_form, translations, candidate_file, europass_import, preview, skill_suggestions) contain no |safe and render only the context the scoped views built; reference rows print permission state in words and never a referee's contact details. - Out of scope by the threat model and not reported: the candidate review page re-validates up to 200 rows per kind and 2000 translations through Django forms on every GET while a file is held and the upload POST is not throttled (a CPU cost per request); denial of service against a personal instance is explicitly out of scope.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of plugins-views

  • Every pattern in src/postulo/plugins/urls.py traced: list, pick, edit, test, consent, backfill, sync_now, delete all sit on OwnedObjectMixin (LoginRequiredMixin) and look the connection up with Connection.objects.for_user(request.user) before get_object_or_404; backfill and sync_now narrow further by kind; action views define only post() so a GET is 405; delete is a DeleteView whose POST is the only mutation.
  • connections:create: kind must be in CONNECTED_KINDS, the plugin must be installed and decide(name, user).offered; POST additionally requires .on; the new row's owner is stamped from request.user after form.save(commit=False). The 404 for a hidden (UNAVAILABLE) plugin and for an uninstalled one are the same status and template, and the Http404 message is not rendered (verified by scratch test: bodies differ only by the echoed path length).
  • ConnectionForm: no ModelChoiceField, ModelMultipleChoiceField or formset; fields come from the plugin's own FieldSpecs plus kind specs; config_fields(user=owner) is given the instance owner, which is request.user on create and the scoped connection's owner on edit; stored secrets are never echoed (placeholder) and blank keeps them; the email notifier re-validates the recipient against the person's verified addresses at send time.
  • Policy enforcement at run time: _switched_off() guards test, consent (except forget), backfill and sync_now; sync_connection, send_copy, webhook pending and due_connections all re-check allows()/refused_connections; the per-request decision memo is an asgiref Local cleared by UserPreferencesMiddleware on every request and at each scheduler pass, and PluginPolicy.save()/delete() forget it, so a cross-process stale decision cannot outlive one request.
  • Consent callback: LoginRequiredMixin; plugin state is signed, 15-minute max age, bound to connection pk and owner, and checked against request.user; the error branch writes nothing; the instance-mail branch uses its own salt, requires is_staff and the administrator who started it (core/mail_auth.finish_consent); tests/security/test_mail_consent.py and tests/test_consent.py cover forged, stale, cross-salt and cross-account states. No other default-salt signing.loads exists in src/postulo.
  • PluginLogoView: sign-in required; a non-staff member sees only a plugin that is on or offered to them; the name is resolved through the registry and the logo file comes from the plugin's package (tests/test_plugin_logos.py covers path, size, SVG sanitising and the switched-off case); the response carries FILE_POLICY CSP and nosniff.
  • Administrator pages (server:plugins, server:person_plugins, PluginPolicyView, PluginRepositoryView, PluginActionView, IdentifierSchemesView) all derive from StaffRequiredMixin; _save_policies iterates the registry's governed plugins rather than POST keys; person_plugins reveals policy rows and decided_by only; the gdpr record of processing on the staff page exposes per-plugin connection counts, not rows; the user's own settings:plugins POST only calls set_choice for rows marked theirs, and set_choice itself refuses the rest.
  • No csrf_exempt, no signal receivers, no Errand use and no unscoped .objects. lookups in the plugins package other than consent.finish (owner-checked, reported as informational) and the scheduler passes (filtered by owner__is_active and refused_connections, each row acted on as its own owner under preferences.as_person). CsrfViewMiddleware is in MIDDLEWARE.
  • Shipped plugins under src/postulo/plugins/*/ (browser, email, own_mail, smtp, webhook, localstore, europass, gdpr, maps, identifiers, link_services, messaging_services, feature plugins) define no views, URL patterns, Django forms or models; data.py confirms no plugin owns a table; the browser plugin dials the user-typed push endpoint through public_only_client with redirects off.
  • Background work started from a request: ConnectionBackfillView hands only the scoped connection to backfill(), which reads documents_of(connection.owner); schedule_copies stamps owner=document.owner; webhook enqueue stamps owner=connection.owner; ConnectionSyncNowView's sync_connection runs as connection.owner; Connection.retire() is only ever called on a connection the sender already holds.
  • Secrets: Connection.secrets is Fernet under a key derived from POSTULO_FIELD_KEY or SECRET_KEY; SecretsUnreadable is caught on the form and on test without a 500 and without exposing ciphertext; delete cascades the row (and SyncLink, DocumentCopy, WebhookDelivery through their FKs); retire() drops secrets unless the plugin asked to keep them.
  • Coverage by the project's own security suite: tests/security/test_isolation_sweep.py lists connections:backfill, consent, delete, edit, sync_now and test in FACTORIES (GET and POST with another account's pk) and excuses create and logo with named tests; tests/security/test_plugin_policy_runtime.py covers the create address of an unavailable plugin, a POST for one forced off, and that test/sync_now/backfill/consent call nothing that is off; tests/test_connections.py checks list/edit/delete/test across accounts. No formset rows exist in this app for test_formset_rows.py to cover.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of plugins-views - Every pattern in src/postulo/plugins/urls.py traced: list, pick, edit, test, consent, backfill, sync_now, delete all sit on OwnedObjectMixin (LoginRequiredMixin) and look the connection up with Connection.objects.for_user(request.user) before get_object_or_404; backfill and sync_now narrow further by kind; action views define only post() so a GET is 405; delete is a DeleteView whose POST is the only mutation. - connections:create: kind must be in CONNECTED_KINDS, the plugin must be installed and decide(name, user).offered; POST additionally requires .on; the new row's owner is stamped from request.user after form.save(commit=False). The 404 for a hidden (UNAVAILABLE) plugin and for an uninstalled one are the same status and template, and the Http404 message is not rendered (verified by scratch test: bodies differ only by the echoed path length). - ConnectionForm: no ModelChoiceField, ModelMultipleChoiceField or formset; fields come from the plugin's own FieldSpecs plus kind specs; config_fields(user=owner) is given the instance owner, which is request.user on create and the scoped connection's owner on edit; stored secrets are never echoed (placeholder) and blank keeps them; the email notifier re-validates the recipient against the person's verified addresses at send time. - Policy enforcement at run time: _switched_off() guards test, consent (except forget), backfill and sync_now; sync_connection, send_copy, webhook pending and due_connections all re-check allows()/refused_connections; the per-request decision memo is an asgiref Local cleared by UserPreferencesMiddleware on every request and at each scheduler pass, and PluginPolicy.save()/delete() forget it, so a cross-process stale decision cannot outlive one request. - Consent callback: LoginRequiredMixin; plugin state is signed, 15-minute max age, bound to connection pk and owner, and checked against request.user; the error branch writes nothing; the instance-mail branch uses its own salt, requires is_staff and the administrator who started it (core/mail_auth.finish_consent); tests/security/test_mail_consent.py and tests/test_consent.py cover forged, stale, cross-salt and cross-account states. No other default-salt signing.loads exists in src/postulo. - PluginLogoView: sign-in required; a non-staff member sees only a plugin that is on or offered to them; the name is resolved through the registry and the logo file comes from the plugin's package (tests/test_plugin_logos.py covers path, size, SVG sanitising and the switched-off case); the response carries FILE_POLICY CSP and nosniff. - Administrator pages (server:plugins, server:person_plugins, PluginPolicyView, PluginRepositoryView, PluginActionView, IdentifierSchemesView) all derive from StaffRequiredMixin; _save_policies iterates the registry's governed plugins rather than POST keys; person_plugins reveals policy rows and decided_by only; the gdpr record of processing on the staff page exposes per-plugin connection counts, not rows; the user's own settings:plugins POST only calls set_choice for rows marked theirs, and set_choice itself refuses the rest. - No csrf_exempt, no signal receivers, no Errand use and no unscoped .objects. lookups in the plugins package other than consent.finish (owner-checked, reported as informational) and the scheduler passes (filtered by owner__is_active and refused_connections, each row acted on as its own owner under preferences.as_person). CsrfViewMiddleware is in MIDDLEWARE. - Shipped plugins under src/postulo/plugins/*/ (browser, email, own_mail, smtp, webhook, localstore, europass, gdpr, maps, identifiers, link_services, messaging_services, feature plugins) define no views, URL patterns, Django forms or models; data.py confirms no plugin owns a table; the browser plugin dials the user-typed push endpoint through public_only_client with redirects off. - Background work started from a request: ConnectionBackfillView hands only the scoped connection to backfill(), which reads documents_of(connection.owner); schedule_copies stamps owner=document.owner; webhook enqueue stamps owner=connection.owner; ConnectionSyncNowView's sync_connection runs as connection.owner; Connection.retire() is only ever called on a connection the sender already holds. - Secrets: Connection.secrets is Fernet under a key derived from POSTULO_FIELD_KEY or SECRET_KEY; SecretsUnreadable is caught on the form and on test without a 500 and without exposing ciphertext; delete cascades the row (and SyncLink, DocumentCopy, WebhookDelivery through their FKs); retire() drops secrets unless the plugin asked to keep them. - Coverage by the project's own security suite: tests/security/test_isolation_sweep.py lists connections:backfill, consent, delete, edit, sync_now and test in FACTORIES (GET and POST with another account's pk) and excuses create and logo with named tests; tests/security/test_plugin_policy_runtime.py covers the create address of an unavailable plugin, a POST for one forced off, and that test/sync_now/backfill/consent call nothing that is off; tests/test_connections.py checks list/edit/delete/test across accounts. No formset rows exist in this app for test_formset_rows.py to cover.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Ownership review of api

  • Every router operation that names a record (applications, listings, companies, contacts, interviews, offers, reminders, CVs, letters, uploads, rendered documents, captures/rendering) looks up through owned()/owned_or_404(), i.e. Model.objects.for_user(request.auth.owner) before get_object_or_404; foreign ids are 404 with one wording (problems._not_found). Matches tests/security/test_isolation_sweep.py API table, which covers every addressed route.
  • Ids in request bodies are validated against the owner before anything is written: interview application_id (404), contact_ids restricted to the owner's contacts at the application's company (422, count compared), reminder application_id (404), referred_by_id/through_agency_id (422), listing-event contact_id/document_id (422), CV prints id/ids via printing.choose/choose_identifiers which accept only rows from printing.offered(cv.owner). Same answer for somebody else's and a missing one; nothing saved on refusal (lookups precede writes, transactions around multi-step writes).
  • List filters (?company=, ?application=, ?status=, ?state=, ?q=, updated_since/after_id) are applied on top of owner-scoped querysets, so a foreign id in a filter narrows to nothing; paginator (Page/LimitOffsetPagination, MAX_LIMIT 200) receives only scoped querysets; _DocumentPages unions owner-scoped key sets and refetches by owner-scoped pk__in.
  • Nested output never reaches another owner's rows: application_out/listing_out/company_out walk FK/M2M of owned rows; listing_event_out uses ListingEvent.points_at, which re-checks artefact.owner_id == posting.owner_id on every read; ContactOut/InterviewOut expose ids only for related rows set through owner-checked paths.
  • Services called from the API stamp the owner from the parent record, never from the caller: create_listing/create_application(owner), apply_to_listing (owner=posting.owner), schedule_interview (owner=application.owner), Reminder create (owner), record_listing_event (owner is the listing's; _check_artefact and contact owner check), get_or_create_company and Company.by_identifier/CompanyIdentifier scoped to owner, Tag.named/Industry.named via slugs.named(filter owner=owner), set_identifiers clash check for_user(company.owner), phone_numbers.save_only_number/web_links.add_links/messaging_handles.add_handles write owner= explicitly.
  • Search, insights, calendar feeds and known-capture checks are owner-scoped: core.search.* all for_user(user); analytics.build/fingerprint keyed on user.pk with every query for_user or application__owner=user; agenda.events_between/dated_days/feed_reminders for_user; jobs.known for_user; remembered.read_page/after_capture FieldHint.objects.for_user(owner).
  • Scope enforcement: router-level scope('read') with per-operation scope('write'), scope('captures'), scope('documents:read'), scope('listings:bind','write'); ScopedAuth checks has_scope before throttling and recording use; captures-only tokens cannot read listings/documents, listings:bind reaches only /listings/choices and POST /listings/{pk}/events (tests/security/test_listing_history.py). openapi.json guarded by for_readers_of_the_api (live token or signed-in person).
  • Token credential handling: ApiToken.issue uses secrets.token_urlsafe(32), stores only SHA-256 (unique), shows raw once with Cache-Control no-store and no session storage (#441); lookup requires active (not revoked/expired) and owner.is_active; record_use writes sparsely; revoke view is POST-only, OwnedObjectMixin + for_user + CSRF; token form offers only the closed SCOPES list; admin registration is read/delete only with no add/change (#368).
  • Idempotency (Idempotency-Key) rows are keyed (owner, key) with a payload fingerprint, so another account cannot replay or collide with a stored answer; claims are released on failure; sweep is per owner.
  • Capture flow: Capture created with owner=token.owner; rendering attach is owner-scoped before the body is examined, measured before read, media type verified by magic bytes, cannot replace an existing rendering, refused once decided; outbound fetch only in _read via plugins.fetching.fetch_page with the account-level capture throttle; errands.send carries the owner explicitly.
  • CSRF/session separation: django-ninja views are csrf_exempt but authenticate solely by bearer (HttpBearer); no router reads request.user; the session-bound token pages (capture-tokens/) sit behind CsrfViewMiddleware with {% csrf_token %} on both forms.
  • File delivery through the API uses serve_private_file (no-store, nosniff, file CSP) after an owner-scoped lookup; uploads additionally pass within=storage_prefix (#663).
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Ownership review of api - Every router operation that names a record (applications, listings, companies, contacts, interviews, offers, reminders, CVs, letters, uploads, rendered documents, captures/rendering) looks up through owned()/owned_or_404(), i.e. Model.objects.for_user(request.auth.owner) before get_object_or_404; foreign ids are 404 with one wording (problems._not_found). Matches tests/security/test_isolation_sweep.py API table, which covers every addressed route. - Ids in request bodies are validated against the owner before anything is written: interview application_id (404), contact_ids restricted to the owner's contacts at the application's company (422, count compared), reminder application_id (404), referred_by_id/through_agency_id (422), listing-event contact_id/document_id (422), CV prints id/ids via printing.choose/choose_identifiers which accept only rows from printing.offered(cv.owner). Same answer for somebody else's and a missing one; nothing saved on refusal (lookups precede writes, transactions around multi-step writes). - List filters (?company=, ?application=, ?status=, ?state=, ?q=, updated_since/after_id) are applied on top of owner-scoped querysets, so a foreign id in a filter narrows to nothing; paginator (Page/LimitOffsetPagination, MAX_LIMIT 200) receives only scoped querysets; _DocumentPages unions owner-scoped key sets and refetches by owner-scoped pk__in. - Nested output never reaches another owner's rows: application_out/listing_out/company_out walk FK/M2M of owned rows; listing_event_out uses ListingEvent.points_at, which re-checks artefact.owner_id == posting.owner_id on every read; ContactOut/InterviewOut expose ids only for related rows set through owner-checked paths. - Services called from the API stamp the owner from the parent record, never from the caller: create_listing/create_application(owner), apply_to_listing (owner=posting.owner), schedule_interview (owner=application.owner), Reminder create (owner), record_listing_event (owner is the listing's; _check_artefact and contact owner check), get_or_create_company and Company.by_identifier/CompanyIdentifier scoped to owner, Tag.named/Industry.named via slugs.named(filter owner=owner), set_identifiers clash check for_user(company.owner), phone_numbers.save_only_number/web_links.add_links/messaging_handles.add_handles write owner= explicitly. - Search, insights, calendar feeds and known-capture checks are owner-scoped: core.search.* all for_user(user); analytics.build/fingerprint keyed on user.pk with every query for_user or application__owner=user; agenda.events_between/dated_days/feed_reminders for_user; jobs.known for_user; remembered.read_page/after_capture FieldHint.objects.for_user(owner). - Scope enforcement: router-level scope('read') with per-operation scope('write'), scope('captures'), scope('documents:read'), scope('listings:bind','write'); ScopedAuth checks has_scope before throttling and recording use; captures-only tokens cannot read listings/documents, listings:bind reaches only /listings/choices and POST /listings/{pk}/events (tests/security/test_listing_history.py). openapi.json guarded by for_readers_of_the_api (live token or signed-in person). - Token credential handling: ApiToken.issue uses secrets.token_urlsafe(32), stores only SHA-256 (unique), shows raw once with Cache-Control no-store and no session storage (#441); lookup requires active (not revoked/expired) and owner.is_active; record_use writes sparsely; revoke view is POST-only, OwnedObjectMixin + for_user + CSRF; token form offers only the closed SCOPES list; admin registration is read/delete only with no add/change (#368). - Idempotency (Idempotency-Key) rows are keyed (owner, key) with a payload fingerprint, so another account cannot replay or collide with a stored answer; claims are released on failure; sweep is per owner. - Capture flow: Capture created with owner=token.owner; rendering attach is owner-scoped before the body is examined, measured before read, media type verified by magic bytes, cannot replace an existing rendering, refused once decided; outbound fetch only in _read via plugins.fetching.fetch_page with the account-level capture throttle; errands.send carries the owner explicitly. - CSRF/session separation: django-ninja views are csrf_exempt but authenticate solely by bearer (HttpBearer); no router reads request.user; the session-bound token pages (capture-tokens/) sit behind CsrfViewMiddleware with {% csrf_token %} on both forms. - File delivery through the API uses serve_private_file (no-store, nosniff, file CSP) after an owner-scoped lookup; uploads additionally pass within=storage_prefix (#663).
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Authentication and sessions

  • Invitation tokens: 32 random bytes (secrets.token_urlsafe), only the SHA-256 fingerprint stored, 14-day expiry, single use enforced by an atomic conditional UPDATE in Invite.accept with select_for_update in the signup POST, staff-only issuance and revocation (tests/security/test_staff_only.py sweeps every server:/invite route), an invitation can never grant is_staff, and an address-bound invitation is enforced in AccountAdapter.clean_email (self.request is populated from allauth's context var by AccountMiddleware, verified).
  • Recovery links: 256-bit token, fingerprint only, one hour, one live link per person, 404 with one message for every bad state, token kept out of the form URL (session pk instead), spent only on POST so link-preview bots cannot burn it, 10/h per administrator throttle, revocation, sets a password only (no session, MFA untouched), and the password change invalidates the person's other sessions through Django's session auth hash.
  • Sign-in rate limiting: allauth defaults are active (login 30/m/ip; login_failed 10/m/ip plus 5 per 5 min per account through the overridden _get_login_attempts_cache_key, which resolves username and every address to one account and refuses to collide typed names with account keys; request_login_code 20/m/ip + 3/m/address; code attempts capped at 3 and resends at 3; reset_password 20/m/ip + 5/m/address; reset_from_key 20/m/ip; signup 20/m/ip; MFA/TOTP/passkey/recovery-code attempts share the per-user login_failed key; reauthenticate 10/m/user; manage_email 10/m/user). Counters live in the shared database cache, not per process.
  • IP keying: TrustedProxyMiddleware runs first, strips X-Forwarded-* from any peer outside POSTULO_TRUSTED_PROXIES (loopback by default) and otherwise takes the rightmost untrusted X-Forwarded-For entry, so a client cannot pick its own limiter identity; allauth reads the rewritten REMOTE_ADDR (ALLAUTH_TRUSTED_PROXY_COUNT unset); SECURE_PROXY_SSL_HEADER is only honoured after that stripping.
  • Sessions and cookies: Django's login cycles the session key; SESSION/CSRF cookies HttpOnly, SameSite=Lax, Secure in prod (POSTULO_SECURE_COOKIES); MFA trust cookie is signed with Signer and inherits the session cookie flags, 30 days; logout is POST-only (ACCOUNT_LOGOUT_ON_GET default False); 'remember me' uses allauth's default session age; HSTS and SSL redirect on in prod with health/metrics exemptions anchored.
  • MFA: SecondFactorStage only skips the prompt for a provider login when the operator explicitly says the IdP is the second factor; login-by-code still meets TOTP (tests/security/test_email_sign_in.py); TOTP replay within a period is blocked by allauth's cache mark; passkey relying-party id is the request host and MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN is set only in dev/test; passkey signup disabled; recovery codes, TOTP and WebAuthn views all require reauthentication; a console mfa_reset command exists but needs a shell.
  • Social/OIDC: provider must assert email_verified for any linking (tests/test_sso.py); registration through SSO is closed unless POSTULO_OIDC_AUTO_SIGNUP, open registration or a pending invitation; state parameter and token storage are allauth's (STORE_TOKENS off); the username blacklist is honoured on SSO auto-signup (a preferred_username of admin/root/support/me was replaced with 'user' in a scratch test); the first-account-becomes-admin receiver is the documented bootstrap.
  • Password change and reset: allauth's ChangePasswordForm requires the current password; other sessions are invalidated by the session hash while the current one is kept; validators require 12 characters, not common, not numeric, not similar to the person's attributes; a reset clears the account's lockout counter.
  • Email addresses: mandatory verification before an address signs in or becomes primary; ACCOUNT_MAX_EMAIL_ADDRESSES = 5; ACCOUNT_UNIQUE_EMAIL with enumeration prevention; confirmation on POST only (CONFIRM_EMAIL_ON_GET default False) with HMAC keys. (The unverified-address pre-claim weakness is reported above.)
  • Account deletion: allauth reauthentication (enabled by default: enabled=None means required), the address typed back, last-administrator guard, logout first, database cascade covering allauth rows, authenticators, API tokens, recovery links and invites, with files removed only on commit; the administrator's PersonDeleteView refuses self and the last administrator and demands the username.
  • Administrator toggles: PersonAdminView/PersonActiveView/PersonUsernameView/PersonRecoveryView are StaffRequiredMixin-only and swept by tests/security/test_staff_only.py; the last active administrator cannot be demoted, deactivated or deleted; self-deactivation is refused; deactivation ends web sessions on the next request (ModelBackend.get_user checks is_active, confirmed by scratch test) and API tokens refuse an inactive owner (api/auth.py:51).
  • Django admin: not mounted unless POSTULO_ADMIN_URL is set; PostuloAdminSite.login never shows a form and redirects through allauth with a safe next check; only User, Invite and token metadata registered (tests/security/test_admin_exposure.py); appointed administrators get is_staff only, never is_superuser.
  • Impersonation: no impersonation feature exists in src/postulo; AvatarView is the only cross-account read and is limited to administrators by design.
  • Templates under account/, mfa/, socialaccount/, allauth/, server/ and settings/: no |safe or autoescape off; every state-changing form carries csrf_token (allauth's element wrapper receives it from the body slot); recovery and invitation links are rendered once and never into messages.
  • Redirects: ThemeView and the admin login signpost go through safe_next / url_has_allowed_host_and_scheme; HtmxLoginRedirectMiddleware only rewrites relative sign-in redirects.
  • Secret-key handling: prod refuses a missing, placeholder or short POSTULO_SECRET_KEY and POSTULO_FIELD_KEY (keys.py), with an explicit rotation warning; the test settings key is isolated.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Authentication and sessions - Invitation tokens: 32 random bytes (secrets.token_urlsafe), only the SHA-256 fingerprint stored, 14-day expiry, single use enforced by an atomic conditional UPDATE in Invite.accept with select_for_update in the signup POST, staff-only issuance and revocation (tests/security/test_staff_only.py sweeps every server:/invite route), an invitation can never grant is_staff, and an address-bound invitation is enforced in AccountAdapter.clean_email (self.request is populated from allauth's context var by AccountMiddleware, verified). - Recovery links: 256-bit token, fingerprint only, one hour, one live link per person, 404 with one message for every bad state, token kept out of the form URL (session pk instead), spent only on POST so link-preview bots cannot burn it, 10/h per administrator throttle, revocation, sets a password only (no session, MFA untouched), and the password change invalidates the person's other sessions through Django's session auth hash. - Sign-in rate limiting: allauth defaults are active (login 30/m/ip; login_failed 10/m/ip plus 5 per 5 min per account through the overridden _get_login_attempts_cache_key, which resolves username and every address to one account and refuses to collide typed names with account keys; request_login_code 20/m/ip + 3/m/address; code attempts capped at 3 and resends at 3; reset_password 20/m/ip + 5/m/address; reset_from_key 20/m/ip; signup 20/m/ip; MFA/TOTP/passkey/recovery-code attempts share the per-user login_failed key; reauthenticate 10/m/user; manage_email 10/m/user). Counters live in the shared database cache, not per process. - IP keying: TrustedProxyMiddleware runs first, strips X-Forwarded-* from any peer outside POSTULO_TRUSTED_PROXIES (loopback by default) and otherwise takes the rightmost untrusted X-Forwarded-For entry, so a client cannot pick its own limiter identity; allauth reads the rewritten REMOTE_ADDR (ALLAUTH_TRUSTED_PROXY_COUNT unset); SECURE_PROXY_SSL_HEADER is only honoured after that stripping. - Sessions and cookies: Django's login cycles the session key; SESSION/CSRF cookies HttpOnly, SameSite=Lax, Secure in prod (POSTULO_SECURE_COOKIES); MFA trust cookie is signed with Signer and inherits the session cookie flags, 30 days; logout is POST-only (ACCOUNT_LOGOUT_ON_GET default False); 'remember me' uses allauth's default session age; HSTS and SSL redirect on in prod with health/metrics exemptions anchored. - MFA: SecondFactorStage only skips the prompt for a provider login when the operator explicitly says the IdP is the second factor; login-by-code still meets TOTP (tests/security/test_email_sign_in.py); TOTP replay within a period is blocked by allauth's cache mark; passkey relying-party id is the request host and MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN is set only in dev/test; passkey signup disabled; recovery codes, TOTP and WebAuthn views all require reauthentication; a console mfa_reset command exists but needs a shell. - Social/OIDC: provider must assert email_verified for any linking (tests/test_sso.py); registration through SSO is closed unless POSTULO_OIDC_AUTO_SIGNUP, open registration or a pending invitation; state parameter and token storage are allauth's (STORE_TOKENS off); the username blacklist is honoured on SSO auto-signup (a preferred_username of admin/root/support/me was replaced with 'user' in a scratch test); the first-account-becomes-admin receiver is the documented bootstrap. - Password change and reset: allauth's ChangePasswordForm requires the current password; other sessions are invalidated by the session hash while the current one is kept; validators require 12 characters, not common, not numeric, not similar to the person's attributes; a reset clears the account's lockout counter. - Email addresses: mandatory verification before an address signs in or becomes primary; ACCOUNT_MAX_EMAIL_ADDRESSES = 5; ACCOUNT_UNIQUE_EMAIL with enumeration prevention; confirmation on POST only (CONFIRM_EMAIL_ON_GET default False) with HMAC keys. (The unverified-address pre-claim weakness is reported above.) - Account deletion: allauth reauthentication (enabled by default: enabled=None means required), the address typed back, last-administrator guard, logout first, database cascade covering allauth rows, authenticators, API tokens, recovery links and invites, with files removed only on commit; the administrator's PersonDeleteView refuses self and the last administrator and demands the username. - Administrator toggles: PersonAdminView/PersonActiveView/PersonUsernameView/PersonRecoveryView are StaffRequiredMixin-only and swept by tests/security/test_staff_only.py; the last active administrator cannot be demoted, deactivated or deleted; self-deactivation is refused; deactivation ends web sessions on the next request (ModelBackend.get_user checks is_active, confirmed by scratch test) and API tokens refuse an inactive owner (api/auth.py:51). - Django admin: not mounted unless POSTULO_ADMIN_URL is set; PostuloAdminSite.login never shows a form and redirects through allauth with a safe next check; only User, Invite and token metadata registered (tests/security/test_admin_exposure.py); appointed administrators get is_staff only, never is_superuser. - Impersonation: no impersonation feature exists in src/postulo; AvatarView is the only cross-account read and is limited to administrators by design. - Templates under account/, mfa/, socialaccount/, allauth/, server/ and settings/: no |safe or autoescape off; every state-changing form carries csrf_token (allauth's element wrapper receives it from the body slot); recovery and invitation links are rendered once and never into messages. - Redirects: ThemeView and the admin login signpost go through safe_next / url_has_allowed_host_and_scheme; HtmxLoginRedirectMiddleware only rewrites relative sign-in redirects. - Secret-key handling: prod refuses a missing, placeholder or short POSTULO_SECRET_KEY and POSTULO_FIELD_KEY (keys.py), with an explicit rotation warning; the test settings key is isolated.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

File handling

  • upload_to callables (documents, captures, exports) all place files under <kind>/<owner_id>/<Y/m>/; names pass through Django's generate_filename (validate_file_name + get_valid_filename), and the importer uses only the basename, so a crafted name cannot traverse or land in another owner's folder.
  • serve_private_file: every call site narrows with for_user()/owned_or_404 first; within= holds uploads to their owner's prefix; FileSystemStorage.path (safe_join) plus resolve_media_path refuse names that leave or symlink out of MEDIA_ROOT; responses carry Cache-Control no-store, nosniff and the default-src 'none'; sandbox policy; SVG is never handed to the proxy; CapturedRenderingView sets Content-Type from the stored kind and serves stranger PDFs as attachments; kept page sources leave only through serve_private_text as text/plain attachments.
  • MEDIA_URL is not routed anywhere; WhiteNoise serves STATIC_ROOT only (no WHITENOISE_ROOT); the DEBUG static() mount covers STATIC_URL only; no nginx config in the repository exposes media; tests/security/test_files.py asserts the 404.
  • Upload validation: extension allow-list (no html/svg/xml), magic bytes for pdf/doc/rtf/txt, zip-directory check (listed, never unpacked) for odt/docx, Pillow decode for png/jpg under MAX_IMAGE_PIXELS with LOAD_TRUNCATED_IMAGES forced off; size caps compared with upload.size before any read for documents, logos, avatars, Europass, candidate, kind files, iCalendar and backups.
  • Pictures: avatars and logos are re-encoded PNG rows (metadata stripped, byte budget, square crop for faces); SVG logos go through a defusedxml (forbid_dtd) allowlist that drops script/foreignObject/style/image/a, every on* attribute, non-# href/xlink:href and any url() not pointing at #; served under the sandbox policy; plugin marks use the same sanitiser.
  • Backups: archive names must fullmatch postulo-(backup|upload)-\d{8}-\d{6}(-\d+)?\.tar\.gz, be a non-symlink regular file whose real parent is the backups directory; restore is CLI-only, refuses non-regular members, absolute paths, .., empty parts, backslash/colon/NUL, checks the resolved destination is under the root before writing anything, and refuses a non-empty instance without --force; download and delete require recent authentication; upload goes to mkstemp (0600) and is unlinked unless it verifies.
  • Export: archive built only from file names the owner's own rows store; ExportArchive is owner-scoped, served as an attachment, expires and is reaped; the errand writes through mkstemp.
  • Importer (manage.py import_data, operator-run): entries are fetched by getinfo with file_size compared to the cap and a limit+1 read, must start with media/, uploads are re-checked for checksum, size, extension and magic bytes, kept pages are re-measured, re-packed and renamed, avatars and logos re-processed; the manifest is capped at MANIFEST_MAX_BYTES.
  • JSON and text importers: kind files (2 MB, 5000 rows) and candidate file (1 MB) catch RecursionError and refuse NaN/Infinity; iCalendar reader bounds size, line length, depth, component count and entries; CSV export cells are formula-guarded; Europass goes through an expat DOCTYPE probe and defusedxml, and its PDF attachment extractor allows only FlateDecode with an inflate cap and at most 8 attachments.
  • PDF rendering: WeasyPrint is given URLFetcher(allowed_protocols={'data'}, allow_redirects=False) and none of the fetcher-bypassing write_pdf arguments; the Chromium fallback aborts every network request via page.route; a captured page is drawn in a child process with no protocols at all, RLIMIT_DATA 1 GiB, RLIMIT_CPU 60 s, a 45 s wall clock and a %PDF- check on what comes back.
  • ODT/DOCX generation escapes text with xml.sax.saxutils and writes fixed member names; downloads of generated formats use fixed per-format content types and Django's content_disposition_header; no lxml anywhere; every XML parse is defusedxml.
  • Slug-driven lookups (help topics, themes, formats, CV download <slug:format>) are dictionary/registry lookups and never build a filesystem path; no view joins request data onto a path; the only request-driven deletions are owner-scoped rows (uploads, kept pages) and regex-checked backup names.
  • Temporary files use mkstemp/TemporaryFile/TemporaryDirectory with default 0600 modes and no predictable names; account deletion removes only files named by the user's own rows and prunes <folder>/<pk> directories.
  • Hand-built Content-Disposition headers (interview .ics, report CSV/PDF, contact JSON) use fixed names; every user-named filename goes through content_disposition_header, which escapes or RFC 6266-encodes it.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## File handling - upload_to callables (documents, captures, exports) all place files under `<kind>/<owner_id>/<Y/m>/`; names pass through Django's generate_filename (validate_file_name + get_valid_filename), and the importer uses only the basename, so a crafted name cannot traverse or land in another owner's folder. - serve_private_file: every call site narrows with for_user()/owned_or_404 first; `within=` holds uploads to their owner's prefix; FileSystemStorage.path (safe_join) plus resolve_media_path refuse names that leave or symlink out of MEDIA_ROOT; responses carry Cache-Control no-store, nosniff and the `default-src 'none'; sandbox` policy; SVG is never handed to the proxy; CapturedRenderingView sets Content-Type from the stored kind and serves stranger PDFs as attachments; kept page sources leave only through serve_private_text as text/plain attachments. - MEDIA_URL is not routed anywhere; WhiteNoise serves STATIC_ROOT only (no WHITENOISE_ROOT); the DEBUG `static()` mount covers STATIC_URL only; no nginx config in the repository exposes media; tests/security/test_files.py asserts the 404. - Upload validation: extension allow-list (no html/svg/xml), magic bytes for pdf/doc/rtf/txt, zip-directory check (listed, never unpacked) for odt/docx, Pillow decode for png/jpg under MAX_IMAGE_PIXELS with LOAD_TRUNCATED_IMAGES forced off; size caps compared with upload.size before any read for documents, logos, avatars, Europass, candidate, kind files, iCalendar and backups. - Pictures: avatars and logos are re-encoded PNG rows (metadata stripped, byte budget, square crop for faces); SVG logos go through a defusedxml (forbid_dtd) allowlist that drops script/foreignObject/style/image/a, every on* attribute, non-`#` href/xlink:href and any url() not pointing at `#`; served under the sandbox policy; plugin marks use the same sanitiser. - Backups: archive names must fullmatch `postulo-(backup|upload)-\d{8}-\d{6}(-\d+)?\.tar\.gz`, be a non-symlink regular file whose real parent is the backups directory; restore is CLI-only, refuses non-regular members, absolute paths, `..`, empty parts, backslash/colon/NUL, checks the resolved destination is under the root before writing anything, and refuses a non-empty instance without --force; download and delete require recent authentication; upload goes to mkstemp (0600) and is unlinked unless it verifies. - Export: archive built only from file names the owner's own rows store; ExportArchive is owner-scoped, served as an attachment, expires and is reaped; the errand writes through mkstemp. - Importer (manage.py import_data, operator-run): entries are fetched by getinfo with file_size compared to the cap and a limit+1 read, must start with `media/`, uploads are re-checked for checksum, size, extension and magic bytes, kept pages are re-measured, re-packed and renamed, avatars and logos re-processed; the manifest is capped at MANIFEST_MAX_BYTES. - JSON and text importers: kind files (2 MB, 5000 rows) and candidate file (1 MB) catch RecursionError and refuse NaN/Infinity; iCalendar reader bounds size, line length, depth, component count and entries; CSV export cells are formula-guarded; Europass goes through an expat DOCTYPE probe and defusedxml, and its PDF attachment extractor allows only FlateDecode with an inflate cap and at most 8 attachments. - PDF rendering: WeasyPrint is given URLFetcher(allowed_protocols={'data'}, allow_redirects=False) and none of the fetcher-bypassing write_pdf arguments; the Chromium fallback aborts every network request via page.route; a captured page is drawn in a child process with no protocols at all, RLIMIT_DATA 1 GiB, RLIMIT_CPU 60 s, a 45 s wall clock and a `%PDF-` check on what comes back. - ODT/DOCX generation escapes text with xml.sax.saxutils and writes fixed member names; downloads of generated formats use fixed per-format content types and Django's content_disposition_header; no lxml anywhere; every XML parse is defusedxml. - Slug-driven lookups (help topics, themes, formats, CV download `<slug:format>`) are dictionary/registry lookups and never build a filesystem path; no view joins request data onto a path; the only request-driven deletions are owner-scoped rows (uploads, kept pages) and regex-checked backup names. - Temporary files use mkstemp/TemporaryFile/TemporaryDirectory with default 0600 modes and no predictable names; account deletion removes only files named by the user's own rows and prunes `<folder>/<pk>` directories. - Hand-built Content-Disposition headers (interview .ics, report CSV/PDF, contact JSON) use fixed names; every user-named filename goes through content_disposition_header, which escapes or RFC 6266-encodes it.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

SSRF and outbound traffic

  • Address classifier (core/destinations.is_public + plugins/public_addresses): loopback, 0.0.0.0/8, RFC1918, 169.254/16 incl. 169.254.169.254, CGNAT 100.64/10, 192.0.0.0/24, 198.18/15, 240/4, broadcast, ::1, ::, ::/8, fe80::/10, fc00::/7, site-local fec0::/10, IPv4-mapped ::ffff:127.0.0.1 and ::ffff:7f00:1, NAT64 64:ff9b::7f00:1 and ::ffff:a9fe:a9fe, 6to4 2002:7f00:1::, Teredo 2001:0:7f00:1:: are all refused; decimal/hex/octal/short IPv4 forms (0x7f000001, 2130706433, 127.1, 0177.0.0.1, '0') are canonicalised by getaddrinfo before the check and refused (verified by executing the functions).
  • DNS rebinding: both clients resolve in a request hook on every request (redirect hops included), approve every address the name answers with, rewrite the URL host to one approved address (_pin), restore the typed name for the Host header and SNI, keep the named URL for building redirects (_restore_name), and fall back only among the approved addresses; no second resolution precedes the socket connect. Mail does the same with PinnedSMTP/PinnedSMTP_SSL (certificate name kept for STARTTLS and implicit TLS).
  • Schemes and redirect targets: only http/https pass public_addresses_for; the hook runs on the redirect request so a Location of file:, ftp:, data: or an unparsable address is refused (tests/security/test_outbound.py:1354); relative Locations are joined to the named URL, not the pinned IP.
  • Capture and logo fetches follow redirects by hand (follow_redirects=False), close every redirect body unread, re-validate each hop, cap bodies via streamed read_body with Content-Length lies ignored, inflate gzip/deflate piecewise with a compressed-size cap, refuse brotli/zstd/unknown codings unread, and cut every network wait to a whole-download deadline through a wrapped httpcore backend (also for proxy pools). robots.txt is fetched through the same guarded client and read to a 500 KiB cap.
  • Credential leakage on redirect: httpx strips Authorization on cross-origin hops, and same-origin is judged on the restored name (tests 120/144 in test_outbound.py); the webhook and web push clients do not follow redirects at all; no cookie jar persists between calls.
  • TLS: verify is httpx's default everywhere (no verify=False, no custom CA in the codebase); on direct connections httpcore passes the sni_hostname extension as server_hostname so the certificate is checked against the typed name; smtplib paths use ssl.create_default_context().
  • Request header injection: h11 refuses CR/LF in header values (verified: LocalProtocolError 'Illegal header value'); no user-typed data reaches an outbound header value in any shipped caller (User-Agent fixed, Postulo-Event/Delivery internal, VAPID Authorization computed).
  • URL parsing differences: the hook checks urlparse(str(request.url)), i.e. httpx's own serialisation, and then connects to the pinned IP, so userinfo (http://evil@127.0.0.1), backslash tricks, IDNA/Unicode hosts (httpx raises InvalidURL for non-IDNA names), trailing dots and host:port confusions cannot make the check and the connection disagree; IPv6 pinning is bracketed correctly by httpx 0.28.1 copy_with(host=...).
  • Every outbound caller in the codebase was enumerated: plugins/fetching, jobs/logos, resume/links, plugins/browser/webpush (hand-written VAPID/RFC 8291, public-only client, HTTPS-only endpoints, status only returned), plugins/webhook (HMAC-signed, check_destination at validate/test/every delivery), plugins/consent, plugins/catalogue (signed index, wheel checksum, streamed download with cap and deadline), accounts/avatars (Gravatar: opt-in via profile.use_gravatar, SHA-256 hash, server-side one-off fetch of a fixed host), core/updates (off by default, operator URL), fetch_geonames/fetch_esco (fixed public URLs, operator-run), core/mail + plugins/smtp + plugins/own_mail (SMTP pinned via destinations.approve_all). No use of requests, urllib, aiohttp, pywebpush, imaplib or dnspython; subprocess calls are fixed tools (pip --only-binary :all:, WeasyPrint child process, backup tool) with no curl/wget.
  • Operator vs user mail hosts: the instance transport and the Email page test (administrator-only, StaffRequiredMixin) use host_policy(), whose private-address exemption applies only when the host comes from POSTULO_EMAIL_HOST in the environment; a person's own outbox passes destinations.private_allowed() alone, refuses credentials over an unencrypted session, and is throttled (POSTULO_OUTBOX_RATE 60/h).
  • Rate limits exist on every user-triggered outbound surface: capture 30/h shared between the form and the API (spent on the fetching branch of /captures and /captures/preview), fetch 300/h for link checks and logo fetches, connection tests 10/h, outbox sends 60/h.
  • PDF rendering makes no network requests: WeasyPrint is given URLFetcher(allowed_protocols={'data'}, allow_redirects=False); the Chromium fallback aborts every route before set_content.
  • Maps: there is no tile proxy in the code; the map is a server-written SVG and nothing is sent to any tile or geocoding service.
  • OAuth consent callbacks are bound to the initiating owner (connection) or the initiating administrator (instance mail) through signed, short-lived state values; tokens are stored in the encrypted secrets store.
  • The capture API and the capture form hand the URL to fetch_page (public-only) and the errand turns every CaptureError/DestinationRefused into a refusal message rather than a traceback.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## SSRF and outbound traffic - Address classifier (core/destinations.is_public + plugins/public_addresses): loopback, 0.0.0.0/8, RFC1918, 169.254/16 incl. 169.254.169.254, CGNAT 100.64/10, 192.0.0.0/24, 198.18/15, 240/4, broadcast, ::1, ::, ::/8, fe80::/10, fc00::/7, site-local fec0::/10, IPv4-mapped ::ffff:127.0.0.1 and ::ffff:7f00:1, NAT64 64:ff9b::7f00:1 and ::ffff:a9fe:a9fe, 6to4 2002:7f00:1::, Teredo 2001:0:7f00:1:: are all refused; decimal/hex/octal/short IPv4 forms (0x7f000001, 2130706433, 127.1, 0177.0.0.1, '0') are canonicalised by getaddrinfo before the check and refused (verified by executing the functions). - DNS rebinding: both clients resolve in a request hook on every request (redirect hops included), approve every address the name answers with, rewrite the URL host to one approved address (`_pin`), restore the typed name for the Host header and SNI, keep the named URL for building redirects (`_restore_name`), and fall back only among the approved addresses; no second resolution precedes the socket connect. Mail does the same with PinnedSMTP/PinnedSMTP_SSL (certificate name kept for STARTTLS and implicit TLS). - Schemes and redirect targets: only http/https pass `public_addresses_for`; the hook runs on the redirect request so a Location of file:, ftp:, data: or an unparsable address is refused (tests/security/test_outbound.py:1354); relative Locations are joined to the named URL, not the pinned IP. - Capture and logo fetches follow redirects by hand (follow_redirects=False), close every redirect body unread, re-validate each hop, cap bodies via streamed `read_body` with Content-Length lies ignored, inflate gzip/deflate piecewise with a compressed-size cap, refuse brotli/zstd/unknown codings unread, and cut every network wait to a whole-download deadline through a wrapped httpcore backend (also for proxy pools). robots.txt is fetched through the same guarded client and read to a 500 KiB cap. - Credential leakage on redirect: httpx strips Authorization on cross-origin hops, and same-origin is judged on the restored name (tests 120/144 in test_outbound.py); the webhook and web push clients do not follow redirects at all; no cookie jar persists between calls. - TLS: verify is httpx's default everywhere (no verify=False, no custom CA in the codebase); on direct connections httpcore passes the `sni_hostname` extension as server_hostname so the certificate is checked against the typed name; smtplib paths use ssl.create_default_context(). - Request header injection: h11 refuses CR/LF in header values (verified: LocalProtocolError 'Illegal header value'); no user-typed data reaches an outbound header value in any shipped caller (User-Agent fixed, Postulo-Event/Delivery internal, VAPID Authorization computed). - URL parsing differences: the hook checks urlparse(str(request.url)), i.e. httpx's own serialisation, and then connects to the pinned IP, so userinfo (`http://evil@127.0.0.1`), backslash tricks, IDNA/Unicode hosts (httpx raises InvalidURL for non-IDNA names), trailing dots and host:port confusions cannot make the check and the connection disagree; IPv6 pinning is bracketed correctly by httpx 0.28.1 `copy_with(host=...)`. - Every outbound caller in the codebase was enumerated: plugins/fetching, jobs/logos, resume/links, plugins/browser/webpush (hand-written VAPID/RFC 8291, public-only client, HTTPS-only endpoints, status only returned), plugins/webhook (HMAC-signed, check_destination at validate/test/every delivery), plugins/consent, plugins/catalogue (signed index, wheel checksum, streamed download with cap and deadline), accounts/avatars (Gravatar: opt-in via profile.use_gravatar, SHA-256 hash, server-side one-off fetch of a fixed host), core/updates (off by default, operator URL), fetch_geonames/fetch_esco (fixed public URLs, operator-run), core/mail + plugins/smtp + plugins/own_mail (SMTP pinned via destinations.approve_all). No use of requests, urllib, aiohttp, pywebpush, imaplib or dnspython; subprocess calls are fixed tools (pip --only-binary :all:, WeasyPrint child process, backup tool) with no curl/wget. - Operator vs user mail hosts: the instance transport and the Email page test (administrator-only, StaffRequiredMixin) use host_policy(), whose private-address exemption applies only when the host comes from POSTULO_EMAIL_HOST in the environment; a person's own outbox passes destinations.private_allowed() alone, refuses credentials over an unencrypted session, and is throttled (POSTULO_OUTBOX_RATE 60/h). - Rate limits exist on every user-triggered outbound surface: capture 30/h shared between the form and the API (spent on the fetching branch of /captures and /captures/preview), fetch 300/h for link checks and logo fetches, connection tests 10/h, outbox sends 60/h. - PDF rendering makes no network requests: WeasyPrint is given URLFetcher(allowed_protocols={'data'}, allow_redirects=False); the Chromium fallback aborts every route before set_content. - Maps: there is no tile proxy in the code; the map is a server-written SVG and nothing is sent to any tile or geocoding service. - OAuth consent callbacks are bound to the initiating owner (connection) or the initiating administrator (instance mail) through signed, short-lived state values; tokens are stored in the encrypted secrets store. - The capture API and the capture form hand the URL to fetch_page (public-only) and the errand turns every CaptureError/DestinationRefused into a refusal message rather than a traceback.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

XSS and content security policy

  • Template autoescaping: across all 264 templates there is no |safe, {% autoescape off %}, json_script or inline <script> other than partials/messages.html:12 (whose 'safe' messages are all built with format_html) and the three external <script src> tags; no unquoted attributes; every href/src/action/formaction/hx-/data- value is a quoted, autoescaped expression.
  • Template-tag HTML builders (icon, brand, flag, option_icons, company_identifier, phone_link, highlight, account_row_current, avatar/company_logo/plugin_logo): names are regex-checked against shipped files before becoming paths, attributes and labels go through escape()/format_html, and data-driven icon names (Tag.glyph, link_services.brand_name, OptionIcons/shipped(), flags._have_flag, web_links/messaging_handles.service_icon) are filtered to shipped files before {% icon %}/{% brand %} can raise.
  • Markdown (core/markdown.py): markdown-it 'zero' preset with html=False plus an nh3 allowlist (http/https/mailto only, relative denied, forced rel/target, comments stripped); scratch-tested: javascript:, entity-encoded javascript:, data: and relative link targets are dropped, raw <a>/<img onerror> is escaped as text, and listing descriptions reach the page only through render()/plain().
  • Search (core/search.py, templatetags highlight): excerpts are cut from plain text (markdown.plain, description_text) and the highlight filter escapes both the surrounding text and the match before mark_safe; truncation happens before escaping so no entity is cut.
  • Kept page sources (jobs/pages.py, jobs/page_views.py, capture_page.html): shown escaped inside <pre>, downloaded only through serve_private_text (fixed text/plain, attachment, nosniff, 'default-src none; sandbox'); renderings are validated by magic bytes, PDFs are attachments, pictures are inline under FILE_POLICY; the instance draws them in a child process with a WeasyPrint fetcher that opens nothing, and document PDFs use URLFetcher(allowed_protocols={'data'}).
  • Document previews (documents/views.py 340-379, 571-): same-origin iframe by src (not srcdoc), the nonce is Django's per-request get_nonce() read before rendering so it reaches the header, the only CSP override flips frame-ancestors to 'self' with X-Frame-Options SAMEORIGIN; cover-letter placeholders are a fixed regex substitution (fill_placeholders), never the template engine; theme templates come only from shipped directories or installed plugins' templates/ appended after Postulo's own (plugins/themes.py), so a plugin cannot shadow an interface template.
  • SVG: uploaded avatars, company logos and plugin marks pass pictures.sanitise_svg (defusedxml, element and attribute allowlist with no script/style/foreignObject/image/animate, event handlers and external url()/href refused) and are served with 'default-src none; sandbox' and nosniff via serve_stored_picture / plugins logo view; the CV photo is embedded as a data: image inside an <img>.
  • URL inputs on the live paths validate the scheme: ModelForm URLFields, core.addresses.web_address/page_address for the API and captures (schemas.py WebAddress, plugins/base.py url_must_be_a_page), CSV import requires http(s), candidate-file import goes through WebLinkForm; identifier links are built from scheme templates with quoted values and _steps_out checks; tel: links carry digits only.
  • CSP (settings/base.py 703-731) applies to every response including the 500 handler: default-src 'none', script-src 'self', no 'unsafe-inline' or 'unsafe-eval', style nonce only where read, connect/worker/manifest/font 'self', object-src 'none', form-action 'self', frame-ancestors 'none', frame-src 'self'; prod.py adds HSTS/SSL/cookie settings and nothing relaxes the policy. htmx 2.0.10 is configured by meta attribute (no indicator styles, no history cache) and app.js has a single insertAdjacentHTML fed by a same-origin server-rendered fragment; hx-on/eval are blocked by the policy.
  • Headers: every Content-Disposition is content_disposition_header() or a constant/slug; HX-Redirect is only ever a relative Django redirect (HtmxLoginRedirectMiddleware checks for no scheme/netloc) or reverse(); X-Request-ID is echoed only when it matches logs.acceptable; Content-Language is normalised; HX-Trigger is read, never written from user data.
  • django-cotton components: attributes are rendered under autoescape before the component echoes them, slots are rendered template output, no component prints {{ attrs }} raw; allauth element overrides print attrs.href/src/value through autoescape and attrs.errors is Django's escaped ErrorList.
  • Messages and i18n: messages.* calls with user data are not marked safe except the three format_html-built capture messages; blocktranslate blocks with markup only carry {% url %} values or escaped variables; no HTML email anywhere (send_mail/EmailMessage plain text).
  • Plugin-supplied strings: connection form labels/help/choices are drawn through <c-field> (escaped), form_attributes become data-* through format_html_join with regex-restricted names; plugin names, labels, summaries, authors and licences are printed escaped on the server pages.
  • Server-written SVG (company_map.html, translation_bar.html) interpolates numbers with floatformat and names inside autoescaped <title>/<a>; the service worker only opens same-origin URLs (sw.js sameSite); the web app manifest and healthz are JSON responses.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## XSS and content security policy - Template autoescaping: across all 264 templates there is no |safe, {% autoescape off %}, json_script or inline &lt;script> other than partials/messages.html:12 (whose 'safe' messages are all built with format_html) and the three external &lt;script src> tags; no unquoted attributes; every href/src/action/formaction/hx-*/data-* value is a quoted, autoescaped expression. - Template-tag HTML builders (icon, brand, flag, option_icons, company_identifier, phone_link, highlight, account_row_current, avatar/company_logo/plugin_logo): names are regex-checked against shipped files before becoming paths, attributes and labels go through escape()/format_html, and data-driven icon names (Tag.glyph, link_services.brand_name, OptionIcons/shipped(), flags._have_flag, web_links/messaging_handles.service_icon) are filtered to shipped files before {% icon %}/{% brand %} can raise. - Markdown (core/markdown.py): markdown-it 'zero' preset with html=False plus an nh3 allowlist (http/https/mailto only, relative denied, forced rel/target, comments stripped); scratch-tested: javascript:, entity-encoded javascript:, data: and relative link targets are dropped, raw &lt;a>/&lt;img onerror> is escaped as text, and listing descriptions reach the page only through render()/plain(). - Search (core/search.py, templatetags highlight): excerpts are cut from plain text (markdown.plain, description_text) and the highlight filter escapes both the surrounding text and the match before mark_safe; truncation happens before escaping so no entity is cut. - Kept page sources (jobs/pages.py, jobs/page_views.py, capture_page.html): shown escaped inside &lt;pre>, downloaded only through serve_private_text (fixed text/plain, attachment, nosniff, 'default-src none; sandbox'); renderings are validated by magic bytes, PDFs are attachments, pictures are inline under FILE_POLICY; the instance draws them in a child process with a WeasyPrint fetcher that opens nothing, and document PDFs use URLFetcher(allowed_protocols={'data'}). - Document previews (documents/views.py 340-379, 571-): same-origin iframe by src (not srcdoc), the nonce is Django's per-request get_nonce() read before rendering so it reaches the header, the only CSP override flips frame-ancestors to 'self' with X-Frame-Options SAMEORIGIN; cover-letter placeholders are a fixed regex substitution (fill_placeholders), never the template engine; theme templates come only from shipped directories or installed plugins' templates/ appended after Postulo's own (plugins/themes.py), so a plugin cannot shadow an interface template. - SVG: uploaded avatars, company logos and plugin marks pass pictures.sanitise_svg (defusedxml, element and attribute allowlist with no script/style/foreignObject/image/animate, event handlers and external url()/href refused) and are served with 'default-src none; sandbox' and nosniff via serve_stored_picture / plugins logo view; the CV photo is embedded as a data: image inside an &lt;img>. - URL inputs on the live paths validate the scheme: ModelForm URLFields, core.addresses.web_address/page_address for the API and captures (schemas.py WebAddress, plugins/base.py url_must_be_a_page), CSV import requires http(s), candidate-file import goes through WebLinkForm; identifier links are built from scheme templates with quoted values and _steps_out checks; tel: links carry digits only. - CSP (settings/base.py 703-731) applies to every response including the 500 handler: default-src 'none', script-src 'self', no 'unsafe-inline' or 'unsafe-eval', style nonce only where read, connect/worker/manifest/font 'self', object-src 'none', form-action 'self', frame-ancestors 'none', frame-src 'self'; prod.py adds HSTS/SSL/cookie settings and nothing relaxes the policy. htmx 2.0.10 is configured by meta attribute (no indicator styles, no history cache) and app.js has a single insertAdjacentHTML fed by a same-origin server-rendered fragment; hx-on/eval are blocked by the policy. - Headers: every Content-Disposition is content_disposition_header() or a constant/slug; HX-Redirect is only ever a relative Django redirect (HtmxLoginRedirectMiddleware checks for no scheme/netloc) or reverse(); X-Request-ID is echoed only when it matches logs.acceptable; Content-Language is normalised; HX-Trigger is read, never written from user data. - django-cotton components: attributes are rendered under autoescape before the component echoes them, slots are rendered template output, no component prints {{ attrs }} raw; allauth element overrides print attrs.href/src/value through autoescape and attrs.errors is Django's escaped ErrorList. - Messages and i18n: messages.* calls with user data are not marked safe except the three format_html-built capture messages; blocktranslate blocks with markup only carry {% url %} values or escaped variables; no HTML email anywhere (send_mail/EmailMessage plain text). - Plugin-supplied strings: connection form labels/help/choices are drawn through &lt;c-field> (escaped), form_attributes become data-* through format_html_join with regex-restricted names; plugin names, labels, summaries, authors and licences are printed escaped on the server pages. - Server-written SVG (company_map.html, translation_bar.html) interpolates numbers with floatformat and names inside autoescaped &lt;title>/&lt;a>; the service worker only opens same-origin URLs (sw.js sameSite); the web app manifest and healthz are JSON responses.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Injection and parsers

  • Raw SQL: the only cursor.execute calls are constant statements (core/backup.py:207 pg_stat_activity, core/metrics.py:87 SELECT 1); jobs/places.py uses parameterised sqlite3 queries; no .raw()/.extra()/RawSQL anywhere; table filters (core/tables.py:729-760) build lookups from column declarations in code, never from request keys.
  • core/sqlite_like.py: the LIKE-to-regex translation re.escape()s every literal, honours the ESCAPE character Django emits for % and _, and the compiled-pattern cache is bounded (lru_cache 512).
  • Subprocesses: backup.py (pg_dump/pg_restore/psql), jobs/rendering.py (fixed command, HTML on stdin, timeout, rlimits in the child), documents/pdf.py (fixed -c script), plugins/installing.py (uv/pip argv built from server-made paths; uploaded wheels are stored under a random token name; verify script receives argv only) — no shell, no user-controlled argv, no os.system/shlex execution.
  • XML: Europass reader and SVG sanitiser parse with defusedxml forbid_dtd=True; plugins/base.py refuses any DOCTYPE with an expat handler that raises before entities are read; imports capped at 5 MB; ODT/DOCX writers escape every text and attribute with xml.sax.saxutils.
  • Archives: importer reads zip members through getinfo() size checks plus read(limit+1); backup restore validates every tar member (_safe_member_path, resolve().is_relative_to, plain files only, no symlinks) before writing; kept-page gzip is unpacked under a byte limit (jobs/kept.py); Europass PDF Flate streams are inflated under a limit; documents/integrity.py lists zip directories without unpacking.
  • Deserialisation and dynamic code: no pickle/yaml/marshal/eval/exec/literal_eval; JSON only (kind files use parse_constant=no_constants); plugins load only via entry points of administrator-installed packages; registry lookups are by name against loaded objects; PluginActionView maps actions through a fixed dict rather than getattr on request data.
  • Templates: no Template()/from_string/engines[...] use; every get_template/render_to_string name comes from code registries (help topics dict, widget specs, documents/themes.template_for); cover-letter placeholders are a \w+ regex substitution on plain text rendered through autoescaped context with no mark_safe.
  • Regular expressions: patterns over captured pages run on bounded windows (WINDOW=160, LABEL_REACH=40) with escaped label words; custom identifier patterns (administrator-only) compile to possessive quantifiers with an ambiguity check and a measured budget; core/languages._SHAPE is length-capped and measured linear on adversarial input; link/messaging service patterns are static and simple; csv.Sniffer measured linear on a crafted 48 KB single-line sample.
  • CSV: every CSV Postulo writes goes through spreadsheets.row (reports.py, the import template), which neutralises = + - @ tab and CR; import is capped at 2 MB/5000 rows, field-size csv.Error is caught, column mapping is restricted to FIELD_KEYS, and a URL cell is kept only with an http(s) scheme.
  • iCalendar import: size, line length, nesting depth and component count are checked on the text before icalendar parses; RRULE/RDATE/RECURRENCE-ID entries are refused; VALARM, ATTACH, URL, CONFERENCE and ATTENDEE are never read; the export strips control characters and uses the library's escaping; interview/reminder lookups on review are for_user.
  • vCard: the hand-written reader is bounded (bytes, cards, properties per card, line length, per-kind counts), never fetches PHOTO/LOGO/KEY/SOURCE, and jobs/vcards.py validates links with URLValidator(schemes=http,https) and addresses.web_address; the writer strips every control character and folds at 75 octets.
  • Email headers: notification subjects are collapsed to one line (plugins/email._subject); the send-documents recipient is an EmailField; Django's forbid_multi_line_headers/sanitize_address refuse CR/LF so no header injection is reachable; the outbox never rewrites From.
  • HTTP headers: Content-Disposition uses django.utils.http.content_disposition_header wherever a user-typed name is involved (core/files.py, documents/views.py, vcard/ical/kind-file/candidate downloads); the remaining f-string sites use only ints and dates; X-Accel-Redirect quotes a path already checked against MEDIA_ROOT and the owner's folder; X-Request-ID is echoed only when it matches [A-Za-z0-9._:-]{1,200}; HX-Redirect is set only for relative sign-in paths; a kept rendering's Content-Type comes from an enum.
  • /logs endpoint (core/views_logs.py): a collector read endpoint, not a browser log sink — 404 when off, 503 without a token, constant-time bearer comparison, per-address wrong-token and request throttles, bounded limit; JSON file formatter cannot be line-injected.
  • File names and paths: backups.archive_path enforces a strict regex plus symlink and real-parent checks; brand, flag and icon names are regex-checked before becoming paths; plugin logos are read via importlib.resources after refusing separators; uploaded documents are served with the stored extension and a sandboxing CSP; resolve_media_path keeps every served name under MEDIA_ROOT.
  • Mass assignment: the archive importer's _carried() drops id and every *_id key and passes only exporter-declared fields, resolving relations through its own maps (tests/security/test_import_isolation.py); the Europass Record is written field by field; the candidate file reads entries through model forms narrowed to declared names with for_user lookups and ids used only as in-file names; kind files prune rows to a schema and run them through model forms; API PATCH schemas (ProfilePatch, CVPatch, ReminderPatch, CompanyPatch) carry no owner/id/is_staff fields and every referenced id is resolved with owned(); nothing lets a file or payload set owner, id or staff flags or link another owner's rows.
  • Markdown: markdown-it-py zero preset with html/linkify off plus an nh3 allowlist (relative URLs denied, rel forced); phone numbers go through the phonenumbers library; postal rules are static anchored patterns on folded short strings; management commands that take paths (import_data, import_csv, import_europass, restore, backup) are operator-only CLI.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Injection and parsers - Raw SQL: the only cursor.execute calls are constant statements (core/backup.py:207 pg_stat_activity, core/metrics.py:87 SELECT 1); jobs/places.py uses parameterised sqlite3 queries; no .raw()/.extra()/RawSQL anywhere; table filters (core/tables.py:729-760) build lookups from column declarations in code, never from request keys. - core/sqlite_like.py: the LIKE-to-regex translation re.escape()s every literal, honours the ESCAPE character Django emits for % and _, and the compiled-pattern cache is bounded (lru_cache 512). - Subprocesses: backup.py (pg_dump/pg_restore/psql), jobs/rendering.py (fixed command, HTML on stdin, timeout, rlimits in the child), documents/pdf.py (fixed -c script), plugins/installing.py (uv/pip argv built from server-made paths; uploaded wheels are stored under a random token name; verify script receives argv only) — no shell, no user-controlled argv, no os.system/shlex execution. - XML: Europass reader and SVG sanitiser parse with defusedxml forbid_dtd=True; plugins/base.py refuses any DOCTYPE with an expat handler that raises before entities are read; imports capped at 5 MB; ODT/DOCX writers escape every text and attribute with xml.sax.saxutils. - Archives: importer reads zip members through getinfo() size checks plus read(limit+1); backup restore validates every tar member (_safe_member_path, resolve().is_relative_to, plain files only, no symlinks) before writing; kept-page gzip is unpacked under a byte limit (jobs/kept.py); Europass PDF Flate streams are inflated under a limit; documents/integrity.py lists zip directories without unpacking. - Deserialisation and dynamic code: no pickle/yaml/marshal/eval/exec/literal_eval; JSON only (kind files use parse_constant=no_constants); plugins load only via entry points of administrator-installed packages; registry lookups are by name against loaded objects; PluginActionView maps actions through a fixed dict rather than getattr on request data. - Templates: no Template()/from_string/engines[...] use; every get_template/render_to_string name comes from code registries (help topics dict, widget specs, documents/themes.template_for); cover-letter placeholders are a \w+ regex substitution on plain text rendered through autoescaped context with no mark_safe. - Regular expressions: patterns over captured pages run on bounded windows (WINDOW=160, LABEL_REACH=40) with escaped label words; custom identifier patterns (administrator-only) compile to possessive quantifiers with an ambiguity check and a measured budget; core/languages._SHAPE is length-capped and measured linear on adversarial input; link/messaging service patterns are static and simple; csv.Sniffer measured linear on a crafted 48 KB single-line sample. - CSV: every CSV Postulo writes goes through spreadsheets.row (reports.py, the import template), which neutralises = + - @ tab and CR; import is capped at 2 MB/5000 rows, field-size csv.Error is caught, column mapping is restricted to FIELD_KEYS, and a URL cell is kept only with an http(s) scheme. - iCalendar import: size, line length, nesting depth and component count are checked on the text before icalendar parses; RRULE/RDATE/RECURRENCE-ID entries are refused; VALARM, ATTACH, URL, CONFERENCE and ATTENDEE are never read; the export strips control characters and uses the library's escaping; interview/reminder lookups on review are for_user. - vCard: the hand-written reader is bounded (bytes, cards, properties per card, line length, per-kind counts), never fetches PHOTO/LOGO/KEY/SOURCE, and jobs/vcards.py validates links with URLValidator(schemes=http,https) and addresses.web_address; the writer strips every control character and folds at 75 octets. - Email headers: notification subjects are collapsed to one line (plugins/email._subject); the send-documents recipient is an EmailField; Django's forbid_multi_line_headers/sanitize_address refuse CR/LF so no header injection is reachable; the outbox never rewrites From. - HTTP headers: Content-Disposition uses django.utils.http.content_disposition_header wherever a user-typed name is involved (core/files.py, documents/views.py, vcard/ical/kind-file/candidate downloads); the remaining f-string sites use only ints and dates; X-Accel-Redirect quotes a path already checked against MEDIA_ROOT and the owner's folder; X-Request-ID is echoed only when it matches [A-Za-z0-9._:-]{1,200}; HX-Redirect is set only for relative sign-in paths; a kept rendering's Content-Type comes from an enum. - /logs endpoint (core/views_logs.py): a collector read endpoint, not a browser log sink — 404 when off, 503 without a token, constant-time bearer comparison, per-address wrong-token and request throttles, bounded limit; JSON file formatter cannot be line-injected. - File names and paths: backups.archive_path enforces a strict regex plus symlink and real-parent checks; brand, flag and icon names are regex-checked before becoming paths; plugin logos are read via importlib.resources after refusing separators; uploaded documents are served with the stored extension and a sandboxing CSP; resolve_media_path keeps every served name under MEDIA_ROOT. - Mass assignment: the archive importer's _carried() drops id and every *_id key and passes only exporter-declared fields, resolving relations through its own maps (tests/security/test_import_isolation.py); the Europass Record is written field by field; the candidate file reads entries through model forms narrowed to declared names with for_user lookups and ids used only as in-file names; kind files prune rows to a schema and run them through model forms; API PATCH schemas (ProfilePatch, CVPatch, ReminderPatch, CompanyPatch) carry no owner/id/is_staff fields and every referenced id is resolved with owned(); nothing lets a file or payload set owner, id or staff flags or link another owner's rows. - Markdown: markdown-it-py zero preset with html/linkify off plus an nh3 allowlist (relative URLs denied, rel forced); phone numbers go through the phonenumbers library; postal rules are static anchored patterns on folded short strings; management commands that take paths (import_data, import_csv, import_europass, restore, backup) are operator-only CLI.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Secrets and cryptography

  • API tokens (src/postulo/api/models.py, auth.py): 32 random bytes via secrets.token_urlsafe, only SHA-256 stored in a unique indexed column, lookup by hash (no timing-usable comparison of the secret), shown once on a no-store response and never written to the session, expiry/revocation/owner.is_active all enforced in lookup(), last-used write throttled, per-token rate limit, every router and route carries an explicit scope (captures, listings:bind, read, write, documents:read) and /me returns only token metadata; admin registration is read-only and cannot mint.
  • Invitation and recovery tokens (accounts/tokens.py, recovery.py, models.py, views.py, adapter.py, signals.py): 32-byte random tokens, only SHA-256 fingerprints stored, single use (invite rows locked with select_for_update on the signup POST and consumed inside the request transaction; recovery links set used_at), 14-day / 1-hour lifetimes, the token moved into the server-side session after the first GET so it never reaches Referer or history, recovery consumed only on POST (link-preview safe), issuing rate-limited per administrator, one uniform 'unusable' answer.
  • Field encryption (plugins/secrets.py, plugins/models.py, core/models.py): Fernet (AES-128-CBC + HMAC-SHA256, fresh random IV per token, authenticated) with domain-separated derivation prefixes for the Fernet key, the fingerprint and the VAPID key; a changed key raises SecretsUnreadable rather than yielding garbage; stored secrets are never rendered back into any form (SECRET_PLACEHOLDER / PasswordInput(render_value=False) in plugins/forms.py; the server Email form is write-only with only has_password / has_client_secret booleans in context); POSTULO_FIELD_KEY is refused when weak exactly as SECRET_KEY is.
  • Weak-key refusal (config/settings/keys.py, prod.py, tests/security/test_secret_key.py): length >= 50, >= 5 distinct characters, placeholder list and django-insecure- prefix, applied to both keys at production import; the override flag cannot admit an empty key or a placeholder; SECRET_KEY is never printed or logged.
  • /logs and /metrics (core/views_logs.py, views_metrics.py, checks.py): 404 when off, refuse-to-serve when on without a token, hmac.compare_digest over bytes, Cache-Control no-store, per-address wrong-token bar and request rate, start-up warning on tokens shorter than 24 characters; the metrics body carries counts and versions only, no per-person labels (confirmed against metrics.py and its tests).
  • Webhook notifier (plugins/webhook/init.py, notifications/webhooks.py): HMAC-SHA256 over '<timestamp>.<body>' with a hex digest, the reference verifier uses hmac.compare_digest and a 5-minute tolerance, secret minimum 16 characters and stored encrypted, redirects not followed, destination policy re-checked at every delivery, failures recorded without the secret.
  • Plugin catalogue and installer (plugins/catalogue.py, installing.py, forms.py): Ed25519 signature over the exact index bytes against an administrator-pinned key, wheel SHA-256 checked after a size- and time-bounded download, only py3-none-any wheels and only built wheels, Postulo's own pins enforced as constraints, a key change on a repository row is called out and logged; no path for an unsigned catalogue.
  • Trusted-proxy handling (core/proxy.py, prod.py, middleware.py, tests/security/test_proxy.py): default trust is loopback only, all five forwarding headers are removed from an untrusted peer before SecurityMiddleware reads X-Forwarded-Proto, the client address is the rightmost non-trusted X-Forwarded-For entry with ports stripped, an unparseable configured range is ignored (fail-closed), USE_X_FORWARDED_HOST/PORT are not enabled, the health-probe shortcut applies only to loopback peers with no forwarding header.
  • Throttle (core/throttle.py): cache keys are namespaced by action, identity (a primary key or a validated IP) and window, so a caller cannot choose another's bucket; add/incr pattern with window expiry; rates validated by a start-up check.
  • Web push (plugins/browser/webpush.py, notifications/views.py, static/js/sw.js): RFC 8291 aes128gcm with a fresh ECDH sender key and 16-byte random salt per message, VAPID ES256 JWT with aud = the push origin and a 12-hour expiry, subscriptions validated (https endpoint, 65-byte P-256 point, 16-byte auth) and stored as an encrypted secret, pushes sent only through the public-only guarded client without redirects, the service worker opens only same-origin URLs and does no caching.
  • OAuth consent (plugins/consent.py, core/mail_auth.py, plugins/views.py, tests/security/test_mail_consent.py): state is a Django TimestampSigner value with a 15-minute max_age; connection states and instance-mail states use different salts and each is tested against the other; the callback requires a signed-in user and an owner match; the instance-mail callback requires the same staff user who started it; access/refresh tokens and client secrets live only in the Fernet stores, never in allauth SocialToken (SOCIALACCOUNT_STORE_TOKENS=False); refreshes go through the guarded HTTP client; the start of a flow is a CSRF-protected POST.
  • Secrets in configuration and images (.env.example, docker/Dockerfile, compose*.yml, entrypoint.sh, settings/dev.py): no default secret ships, compose reads the operator's .env, the entrypoint prints nothing sensitive, dev generates a random per-machine key and the test settings pin a throwaway one; POSTGRES_PASSWORD is documented, not defaulted.
  • Templates and context processors (core/context_processors.py, notifications/context_processors.py, base.html): nothing settings-derived beyond public URLs, version (signed-in only) and UI preferences reaches templates; no template renders a secret, password or token value (only the one-shot invite/recovery/API-token pages, by design).
  • Logging (core/logs.py, settings LOGGING, grep of logger calls): no logger call formats a token, password, key or Authorization header; httpx/httpcore are held at WARNING so URLs are not logged; X-Request-ID is validated before it is echoed or logged.
  • Session, CSRF and transport settings (base.py, prod.py): HttpOnly and SameSite=Lax cookies, Secure cookies and HSTS in production, SECURE_PROXY_SSL_HEADER only effective behind the trusted-proxy middleware, CSRF_TRUSTED_ORIGINS from the environment, Django's default JSON session serializer and PBKDF2 password hasher (MD5 only in the test settings), strict CSP with no inline script, allauth email verification mandatory and login-by-code limited to 3 attempts in 180 s.
  • Django admin (settings, config/urls.py, api/admin.py, accounts/admin.py, tests/security/test_admin_exposure.py): unmounted unless an operator names a path, no login of its own, holds only accounts, invitations and token metadata; token_hash and token_fingerprint shown there are one-way digests of 256-bit random values.
  • Idempotency keys (api/idempotency.py): scoped per owner with a SHA-256 fingerprint of the body, so a key cannot replay another account's answer or answer a different request.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Secrets and cryptography - API tokens (src/postulo/api/models.py, auth.py): 32 random bytes via secrets.token_urlsafe, only SHA-256 stored in a unique indexed column, lookup by hash (no timing-usable comparison of the secret), shown once on a no-store response and never written to the session, expiry/revocation/owner.is_active all enforced in lookup(), last-used write throttled, per-token rate limit, every router and route carries an explicit scope (captures, listings:bind, read, write, documents:read) and /me returns only token metadata; admin registration is read-only and cannot mint. - Invitation and recovery tokens (accounts/tokens.py, recovery.py, models.py, views.py, adapter.py, signals.py): 32-byte random tokens, only SHA-256 fingerprints stored, single use (invite rows locked with select_for_update on the signup POST and consumed inside the request transaction; recovery links set used_at), 14-day / 1-hour lifetimes, the token moved into the server-side session after the first GET so it never reaches Referer or history, recovery consumed only on POST (link-preview safe), issuing rate-limited per administrator, one uniform 'unusable' answer. - Field encryption (plugins/secrets.py, plugins/models.py, core/models.py): Fernet (AES-128-CBC + HMAC-SHA256, fresh random IV per token, authenticated) with domain-separated derivation prefixes for the Fernet key, the fingerprint and the VAPID key; a changed key raises SecretsUnreadable rather than yielding garbage; stored secrets are never rendered back into any form (SECRET_PLACEHOLDER / PasswordInput(render_value=False) in plugins/forms.py; the server Email form is write-only with only has_password / has_client_secret booleans in context); POSTULO_FIELD_KEY is refused when weak exactly as SECRET_KEY is. - Weak-key refusal (config/settings/keys.py, prod.py, tests/security/test_secret_key.py): length >= 50, >= 5 distinct characters, placeholder list and django-insecure- prefix, applied to both keys at production import; the override flag cannot admit an empty key or a placeholder; SECRET_KEY is never printed or logged. - /logs and /metrics (core/views_logs.py, views_metrics.py, checks.py): 404 when off, refuse-to-serve when on without a token, hmac.compare_digest over bytes, Cache-Control no-store, per-address wrong-token bar and request rate, start-up warning on tokens shorter than 24 characters; the metrics body carries counts and versions only, no per-person labels (confirmed against metrics.py and its tests). - Webhook notifier (plugins/webhook/__init__.py, notifications/webhooks.py): HMAC-SHA256 over '&lt;timestamp>.&lt;body>' with a hex digest, the reference verifier uses hmac.compare_digest and a 5-minute tolerance, secret minimum 16 characters and stored encrypted, redirects not followed, destination policy re-checked at every delivery, failures recorded without the secret. - Plugin catalogue and installer (plugins/catalogue.py, installing.py, forms.py): Ed25519 signature over the exact index bytes against an administrator-pinned key, wheel SHA-256 checked after a size- and time-bounded download, only py3-none-any wheels and only built wheels, Postulo's own pins enforced as constraints, a key change on a repository row is called out and logged; no path for an unsigned catalogue. - Trusted-proxy handling (core/proxy.py, prod.py, middleware.py, tests/security/test_proxy.py): default trust is loopback only, all five forwarding headers are removed from an untrusted peer before SecurityMiddleware reads X-Forwarded-Proto, the client address is the rightmost non-trusted X-Forwarded-For entry with ports stripped, an unparseable configured range is ignored (fail-closed), USE_X_FORWARDED_HOST/PORT are not enabled, the health-probe shortcut applies only to loopback peers with no forwarding header. - Throttle (core/throttle.py): cache keys are namespaced by action, identity (a primary key or a validated IP) and window, so a caller cannot choose another's bucket; add/incr pattern with window expiry; rates validated by a start-up check. - Web push (plugins/browser/webpush.py, notifications/views.py, static/js/sw.js): RFC 8291 aes128gcm with a fresh ECDH sender key and 16-byte random salt per message, VAPID ES256 JWT with aud = the push origin and a 12-hour expiry, subscriptions validated (https endpoint, 65-byte P-256 point, 16-byte auth) and stored as an encrypted secret, pushes sent only through the public-only guarded client without redirects, the service worker opens only same-origin URLs and does no caching. - OAuth consent (plugins/consent.py, core/mail_auth.py, plugins/views.py, tests/security/test_mail_consent.py): state is a Django TimestampSigner value with a 15-minute max_age; connection states and instance-mail states use different salts and each is tested against the other; the callback requires a signed-in user and an owner match; the instance-mail callback requires the same staff user who started it; access/refresh tokens and client secrets live only in the Fernet stores, never in allauth SocialToken (SOCIALACCOUNT_STORE_TOKENS=False); refreshes go through the guarded HTTP client; the start of a flow is a CSRF-protected POST. - Secrets in configuration and images (.env.example, docker/Dockerfile, compose*.yml, entrypoint.sh, settings/dev.py): no default secret ships, compose reads the operator's .env, the entrypoint prints nothing sensitive, dev generates a random per-machine key and the test settings pin a throwaway one; POSTGRES_PASSWORD is documented, not defaulted. - Templates and context processors (core/context_processors.py, notifications/context_processors.py, base.html): nothing settings-derived beyond public URLs, version (signed-in only) and UI preferences reaches templates; no template renders a secret, password or token value (only the one-shot invite/recovery/API-token pages, by design). - Logging (core/logs.py, settings LOGGING, grep of logger calls): no logger call formats a token, password, key or Authorization header; httpx/httpcore are held at WARNING so URLs are not logged; X-Request-ID is validated before it is echoed or logged. - Session, CSRF and transport settings (base.py, prod.py): HttpOnly and SameSite=Lax cookies, Secure cookies and HSTS in production, SECURE_PROXY_SSL_HEADER only effective behind the trusted-proxy middleware, CSRF_TRUSTED_ORIGINS from the environment, Django's default JSON session serializer and PBKDF2 password hasher (MD5 only in the test settings), strict CSP with no inline script, allauth email verification mandatory and login-by-code limited to 3 attempts in 180 s. - Django admin (settings, config/urls.py, api/admin.py, accounts/admin.py, tests/security/test_admin_exposure.py): unmounted unless an operator names a path, no login of its own, holds only accounts, invitations and token metadata; token_hash and token_fingerprint shown there are one-way digests of 256-bit random values. - Idempotency keys (api/idempotency.py): scoped per owner with a SHA-256 fingerprint of the body, so a key cannot replay another account's answer or answer a different request.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

CSRF, methods and redirects

  • No csrf_exempt anywhere in src/postulo; CsrfViewMiddleware active; Client(enforce_csrf_checks=True) POST without token answered 403 on /accounts/login/, /accounts/logout/, /accounts/theme/, reminder later, listing discard, token revoke, person administrator, /notifications/waiting/, settings save_field, table settings and the dashboard arranging POST.
  • Programmatic sweep of the whole URLconf: every class-based view that defines post() and no get() (49 endpoints incl. ListingStateView shortlist/discard/restore, ReminderLaterView, ReminderCompleteView, ApplicationQuietActionView, SuggestionActionView, InterviewOutcomeView, InviteRevokeView, ApiTokenRevokeView, PersonAdminView, PersonActiveView, EmailTestView, EmailConsentView, PluginActionView, PluginPolicyView, PluginRepositoryView, backup run/upload/verify, GravatarRefreshView, LinkCheckView, capture discard/restore/bind/draw, CV export/add/move, send copies, bulk views) answers GET with 405; table_settings, table_views, import_csv_forget, export_download, notifications waiting are require_POST.
  • Confirmation-page GETs (DeleteView subclasses, PersonDeleteView, BackupDeleteView, CapturedPageForgetView, MergeView, RemoveRowView) change nothing; backup download/delete and account deletion additionally require allauth reauthentication.
  • Theme (POST only, htmx or form) and locale (UpdateView POST) preference changes cannot be made by GET; SaveFieldView is POST and 404s unless the person opted in.
  • Logout is a POST form; ACCOUNT_LOGOUT_ON_GET and ACCOUNT_CONFIRM_EMAIL_ON_GET keep allauth defaults (False); confirmed GET /accounts/logout/ shows a page and keeps the session.
  • Login CSRF: the custom SignInView is allauth's LoginView under the CSRF middleware (403 without token); session key rotates on sign-in and ends on sign-out (project tests).
  • API: bearer-only HttpBearer auth, no session fallback (session-only POST/GET answered 401), ninja marks operations csrf_exempt which is correct for bearer auth, openapi.json readable to a session only on GET, catch-all under /api/v1/ answers 401 without a token.
  • safe_next: Django's url_has_allowed_host_and_scheme with the request host and require_https; //evil, \evil, /\evil, /evil, ///evil, https://evil, javascript:, leading space/tab/NUL, userinfo trick, http:/// all fall back; a CRLF in next is percent-encoded by Django's redirect (no header injection); used at every next site found (30+), admin_site.login applies the same rule, views_help further restricts next to the topic's own page.
  • HtmxLoginRedirectMiddleware converts only a relative Location whose path is the sign-in page into HX-Redirect; the one HX-Redirect a view sets is a reverse()d path.
  • HealthProbeMiddleware answers only GET/HEAD of exactly /healthz from a loopback REMOTE_ADDR with no proxy-forwarded marker; it sits after SecurityMiddleware so the SSL-redirect exemption still governs; path variants (//healthz, /healthz/, case) are not matched.
  • TrustedProxyMiddleware strips X-Forwarded-For/Proto/Host/Port and Forwarded from peers outside POSTULO_TRUSTED_PROXIES (default loopback only), picks the rightmost non-trusted X-Forwarded-For entry, and SECURE_PROXY_SSL_HEADER is set only in prod; USE_X_FORWARDED_HOST and USE_X_FORWARDED_PORT are off.
  • Host handling: ALLOWED_HOSTS defaults to localhost/127.0.0.1 and must be set; absolute URLs for emails and API answers come from the validated request host or POSTULO_PUBLIC_URL (validated at start-up) for request-less jobs; APPEND_SLASH redirects are relative with escaped leading slashes; PREPEND_WWW off.
  • Consent callbacks: signed state with 15-minute max_age, checked against the signed-in owner (a state for another account is refused, confirmed) and, for the instance mailbox, against the starting administrator who must still be staff; starting consent is POST only.
  • htmx: all nine hx-post sites are on <form method="post"> elements carrying {% csrf_token %}; fetch() calls in app.js send X-CSRFToken; every hx-get in templates is a read (table fragments, pagination, sorting, filters, cell editor open/abandon, errand polling, skill suggestions).
  • Service worker: no fetch handler, GET-only with Cache-Control no-cache, notification click confined to same origin by sameSite(); push subscriptions are stored through the plugin connection form (POST, CSRF).
  • Cookies and headers: session HttpOnly, SameSite=Lax on session and CSRF cookies, Secure in prod unless POSTULO_SECURE_COOKIES=false, Referrer-Policy same-origin, COOP same-origin, X-Frame-Options DENY, frame-ancestors none, form-action self.
  • /logs and /metrics compare bearer tokens with hmac.compare_digest, /logs refuses when no token is set, both throttle wrong tokens per address and are 404 when disabled; SECURE_REDIRECT_EXEMPT patterns are anchored.
  • Invitation and recovery links store only a fingerprint/pk in the session on GET and spend nothing, so link-preview bots cannot burn them; RecoverySetPasswordView and signup consume them on POST.
  • No @login_not_required outside the admin signpost; no view accepts a PUT with an empty body to clear data other than the one IdentifierSchemesView already guards.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## CSRF, methods and redirects - No csrf_exempt anywhere in src/postulo; CsrfViewMiddleware active; Client(enforce_csrf_checks=True) POST without token answered 403 on /accounts/login/, /accounts/logout/, /accounts/theme/, reminder later, listing discard, token revoke, person administrator, /notifications/waiting/, settings save_field, table settings and the dashboard arranging POST. - Programmatic sweep of the whole URLconf: every class-based view that defines post() and no get() (49 endpoints incl. ListingStateView shortlist/discard/restore, ReminderLaterView, ReminderCompleteView, ApplicationQuietActionView, SuggestionActionView, InterviewOutcomeView, InviteRevokeView, ApiTokenRevokeView, PersonAdminView, PersonActiveView, EmailTestView, EmailConsentView, PluginActionView, PluginPolicyView, PluginRepositoryView, backup run/upload/verify, GravatarRefreshView, LinkCheckView, capture discard/restore/bind/draw, CV export/add/move, send copies, bulk views) answers GET with 405; table_settings, table_views, import_csv_forget, export_download, notifications waiting are require_POST. - Confirmation-page GETs (DeleteView subclasses, PersonDeleteView, BackupDeleteView, CapturedPageForgetView, MergeView, RemoveRowView) change nothing; backup download/delete and account deletion additionally require allauth reauthentication. - Theme (POST only, htmx or form) and locale (UpdateView POST) preference changes cannot be made by GET; SaveFieldView is POST and 404s unless the person opted in. - Logout is a POST form; ACCOUNT_LOGOUT_ON_GET and ACCOUNT_CONFIRM_EMAIL_ON_GET keep allauth defaults (False); confirmed GET /accounts/logout/ shows a page and keeps the session. - Login CSRF: the custom SignInView is allauth's LoginView under the CSRF middleware (403 without token); session key rotates on sign-in and ends on sign-out (project tests). - API: bearer-only HttpBearer auth, no session fallback (session-only POST/GET answered 401), ninja marks operations csrf_exempt which is correct for bearer auth, openapi.json readable to a session only on GET, catch-all under /api/v1/ answers 401 without a token. - safe_next: Django's url_has_allowed_host_and_scheme with the request host and require_https; //evil, \\evil, /\evil, \/evil, ///evil, https://evil, javascript:, leading space/tab/NUL, userinfo trick, http:/// all fall back; a CRLF in next is percent-encoded by Django's redirect (no header injection); used at every next site found (30+), admin_site.login applies the same rule, views_help further restricts next to the topic's own page. - HtmxLoginRedirectMiddleware converts only a relative Location whose path is the sign-in page into HX-Redirect; the one HX-Redirect a view sets is a reverse()d path. - HealthProbeMiddleware answers only GET/HEAD of exactly /healthz from a loopback REMOTE_ADDR with no proxy-forwarded marker; it sits after SecurityMiddleware so the SSL-redirect exemption still governs; path variants (//healthz, /healthz/, case) are not matched. - TrustedProxyMiddleware strips X-Forwarded-For/Proto/Host/Port and Forwarded from peers outside POSTULO_TRUSTED_PROXIES (default loopback only), picks the rightmost non-trusted X-Forwarded-For entry, and SECURE_PROXY_SSL_HEADER is set only in prod; USE_X_FORWARDED_HOST and USE_X_FORWARDED_PORT are off. - Host handling: ALLOWED_HOSTS defaults to localhost/127.0.0.1 and must be set; absolute URLs for emails and API answers come from the validated request host or POSTULO_PUBLIC_URL (validated at start-up) for request-less jobs; APPEND_SLASH redirects are relative with escaped leading slashes; PREPEND_WWW off. - Consent callbacks: signed state with 15-minute max_age, checked against the signed-in owner (a state for another account is refused, confirmed) and, for the instance mailbox, against the starting administrator who must still be staff; starting consent is POST only. - htmx: all nine hx-post sites are on &lt;form method="post"> elements carrying {% csrf_token %}; fetch() calls in app.js send X-CSRFToken; every hx-get in templates is a read (table fragments, pagination, sorting, filters, cell editor open/abandon, errand polling, skill suggestions). - Service worker: no fetch handler, GET-only with Cache-Control no-cache, notification click confined to same origin by sameSite(); push subscriptions are stored through the plugin connection form (POST, CSRF). - Cookies and headers: session HttpOnly, SameSite=Lax on session and CSRF cookies, Secure in prod unless POSTULO_SECURE_COOKIES=false, Referrer-Policy same-origin, COOP same-origin, X-Frame-Options DENY, frame-ancestors none, form-action self. - /logs and /metrics compare bearer tokens with hmac.compare_digest, /logs refuses when no token is set, both throttle wrong tokens per address and are 404 when disabled; SECURE_REDIRECT_EXEMPT patterns are anchored. - Invitation and recovery links store only a fingerprint/pk in the session on GET and spend nothing, so link-preview bots cannot burn them; RecoverySetPasswordView and signup consume them on POST. - No @login_not_required outside the admin signpost; no view accepts a PUT with an empty body to clear data other than the one IdentifierSchemesView already guards.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Administrator surface and plugins

  • Every server:* view (overview, people and the five person actions, sign-in, email + test/consent/connection-test, plugins, plugin action, plugin policy, identifier schemes, person plugins, plugin repositories, data protection, record of processing, logs, backups and its six actions, design, capture, defaults) and accounts:invite_list/create/revoke inherit StaffRequiredMixin (LoginRequiredMixin + UserPassesTestMixin on is_staff) with the check in dispatch, so GET, POST and the one htmx partial (IdentifierSchemesView #fields) are all gated; tests/security/test_staff_only.py walks them with a member's GET and empty POST and an anonymous visitor; Backups answers 404 to a signed-in non-admin (BackupsMixin.handle_no_permission) with its own sweep.
  • No ModelForm on User/Profile carries is_staff/is_superuser/is_active/email verification: ProfileForm, AccessibilityForm, CaptureKeepingForm, AppearanceForm, LocaleForm, AccountForm (username only) and InviteForm (email, note) list explicit Meta.fields; an invitation cannot carry administrator; PersonAdminView sets is_staff only, never is_superuser.
  • PluginActionView dispatches only the eight names in its ACTIONS dict (no getattr on arbitrary names, #484); the uploaded wheel is read with zipfile only (metadata, WHEEL tags, entry_points) before confirmation, re-read and re-checked at install, sha256-matched against a signed index when it came from a catalogue, pure-Python only, --only-binary :all:, a constraints file covering core and every other plugin, record written under a file lock, snapshot/rollback on failure, and the import check runs in a subprocess.
  • Catalogue fetch: Ed25519 signature over the exact index bytes against the configured key, index and .sig fetched through the guarded http client (destination policy + per-request pinning), wheel downloaded with a size cap and time budget and checked against the signed SHA-256 before anything is unpacked; a disabled repository disappears from configured(); catalogue names pinned by POSTULO_PLUGIN_CATALOGUES cannot be edited from the page; key changes are logged and warned.
  • PluginLogoView: name resolved through the registry (no filesystem lookup by name), logo file name refused if it contains a separator or leading dot and read via importlib.resources inside the plugin's own package, SVG passed through defusedxml-based sanitise_svg (no DTD/entities, allowlist) and served with Content-Security-Policy 'default-src none; sandbox' and nosniff; non-staff can only see logos of plugins offered to them.
  • Backup page: archive names must fullmatch postulo-(backup|upload)-YYYYMMDD-HHMMSS(-n).tar.gz, be a regular non-symlink file whose resolved parent is the backup directory; download and delete require allauth reauthentication and log who; restore is CLI-only; restore_backup validates every member (prefix, no '..', no backslash/colon/NUL, plain files only, resolved path under the root) before writing anything and refuses non-empty instances without --force; the secret-key fingerprint is a domain-separated SHA-256 and never the key.
  • GDPR pages: contact export (ContactExportView) and erasure (contact delete view) are OwnedObjectMixin/for_user-scoped to the contact's owner; the administrator's dry run returns counts only and names nobody; record of processing lists plugins and connection counts without labels or configuration.
  • Logs: the page and /logs endpoint render records through autoescaping templates / json.dumps; /logs is 404 when off, 503 when on without a token, bearer-token checked in constant time, throttled per address; httpx/httpcore are held to WARNING so URLs are not logged; no log statement in the code base writes an email address, token, password or secret value (grep over logger calls).
  • Metrics: 404 when off, optional constant-time token, throttled, counts only — no label carries a user id, email, URL or connection label (tests/security/test_metrics.py).
  • Mail consent (instance mailbox): state signed with its own salt, bound to the administrator who started it and re-checked for is_staff at finish; tokens and client secret stored Fernet-encrypted on the policy row; EmailForm drops pinned fields server-side and never renders a stored password; SMTP connection test honours host_policy()/destination approval.
  • Connection consent/test/sync-now/send-everything/delete are all for_user-scoped, refuse a plugin the policy has off, and the test is throttled; webhook plugin validates the receiver through check_destination at save, at test and again at every delivery, posts through the guarded client with redirects not followed, and never echoes a receiver's body (only a Location header to the owner).
  • Plugin policy: _save_policies only touches rows whose marker the form drew; ungoverned kinds (transport, identifier, link-service, messaging-service) can never be switched off by a hand-written POST; a person's own set_choice refuses anything not 'theirs'.
  • Django admin: unmounted unless POSTULO_ADMIN_URL is set, its login view redirects to allauth's sign-in (MFA and rate limits apply) and raises 403 for a signed-in non-staff user, only User, Invite (fingerprint read-only) and ApiToken (view/delete only, hash read-only) are registered; superusers get no page listing anyone's applications.
  • Docker: runtime stage runs as uid 1000 'postulo', writes only under /app/data, binds 127.0.0.1:8000 in compose, pip removed from the base interpreter, scheduler/worker skip plugin sync so only one process writes the record at boot; the entrypoint runs check --deploy; a weak POSTULO_SECRET_KEY or POSTULO_FIELD_KEY is a start-up refusal in prod settings.
  • Recovery links and invitations store only a SHA-256 fingerprint of a 32-byte token, are single-use/time-limited, keep the token out of the form URL (session-held), and setting a password never creates a session.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Administrator surface and plugins - Every server:* view (overview, people and the five person actions, sign-in, email + test/consent/connection-test, plugins, plugin action, plugin policy, identifier schemes, person plugins, plugin repositories, data protection, record of processing, logs, backups and its six actions, design, capture, defaults) and accounts:invite_list/create/revoke inherit StaffRequiredMixin (LoginRequiredMixin + UserPassesTestMixin on is_staff) with the check in dispatch, so GET, POST and the one htmx partial (IdentifierSchemesView #fields) are all gated; tests/security/test_staff_only.py walks them with a member's GET and empty POST and an anonymous visitor; Backups answers 404 to a signed-in non-admin (BackupsMixin.handle_no_permission) with its own sweep. - No ModelForm on User/Profile carries is_staff/is_superuser/is_active/email verification: ProfileForm, AccessibilityForm, CaptureKeepingForm, AppearanceForm, LocaleForm, AccountForm (username only) and InviteForm (email, note) list explicit Meta.fields; an invitation cannot carry administrator; PersonAdminView sets is_staff only, never is_superuser. - PluginActionView dispatches only the eight names in its ACTIONS dict (no getattr on arbitrary names, #484); the uploaded wheel is read with zipfile only (metadata, WHEEL tags, entry_points) before confirmation, re-read and re-checked at install, sha256-matched against a signed index when it came from a catalogue, pure-Python only, --only-binary :all:, a constraints file covering core and every other plugin, record written under a file lock, snapshot/rollback on failure, and the import check runs in a subprocess. - Catalogue fetch: Ed25519 signature over the exact index bytes against the configured key, index and .sig fetched through the guarded http client (destination policy + per-request pinning), wheel downloaded with a size cap and time budget and checked against the signed SHA-256 before anything is unpacked; a disabled repository disappears from configured(); catalogue names pinned by POSTULO_PLUGIN_CATALOGUES cannot be edited from the page; key changes are logged and warned. - PluginLogoView: name resolved through the registry (no filesystem lookup by name), logo file name refused if it contains a separator or leading dot and read via importlib.resources inside the plugin's own package, SVG passed through defusedxml-based sanitise_svg (no DTD/entities, allowlist) and served with Content-Security-Policy 'default-src none; sandbox' and nosniff; non-staff can only see logos of plugins offered to them. - Backup page: archive names must fullmatch postulo-(backup|upload)-YYYYMMDD-HHMMSS(-n).tar.gz, be a regular non-symlink file whose resolved parent is the backup directory; download and delete require allauth reauthentication and log who; restore is CLI-only; restore_backup validates every member (prefix, no '..', no backslash/colon/NUL, plain files only, resolved path under the root) before writing anything and refuses non-empty instances without --force; the secret-key fingerprint is a domain-separated SHA-256 and never the key. - GDPR pages: contact export (ContactExportView) and erasure (contact delete view) are OwnedObjectMixin/for_user-scoped to the contact's owner; the administrator's dry run returns counts only and names nobody; record of processing lists plugins and connection counts without labels or configuration. - Logs: the page and /logs endpoint render records through autoescaping templates / json.dumps; /logs is 404 when off, 503 when on without a token, bearer-token checked in constant time, throttled per address; httpx/httpcore are held to WARNING so URLs are not logged; no log statement in the code base writes an email address, token, password or secret value (grep over logger calls). - Metrics: 404 when off, optional constant-time token, throttled, counts only — no label carries a user id, email, URL or connection label (tests/security/test_metrics.py). - Mail consent (instance mailbox): state signed with its own salt, bound to the administrator who started it and re-checked for is_staff at finish; tokens and client secret stored Fernet-encrypted on the policy row; EmailForm drops pinned fields server-side and never renders a stored password; SMTP connection test honours host_policy()/destination approval. - Connection consent/test/sync-now/send-everything/delete are all for_user-scoped, refuse a plugin the policy has off, and the test is throttled; webhook plugin validates the receiver through check_destination at save, at test and again at every delivery, posts through the guarded client with redirects not followed, and never echoes a receiver's body (only a Location header to the owner). - Plugin policy: _save_policies only touches rows whose marker the form drew; ungoverned kinds (transport, identifier, link-service, messaging-service) can never be switched off by a hand-written POST; a person's own set_choice refuses anything not 'theirs'. - Django admin: unmounted unless POSTULO_ADMIN_URL is set, its login view redirects to allauth's sign-in (MFA and rate limits apply) and raises 403 for a signed-in non-staff user, only User, Invite (fingerprint read-only) and ApiToken (view/delete only, hash read-only) are registered; superusers get no page listing anyone's applications. - Docker: runtime stage runs as uid 1000 'postulo', writes only under /app/data, binds 127.0.0.1:8000 in compose, pip removed from the base interpreter, scheduler/worker skip plugin sync so only one process writes the record at boot; the entrypoint runs check --deploy; a weak POSTULO_SECRET_KEY or POSTULO_FIELD_KEY is a start-up refusal in prod settings. - Recovery links and invitations store only a SHA-256 fingerprint of a 32-byte token, are single-use/time-limited, keep the token out of the form URL (session-held), and setting a password never creates a session.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Information disclosure and privacy

  • Global search (core/search.py, views_search.py, api/routers/search.py): every group filters with for_user() before any icontains match; counts and excerpts are per-owner; sqlite_like.py is only a LIKE helper. No cross-owner leakage.
  • Avatar (accounts:avatar by pk): owner-only or staff; Profile.picture never offers the CV photo; served with private cache and FILE_POLICY. Company logo by pk is for_user(). Plugin logos are 404 for plugins not offered to the person.
  • vCard downloads (me.vcf, all.vcf, contact/company cards): all through Contact/Company.for_user(); own card has no id; Cache-Control no-store.
  • Duplicate detection (jobs/duplicates.py): every query is owner_id-scoped; remembered places (jobs/remembered.py): FieldHint.for_user() on read, learn and forget. ESCO skills and GeoNames places are static datasets with no user data; skill suggestions are rate-limited and answered no-store.
  • Resume link checks: Link.for_user(); candidate file and Europass import name no record.
  • Calendar/iCal (applications/views_ical.py, InterviewCalendarView): session-authenticated only, no tokenised feed URLs exist; interview and reminder feeds use for_user(); report CSV/PDF are built for request.user; insights is a redirect.
  • API (api/auth.py, api.py, routers/*): no unscoped querysets in any router; unknown addresses under /api/v1 answer 401 without a token and 404 with one; openapi.json guarded by for_readers_of_the_api; known-captures endpoint scoped to the token owner; problem documents carry only path, status, request id and typed extensions; validation sentences name field names, not other rows; rate-limit refusals are generic; tokens stored as SHA-256 with a short prefix.
  • Notifications: inbox notices per owner and marked shown by id; web push payloads encrypted end-to-end with RFC 8291 aes128gcm before leaving the server; sw.js is a fixed static worker; webhook deliveries go to the owner's own configured URL.
  • Documents: properties.py lets the author/subject/keywords be omitted and keeps only a neutral title and language; RenderedCompareView finds the earlier render among the owner's own rows (previous() filters owner_id); download names come from owner-typed titles.
  • Error handling: 404/403/500 templates are generic; the 500 view renders no context processors; DEBUG is False in prod/test; ADMINS is empty so no error mail; no Sentry.
  • Server settings → People shows only username, name, email, last sign-in, role and picture (accounts only, as the threat model says); per-person plugin page shows policy rows only; messages there name the username only.
  • Enumeration on allauth surfaces (65.19.5): ACCOUNT_PREVENT_ENUMERATION left at its default (on); password reset and login-by-code send an unknown-account mail so timing is level even though SMTP is synchronous; the backend runs a dummy hash for unknown logins; adding an email address does not reveal another account's; the login-by-code door is a 404 based on instance policy only.
  • Invitations and recovery links: 32 random bytes, stored only as SHA-256 fingerprints, one generic refusal for spent/expired/revoked/unknown, token moved into the session before the signup/password form so it never reaches a Referer; invite bound to its email at signup.
  • Request-id header: accepted only when it matches [A-Za-z0-9._:-]{1,200}, so no CRLF or log-line injection through X-Request-ID; echoed in headers and problem bodies safely.
  • Logs endpoint (/logs): 404 when off, 503 with no token, constant-time bearer compare, per-address wrong-token and request throttles, no-store; admin Logs page autoescapes message and extras.
  • Metrics endpoint: counts only, no per-user labels; 404 when off; token compared in constant time.
  • Table settings and saved views: stored on the person's own Profile.table_settings; editable cells (CompanyCellView, ListingCellView) are LoginRequired and for_user(); SaveFieldView only touches the signed-in profile.
  • Captured pages, sources and renderings: Capture.for_user() in every page view; sources served only via serve_private_text as text/plain attachments.
  • Exports, errands, CSV import, plugin connections, API token pages, contact GDPR export: all for_user()/OwnedObjectMixin; export archives served through serve_private_file with no-store.
  • Design gallery is staff-only; help topics require login and show static text; no robots.txt/sitemap, no source maps in static/, WhiteNoise manifest storage with no directory listing.
  • Account deletion cascades through user.delete() and removes pending invites; TOTP secrets are encrypted by allauth; sessions are server-side (database).
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Information disclosure and privacy - Global search (core/search.py, views_search.py, api/routers/search.py): every group filters with for_user() before any icontains match; counts and excerpts are per-owner; sqlite_like.py is only a LIKE helper. No cross-owner leakage. - Avatar (accounts:avatar by pk): owner-only or staff; Profile.picture never offers the CV photo; served with private cache and FILE_POLICY. Company logo by pk is for_user(). Plugin logos are 404 for plugins not offered to the person. - vCard downloads (me.vcf, all.vcf, contact/company cards): all through Contact/Company.for_user(); own card has no id; Cache-Control no-store. - Duplicate detection (jobs/duplicates.py): every query is owner_id-scoped; remembered places (jobs/remembered.py): FieldHint.for_user() on read, learn and forget. ESCO skills and GeoNames places are static datasets with no user data; skill suggestions are rate-limited and answered no-store. - Resume link checks: Link.for_user(); candidate file and Europass import name no record. - Calendar/iCal (applications/views_ical.py, InterviewCalendarView): session-authenticated only, no tokenised feed URLs exist; interview and reminder feeds use for_user(); report CSV/PDF are built for request.user; insights is a redirect. - API (api/auth.py, api.py, routers/*): no unscoped querysets in any router; unknown addresses under /api/v1 answer 401 without a token and 404 with one; openapi.json guarded by for_readers_of_the_api; known-captures endpoint scoped to the token owner; problem documents carry only path, status, request id and typed extensions; validation sentences name field names, not other rows; rate-limit refusals are generic; tokens stored as SHA-256 with a short prefix. - Notifications: inbox notices per owner and marked shown by id; web push payloads encrypted end-to-end with RFC 8291 aes128gcm before leaving the server; sw.js is a fixed static worker; webhook deliveries go to the owner's own configured URL. - Documents: properties.py lets the author/subject/keywords be omitted and keeps only a neutral title and language; RenderedCompareView finds the earlier render among the owner's own rows (previous() filters owner_id); download names come from owner-typed titles. - Error handling: 404/403/500 templates are generic; the 500 view renders no context processors; DEBUG is False in prod/test; ADMINS is empty so no error mail; no Sentry. - Server settings → People shows only username, name, email, last sign-in, role and picture (accounts only, as the threat model says); per-person plugin page shows policy rows only; messages there name the username only. - Enumeration on allauth surfaces (65.19.5): ACCOUNT_PREVENT_ENUMERATION left at its default (on); password reset and login-by-code send an unknown-account mail so timing is level even though SMTP is synchronous; the backend runs a dummy hash for unknown logins; adding an email address does not reveal another account's; the login-by-code door is a 404 based on instance policy only. - Invitations and recovery links: 32 random bytes, stored only as SHA-256 fingerprints, one generic refusal for spent/expired/revoked/unknown, token moved into the session before the signup/password form so it never reaches a Referer; invite bound to its email at signup. - Request-id header: accepted only when it matches [A-Za-z0-9._:-]{1,200}, so no CRLF or log-line injection through X-Request-ID; echoed in headers and problem bodies safely. - Logs endpoint (/logs): 404 when off, 503 with no token, constant-time bearer compare, per-address wrong-token and request throttles, no-store; admin Logs page autoescapes message and extras. - Metrics endpoint: counts only, no per-user labels; 404 when off; token compared in constant time. - Table settings and saved views: stored on the person's own Profile.table_settings; editable cells (CompanyCellView, ListingCellView) are LoginRequired and for_user(); SaveFieldView only touches the signed-in profile. - Captured pages, sources and renderings: Capture.for_user() in every page view; sources served only via serve_private_text as text/plain attachments. - Exports, errands, CSV import, plugin connections, API token pages, contact GDPR export: all for_user()/OwnedObjectMixin; export archives served through serve_private_file with no-store. - Design gallery is staff-only; help topics require login and show static text; no robots.txt/sitemap, no source maps in static/, WhiteNoise manifest storage with no directory listing. - Account deletion cascades through user.delete() and removes pending invites; TOTP secrets are encrypted by allauth; sessions are server-side (database).
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Configuration, deployment and dependencies

  • DEBUG: prod.py:18 forces DEBUG=False regardless of POSTULO_DEBUG; the image sets DJANGO_SETTINGS_MODULE=postulo.config.settings.prod (Dockerfile:166) so manage.py's dev default never applies in the container; dev.py pins ALLOWED_HOSTS to localhost.
  • SECRET_KEY: required in prod and refused when shorter than 50 chars, fewer than 5 distinct chars, or a placeholder (keys.py:102-125); POSTULO_ALLOW_WEAK_SECRET_KEY cannot bypass a placeholder; POSTULO_FIELD_KEY held to the same rule (prod.py:15-16); the build-time collectstatic key is random and discarded (Dockerfile:301-303); tests/security/test_secret_key.py and tests/test_image_build.py:35-47 hold these.
  • Field-key derivation: Fernet key from SHA-256 of a domain-prefixed SECRET_KEY/POSTULO_FIELD_KEY (plugins/secrets.py:34-36); backups carry only a separately domain-prefixed fingerprint, never the key.
  • ALLOWED_HOSTS defaults to localhost/127.0.0.1; the only bypass is HealthProbeMiddleware, which answers GET/HEAD /healthz solely for a loopback peer that sent no forwarding header (middleware.py:64-75; tests/security/test_health_probe_host.py).
  • Reverse-proxy trust: only 127.0.0.0/8 and ::1 by default; every forwarding header is stripped from untrusted peers; X-Forwarded-For is read right-to-left skipping trusted proxies; SECURE_PROXY_SSL_HEADER is set only in prod and only takes effect behind that middleware (proxy.py; tests/security/test_proxy.py covers direct-claim, LAN and prefix attacks).
  • Transport headers: HSTS 31536000 with includeSubDomains, preload opt-in with W021 silenced only while off; SECURE_SSL_REDIRECT on with anchored exemptions for healthz/metrics; SESSION/CSRF cookies Secure (explicit opt-out for mesh VPNs), HttpOnly session cookie, SameSite=Lax; X_FRAME_OPTIONS DENY; Referrer-Policy same-origin; nosniff; COOP Django default same-origin.
  • CSP is set in base.py for every environment: default-src 'none', script-src 'self' (no inline, no eval), style-src 'self' + nonce (used only by document previews), img-src 'self' data:, frame-ancestors 'none', form-action and base-uri 'self'; the browser suite runs under it.
  • Django admin is unmounted unless POSTULO_ADMIN_URL is set (urls.py:38-39) and then uses Postulo's own sign-in with allauth's limits and MFA.
  • Cache: database-backed by default so allauth and Postulo limits are shared across the three workers and survive restarts (base.py:181-191); test settings pin it; Redis/Memcached opt-in; tidy_up_if_due fails safe when the cache is down (slow.py:176-180).
  • Rate limiting: per-account (capture 30/h, fetch 300/h, outbox 60/h, confirmations 5/h, number collisions 20/h, recovery links 10/h, connection tests 10/h, suggestions 600/h), per-token for the API (600/h), per-address for /logs and /metrics (120/h) with a separate 10/h wrong-token bar that refuses even a correct token afterwards; malformed rate strings fail startup via postulo.E002; tests/security/test_rate_limits.py covers all of it.
  • /logs: off by default, 404 when off, refuses to serve (503) when enabled without a token, constant-time bytes comparison, limit capped at 1000, paged by time, Cache-Control no-store and nosniff (views_logs.py).
  • /metrics: 404 when off, constant-time comparison when a token is set, rate limited, short tokens reported by postulo.W001 (checks.py:166-186); exposes counts only, no personal data (metrics.py).
  • Background work: a single task function taking only an integer errand id (tasks.py:19-23); handlers resolved server-side from a registry; the only payload carried is the CSV sheet bounded to 2 MB/5000 rows and dropped from the row once read (slow.py:85-87); allowances are spent in the view before enqueueing; stuck errands failed after 24 h and forgotten after 7 days; the worker is the upstream db_worker in batch mode with a heartbeat (work.py).
  • Scheduler: a cache lease plus claim-by-UPDATE on each reminder (send_due_reminders.py:61-64) so cron and --loop together announce once; an exception ends one pass, not the process; heartbeat written atomically via mkstemp+os.replace (scheduler.py:51-67); the update check runs only from this loop, once a day, when enabled.
  • ATOMIC_REQUESTS is on; every slow view (capture, fetch, link check, exports, imports, backups, plugin install, connection tests, PDF rendering) is marked non_atomic_requests, and the three slow API routes are wrapped the same way (api.py:851-868).
  • SQLite: WAL, IMMEDIATE transactions and a 20 s timeout applied only to file databases (config/sqlite.py); the data directory is created at startup; the database path is outside STATIC_ROOT and never served.
  • Database password: read from POSTULO_DATABASE_PASSWORD or POSTGRES_PASSWORD rather than the URL (database_password.py); never written to a log; only the database name and vendor are shown on the staff overview (server_views.py:152-158); pg_dump/pg_restore get the password via PGPASSWORD in the environment, not argv (backup.py:240-241).
  • Static files: WhiteNoise CompressedManifestStaticFilesStorage collected at build time into a root-owned /app/staticfiles; MEDIA_URL is not routed anywhere (urls.py adds static() only under DEBUG), so uploads are served only through the ownership-checked views.
  • Logging: httpx/httpcore quietened to WARNING so captured and webhook URLs are not written (base.py:845-852); X-Request-ID accepted only when it matches [A-Za-z0-9._:-]{1,200}; JSON formatter; cross-process rotation under flock; no logger call found that formats a token, password, secret or email body.
  • SMTP: STARTTLS and implicit TLS both use ssl.create_default_context() (mail.py:109,190), and the pinned connection restores the typed hostname before wrapping so certificate verification is against the name, not the IP (destinations.py:229-240); port/mode mismatches are refused with an explanation.
  • Outbound fetches for GeoNames, ESCO and the update check go through public_only_client (certificate verification on, private addresses refused, at most 3 redirects each re-checked); httpx is held to one minor because its private transports are wrapped to enforce download deadlines.
  • Capture rendering: WeasyPrint only, in a child process with RLIMIT_DATA 1 GiB and RLIMIT_CPU 60 s, a fetcher allowing no protocol at all, a parent wall-clock timeout, and a byte ceiling on the output (rendering_child.py, rendering.py:91-130). CV rendering: WeasyPrint fetcher limited to data:, Chromium aborts every route and is launched with its default sandbox (no --no-sandbox) (documents/pdf.py:244, 272, 293).
  • Images: Pillow MAX_IMAGE_PIXELS set per decode and the header dimensions checked before load; truncated files refused; DecompressionBombError mapped to a refusal (core/pictures.py:109-137).
  • Upload and import caps: documents 20 MB, avatars and logos 5 MB, CSV and kind files 2 MB/5000 rows, vCard 2 MB, candidate file 1 MB/200 rows, Europass attachments bounded, archive import members read within caps (importer.py), backup upload bounded by POSTULO_BACKUP_UPLOAD_MAX_MB (512) and verified under a temporary name before being kept; capture source/rendering/account caps validated at startup (postulo.E004/E005).
  • Restore: every tar member checked before anything is written (prefix, no '..', no absolute, no backslash/colon/NUL, resolved under its root, plain files only), engine and version matched, PGPASSWORD via env, all-or-nothing PostgreSQL load (backup.py:563-720).
  • Pagination is bounded everywhere: table pages 25/50/100 only (core/tables.py), list views paginate_by 50, API limit 1..200 with 422 above (api/paging.py), log endpoint 1000.
  • Docker: three-stage build; runtime runs as uid 1000 postulo with /app read-only to it and only /app/data writable; no secrets baked; .env, data, sqlite and tests excluded by .dockerignore; pip removed from the base interpreter; host ports bound to 127.0.0.1; migrations and plugin sync run as the unprivileged user; apt upgrade with a cache-busting build argument; PostgreSQL apt key fetched over HTTPS and scoped with signed-by; healthcheck exercises the database through /healthz; scheduler and worker have file-based healthchecks.
  • Repository hygiene: git ls-files contains no .env, key, certificate, sqlite, media or data files; .gitignore covers data/, media/, .sqlite3, .env, *.mo; pre-commit runs detect-private-key and check-added-large-files; .env.example documents every dangerous default with its reason.
  • CI: persist-credentials false on every checkout; secrets reach scripts through env, never interpolated; no pull_request_target; release and image workflows start with permissions: {} and grant only what they need; releases refused unless CI passed on the tagged commit; images scanned with two scanners before push and verified in the registry afterwards; SBOM attached; pip-audit --strict on every push and weekly with one named ignore whose reason is held by tests/test_security_audit.py; npm audit at high; zizmor over the workflows.
  • Dependency versions (uv.lock): Django 6.1.1, django-allauth 65.19.5, django-ninja 1.7.0, Pillow 12.3.0, WeasyPrint 70.0, httpx 0.28.1, httpcore 1.x, cryptography 50.0.1, pydantic 2.13.5, whitenoise 6.12.0, django-htmx 1.29.0, django-tasks-db 0.13.0, icalendar 7.3.0 (>= the 7.1.3 nesting-depth fix), defusedxml 0.7.1, markdown-it-py 4.2.0, nh3 0.3.7, phonenumberslite 9.x, gunicorn 26.2.0, psycopg 3.3.5, certifi 2026.7.22; no vobject or lxml in the lock. pip-audit 2.10.1 run here against the exported lock reports only oauthlib 3.3.1 PYSEC-2026-4114 (CVE-2026-49265, provider-side PKCE timing; Postulo is only an OIDC client), which CI already ignores by name with a test that fails once the lock can take oauthlib 4.
  • Vendored JavaScript: htmx 2.0.10 in static/js/vendor matches package-lock.json; @zxcvbn-ts/core 4.2.0; axe-core 4.13.0; CI re-syncs vendored files from node_modules and fails on drift, so the committed copies are the locked versions.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Configuration, deployment and dependencies - DEBUG: prod.py:18 forces DEBUG=False regardless of POSTULO_DEBUG; the image sets DJANGO_SETTINGS_MODULE=postulo.config.settings.prod (Dockerfile:166) so manage.py's dev default never applies in the container; dev.py pins ALLOWED_HOSTS to localhost. - SECRET_KEY: required in prod and refused when shorter than 50 chars, fewer than 5 distinct chars, or a placeholder (keys.py:102-125); POSTULO_ALLOW_WEAK_SECRET_KEY cannot bypass a placeholder; POSTULO_FIELD_KEY held to the same rule (prod.py:15-16); the build-time collectstatic key is random and discarded (Dockerfile:301-303); tests/security/test_secret_key.py and tests/test_image_build.py:35-47 hold these. - Field-key derivation: Fernet key from SHA-256 of a domain-prefixed SECRET_KEY/POSTULO_FIELD_KEY (plugins/secrets.py:34-36); backups carry only a separately domain-prefixed fingerprint, never the key. - ALLOWED_HOSTS defaults to localhost/127.0.0.1; the only bypass is HealthProbeMiddleware, which answers GET/HEAD /healthz solely for a loopback peer that sent no forwarding header (middleware.py:64-75; tests/security/test_health_probe_host.py). - Reverse-proxy trust: only 127.0.0.0/8 and ::1 by default; every forwarding header is stripped from untrusted peers; X-Forwarded-For is read right-to-left skipping trusted proxies; SECURE_PROXY_SSL_HEADER is set only in prod and only takes effect behind that middleware (proxy.py; tests/security/test_proxy.py covers direct-claim, LAN and prefix attacks). - Transport headers: HSTS 31536000 with includeSubDomains, preload opt-in with W021 silenced only while off; SECURE_SSL_REDIRECT on with anchored exemptions for healthz/metrics; SESSION/CSRF cookies Secure (explicit opt-out for mesh VPNs), HttpOnly session cookie, SameSite=Lax; X_FRAME_OPTIONS DENY; Referrer-Policy same-origin; nosniff; COOP Django default same-origin. - CSP is set in base.py for every environment: default-src 'none', script-src 'self' (no inline, no eval), style-src 'self' + nonce (used only by document previews), img-src 'self' data:, frame-ancestors 'none', form-action and base-uri 'self'; the browser suite runs under it. - Django admin is unmounted unless POSTULO_ADMIN_URL is set (urls.py:38-39) and then uses Postulo's own sign-in with allauth's limits and MFA. - Cache: database-backed by default so allauth and Postulo limits are shared across the three workers and survive restarts (base.py:181-191); test settings pin it; Redis/Memcached opt-in; tidy_up_if_due fails safe when the cache is down (slow.py:176-180). - Rate limiting: per-account (capture 30/h, fetch 300/h, outbox 60/h, confirmations 5/h, number collisions 20/h, recovery links 10/h, connection tests 10/h, suggestions 600/h), per-token for the API (600/h), per-address for /logs and /metrics (120/h) with a separate 10/h wrong-token bar that refuses even a correct token afterwards; malformed rate strings fail startup via postulo.E002; tests/security/test_rate_limits.py covers all of it. - /logs: off by default, 404 when off, refuses to serve (503) when enabled without a token, constant-time bytes comparison, limit capped at 1000, paged by time, Cache-Control no-store and nosniff (views_logs.py). - /metrics: 404 when off, constant-time comparison when a token is set, rate limited, short tokens reported by postulo.W001 (checks.py:166-186); exposes counts only, no personal data (metrics.py). - Background work: a single task function taking only an integer errand id (tasks.py:19-23); handlers resolved server-side from a registry; the only payload carried is the CSV sheet bounded to 2 MB/5000 rows and dropped from the row once read (slow.py:85-87); allowances are spent in the view before enqueueing; stuck errands failed after 24 h and forgotten after 7 days; the worker is the upstream db_worker in batch mode with a heartbeat (work.py). - Scheduler: a cache lease plus claim-by-UPDATE on each reminder (send_due_reminders.py:61-64) so cron and --loop together announce once; an exception ends one pass, not the process; heartbeat written atomically via mkstemp+os.replace (scheduler.py:51-67); the update check runs only from this loop, once a day, when enabled. - ATOMIC_REQUESTS is on; every slow view (capture, fetch, link check, exports, imports, backups, plugin install, connection tests, PDF rendering) is marked non_atomic_requests, and the three slow API routes are wrapped the same way (api.py:851-868). - SQLite: WAL, IMMEDIATE transactions and a 20 s timeout applied only to file databases (config/sqlite.py); the data directory is created at startup; the database path is outside STATIC_ROOT and never served. - Database password: read from POSTULO_DATABASE_PASSWORD or POSTGRES_PASSWORD rather than the URL (database_password.py); never written to a log; only the database name and vendor are shown on the staff overview (server_views.py:152-158); pg_dump/pg_restore get the password via PGPASSWORD in the environment, not argv (backup.py:240-241). - Static files: WhiteNoise CompressedManifestStaticFilesStorage collected at build time into a root-owned /app/staticfiles; MEDIA_URL is not routed anywhere (urls.py adds static() only under DEBUG), so uploads are served only through the ownership-checked views. - Logging: httpx/httpcore quietened to WARNING so captured and webhook URLs are not written (base.py:845-852); X-Request-ID accepted only when it matches [A-Za-z0-9._:-]{1,200}; JSON formatter; cross-process rotation under flock; no logger call found that formats a token, password, secret or email body. - SMTP: STARTTLS and implicit TLS both use ssl.create_default_context() (mail.py:109,190), and the pinned connection restores the typed hostname before wrapping so certificate verification is against the name, not the IP (destinations.py:229-240); port/mode mismatches are refused with an explanation. - Outbound fetches for GeoNames, ESCO and the update check go through public_only_client (certificate verification on, private addresses refused, at most 3 redirects each re-checked); httpx is held to one minor because its private transports are wrapped to enforce download deadlines. - Capture rendering: WeasyPrint only, in a child process with RLIMIT_DATA 1 GiB and RLIMIT_CPU 60 s, a fetcher allowing no protocol at all, a parent wall-clock timeout, and a byte ceiling on the output (rendering_child.py, rendering.py:91-130). CV rendering: WeasyPrint fetcher limited to data:, Chromium aborts every route and is launched with its default sandbox (no --no-sandbox) (documents/pdf.py:244, 272, 293). - Images: Pillow MAX_IMAGE_PIXELS set per decode and the header dimensions checked before load; truncated files refused; DecompressionBombError mapped to a refusal (core/pictures.py:109-137). - Upload and import caps: documents 20 MB, avatars and logos 5 MB, CSV and kind files 2 MB/5000 rows, vCard 2 MB, candidate file 1 MB/200 rows, Europass attachments bounded, archive import members read within caps (importer.py), backup upload bounded by POSTULO_BACKUP_UPLOAD_MAX_MB (512) and verified under a temporary name before being kept; capture source/rendering/account caps validated at startup (postulo.E004/E005). - Restore: every tar member checked before anything is written (prefix, no '..', no absolute, no backslash/colon/NUL, resolved under its root, plain files only), engine and version matched, PGPASSWORD via env, all-or-nothing PostgreSQL load (backup.py:563-720). - Pagination is bounded everywhere: table pages 25/50/100 only (core/tables.py), list views paginate_by 50, API limit 1..200 with 422 above (api/paging.py), log endpoint 1000. - Docker: three-stage build; runtime runs as uid 1000 `postulo` with /app read-only to it and only /app/data writable; no secrets baked; .env, data, sqlite and tests excluded by .dockerignore; pip removed from the base interpreter; host ports bound to 127.0.0.1; migrations and plugin sync run as the unprivileged user; apt upgrade with a cache-busting build argument; PostgreSQL apt key fetched over HTTPS and scoped with signed-by; healthcheck exercises the database through /healthz; scheduler and worker have file-based healthchecks. - Repository hygiene: git ls-files contains no .env, key, certificate, sqlite, media or data files; .gitignore covers data/, media/, *.sqlite3, .env*, *.mo; pre-commit runs detect-private-key and check-added-large-files; .env.example documents every dangerous default with its reason. - CI: persist-credentials false on every checkout; secrets reach scripts through env, never interpolated; no pull_request_target; release and image workflows start with permissions: {} and grant only what they need; releases refused unless CI passed on the tagged commit; images scanned with two scanners before push and verified in the registry afterwards; SBOM attached; pip-audit --strict on every push and weekly with one named ignore whose reason is held by tests/test_security_audit.py; npm audit at high; zizmor over the workflows. - Dependency versions (uv.lock): Django 6.1.1, django-allauth 65.19.5, django-ninja 1.7.0, Pillow 12.3.0, WeasyPrint 70.0, httpx 0.28.1, httpcore 1.x, cryptography 50.0.1, pydantic 2.13.5, whitenoise 6.12.0, django-htmx 1.29.0, django-tasks-db 0.13.0, icalendar 7.3.0 (>= the 7.1.3 nesting-depth fix), defusedxml 0.7.1, markdown-it-py 4.2.0, nh3 0.3.7, phonenumberslite 9.x, gunicorn 26.2.0, psycopg 3.3.5, certifi 2026.7.22; no vobject or lxml in the lock. pip-audit 2.10.1 run here against the exported lock reports only oauthlib 3.3.1 PYSEC-2026-4114 (CVE-2026-49265, provider-side PKCE timing; Postulo is only an OIDC client), which CI already ignores by name with a test that fails once the lock can take oauthlib 4. - Vendored JavaScript: htmx 2.0.10 in static/js/vendor matches package-lock.json; @zxcvbn-ts/core 4.2.0; axe-core 4.13.0; CI re-syncs vendored files from node_modules and fails on drift, so the committed copies are the locked versions.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Client-side code

  • app.js (all 6913 lines read): the only HTML sink is box.insertAdjacentHTML("beforeend", html) at line 2022, fed solely by answer.feedback, which SaveFieldView._answer renders from settings/save_feedback.html → cotton/field_feedback.html with autoescaped {{ error }} for the person's own profile field; every other DOM write uses textContent, createElement, cloneNode of server-drawn <template>s, or CSSOM.
  • No eval / new Function / string timers, no postMessage listeners or senders, no document.cookie reads, no localStorage or IndexedDB; sessionStorage holds only postulo.board.place (a scroll offset plus the current path) and is removed on first read.
  • htmx history cache is disabled: meta sets historyCacheSize:0, and the vendored htmx 2.0.10 calls sessionStorage.removeItem("htmx-history-cache") when the size is ≤0, so no copy of a page with PII is kept; refreshOnHistoryMiss:true makes Back a server load.
  • Navigation sinks: the single window.location.assign goes through sameSiteAddress() (origin comparison; javascript:/foreign origins collapse to /); no window.open; HtmxLoginRedirectMiddleware emits HX-Redirect only for a relative Location that is the sign-in path, and the admin HX-Redirect targets a fixed reverse().
  • Three fetches (save-as-you-go, column widths, waiting notices) and one built form post (widget placement): all to server-written same-origin URLs from data attributes, credentials: same-origin, CSRF token in the body or X-CSRFToken; backed by connect-src 'self', form-action 'self' and htmx selfRequestsOnly:true. The next values the script fills (data-col-here, data-next-here, the board's theQuestionAsItStands) are consumed by views that call safe_next().
  • Select enhancement: data-icon is only ever used as a CSS.escaped selector into server-drawn <template data-option-icons> / #select-parts (nothing is built from a string, nothing fetched); data-flag URLs come from flag_url(), which checks the code against [A-Za-z]{2}(?:-[A-Za-z]{2,3})? and file existence before calling static(); img-src 'self' data: caps any <img src> regardless. data-hosts/data-prefix are constants from link_services.
  • Password meter: @zxcvbn-ts core 4.x runs entirely in the browser (checker.check(value, userInputsAround(input))); nothing is sent; user inputs are read only from the form and the signed-in person's own identity attributes; the factory API merges user inputs into copies, so CVE-2023-34109 (unbounded accumulation, fixed in 3.0.2) does not apply; the meter is included only on pages where a password is chosen, never sign-in.
  • Service worker (/sw.js): no fetch handler, no CacheStorage, caches neither pages nor files; push payload fields are used only as notification text; notificationclick forces the URL to the worker's origin; served by a view at the root with Cache-Control: no-cache, text/javascript, nosniff and the full CSP (worker-src 'self'); identical signed in or out; tests/security/test_browser_notices.py covers these properties. Pushes are VAPID-signed and encrypted to the subscription keys, so a third party cannot inject a notification.
  • Notice collection endpoint notifications:waiting: POST only, CSRF-checked, 401 when signed out, owner-scoped (for_user), marks notices shown by id; the body data attribute that drives it is rendered only for an authenticated person with the notifier on.
  • Content security policy: default-src 'none'; script-src 'self'; style-src 'self' (+ per-request nonce only on CV/letter previews); img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; set in base settings so test, dev and prod all enforce it; the browser suite fails on any console CSP violation; previews override only frame-ancestors to 'self' with matching X-Frame-Options; X-Frame-Options: DENY, Referrer-Policy: same-origin, Cross-Origin-Opener-Policy: same-origin and nosniff present on every response (verified on /sw.js and pages).
  • Templates: no inline <script> or <style> anywhere in src/postulo/templates; allauth's passkey/recovery pages ship data as <script type="application/json"> blobs plus static JS, which the policy allows; no hx-on, js:/javascript: prefixes, hx-trigger filters, hx-boost or hx-history; every hx-get targets {{ request.path }}; the board's hx-vals='{"status": "{{ column.statuses }}"}' is built from statuses filtered through Status.values (verified: ?status=foo%22bar,applied is not reflected); data attributes app.js uses as selectors or JSON (data-focus-after, data-refresh, data-opens-dialog, data-section-link, data-bulk-counts via json.dumps) are template constants, pks or slugs.
  • Markdown (listing descriptions, the one place a captured page's text becomes markup): markdown-it 'zero' preset with raw HTML off, then nh3 with tags allowlisted, attributes limited to a[href] and ol[start], schemes http/https/mailto, relative URLs denied, rel="noopener noreferrer external" and target=_blank forced — hx-* attributes cannot reach the DOM through it.
  • Flash messages tagged safe are built with format_html (escaped interpolation of listing titles) and the message store is signed, so the |safe branch in partials/messages.html cannot carry attacker markup.
  • Stylesheets: compiled app.css has no remote url() (only one data: SVG for the check icon), no remote @import, fonts self-hosted; error.css has no url(); manifest.webmanifest is JSON built from the operator-set instance name/tagline and static icons under manifest-src 'self'.
  • Vendored versions: htmx 2.0.10 (no advisory against the library found; the recent Drupal SA-CORE-2026-011 concerns Drupal's own filter), @zxcvbn-ts core ^4.2.0 / language packs 4.1.x; no source maps are shipped (find src/postulo/static -name '*.map' is empty).
  • Clipboard copies only the marked element's text (an invitation link already on the page); drag-and-drop transfers only a card or widget key and submits the page's own forms; keyboard shortcuts only .click() server-drawn buttons and honour the data-shortcuts="off" switch; the double-submit and leave-page guards do not alter form targets.
  • The captured-page preview is an <img> of an owner-scoped rendering checked by magic bytes and served with the file policy; the kept source is downloaded as text/plain attachment and never framed or swapped by htmx; CV/letter previews are same-origin iframes of owner-scoped views with a nonce'd stylesheet, not srcdoc.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Client-side code - app.js (all 6913 lines read): the only HTML sink is `box.insertAdjacentHTML("beforeend", html)` at line 2022, fed solely by `answer.feedback`, which `SaveFieldView._answer` renders from settings/save_feedback.html → cotton/field_feedback.html with autoescaped `{{ error }}` for the person's own profile field; every other DOM write uses textContent, createElement, cloneNode of server-drawn &lt;template>s, or CSSOM. - No eval / new Function / string timers, no postMessage listeners or senders, no document.cookie reads, no localStorage or IndexedDB; sessionStorage holds only `postulo.board.place` (a scroll offset plus the current path) and is removed on first read. - htmx history cache is disabled: meta sets `historyCacheSize:0`, and the vendored htmx 2.0.10 calls `sessionStorage.removeItem("htmx-history-cache")` when the size is ≤0, so no copy of a page with PII is kept; `refreshOnHistoryMiss:true` makes Back a server load. - Navigation sinks: the single `window.location.assign` goes through `sameSiteAddress()` (origin comparison; `javascript:`/foreign origins collapse to `/`); no window.open; `HtmxLoginRedirectMiddleware` emits HX-Redirect only for a relative Location that is the sign-in path, and the admin HX-Redirect targets a fixed reverse(). - Three fetches (save-as-you-go, column widths, waiting notices) and one built form post (widget placement): all to server-written same-origin URLs from data attributes, `credentials: same-origin`, CSRF token in the body or `X-CSRFToken`; backed by `connect-src 'self'`, `form-action 'self'` and htmx `selfRequestsOnly:true`. The `next` values the script fills (`data-col-here`, `data-next-here`, the board's `theQuestionAsItStands`) are consumed by views that call `safe_next()`. - Select enhancement: `data-icon` is only ever used as a `CSS.escape`d selector into server-drawn &lt;template data-option-icons> / #select-parts (nothing is built from a string, nothing fetched); `data-flag` URLs come from `flag_url()`, which checks the code against `[A-Za-z]{2}(?:-[A-Za-z]{2,3})?` and file existence before calling static(); `img-src 'self' data:` caps any &lt;img src> regardless. `data-hosts`/`data-prefix` are constants from link_services. - Password meter: @zxcvbn-ts core 4.x runs entirely in the browser (`checker.check(value, userInputsAround(input))`); nothing is sent; user inputs are read only from the form and the signed-in person's own identity attributes; the factory API merges user inputs into copies, so CVE-2023-34109 (unbounded accumulation, fixed in 3.0.2) does not apply; the meter is included only on pages where a password is chosen, never sign-in. - Service worker (/sw.js): no fetch handler, no CacheStorage, caches neither pages nor files; push payload fields are used only as notification text; `notificationclick` forces the URL to the worker's origin; served by a view at the root with `Cache-Control: no-cache`, `text/javascript`, nosniff and the full CSP (`worker-src 'self'`); identical signed in or out; tests/security/test_browser_notices.py covers these properties. Pushes are VAPID-signed and encrypted to the subscription keys, so a third party cannot inject a notification. - Notice collection endpoint `notifications:waiting`: POST only, CSRF-checked, 401 when signed out, owner-scoped (`for_user`), marks notices shown by id; the body data attribute that drives it is rendered only for an authenticated person with the notifier on. - Content security policy: `default-src 'none'; script-src 'self'; style-src 'self'` (+ per-request nonce only on CV/letter previews); `img-src 'self' data:`; `object-src 'none'`; `frame-ancestors 'none'`; `base-uri 'self'`; `form-action 'self'`; set in base settings so test, dev and prod all enforce it; the browser suite fails on any console CSP violation; previews override only `frame-ancestors` to 'self' with matching X-Frame-Options; `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, `Cross-Origin-Opener-Policy: same-origin` and nosniff present on every response (verified on /sw.js and pages). - Templates: no inline &lt;script> or &lt;style> anywhere in src/postulo/templates; allauth's passkey/recovery pages ship data as `<script type="application/json">` blobs plus static JS, which the policy allows; no `hx-on`, `js:`/`javascript:` prefixes, `hx-trigger` filters, `hx-boost` or `hx-history`; every `hx-get` targets `{{ request.path }}`; the board's `hx-vals='{"status": "{{ column.statuses }}"}'` is built from statuses filtered through `Status.values` (verified: `?status=foo%22bar,applied` is not reflected); data attributes app.js uses as selectors or JSON (`data-focus-after`, `data-refresh`, `data-opens-dialog`, `data-section-link`, `data-bulk-counts` via json.dumps) are template constants, pks or slugs. - Markdown (listing descriptions, the one place a captured page's text becomes markup): markdown-it 'zero' preset with raw HTML off, then nh3 with tags allowlisted, attributes limited to `a[href]` and `ol[start]`, schemes http/https/mailto, relative URLs denied, `rel="noopener noreferrer external"` and `target=_blank` forced — hx-* attributes cannot reach the DOM through it. - Flash messages tagged `safe` are built with `format_html` (escaped interpolation of listing titles) and the message store is signed, so the `|safe` branch in partials/messages.html cannot carry attacker markup. - Stylesheets: compiled app.css has no remote `url()` (only one `data:` SVG for the check icon), no remote `@import`, fonts self-hosted; error.css has no url(); manifest.webmanifest is JSON built from the operator-set instance name/tagline and static icons under `manifest-src 'self'`. - Vendored versions: htmx 2.0.10 (no advisory against the library found; the recent Drupal SA-CORE-2026-011 concerns Drupal's own filter), @zxcvbn-ts core ^4.2.0 / language packs 4.1.x; no source maps are shipped (`find src/postulo/static -name '*.map'` is empty). - Clipboard copies only the marked element's text (an invitation link already on the page); drag-and-drop transfers only a card or widget key and submits the page's own forms; keyboard shortcuts only `.click()` server-drawn buttons and honour the `data-shortcuts="off"` switch; the double-submit and leave-page guards do not alter form targets. - The captured-page preview is an &lt;img> of an owner-scoped rendering checked by magic bytes and served with the file policy; the kept source is downloaded as text/plain attachment and never framed or swapped by htmx; CV/letter previews are same-origin iframes of owner-scoped views with a nonce'd stylesheet, not srcdoc.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Business logic and imports

  • JSON archive importer (core/importer.py): operator-only management command; every row built through _carried() which strips id/*_id keys; relations resolved only through the importer's own maps; owner always user; uploads re-checked by checksum/size/extension/bytes; zip entries bounded by _read_within; verified_at/is_recovery/esco_uri discarded; UIDs per-owner unique; usernames through allauth's clean_username.
  • CSV import (core/csv_import.py, views_import.py): 2 MB and MAX_ROWS caps; rows created via create_application/change_status/record_event with owner; companies via get_or_create_company(user, ...) (IntegrityError-safe); errand owner-scoped; payload cleared once read.
  • iCal import (applications/ical_import.py, views_ical.py): no ids in URLs; RowForm.application queryset is for_user; UIDs unique per owner; interviews via schedule_interview.
  • Candidate file and kind files (resume/views_candidate.py, candidate.py, importing.py, core/kind_files.py): session holds parsed values not the file; all lookups for_user/owner=; ids and *_id keys excluded from schemas; bounded rows; phone-number answers charged to the account.
  • Company and contact merges (jobs/merging.py, views.py CompanyMergeView/ContactMergeView): both kept and other resolved from for_user; _same_owner guard; every re-pointing UPDATE filtered by owner_id; select_for_update on both rows; Django Collector refuses anything unexpected before deleting.
  • Bulk actions (core/bulk.py + jobs/listing_views.py, jobs/views.py, applications/views.py): ids capped at 1000, re-scoped by for_user; action names whitelisted; companion tag/industry/status looked up among the owner's rows or choices; status via change_status.
  • Duplicate detection and remembered places (jobs/duplicates.py, remembered.py, known.py): every query filtered by owner; nothing cross-account feeds suggestions.
  • Application status machine: web ApplicationForm saves status only through change_status under select_for_update (drawn_status tamper cannot bypass); EventForm and API add_event refuse SYSTEM_EVENT_KINDS; API set_status validates choices and end reasons.
  • Mail-client listings:bind scope (api/routers/listings.py): listing, contact and file each resolved through owned(); record_listing_event re-checks owner_id of contact/artefact against the listing; external_id dedupe per listing; bind_capture checks owners and locks the capture.
  • Documents send/compare/draft/proofs (documents/views.py, forms.py, proofs.py, slow.py): SendDocumentsForm querysets all for_user; sent_documents errand re-resolves cv/letter/uploads by owner; RenderedCompareView finds the earlier render by owner_id, no second id; drafts are GET, owner-scoped, filed nowhere; proves/replaces/referee choices built from the owner's rows; AddCVItemsForm uses MultipleChoiceField built from for_user(cv.owner); CV pinned details via printing.choose among offered(owner).
  • Reminders and notifications (notifications/service.py, slow.py, send_due_reminders, closing.py, quiet.py, webhooks.py, inbox.py, plugins/browser): every delivery walks Connection.objects.for_user(owner); due reminders notify reminder.owner; push subscriptions live in the owner's own connection secrets; browser notices and webhook rows are OwnedModel; backfill uses documents_of(connection.owner); sync runs as_person(connection.owner) with a conditional claim.
  • Invitations (accounts/views.py, adapter.py, signals.py, models.py): 32-byte urlsafe token stored as SHA-256 fingerprint; invalid/expired/accepted → 404; email binding enforced in clean_email (allauth sets self.request from context.request); acceptance is a conditional UPDATE (#544) with select_for_update on POST; signed-in visitors are redirected, so no account linking; staff-only issue/revoke.
  • Quotas and limits: capture allowance per account on both web and API; number-collision allowance per account; API Idempotency-Key per owner with fingerprint; rendering claim per capture is atomic; CSV/iCal/candidate/kind-file sizes capped; known queries capped at 100.
  • Account deletion and GDPR erasure (accounts/deletion.py, views.py DeleteAccountView, core/gdpr.py, jobs/views.py ContactDeleteView/ContactExportView, plugins/data.py): reauthentication plus typed address; last administrator refused; every FileField of every owned model collected from the model registry and removed on commit; invites revoked; erasure all-or-nothing with plugin rows; contact export/erase views owner-scoped; retention dry run names nobody.
  • Language/time zone/theme: ThemeView validates against Theme.values; LanguageField and ChoiceField time zones; preferences.apply tolerates unknown zones; no set_language route.
  • TOCTOU elsewhere: get_or_create_company handles IntegrityError in a savepoint; capture decide/discard/restore/delete under select_for_update; Invite.accept, webhook/sync/reminder claims are conditional UPDATEs; throttle's non-atomic incr on the DB cache is acknowledged in its docstring.
  • Listing apply/restore/discard and ListingEventCreateView: all through OwnedObjectMixin/for_user; apply_to_listing sets owner=posting.owner; form contact/document querysets for_user.
  • Backup restore (core/backup.py): CLI only; tar members checked for traversal, absolute paths, backslashes/colons, non-regular files, and resolved under the root before anything is written.
  • Export (core/export.py, views_export.py): archive built from for_user querysets only; served through serve_private_file with no-store; errand owner-scoped.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Business logic and imports - JSON archive importer (core/importer.py): operator-only management command; every row built through `_carried()` which strips `id`/`*_id` keys; relations resolved only through the importer's own maps; owner always `user`; uploads re-checked by checksum/size/extension/bytes; zip entries bounded by `_read_within`; `verified_at`/`is_recovery`/`esco_uri` discarded; UIDs per-owner unique; usernames through allauth's `clean_username`. - CSV import (core/csv_import.py, views_import.py): 2 MB and MAX_ROWS caps; rows created via `create_application`/`change_status`/`record_event` with owner; companies via `get_or_create_company(user, ...)` (IntegrityError-safe); errand owner-scoped; payload cleared once read. - iCal import (applications/ical_import.py, views_ical.py): no ids in URLs; `RowForm.application` queryset is `for_user`; UIDs unique per owner; interviews via `schedule_interview`. - Candidate file and kind files (resume/views_candidate.py, candidate.py, importing.py, core/kind_files.py): session holds parsed values not the file; all lookups `for_user`/owner=; ids and `*_id` keys excluded from schemas; bounded rows; phone-number answers charged to the account. - Company and contact merges (jobs/merging.py, views.py CompanyMergeView/ContactMergeView): both `kept` and `other` resolved from `for_user`; `_same_owner` guard; every re-pointing UPDATE filtered by `owner_id`; `select_for_update` on both rows; Django Collector refuses anything unexpected before deleting. - Bulk actions (core/bulk.py + jobs/listing_views.py, jobs/views.py, applications/views.py): ids capped at 1000, re-scoped by `for_user`; action names whitelisted; companion tag/industry/status looked up among the owner's rows or choices; status via `change_status`. - Duplicate detection and remembered places (jobs/duplicates.py, remembered.py, known.py): every query filtered by owner; nothing cross-account feeds suggestions. - Application status machine: web `ApplicationForm` saves status only through `change_status` under `select_for_update` (drawn_status tamper cannot bypass); `EventForm` and API `add_event` refuse SYSTEM_EVENT_KINDS; API `set_status` validates choices and end reasons. - Mail-client `listings:bind` scope (api/routers/listings.py): listing, contact and file each resolved through `owned()`; `record_listing_event` re-checks `owner_id` of contact/artefact against the listing; external_id dedupe per listing; `bind_capture` checks owners and locks the capture. - Documents send/compare/draft/proofs (documents/views.py, forms.py, proofs.py, slow.py): `SendDocumentsForm` querysets all `for_user`; `sent_documents` errand re-resolves cv/letter/uploads by owner; `RenderedCompareView` finds the earlier render by owner_id, no second id; drafts are GET, owner-scoped, filed nowhere; `proves`/`replaces`/`referee` choices built from the owner's rows; `AddCVItemsForm` uses MultipleChoiceField built from `for_user(cv.owner)`; CV pinned details via `printing.choose` among `offered(owner)`. - Reminders and notifications (notifications/service.py, slow.py, send_due_reminders, closing.py, quiet.py, webhooks.py, inbox.py, plugins/browser): every delivery walks `Connection.objects.for_user(owner)`; due reminders notify `reminder.owner`; push subscriptions live in the owner's own connection secrets; browser notices and webhook rows are OwnedModel; backfill uses `documents_of(connection.owner)`; sync runs `as_person(connection.owner)` with a conditional claim. - Invitations (accounts/views.py, adapter.py, signals.py, models.py): 32-byte urlsafe token stored as SHA-256 fingerprint; invalid/expired/accepted → 404; email binding enforced in `clean_email` (allauth sets `self.request` from `context.request`); acceptance is a conditional UPDATE (#544) with `select_for_update` on POST; signed-in visitors are redirected, so no account linking; staff-only issue/revoke. - Quotas and limits: capture allowance per account on both web and API; number-collision allowance per account; API `Idempotency-Key` per owner with fingerprint; rendering claim per capture is atomic; CSV/iCal/candidate/kind-file sizes capped; `known` queries capped at 100. - Account deletion and GDPR erasure (accounts/deletion.py, views.py DeleteAccountView, core/gdpr.py, jobs/views.py ContactDeleteView/ContactExportView, plugins/data.py): reauthentication plus typed address; last administrator refused; every FileField of every owned model collected from the model registry and removed on commit; invites revoked; erasure all-or-nothing with plugin rows; contact export/erase views owner-scoped; retention dry run names nobody. - Language/time zone/theme: `ThemeView` validates against `Theme.values`; `LanguageField` and `ChoiceField` time zones; `preferences.apply` tolerates unknown zones; no `set_language` route. - TOCTOU elsewhere: `get_or_create_company` handles IntegrityError in a savepoint; capture decide/discard/restore/delete under `select_for_update`; `Invite.accept`, webhook/sync/reminder claims are conditional UPDATEs; throttle's non-atomic `incr` on the DB cache is acknowledged in its docstring. - Listing apply/restore/discard and ListingEventCreateView: all through `OwnedObjectMixin`/`for_user`; `apply_to_listing` sets `owner=posting.owner`; form contact/document querysets `for_user`. - Backup restore (core/backup.py): CLI only; tar members checked for traversal, absolute paths, backslashes/colons, non-regular files, and resolved under the root before anything is written. - Export (core/export.py, views_export.py): archive built from `for_user` querysets only; served through `serve_private_file` with no-store; errand owner-scoped.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

REST API

  • Authentication: the API default is TokenAuth and every router sets auth=scope(...); no operation uses auth=None, django_auth or a cookie/session scheme (grep over src/postulo/api); ninja marks its views csrf_exempt but only the Authorization: Bearer header is read, so a signed-in session is refused with 401 (tests/security/test_requests.py and scratch run) and there is no CSRF surface on operations; auth and scope checks run in Operation._run_checks before the body is parsed.
  • OpenAPI: docs_url is None (no Swagger page) and openapi.json is wrapped by for_readers_of_the_api (django-ninja applies docs_decorator to the schema view), answering 401 in the standard problem shape to anyone without a live token or session; the schema reveals only the API's own shape.
  • Scopes: read routes only read and write routes write through the services (change_status, record_event, record_listing_event, schedule_interview, apply_to_listing); listings:bind reaches only GET /listings/choices (ListingChoiceOut: id, title, company_name, location, state, dates) and POST /listings/{pk}/events; documents:read guards only the download; PATCH /cvs/{pk} omits offered for a token without read; scope names are validated in ApiToken.issue and the web form offers only SCOPES.
  • Ownership: every queryset passes through owned()/for_user() before get_object_or_404 in all eleven routers and the capture calls; ids carried in bodies (referred_by_id, through_agency_id, contact_id, document_id, contact_ids, application_id, CV print row ids and identifier ids, company_wikidata) are resolved among the owner's rows with one answer for a foreign and a missing id, and nothing is created on refusal (verified by scratch tests and tests/security/test_isolation_sweep.py); list filters company=/application= only narrow an owned queryset (foreign ids give empty lists, verified); search, insights, the calendar feeds and captures/known are owner-scoped; /profile has no id in its address.
  • Mass assignment: output schemas are hand-built; CompanyPatch, InterviewPatch, ReminderPatch, CVPatch and ProfilePatch list only editable fields (no owner, id, status, email, is_staff, done_at); status moves only through POST /status -> change_status; system event kinds are refused on POST /events.
  • Error handling: Http404 is answered with one sentence for foreign and missing records and never names a model; unhandled exceptions are left to Django (no stack traces or SQL in problem documents; DEBUG off in production); django-ninja's validation builder strips pydantic's input; choice refusals echo only the caller's own value.
  • Rate limiting: a per-token POSTULO_API_RATE is spent in authenticate() before every handler; the capture allowance (POSTULO_CAPTURE_RATE, per account, shared with the web form) is spent before fetching on both POST /captures and POST /captures/preview; a capture that brings its own html fetches nothing; phone-collision answers are bounded per account.
  • Input limits: pagination limit is 1..200 and offset >= 0 (verified 422 on 201); KnownIn.postings <= 100, BatchIn.size <= 500, search q is collapsed to 200 characters with a group limit <= 50; URL fields are validated for http/https (web_address/page_address) with control characters refused, so no javascript: address is stored or linked; profile lines are stripped, bounded and NUL-free; request bodies over DATA_UPLOAD_MAX_MEMORY_SIZE are refused as a 413 problem before being read.
  • Capture endpoints: fetch_page validates the address with public_addresses_for (every resolved address must be public; loopback, private, link-local, NAT64/6to4/Teredo/mapped forms handled in core.destinations.is_public) and the public_only_client hook re-resolves and pins every hop including redirects, with size and time caps; corrections are re-validated through JobPostingData (extra=forbid, url scheme, 40,000-character description cap); keep can only narrow; a rendering is measured against Content-Length before a byte is read, counted while read, checked against magic bytes and stored under an extension of Postulo's choosing; the kept source is gzipped text never served as a page; a reviewed capture takes no rendering and an existing rendering is not replaced.
  • Response shapes: birth date/place, nationalities, gender, form of address and postal details are reachable only through GET/PATCH /profile under read/write; no list endpoint or nested object carries them; captures/known returns id, title, date and link only.
  • CORS: no CORS middleware or Access-Control-* headers anywhere in settings or middleware.
  • Token lifecycle: 32 random bytes hashed with SHA-256 before storage, prefix of 8 characters kept for display, secret shown once on a no-store response and never put in the session; revoke is owner-scoped, POST-only and CSRF-protected; expired, revoked and disabled-account tokens are refused with the same 401 sentence as a missing one; last_used_at is written at most every five minutes; the admin can view and delete token metadata but not add or edit one.
  • Idempotency: keys are per owner, fingerprinted against the payload body, claimed before work and released on failure; a reused key with a different body is refused.
  • HTTP methods: an unknown method is answered 405 before any handler runs, so HEAD/OPTIONS never reach a view; the unknown-address catch-all answers 401 without a token and 404 with one (the 405 path itself is the subject of finding 1).
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## REST API - Authentication: the API default is TokenAuth and every router sets auth=scope(...); no operation uses auth=None, django_auth or a cookie/session scheme (grep over src/postulo/api); ninja marks its views csrf_exempt but only the Authorization: Bearer header is read, so a signed-in session is refused with 401 (tests/security/test_requests.py and scratch run) and there is no CSRF surface on operations; auth and scope checks run in Operation._run_checks before the body is parsed. - OpenAPI: docs_url is None (no Swagger page) and openapi.json is wrapped by for_readers_of_the_api (django-ninja applies docs_decorator to the schema view), answering 401 in the standard problem shape to anyone without a live token or session; the schema reveals only the API's own shape. - Scopes: read routes only read and write routes write through the services (change_status, record_event, record_listing_event, schedule_interview, apply_to_listing); listings:bind reaches only GET /listings/choices (ListingChoiceOut: id, title, company_name, location, state, dates) and POST /listings/{pk}/events; documents:read guards only the download; PATCH /cvs/{pk} omits `offered` for a token without read; scope names are validated in ApiToken.issue and the web form offers only SCOPES. - Ownership: every queryset passes through owned()/for_user() before get_object_or_404 in all eleven routers and the capture calls; ids carried in bodies (referred_by_id, through_agency_id, contact_id, document_id, contact_ids, application_id, CV print row ids and identifier ids, company_wikidata) are resolved among the owner's rows with one answer for a foreign and a missing id, and nothing is created on refusal (verified by scratch tests and tests/security/test_isolation_sweep.py); list filters company=/application= only narrow an owned queryset (foreign ids give empty lists, verified); search, insights, the calendar feeds and captures/known are owner-scoped; /profile has no id in its address. - Mass assignment: output schemas are hand-built; CompanyPatch, InterviewPatch, ReminderPatch, CVPatch and ProfilePatch list only editable fields (no owner, id, status, email, is_staff, done_at); status moves only through POST /status -> change_status; system event kinds are refused on POST /events. - Error handling: Http404 is answered with one sentence for foreign and missing records and never names a model; unhandled exceptions are left to Django (no stack traces or SQL in problem documents; DEBUG off in production); django-ninja's validation builder strips pydantic's `input`; choice refusals echo only the caller's own value. - Rate limiting: a per-token POSTULO_API_RATE is spent in authenticate() before every handler; the capture allowance (POSTULO_CAPTURE_RATE, per account, shared with the web form) is spent before fetching on both POST /captures and POST /captures/preview; a capture that brings its own html fetches nothing; phone-collision answers are bounded per account. - Input limits: pagination limit is 1..200 and offset >= 0 (verified 422 on 201); KnownIn.postings &lt;= 100, BatchIn.size &lt;= 500, search q is collapsed to 200 characters with a group limit &lt;= 50; URL fields are validated for http/https (web_address/page_address) with control characters refused, so no javascript: address is stored or linked; profile lines are stripped, bounded and NUL-free; request bodies over DATA_UPLOAD_MAX_MEMORY_SIZE are refused as a 413 problem before being read. - Capture endpoints: fetch_page validates the address with public_addresses_for (every resolved address must be public; loopback, private, link-local, NAT64/6to4/Teredo/mapped forms handled in core.destinations.is_public) and the public_only_client hook re-resolves and pins every hop including redirects, with size and time caps; corrections are re-validated through JobPostingData (extra=forbid, url scheme, 40,000-character description cap); `keep` can only narrow; a rendering is measured against Content-Length before a byte is read, counted while read, checked against magic bytes and stored under an extension of Postulo's choosing; the kept source is gzipped text never served as a page; a reviewed capture takes no rendering and an existing rendering is not replaced. - Response shapes: birth date/place, nationalities, gender, form of address and postal details are reachable only through GET/PATCH /profile under read/write; no list endpoint or nested object carries them; captures/known returns id, title, date and link only. - CORS: no CORS middleware or Access-Control-* headers anywhere in settings or middleware. - Token lifecycle: 32 random bytes hashed with SHA-256 before storage, prefix of 8 characters kept for display, secret shown once on a no-store response and never put in the session; revoke is owner-scoped, POST-only and CSRF-protected; expired, revoked and disabled-account tokens are refused with the same 401 sentence as a missing one; last_used_at is written at most every five minutes; the admin can view and delete token metadata but not add or edit one. - Idempotency: keys are per owner, fingerprinted against the payload body, claimed before work and released on failure; a reused key with a different body is refused. - HTTP methods: an unknown method is answered 405 before any handler runs, so HEAD/OPTIONS never reach a view; the unknown-address catch-all answers 401 without a token and 404 with one (the 405 path itself is the subject of finding 1).
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Completeness review: coverage

  • URL coverage, every named pattern of every urls file (core, notifications, accounts, accounts settings, plugins/connections, server, applications, listings, jobs, career, documents, capture-tokens, api/v1, allauth): requested as anonymous (GET and POST), as a member and as an administrator, with ordinary and 20-digit ids and hostile query strings. Anonymous users get 200 only on /, /healthz, /manifest.webmanifest, /sw.js and allauth's sign-in, sign-up, reset and confirm pages; everything else redirects to sign-in or answers 401/404.
  • CSRF: every non-API route POSTed with enforce_csrf_checks, as a member and as an anonymous visitor, answers 403 (no csrf_exempt reachable).
  • GET side effects: a full table-contents hash before and after a GET of every route as an administrator with data changed only core_sitesettings (the singleton row created lazily on first read); no GET writes anything else.
  • Response headers on all 161 pages that answered 200: Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, X-Frame-Options and Cross-Origin-Opener-Policy present everywhere (only /healthz lacks X-Frame-Options); Cache-Control is absent on 139 of them (already a confirmed finding).
  • API: all operations in the OpenAPI schema were called with six token shapes (no scope, captures, listings:bind, read, write, documents:read). What each can reach matches the documented scopes (including the already-confirmed captures-token GET /captures and /me); a write-only token reads the profile through PATCH /profile {} but the threat model states the profile is under 'read and write'. Every POST/PATCH/PUT body field was fuzzed with typed hostile values and pathological JSON (50,000-deep nesting, 100,000-digit integers, NaN, duplicate keys, invalid UTF-8): no 5xx.
  • Form scoping: every ModelChoiceField / ModelMultipleChoiceField on every form class in src/postulo (instantiated with a user) has an owner-scoped queryset; the only unscoped ones are Django-admin UserEditForm groups/permissions behind POSTULO_ADMIN_URL.
  • Model ownership: only 11 models under postulo.* are not OwnedModel subclasses (SiteSettings, PluginRepository, PluginPolicy, User, Profile, PersonIdentifier, ProfilePicture, RecoveryLink, Invite, CompanyLogo, ListingEvent, ApplicationEvent), each a singleton, policy row, child of an owned row or account metadata.
  • Fuzzing with no 5xx beyond the findings above: form POSTs on every route with 19 hostile values (all-fields and id-fields modes, 60+ POST parameter names); list and table query parameters (242 names x 27 values x 13 list pages, plain and htmx); editable cells of both tables; save-as-you-go fields; profile, contact and company formsets (management form fields, row ids, DELETE flags; a foreign phone-number row stayed untouched); candidate file (35 leaves x 21 values); Europass XML via the web view (about 120 mutation sites) and by function, Europass JSON (1,496 mutations) and the Europass PDF attachment extractor (286 crafted PDFs incl. an inflate bomb); vCard, iCalendar and CSV readers by mutation; JSON-LD parsers (2,400 type-confusion combinations); document and picture upload validators (17 hostile files); duplicate-name collisions (industry, company, CV, letter) return form errors.
  • SVG sanitiser: 19 XSS and exfiltration vectors (script, on*, javascript: and data: hrefs, use, foreignObject, style @import, url() fills and masks, set/animate, namespace tricks, CDATA, xml-stylesheet, xml:base, entities) are all neutralised or refused.
  • Notifications app read end to end (views, inbox, base, service, slow, webhooks, transport) plus the email notifier, the own-mail outbox, correspondence.send and the send-documents-by-email path: recipients are bound to verified addresses or the sender's own outbox, EmailMessage blocks header injection, sends are throttled per account, queues and webhook rows are owner-scoped, and the webhook is HMAC-signed, destination-checked on every delivery and not redirected.
  • Management commands (all 21) are operator-only CLI with no web path; seed_demo uses secrets.token_urlsafe for a missing password; plugins sync/install and fetch_esco/fetch_geonames take only operator input.
  • Feature plugins email_addresses, employer_structure, maps, messaging_contacts, phone_numbers, social_profiles, websites and gdpr are flag declarations of under 75 lines with no I/O; there is no tile proxy anywhere; the smtp transport and own-mail outbox use the pinned, guarded SMTP backend.
  • Static analysis: ruff --select S --ignore-noqa lists 26 sites (mark_safe, subprocess, 'hardcoded password' names); each reviewed, the mark_safe sites escape every interpolated value and the subprocess calls use fixed argv. A repository-wide scan found no committed private key, token or credential; .github holds no workflows.
  • Anonymous traffic (sign-in failures, sign-up, reset, invitation and recovery probes, API 401s) creates no session rows. Control characters in next are percent-encoded by Django's redirect, so no header injection.
  • Caches: the insights cache key hashes user.pk with record counts and timestamps (no cross-user key reuse); the DB cache is only written with server-built values.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Completeness review: coverage - URL coverage, every named pattern of every urls file (core, notifications, accounts, accounts settings, plugins/connections, server, applications, listings, jobs, career, documents, capture-tokens, api/v1, allauth): requested as anonymous (GET and POST), as a member and as an administrator, with ordinary and 20-digit ids and hostile query strings. Anonymous users get 200 only on /, /healthz, /manifest.webmanifest, /sw.js and allauth's sign-in, sign-up, reset and confirm pages; everything else redirects to sign-in or answers 401/404. - CSRF: every non-API route POSTed with enforce_csrf_checks, as a member and as an anonymous visitor, answers 403 (no csrf_exempt reachable). - GET side effects: a full table-contents hash before and after a GET of every route as an administrator with data changed only core_sitesettings (the singleton row created lazily on first read); no GET writes anything else. - Response headers on all 161 pages that answered 200: Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, X-Frame-Options and Cross-Origin-Opener-Policy present everywhere (only /healthz lacks X-Frame-Options); Cache-Control is absent on 139 of them (already a confirmed finding). - API: all operations in the OpenAPI schema were called with six token shapes (no scope, captures, listings:bind, read, write, documents:read). What each can reach matches the documented scopes (including the already-confirmed captures-token GET /captures and /me); a write-only token reads the profile through PATCH /profile {} but the threat model states the profile is under 'read and write'. Every POST/PATCH/PUT body field was fuzzed with typed hostile values and pathological JSON (50,000-deep nesting, 100,000-digit integers, NaN, duplicate keys, invalid UTF-8): no 5xx. - Form scoping: every ModelChoiceField / ModelMultipleChoiceField on every form class in src/postulo (instantiated with a user) has an owner-scoped queryset; the only unscoped ones are Django-admin UserEditForm groups/permissions behind POSTULO_ADMIN_URL. - Model ownership: only 11 models under postulo.* are not OwnedModel subclasses (SiteSettings, PluginRepository, PluginPolicy, User, Profile, PersonIdentifier, ProfilePicture, RecoveryLink, Invite, CompanyLogo, ListingEvent, ApplicationEvent), each a singleton, policy row, child of an owned row or account metadata. - Fuzzing with no 5xx beyond the findings above: form POSTs on every route with 19 hostile values (all-fields and id-fields modes, 60+ POST parameter names); list and table query parameters (242 names x 27 values x 13 list pages, plain and htmx); editable cells of both tables; save-as-you-go fields; profile, contact and company formsets (management form fields, row ids, DELETE flags; a foreign phone-number row stayed untouched); candidate file (35 leaves x 21 values); Europass XML via the web view (about 120 mutation sites) and by function, Europass JSON (1,496 mutations) and the Europass PDF attachment extractor (286 crafted PDFs incl. an inflate bomb); vCard, iCalendar and CSV readers by mutation; JSON-LD parsers (2,400 type-confusion combinations); document and picture upload validators (17 hostile files); duplicate-name collisions (industry, company, CV, letter) return form errors. - SVG sanitiser: 19 XSS and exfiltration vectors (script, on*, javascript: and data: hrefs, use, foreignObject, style @import, url() fills and masks, set/animate, namespace tricks, CDATA, xml-stylesheet, xml:base, entities) are all neutralised or refused. - Notifications app read end to end (views, inbox, base, service, slow, webhooks, transport) plus the email notifier, the own-mail outbox, correspondence.send and the send-documents-by-email path: recipients are bound to verified addresses or the sender's own outbox, EmailMessage blocks header injection, sends are throttled per account, queues and webhook rows are owner-scoped, and the webhook is HMAC-signed, destination-checked on every delivery and not redirected. - Management commands (all 21) are operator-only CLI with no web path; seed_demo uses secrets.token_urlsafe for a missing password; plugins sync/install and fetch_esco/fetch_geonames take only operator input. - Feature plugins email_addresses, employer_structure, maps, messaging_contacts, phone_numbers, social_profiles, websites and gdpr are flag declarations of under 75 lines with no I/O; there is no tile proxy anywhere; the smtp transport and own-mail outbox use the pinned, guarded SMTP backend. - Static analysis: ruff --select S --ignore-noqa lists 26 sites (mark_safe, subprocess, 'hardcoded password' names); each reviewed, the mark_safe sites escape every interpolated value and the subprocess calls use fixed argv. A repository-wide scan found no committed private key, token or credential; .github holds no workflows. - Anonymous traffic (sign-in failures, sign-up, reset, invitation and recovery probes, API 401s) creates no session rows. Control characters in next are percent-encoded by Django's redirect, so no header injection. - Caches: the insights cache key hashes user.pk with record counts and timestamps (no cross-user key reuse); the DB cache is only written with server-built values.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Completeness review: tests against claims

  • Anonymous GET and POST against all 335 resolver patterns: every record-bearing, settings, server, API-write and career route redirects to sign-in or answers 401/403; only the intended public routes answer otherwise (landing page, healthz, manifest, sw.js, sign-in, sign-up, password reset, invitation and recovery token routes, /logs and /metrics 404 while off).
  • Cross-account read check: second account given one of nearly everything (companies, listings, applications, interviews, reminders, offers, suggestions, tags, contacts, industries, captures, career entries, CV items, letters, uploads, links, connections, tokens, phone/links/handles/addresses/identifiers, errands, export archive, publications/honours/memberships/courses/events/licence), strings renamed to a unique marker; the first account then requested every parameterless web route and every pk route through the sweep factories (with ?q=Their/Somebody/Zebra), all API lists, /api/v1/search, both .ics feeds, the export archive members and file names, the four kind-file downloads and the candidate file: 825 responses, zero hits. Positive control: the second account's own API token saw the marker 63 times.
  • GET query-parameter fuzz over every parameterless non-admin web route (33 parameter names x 7 hostile values: huge ints, negatives, 1e999, NUL, 3000-character strings, 400 digits): zero 5xx.
  • Web forms: 16,655 hostile POST values (26-digit ints, NaN, inf, 1e400, year 1 and year 9999 dates and datetimes, NULs, 5,000-character strings, impossible dates) to every field of 285 form pages including all 15 career sections: only the tags/industries/contacts overflow reported above produced a 5xx.
  • Upload handlers: PNG decompression bomb, 200 MB-expanding docx, SVG billion-laughs, wide/NUL CSV, 200k-deep JSON, 5,000-event and 3,000-deep iCalendar, 1.5 MB vCard line, XXE Europass file sent to document upload, avatar, CV photo, company logo, CSV import, iCal import, vCard import, Europass import, candidate file and the four kind files: all answered quickly (under about a second each) with no 5xx and no hang.
  • Extreme but stored dates (listing closes_at/posted_at and application deadline at year 1 and 9999): 20 web pages, calendar months 0001-01 and 9999-12 and agenda, three iCalendar downloads and the API list/ics/insights calls render without error. (Only the datetime-with-timezone path in finding 2 breaks.)
  • Every file-like response I fetched (avatar, logo, uploads, rendered documents, CV/letter text, vCards, kind files, candidate file, export archive) carries Content-Security-Policy default-src 'none'; sandbox where it is a private file, X-Content-Type-Options: nosniff, and a Content-Disposition built by Django; uploads, rendered documents, vCards, kind files and candidate file carry no-store.
  • allauth login_on_verification only logs in when the same session is mid-sign-up for that user, so ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION=True cannot be used to put a victim into an attacker's account via an unsolicited confirmation mail.
  • Redirect sites that do not take safe_next directly: saved-view opening_url is built from a re-parsed stored QueryDict plus request.path; views_tables rebuilds from urlsplit(back).path of a safe_next result; no HTTP_REFERER is used for navigation.
  • Remaining generic formsets (phone numbers, web links, messaging handles, postal addresses, person identifiers) all use RowsAlreadyGone, so a foreign or invented row id behaves like a removed row; only CompanyIdentifier differs (already reported).
  • mail_auth Provider.authorise/token format() a user-typed tenant only into the path of login.microsoftonline.com URLs, so it cannot change host; the email notifier can only address the person's own verified addresses; correspondence.send refuses a sender other than the outbox's.
  • Hrefs fed by stored text are guarded: Interview.is_link requires http(s), resume Link/Publication/Course/etc. use URLField, Europass import keeps only http(s) addresses, the capture source bounds amounts and validates the posting URL; no template contains an inline <script>, on*= handler or javascript: href, and theme <style> blocks interpolate no user text.
  • plugins/http.py, plugins/public_addresses.py and core/destinations.py re-read end to end: resolve once per hop, approve every answer, pin to an approved address with Host and SNI kept, restore the name before redirects, fall back only among approved addresses; I found no gap beyond the items already on the list.
  • No lru_cache, functools.cache or django cache entry holds per-person data keyed without the person; cache uses are throttles, leases, static datasets or operator state.
  • API scope matrix re-read (captures, listings:bind, read, write, documents:read): no route reachable with a scope the documentation does not grant beyond the items already listed.
  • Server-rendered administrator pages show counts and account metadata only (data-protection dry run, record of processing), as #369 claims.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Completeness review: tests against claims - Anonymous GET and POST against all 335 resolver patterns: every record-bearing, settings, server, API-write and career route redirects to sign-in or answers 401/403; only the intended public routes answer otherwise (landing page, healthz, manifest, sw.js, sign-in, sign-up, password reset, invitation and recovery token routes, /logs and /metrics 404 while off). - Cross-account read check: second account given one of nearly everything (companies, listings, applications, interviews, reminders, offers, suggestions, tags, contacts, industries, captures, career entries, CV items, letters, uploads, links, connections, tokens, phone/links/handles/addresses/identifiers, errands, export archive, publications/honours/memberships/courses/events/licence), strings renamed to a unique marker; the first account then requested every parameterless web route and every pk route through the sweep factories (with ?q=Their/Somebody/Zebra), all API lists, /api/v1/search, both .ics feeds, the export archive members and file names, the four kind-file downloads and the candidate file: 825 responses, zero hits. Positive control: the second account's own API token saw the marker 63 times. - GET query-parameter fuzz over every parameterless non-admin web route (33 parameter names x 7 hostile values: huge ints, negatives, 1e999, NUL, 3000-character strings, 400 digits): zero 5xx. - Web forms: 16,655 hostile POST values (26-digit ints, NaN, inf, 1e400, year 1 and year 9999 dates and datetimes, NULs, 5,000-character strings, impossible dates) to every field of 285 form pages including all 15 career sections: only the tags/industries/contacts overflow reported above produced a 5xx. - Upload handlers: PNG decompression bomb, 200 MB-expanding docx, SVG billion-laughs, wide/NUL CSV, 200k-deep JSON, 5,000-event and 3,000-deep iCalendar, 1.5 MB vCard line, XXE Europass file sent to document upload, avatar, CV photo, company logo, CSV import, iCal import, vCard import, Europass import, candidate file and the four kind files: all answered quickly (under about a second each) with no 5xx and no hang. - Extreme but stored dates (listing closes_at/posted_at and application deadline at year 1 and 9999): 20 web pages, calendar months 0001-01 and 9999-12 and agenda, three iCalendar downloads and the API list/ics/insights calls render without error. (Only the datetime-with-timezone path in finding 2 breaks.) - Every file-like response I fetched (avatar, logo, uploads, rendered documents, CV/letter text, vCards, kind files, candidate file, export archive) carries Content-Security-Policy `default-src 'none'; sandbox` where it is a private file, X-Content-Type-Options: nosniff, and a Content-Disposition built by Django; uploads, rendered documents, vCards, kind files and candidate file carry no-store. - allauth login_on_verification only logs in when the same session is mid-sign-up for that user, so ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION=True cannot be used to put a victim into an attacker's account via an unsolicited confirmation mail. - Redirect sites that do not take safe_next directly: saved-view opening_url is built from a re-parsed stored QueryDict plus request.path; views_tables rebuilds from urlsplit(back).path of a safe_next result; no HTTP_REFERER is used for navigation. - Remaining generic formsets (phone numbers, web links, messaging handles, postal addresses, person identifiers) all use RowsAlreadyGone, so a foreign or invented row id behaves like a removed row; only CompanyIdentifier differs (already reported). - mail_auth Provider.authorise/token format() a user-typed tenant only into the path of login.microsoftonline.com URLs, so it cannot change host; the email notifier can only address the person's own verified addresses; correspondence.send refuses a sender other than the outbox's. - Hrefs fed by stored text are guarded: Interview.is_link requires http(s), resume Link/Publication/Course/etc. use URLField, Europass import keeps only http(s) addresses, the capture source bounds amounts and validates the posting URL; no template contains an inline &lt;script>, on*= handler or javascript: href, and theme &lt;style> blocks interpolate no user text. - plugins/http.py, plugins/public_addresses.py and core/destinations.py re-read end to end: resolve once per hop, approve every answer, pin to an approved address with Host and SNI kept, restore the name before redirects, fall back only among approved addresses; I found no gap beyond the items already on the list. - No lru_cache, functools.cache or django cache entry holds per-person data keyed without the person; cache uses are throttles, leases, static datasets or operator state. - API scope matrix re-read (captures, listings:bind, read, write, documents:read): no route reachable with a scope the documentation does not grant beyond the items already listed. - Server-rendered administrator pages show counts and account metadata only (data-protection dry run, record of processing), as #369 claims.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Completeness review: recent changes

  • Pictures moved into the database (commit 73110d9): AvatarView and CompanyLogoView narrow before lookup (own pk or staff for avatars; Company.objects.for_user for logos). pictures.keep refuses anything that is neither a PNG nor a sanitised SVG. serve_stored_picture sets nosniff, the 'default-src none; sandbox' policy and the stored media type. Merge, importer, export members, account deletion and the data migrations read only DB-stored names and cascade correctly. Nothing new is registered in the Django admin.
  • CV photo (db45dfe): a third ProfilePicture kind that is never offered by Profile.picture and cannot be fetched through the avatar view. It is processed by the same Pillow path with the same 1 MiB budget as the avatar. It reaches documents only as a data: URI in an autoescaped <img>, only when CV.show_photo is on and contact details are on. It is left out of the candidate file, plain text and Word output. The renderers fetch nothing, and the preview CSP already allows data: images.
  • Upload integrity (14c81fb): integrity.py reads the zip directory without unpacking, Pillow decodes pictures under the pixel cap, and the form and the importer share the checks. The importer pops archive-supplied checksum and size and measures the bytes itself. UploadedDocument.save cleans up the file when the row save fails. Rendered-document save takes its file back on failure.
  • Orphan sweep: only files older than the grace period are removed, the scheduler lease applies, and restore_backup loads the database before it writes media with fresh mtimes, so a restore cannot be swept while it runs. Media in other directories (plugins, backups) is outside MEDIA_ROOT in every shipped configuration.
  • CaptureDeleteView and the Capture.posting CASCADE (ab28e20): owner-scoped queryset (foreign id is 404), a saved capture is refused and re-checked under select_for_update, delete happens only on POST under CSRF, and the route is in the isolation sweep. Company merge still repoints postings first, so the cascade does not delete captures during a merge.
  • Proof link (ef70000): proofs.find uses for_user, the form's choices are the owner's entries, UploadedDocument.check_proves runs on every save (is_own compares owner ids), the importer only maps rows it created, the API field is read-only, the upload list filter returns none() for a foreign value, and the entry delete signal clears the link instead of deleting the file.
  • References (3c755a5, 642fadd): Reference.save and clean check contact.owner_id, the form queryset is Contact.objects.for_user, and the renderer hands themes a frozen Referee value that is built only when permission is Agreed (email and number only with show_details). The candidate file carries no references. GDPR export, erasure and the retention dry run reach them, and merge handles the contact. The Europass referee import goes through phone_numbers.refusal(), which checks the allowance before the lookup, and creates contacts only for the importing owner.
  • ComesBackMixin and the reference form's contact links: next is read through safe_next and rendered in an autoescaped hidden input.
  • Official plugin catalogue (c30851b): scripts/official_catalogue.py verify passes on the committed index and signature, and the index is signed over its exact bytes (.gitattributes -text). The official row stays disabled until an administrator switches it on, and migration 0009 only fills an empty row. This pass saw no new network path beyond the existing guarded client.
  • New career sections (honours, memberships, courses, participations, driving licences): URL fields are URLFields in forms and the candidate file. The Europass importer keeps only http(s) addresses within the column length. No template renders them as href; themes print them as text. Company links come from companies.existing/find_or_add, which are owner-scoped. Search groups use for_user.
  • Routes: probing every URL pattern anonymously (GET and POST) returned 200 only for the intended public routes (/, /healthz, /manifest.webmanifest, /sw.js and the allauth sign-in/sign-up/reset pages). The project's own tests/security suite passes (1233 passed; the HarfBuzz test deselected). ruff's S, B and DJ rules report only two assert uses in plugins/testing.py. No dependency, static JS or CSS changes in the range; the CI change only removes an ignored permissions block.
  • Language scaffolding (nine new languages): the new code is registry data and week-start format modules, with no filesystem lookup built from a language code. The added .po entries contain no markup beyond what existing translated strings already carry.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Completeness review: recent changes - Pictures moved into the database (commit 73110d9): AvatarView and CompanyLogoView narrow before lookup (own pk or staff for avatars; Company.objects.for_user for logos). pictures.keep refuses anything that is neither a PNG nor a sanitised SVG. serve_stored_picture sets nosniff, the 'default-src none; sandbox' policy and the stored media type. Merge, importer, export members, account deletion and the data migrations read only DB-stored names and cascade correctly. Nothing new is registered in the Django admin. - CV photo (db45dfe): a third ProfilePicture kind that is never offered by Profile.picture and cannot be fetched through the avatar view. It is processed by the same Pillow path with the same 1 MiB budget as the avatar. It reaches documents only as a data: URI in an autoescaped &lt;img>, only when CV.show_photo is on and contact details are on. It is left out of the candidate file, plain text and Word output. The renderers fetch nothing, and the preview CSP already allows data: images. - Upload integrity (14c81fb): integrity.py reads the zip directory without unpacking, Pillow decodes pictures under the pixel cap, and the form and the importer share the checks. The importer pops archive-supplied checksum and size and measures the bytes itself. UploadedDocument.save cleans up the file when the row save fails. Rendered-document save takes its file back on failure. - Orphan sweep: only files older than the grace period are removed, the scheduler lease applies, and restore_backup loads the database before it writes media with fresh mtimes, so a restore cannot be swept while it runs. Media in other directories (plugins, backups) is outside MEDIA_ROOT in every shipped configuration. - CaptureDeleteView and the Capture.posting CASCADE (ab28e20): owner-scoped queryset (foreign id is 404), a saved capture is refused and re-checked under select_for_update, delete happens only on POST under CSRF, and the route is in the isolation sweep. Company merge still repoints postings first, so the cascade does not delete captures during a merge. - Proof link (ef70000): proofs.find uses for_user, the form's choices are the owner's entries, UploadedDocument.check_proves runs on every save (is_own compares owner ids), the importer only maps rows it created, the API field is read-only, the upload list filter returns none() for a foreign value, and the entry delete signal clears the link instead of deleting the file. - References (3c755a5, 642fadd): Reference.save and clean check contact.owner_id, the form queryset is Contact.objects.for_user, and the renderer hands themes a frozen Referee value that is built only when permission is Agreed (email and number only with show_details). The candidate file carries no references. GDPR export, erasure and the retention dry run reach them, and merge handles the contact. The Europass referee import goes through phone_numbers.refusal(), which checks the allowance before the lookup, and creates contacts only for the importing owner. - ComesBackMixin and the reference form's contact links: next is read through safe_next and rendered in an autoescaped hidden input. - Official plugin catalogue (c30851b): `scripts/official_catalogue.py verify` passes on the committed index and signature, and the index is signed over its exact bytes (.gitattributes -text). The official row stays disabled until an administrator switches it on, and migration 0009 only fills an empty row. This pass saw no new network path beyond the existing guarded client. - New career sections (honours, memberships, courses, participations, driving licences): URL fields are URLFields in forms and the candidate file. The Europass importer keeps only http(s) addresses within the column length. No template renders them as href; themes print them as text. Company links come from companies.existing/find_or_add, which are owner-scoped. Search groups use for_user. - Routes: probing every URL pattern anonymously (GET and POST) returned 200 only for the intended public routes (/, /healthz, /manifest.webmanifest, /sw.js and the allauth sign-in/sign-up/reset pages). The project's own tests/security suite passes (1233 passed; the HarfBuzz test deselected). ruff's S, B and DJ rules report only two assert uses in plugins/testing.py. No dependency, static JS or CSS changes in the range; the CI change only removes an ignored permissions block. - Language scaffolding (nine new languages): the new code is registry data and week-start format modules, with no filesystem lookup built from a language code. The added .po entries contain no markup beyond what existing translated strings already carry.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Completeness review: shipped plugins

  • Feature-only plugins (email_addresses, employer_structure, gdpr, maps, messaging_contacts, phone_numbers, repositories, social_profiles, websites): pure declarations with no views, forms, models, URLs, outbound calls or storage; enforcement is in core and owner-scoped there.
  • localstore: needs no connection; put() only calls document.file.save(), which lands under the owner's upload folder.
  • Email notifier: recipient restricted to the person's own allauth-verified addresses, re-checked at send time, with the config value only echoed back to its owner. Subject collapsed to one line (str.split handles CR/LF/U+2028). A body or url carrying control characters cannot form headers.
  • smtp / own_mail transport: GuardedBackend approves every resolved address first and dials the pinned one, the certificate is proved against the typed name (_ProvesTheName sets _host before wrapping), STARTTLS is never downgraded (smtplib raises if not advertised), _refuse_the_clear blocks credentials without TLS, the operator private-host exemption does not extend to a person's outbox, sends are throttled per account, and the sender is never rewritten. An outbox is not a recovery transport.
  • webhook: HMAC-SHA256 over timestamp.body with compare_digest in the reference verifier, 16-character minimum secret stored encrypted, destination re-checked at validate, test and every delivery, redirects not followed, queue rows owner-scoped and claimed by conditional UPDATE, SecretsUnreadable handled, no receiver body echoed (only a Location header).
  • browser / webpush: hand-written RFC 8291 and RFC 8292 with fresh ECDH key and salt per message; endpoint must be https and goes through the public-only client with no redirects; only the status is returned. Subscription keys validated as a 65-byte P-256 point and 16-byte auth. The service worker restricts click targets to same-origin.
  • builtin sources: JSON-LD, microdata and RDFa readers use iterative walkers (no recursion on page nesting); a RecursionError from deeply nested @graph is contained by parse_page (verified at depth 3000, falls back to page-metadata). Numeric values are checked finite and bounded, fields cut to 500 characters, description capped, URL validated by JobPostingData. Salary and date regexes run on 160-character windows and were timed linear (about 0.2 ms per hit). No eval, exec, pickle, subprocess, mark_safe or file access. Per-call state only: the shared parse is a ContextVar matched by identity, and lru_caches are keyed by language codes only.
  • hints (remembered places): clean() accepts only the exact keys and shapes it writes, locate() requires exactly one match, learning keeps digests rather than page text and is bounded by kind, count and bytes, and rows are owner-scoped in jobs/remembered.
  • identifiers: admin-defined patterns use a possessive, bounded mini-language with an ambiguity check and a class-weight budget; link templates are http(s) only, values are percent-encoded and ./.. path steps are refused; stored values are strip()-ped by normalise() before the $-anchored .match (the trailing-newline edge is not reachable). Checksum functions run only after the pattern matches.
  • link_services / messaging_services: all shipped patterns fuzzed with 500-character adversarial inputs (gitlab nesting, mastodon, bluesky, youtube, facebook, matrix, xmpp, signal and others), no measurable backtracking; _match refuses userinfo, backslash in the authority and control characters, and uses fullmatch. Messaging handles are never turned into hrefs.
  • postal_rules: static ASCII-anchored fullmatch patterns, NFKC fold of a length-capped field; 300-character adversarial postcodes for every country took at most about 6 ms.
  • europass: any DOCTYPE refused before parsing and defusedxml with forbid_dtd; JSON depth capped and RecursionError caught; the PDF attachment finder is windowed (4 KiB reach, 32 markers, 8 attachments), accepts only Flate, rejects encrypted PDFs and predictors, and inflates under a hard output cap; the attachment goes through every guard again; membership and participation URLs are kept only if http(s) and 200 characters or fewer; reference phones go through the collision allowance.
  • None of the shipped plugin packages contains subprocess, eval/exec, pickle/yaml, open(), importlib, format_html or mark_safe.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Completeness review: shipped plugins - Feature-only plugins (email_addresses, employer_structure, gdpr, maps, messaging_contacts, phone_numbers, repositories, social_profiles, websites): pure declarations with no views, forms, models, URLs, outbound calls or storage; enforcement is in core and owner-scoped there. - localstore: needs no connection; put() only calls document.file.save(), which lands under the owner's upload folder. - Email notifier: recipient restricted to the person's own allauth-verified addresses, re-checked at send time, with the config value only echoed back to its owner. Subject collapsed to one line (str.split handles CR/LF/U+2028). A body or url carrying control characters cannot form headers. - smtp / own_mail transport: GuardedBackend approves every resolved address first and dials the pinned one, the certificate is proved against the typed name (_ProvesTheName sets _host before wrapping), STARTTLS is never downgraded (smtplib raises if not advertised), _refuse_the_clear blocks credentials without TLS, the operator private-host exemption does not extend to a person's outbox, sends are throttled per account, and the sender is never rewritten. An outbox is not a recovery transport. - webhook: HMAC-SHA256 over timestamp.body with compare_digest in the reference verifier, 16-character minimum secret stored encrypted, destination re-checked at validate, test and every delivery, redirects not followed, queue rows owner-scoped and claimed by conditional UPDATE, SecretsUnreadable handled, no receiver body echoed (only a Location header). - browser / webpush: hand-written RFC 8291 and RFC 8292 with fresh ECDH key and salt per message; endpoint must be https and goes through the public-only client with no redirects; only the status is returned. Subscription keys validated as a 65-byte P-256 point and 16-byte auth. The service worker restricts click targets to same-origin. - builtin sources: JSON-LD, microdata and RDFa readers use iterative walkers (no recursion on page nesting); a RecursionError from deeply nested @graph is contained by parse_page (verified at depth 3000, falls back to page-metadata). Numeric values are checked finite and bounded, fields cut to 500 characters, description capped, URL validated by JobPostingData. Salary and date regexes run on 160-character windows and were timed linear (about 0.2 ms per hit). No eval, exec, pickle, subprocess, mark_safe or file access. Per-call state only: the shared parse is a ContextVar matched by identity, and lru_caches are keyed by language codes only. - hints (remembered places): clean() accepts only the exact keys and shapes it writes, locate() requires exactly one match, learning keeps digests rather than page text and is bounded by kind, count and bytes, and rows are owner-scoped in jobs/remembered. - identifiers: admin-defined patterns use a possessive, bounded mini-language with an ambiguity check and a class-weight budget; link templates are http(s) only, values are percent-encoded and `.`/`..` path steps are refused; stored values are strip()-ped by normalise() before the `$`-anchored `.match` (the trailing-newline edge is not reachable). Checksum functions run only after the pattern matches. - link_services / messaging_services: all shipped patterns fuzzed with 500-character adversarial inputs (gitlab nesting, mastodon, bluesky, youtube, facebook, matrix, xmpp, signal and others), no measurable backtracking; _match refuses userinfo, backslash in the authority and control characters, and uses fullmatch. Messaging handles are never turned into hrefs. - postal_rules: static ASCII-anchored fullmatch patterns, NFKC fold of a length-capped field; 300-character adversarial postcodes for every country took at most about 6 ms. - europass: any DOCTYPE refused before parsing and defusedxml with forbid_dtd; JSON depth capped and RecursionError caught; the PDF attachment finder is windowed (4 KiB reach, 32 markers, 8 attachments), accepts only Flate, rejects encrypted PDFs and predictors, and inflates under a hard output cap; the attachment goes through every guard again; membership and participation URLs are kept only if http(s) and 200 characters or fewer; reference phones go through the collision allowance. - None of the shipped plugin packages contains subprocess, eval/exec, pickle/yaml, open(), importlib, format_html or mark_safe.
Author
Owner

Coverage notes from the audit report (SECURITY-AUDIT-2026-10-10-coverage.md): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record.

Completeness review: chains

  • allauth's MFA trust cookie fingerprint binds user pk, the password hash and every authenticator seed, so a password change or recovery-link spend invalidates remembered browsers (allauth/mfa/internal/flows/trust.py).
  • allauth's own rate limiter (core/internal/ratelimit.py) uses a sliding-window list with explicit durations and sha256-hashed keys, so it is NOT affected by the incr() TTL defect. It is still subject to database-cache culling (finding 2).
  • allauth filter_users_by_email(prefer_verified=True, for_login=True) restricts email login to the verified holder when one exists, so a pre-claimed unverified copy of an address cannot be used to authenticate against, or clear the lock-out counter of, a verified holder.
  • allauth login_on_verification only logs in when the signing-up user is in the session, so clicking a confirmation link cannot sign a victim into the attacker's account.
  • Anonymous sweep of every URL pattern, GET and POST (640 route/method pairs): only the intended public pages answer 200, no 5xx, and no anonymous request creates a django_session row.
  • UploadedDocument.storage_prefix ends with '/', so 'documents/1/' does not prefix 'documents/10/...'.
  • SSRF layer re-read end to end (public_addresses.py, destinations.is_public, http._guard/_pin/_restore_name/_public_only): resolve, approve every address, pin to one, restore the name for Host and SNI, re-run on every redirect hop. No gap found. IPv4-mapped, NAT64, 6to4, Teredo, site-local and ::/8 forms are covered.
  • Referee/reference rendering returns a frozen value object and prints details only when agreed and when show_details is set. CV theme templates interpolate no user data into <style> blocks.
  • X-Request-ID validation uses fullmatch, so no trailing-newline log injection. Username pattern uses \A..\Z.
  • Recovery-link and invitation tokens are stored only as SHA-256 fingerprints; the recovery view moves the token into the session; spend runs only on POST. Sessions are invalidated by the password-hash session check after spend.
  • WeasyPrint child process: URLFetcher(allowed_protocols=set()) opens nothing, and RLIMIT_DATA/RLIMIT_CPU plus a wall-clock timeout are applied.
  • StaffRequiredMixin, safe_next(), TrustedProxyMiddleware (rightmost untrusted XFF, headers stripped from untrusted peers) and HealthProbeMiddleware behaved as the round-one reviewers described.
  • Candidate-file profile import is limited to export.CANDIDATE_PROFILE_FIELDS (no theme, time zone, gravatar or capture-keeping switches).
  • CSV import reads only text CSV (no xlsx/zip parsing), with a 2 MB / row cap.
Coverage notes from the audit report (`SECURITY-AUDIT-2026-10-10-coverage.md`): what this reviewer examined and found sound. Where a note disagrees with an issue, the issue is the record. ## Completeness review: chains - allauth's MFA trust cookie fingerprint binds user pk, the password hash and every authenticator seed, so a password change or recovery-link spend invalidates remembered browsers (allauth/mfa/internal/flows/trust.py). - allauth's own rate limiter (core/internal/ratelimit.py) uses a sliding-window list with explicit durations and sha256-hashed keys, so it is NOT affected by the incr() TTL defect. It is still subject to database-cache culling (finding 2). - allauth filter_users_by_email(prefer_verified=True, for_login=True) restricts email login to the verified holder when one exists, so a pre-claimed unverified copy of an address cannot be used to authenticate against, or clear the lock-out counter of, a verified holder. - allauth login_on_verification only logs in when the signing-up user is in the session, so clicking a confirmation link cannot sign a victim into the attacker's account. - Anonymous sweep of every URL pattern, GET and POST (640 route/method pairs): only the intended public pages answer 200, no 5xx, and no anonymous request creates a django_session row. - UploadedDocument.storage_prefix ends with '/', so 'documents/1/' does not prefix 'documents/10/...'. - SSRF layer re-read end to end (public_addresses.py, destinations.is_public, http._guard/_pin/_restore_name/_public_only): resolve, approve every address, pin to one, restore the name for Host and SNI, re-run on every redirect hop. No gap found. IPv4-mapped, NAT64, 6to4, Teredo, site-local and ::/8 forms are covered. - Referee/reference rendering returns a frozen value object and prints details only when agreed and when show_details is set. CV theme templates interpolate no user data into &lt;style> blocks. - X-Request-ID validation uses fullmatch, so no trailing-newline log injection. Username pattern uses \A..\Z. - Recovery-link and invitation tokens are stored only as SHA-256 fingerprints; the recovery view moves the token into the session; spend runs only on POST. Sessions are invalidated by the password-hash session check after spend. - WeasyPrint child process: URLFetcher(allowed_protocols=set()) opens nothing, and RLIMIT_DATA/RLIMIT_CPU plus a wall-clock timeout are applied. - StaffRequiredMixin, safe_next(), TrustedProxyMiddleware (rightmost untrusted XFF, headers stripped from untrusted peers) and HealthProbeMiddleware behaved as the round-one reviewers described. - Candidate-file profile import is limited to export.CANDIDATE_PROFILE_FIELDS (no theme, time zone, gravatar or capture-keeping switches). - CSV import reads only text CSV (no xlsx/zip parsing), with a 2 MB / row cap.
Author
Owner

All 98 findings (#730-#827) are closed: fixed, documented as a decision in the threat model and the wiki, or found to be fixed already (#826 by #738).

All 98 findings (#730-#827) are closed: fixed, documented as a decision in the threat model and the wiki, or found to be fixed already (#826 by #738).
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#828
No description provided.