Security audit of 2026-10-10: index of PST-001 to PST-098 (#730–#827) #828
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#828
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Index of the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md,mainatc30851b). Its 98 issues are filed one per finding as #730–#827, each titled with its PST number and labelledsecurity; 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.tiagoagueda/postulo, commitc30851b(the tip ofmainwhen the audit started), audited 8 to 10 October 2026src/postulo/(about 103,000 lines of Python outside migrations, 264 templates,static/js/app.jsand the vendored scripts), the settings and URL configuration,docker/, the CI workflows,pyproject.tomlanduv.lockdocs/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.SECURITY.mdasks 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.mdanddocs/THREAT-MODEL.md: every query owner-scoped, every file served through a permission check, a content security policy with no inline script, everynextredirect checked, and every outbound request through the guarded client. It ran as a multi-agent review in four stages.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-auditwas run against the lock file and reported one advisory, inoauthlib, which the project's CI already ignores by name with a test that fails once the lock can take the fixed version.Severity scale
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.
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_EMAILleft on, which is its default. It was reproduced by two independent verifiers and again when the report was assembled.Themes
nextvalues raise unhandled exceptions. Each affects only the request that sent it..envreaching the database container.Suggested order of work
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
src/postulo/accounts/social_adapter.pysrc/postulo/config/settings/base.pysrc/postulo/accounts/signals.pysrc/postulo/core/logs.pysrc/postulo/core/throttle.pysrc/postulo/resume/candidate.pysrc/postulo/accounts/social_adapter.pysrc/postulo/config/settings/base.pysrc/postulo/accounts/signals.pysrc/postulo/accounts/recovery.pysrc/postulo/core/server_views.pysrc/postulo/accounts/views.pysrc/postulo/api/auth.pysrc/postulo/accounts/mfa_adapter.pysrc/postulo/accounts/adapter.pysrc/postulo/accounts/forms.pythird-party: allauth/account/adapter.pysrc/postulo/config/settings/base.pysrc/postulo/plugins/consent.pysrc/postulo/accounts/admin.pysrc/postulo/accounts/deletion.pysrc/postulo/core/csv_import.pysrc/postulo/notifications/webhooks.pysrc/postulo/static/js/app.jsdocker/Dockerfilesrc/postulo/config/settings/base.pysrc/postulo/core/throttle.pysrc/postulo/core/throttle.pysrc/postulo/accounts/forms.pysrc/postulo/jobs/forms.pysrc/postulo/api/api.pysrc/postulo/api/api.pysrc/postulo/api/api.pysrc/postulo/api/routers/listings.pysrc/postulo/api/views.pysrc/postulo/api/routers/applications.pysrc/postulo/api/paging.pysrc/postulo/core/bulk.pysrc/postulo/core/redirects.pysrc/postulo/core/kind_files.pysrc/postulo/core/importer.pysrc/postulo/core/files.pysrc/postulo/core/backup.pysrc/postulo/documents/views.pysrc/postulo/documents/models.pysrc/postulo/documents/scrub.pysrc/postulo/jobs/pages.pysrc/postulo/core/pictures.pysrc/postulo/plugins/builtin/htmlutil.pysrc/postulo/plugins/http.pysrc/postulo/plugins/public_addresses.pysrc/postulo/plugins/webhook/__init__.pysrc/postulo/jobs/management/commands/fetch_geonames.pysrc/postulo/plugins/catalogue.pysrc/postulo/plugins/record.pysrc/postulo/plugins/views.pysrc/postulo/plugins/views.pysrc/postulo/config/settings/base.pysrc/postulo/applications/views.pysrc/postulo/config/settings/base.py.forgejo/workflows/ci.ymldocker/compose.postgres.ymlsrc/postulo/core/server_views.pysrc/postulo/config/settings/base.pysrc/postulo/plugins/consent.pysrc/postulo/accounts/views.pysrc/postulo/accounts/views.pysrc/postulo/plugins/registry.pysrc/postulo/core/middleware.pysrc/postulo/api/auth.pysrc/postulo/core/views.pysrc/postulo/core/views_metrics.pysrc/postulo/api/api.pysrc/postulo/api/api.pysrc/postulo/templates/partials/messages.htmlsrc/postulo/core/templatetags/postulo.pysrc/postulo/jobs/capture_views.pysrc/postulo/core/cells.pysrc/postulo/core/kind_files.pysrc/postulo/core/backup.pysrc/postulo/core/views_backups.pysrc/postulo/plugins/secrets.pysrc/postulo/config/settings/base.pysrc/postulo/core/files.pysrc/postulo/jobs/rendering.pysrc/postulo/config/settings/base.pysrc/postulo/core/destinations.pysrc/postulo/plugins/http.pysrc/postulo/plugins/builtin/boards/__init__.pysrc/postulo/plugins/catalogue.pysrc/postulo/plugins/api.pysrc/postulo/plugins/browser/webpush.pysrc/postulo/config/settings/base.pysrc/postulo/templates/base.htmldocker/Dockerfiletests/security/test_isolation_sweep.pysrc/postulo/accounts/signals.py.forgejo/workflows/ci.ymlCandidates 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.
src/postulo/core/importer.pyimporter.load, which calls_restore_phone_numbers(profile, user, numbers, report, ...)at importer.py:1040) is reached only by the management commandmanage.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 importspostulo.core.importer.core/views_import.py, which the finding cites, is the CSV spreadsheet import;csv_import.pyhas no telephone handling at all (its only 'phone' is the interview kind 'phone screen'), and the /import page mentions neither 'archive' nor 'zip'. …src/postulo/core/server_views.pysrc/postulo/core/csv_import.pystash()(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, andunstash()re-decodes and re-parses it on every GET/POST of the mapping page; the view then copies the same base64 intoerrands.send(..., data=...)(views_import.py:66-74) soErrand.payloadholds 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:86Errand.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'). …src/postulo/core/views_backups.pysrc/postulo/core/server_views.pysrc/postulo/core/files.py.htmlso it is typed text/html and shown inline) is stopped twice: the model field carriesFileExtensionValidator(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 ofevil.htmlto 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. …src/postulo/accounts/views.py{% 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. …src/postulo/plugins/secrets.pyconfig(including the destination URL) is plaintext JSON with no integrity protection, and Connection.full_config merges config and decrypted secrets. …src/postulo/plugins/views.pytype(error).__name__: errorfrom httpx transport errors, which do not carry the URL. The auditor also admits that no shipped plugin was confirmed to do it. …src/postulo/api/schemas.pyweb 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. …src/postulo/config/settings/prod.pysrc/postulo/config/settings/prod.py.forgejo/workflows/dev-image.ymlruns-on: docker. The comments at dev-image.yml:8-15 and CONTRIBUTING.md:719-755 say plainly that thedockerrunner usescontainer.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
for_user()before any lookup, and a foreign id answers 404. The reviewers traced every URL pattern, checked everyModelChoiceFieldandModelMultipleChoiceFieldon 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.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 dropidand every*_idkey.csrf_exemptexists outside the API, which authenticates by bearer token alone and ignores the session.is_staff,is_superuseroris_active.Files and uploads
MEDIA_URLis not routed anywhere. Every file goes through an ownership-checked view withno-store,nosniffand a sandboxing policy. Kept page sources are text, shown escaped and downloaded as attachments.defusedxmlallow-list sanitiser, which survived 19 attack vectors.Outbound requests
Web layer
|safe,autoescape offor inline script. The content security policy has nounsafe-inlineorunsafe-evalfor scripts. Markdown is rendered with raw HTML off and then annh3allow-list.app.jshas one HTML sink, fed only by a server-built fragment, and noeval,postMessage, cookie reads orlocalStorage.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
/logsand/metricscompare tokens in constant time and refuse to serve without one.Parsers and dependencies
pickle,yaml,evalorexec. XML parsing usesdefusedxmlwith 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-auditreported one advisory, inoauthlib(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
Limits of this audit
tests/security/test_pdf_fetching.pyfails in the audit container because the HarfBuzz-Subset library is missing. It is not a finding.Companion file
The raw per-reviewer notes of what each reviewer examined and found sound are kept in
SECURITY-AUDIT-2026-10-10-coverage.mdbeside this report.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
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
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
self.model.objects.for_user(self.request.user). All of them are in tests/security/test_isolation_sweep.py FACTORIES.listing(for_user + bind_capture owner check), MergeViewwithon GET and POST (records().exclude(kept)), CompanyBulkViewchosenandindustry(bulk.chosen_rows + Industry.for_user), ListingBulkViewchosen(chosen_rows), CaptureDiscardSelectedViewselected(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.nextgoes through safe_next (url_has_allowed_host_and_scheme with the request host).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
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
replacesandrefereequerysets are for_user(user);provesis a ChoiceField of the owner's own entries, re-resolved through proofs.find(user, ...) and enforced again by UploadedDocument.check_proves() on every save;fileis removed from the edit form (#217). Scratch test: foreign replaces and referee ids are refused._one, for_user) before rendering/freezing; freeze() adds M2M rows from those scoped lists; core:errand page/state views are owner-scoped._carrieddropsid/*_idkeys), 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.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
actionvalue;nexton the move and link-check views goes through safe_next().ownerorcompany(company is editable=False and set only via companies.find_or_add(owner, name), which is for_user/get_or_create_company scoped).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.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
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
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
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
<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.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 thedefault-src 'none'; sandboxpolicy; 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.static()mount covers STATIC_URL only; no nginx config in the repository exposes media; tests/security/test_files.py asserts the 404.#href/xlink:href and any url() not pointing at#; served under the sandbox policy; plugin marks use the same sanitiser.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.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.%PDF-check on what comes back.<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.<folder>/<pk>directories.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
_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).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.read_bodywith 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.sni_hostnameextension as server_hostname so the certificate is checked against the typed name; smtplib paths use ssl.create_default_context().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.1copy_with(host=...).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
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
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
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
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
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
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
postulowith /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.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
box.insertAdjacentHTML("beforeend", html)at line 2022, fed solely byanswer.feedback, whichSaveFieldView._answerrenders 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.postulo.board.place(a scroll offset plus the current path) and is removed on first read.historyCacheSize:0, and the vendored htmx 2.0.10 callssessionStorage.removeItem("htmx-history-cache")when the size is ≤0, so no copy of a page with PII is kept;refreshOnHistoryMiss:truemakes Back a server load.window.location.assigngoes throughsameSiteAddress()(origin comparison;javascript:/foreign origins collapse to/); no window.open;HtmxLoginRedirectMiddlewareemits HX-Redirect only for a relative Location that is the sign-in path, and the admin HX-Redirect targets a fixed reverse().credentials: same-origin, CSRF token in the body orX-CSRFToken; backed byconnect-src 'self',form-action 'self'and htmxselfRequestsOnly:true. Thenextvalues the script fills (data-col-here,data-next-here, the board'stheQuestionAsItStands) are consumed by views that callsafe_next().data-iconis only ever used as aCSS.escaped selector into server-drawn <template data-option-icons> / #select-parts (nothing is built from a string, nothing fetched);data-flagURLs come fromflag_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-prefixare constants from link_services.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.notificationclickforces the URL to the worker's origin; served by a view at the root withCache-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.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.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 onlyframe-ancestorsto 'self' with matching X-Frame-Options;X-Frame-Options: DENY,Referrer-Policy: same-origin,Cross-Origin-Opener-Policy: same-originand nosniff present on every response (verified on /sw.js and pages).<script type="application/json">blobs plus static JS, which the policy allows; nohx-on,js:/javascript:prefixes,hx-triggerfilters,hx-boostorhx-history; everyhx-gettargets{{ request.path }}; the board'shx-vals='{"status": "{{ column.statuses }}"}'is built from statuses filtered throughStatus.values(verified:?status=foo%22bar,appliedis not reflected); data attributes app.js uses as selectors or JSON (data-focus-after,data-refresh,data-opens-dialog,data-section-link,data-bulk-countsvia json.dumps) are template constants, pks or slugs.a[href]andol[start], schemes http/https/mailto, relative URLs denied,rel="noopener noreferrer external"andtarget=_blankforced — hx-* attributes cannot reach the DOM through it.safeare built withformat_html(escaped interpolation of listing titles) and the message store is signed, so the|safebranch in partials/messages.html cannot carry attacker markup.url()(only onedata: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 undermanifest-src 'self'.find src/postulo/static -name '*.map'is empty)..click()server-drawn buttons and honour thedata-shortcuts="off"switch; the double-submit and leave-page guards do not alter form targets.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
_carried()which stripsid/*_idkeys; relations resolved only through the importer's own maps; owner alwaysuser; uploads re-checked by checksum/size/extension/bytes; zip entries bounded by_read_within;verified_at/is_recovery/esco_uridiscarded; UIDs per-owner unique; usernames through allauth'sclean_username.create_application/change_status/record_eventwith owner; companies viaget_or_create_company(user, ...)(IntegrityError-safe); errand owner-scoped; payload cleared once read.RowForm.applicationqueryset isfor_user; UIDs unique per owner; interviews viaschedule_interview.for_user/owner=; ids and*_idkeys excluded from schemas; bounded rows; phone-number answers charged to the account.keptandotherresolved fromfor_user;_same_ownerguard; every re-pointing UPDATE filtered byowner_id;select_for_updateon both rows; Django Collector refuses anything unexpected before deleting.for_user; action names whitelisted; companion tag/industry/status looked up among the owner's rows or choices; status viachange_status.ApplicationFormsaves status only throughchange_statusunderselect_for_update(drawn_status tamper cannot bypass);EventFormand APIadd_eventrefuse SYSTEM_EVENT_KINDS; APIset_statusvalidates choices and end reasons.listings:bindscope (api/routers/listings.py): listing, contact and file each resolved throughowned();record_listing_eventre-checksowner_idof contact/artefact against the listing; external_id dedupe per listing;bind_capturechecks owners and locks the capture.SendDocumentsFormquerysets allfor_user;sent_documentserrand re-resolves cv/letter/uploads by owner;RenderedCompareViewfinds the earlier render by owner_id, no second id; drafts are GET, owner-scoped, filed nowhere;proves/replaces/refereechoices built from the owner's rows;AddCVItemsFormuses MultipleChoiceField built fromfor_user(cv.owner); CV pinned details viaprinting.chooseamongoffered(owner).Connection.objects.for_user(owner); due reminders notifyreminder.owner; push subscriptions live in the owner's own connection secrets; browser notices and webhook rows are OwnedModel; backfill usesdocuments_of(connection.owner); sync runsas_person(connection.owner)with a conditional claim.clean_email(allauth setsself.requestfromcontext.request); acceptance is a conditional UPDATE (#544) withselect_for_updateon POST; signed-in visitors are redirected, so no account linking; staff-only issue/revoke.Idempotency-Keyper owner with fingerprint; rendering claim per capture is atomic; CSV/iCal/candidate/kind-file sizes capped;knownqueries capped at 100.ThemeViewvalidates againstTheme.values;LanguageFieldandChoiceFieldtime zones;preferences.applytolerates unknown zones; noset_languageroute.get_or_create_companyhandles IntegrityError in a savepoint; capture decide/discard/restore/delete underselect_for_update;Invite.accept, webhook/sync/reminder claims are conditional UPDATEs; throttle's non-atomicincron the DB cache is acknowledged in its docstring.OwnedObjectMixin/for_user;apply_to_listingsetsowner=posting.owner; form contact/document querysetsfor_user.for_userquerysets only; served throughserve_private_filewith 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.REST API
offeredfor a token without read; scope names are validated in ApiToken.issue and the web form offers only SCOPES.input; choice refusals echo only the caller's own value.keepcan 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.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
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
default-src 'none'; sandboxwhere 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.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
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.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.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.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.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.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.c30851b):scripts/official_catalogue.py verifypasses 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.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
./..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.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
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).