Threat Model
DOGFOOD is a self-hostable hackathon submission-and-judging platform. An organizer forks it, runs docker compose up, and trusts it to keep each judge's ballots private, enforce the submission deadline, compute a defensible ranking, and export results without corrupting them. This document states — honestly — what the shipped build defends against, what it does not, and how a reviewer can verify each claim without taking our word for it.
Every control below carries one of four labels:
- SHIPPED — implemented and covered by a test or acceptance check named in §10. If the evidence does not exist, the control is not labelled SHIPPED.
- DESIGN ONLY — the design is worked out and the seam exists, but the enforcing code is not in this build. Claimed as future work, never as a mitigation.
- DECLINED — deliberately out of scope, with the reason stated.
- ACCEPTED RISK — a residual we understand and choose to live with at this scale.
A control table that quietly labelled aspirations as "shipped" would be worse than no table: a reviewer who finds one false claim is right to distrust every other. So where the pre-build design over-reached, this document downgrades the claim rather than the evidence.
1. Scope and the one boundary that matters
The system is single-tenant and single-event by construction: one running instance serves one event. (How each view resolves that event has since split in two — the checker-facing routes still take the single oldest event, the newer surfaces take an ext_id from the URL path; §8 states this precisely, because it is an authorization claim.) It is designed to run fully offline — no external identity provider, email, SMS, CAPTCHA, or third-party reputation service is available. That single constraint (§5) shapes everything that follows.
The trust boundary that matters is the operator boundary. Because the platform is self-hosted, whoever runs it holds the database credentials and can docker exec into the container. No application-layer control can stop a party who edits the database directly. This threat model therefore does not pretend to defend the data against its own operator; it is explicit (§7 A8) that detection — not prevention — is the only honest posture there. What the code does enforce is the boundary between the platform's users: participants, judges, and organizers acting through the HTTP surface.
2. Assets
Assets worth attacking, in rough priority:
| Asset | Why it is a target | Defended in |
|---|---|---|
| Judge ballots (per-judge confidentiality) | Seeing peers' scores enables anchoring and collusion | §7 A1 |
| Authorization state (who is judge / organizer) | Role escalation unlocks every other asset | §7 A2 / A12 |
| Submission-deadline fairness | Extra time is a direct competitive advantage | §7 A3 |
| Submission ownership | Submitting as or for another team corrupts attribution | §7 A4 |
| Results-export integrity | A poisoned CSV can execute in the organizer's spreadsheet | §7 A5 |
| The ranking itself (normalizer) | The whole point of the platform is a trustworthy rank | §7 A6 |
| Official results: disclosure timing & publication integrity | Leaking a ranking early skews fairness; a mutable "official" result is unauditable | §7 A11 |
| Ballot history / tamper-evidence | Silent edits undermine every result | §7 A7 |
| Admin credentials, secret key | Full compromise | §7 A9 |
| The judge↔submission graph (assignments, recusals) | It sets A6's leverage bound, so shaping it shapes the ranking | §7 A20 |
| Community vote tallies | A second, unsigned publication surface an attacker can stuff | §7 A13 |
| Exported event bundle | Structural event data plus participant emails and roles in one file | §7 A14 |
| Webhook secrets & internal network reachability | The MAC key forges deliveries; the fetcher is an SSRF pivot | §7 A15 |
| Awards / prize configuration | A prize read as a result would launder a curatorial pick into the ranking | §7 A19 |
3. Adversaries and their budgets
We model attackers by capability, not by name. An offline attacker cannot buy identities or outsource CAPTCHAs; their only budget is the accounts and API access the organizer already granted.
| Code | Who | Capability assumed |
|---|---|---|
| X | Unauthenticated outsider | Can reach the public HTTP surface only |
| P | One participant | A single valid participant session |
| C | Colluding participants | A few coordinated participant sessions |
| J | A judge | A valid judge session plus assigned submissions |
| V | A community voter | No portal account — only the ability to receive mail at one or more addresses (§7 A13) |
| O | Organizer / operator | Organizer role and DB + docker exec on the host |
| N | Host / infrastructure compromise | Out of scope — see §7 A8 |
Adversary O is the distinctive one for a self-hosted product and is treated separately in §7 A8. Adversaries X, P, C, J, and V act only through the application, and are the ones the code below actually constrains. V is the one adversary whose budget the offline constraint does not bound: an outsider who can create mailboxes can create voters, which is why A13 accepts a sybil residual instead of claiming to prevent one.
4. Trust boundaries
- Network → app. Only the web service publishes a port (
:8000); PostgreSQL is never published to the host (docker-compose.ymlexposes nodbports). SHIPPED. - Anonymous → authenticated. Session identity is resolved once, and every protected view re-derives authorization from the caller's membership, never from a URL or form field (§7 A1/A2). SHIPPED.
- User → user (participant / judge / organizer). Enforced at the service and data layer so it holds for every entry point, not only the HTTP handler that happens to be tested. SHIPPED.
- User → operator. Not defensible in a self-hosted model; see §7 A8. ACCEPTED RISK, documented.
5. The offline constraint
Almost every mainstream anti-abuse control assumes an online oracle: email or SMS verification to raise the cost of a fake identity, CAPTCHA to price out automation, IP or device reputation to spot sybils, an external clock to anchor deadlines. DOGFOOD is required to run air-gapped, so none of those are available. This is not a gap to apologise for; it is the design centre. The consequence is that sybil-resistance cannot come from the platform — it comes from the organizer controlling account creation: accounts and roles are seeded or granted by the organizer, never self-served (§7 A2). Where a control would have leaned on an online oracle, this document says so and marks it DECLINED — or, where the surrounding feature ships without it, ACCEPTED RISK with the missing property named — rather than faking a mitigation. Community voting (§7 A13) is the sharpest case: it ships, its identity accounting is database-enforced, and its identity creation is not constrained at all, so it carries an explicit accepted sybil residual instead of a claim.
6. Posture
For each threat we aim for the strongest posture the offline constraint allows:
- PREVENT — the attack cannot succeed through the API (e.g. cross-judge reads).
- DETECT — the attack is visible after the fact.
- EVIDENT & REVERSIBLE — tampering leaves a trail and can be undone.
Honestly: this build achieves PREVENT for the user-boundary threats (isolation, role, deadline, ownership, export, invitation, and — on the surfaces added since — the SSRF gate, the bundle gate and signature, comment and record scoping, and the awards organizer gate). The one user-boundary threat where PREVENT is not achieved is community voting's sybil resistance (§7 A13): identity accounting is prevented at the database, identity creation is not constrained at all, and §5 says why. For DETECT / EVIDENT it now ships the append-only, hash-chained audit log those postures depend on (§7 A7, A8): every ballot and submission write appends an immutable, chained audit event in the same transaction, and an organizer can recompute the chain — and check a signed checkpoint — offline. Ballot edits are additionally EVIDENT & REVERSIBLE — each write appends an immutable BallotRevision, so a prior score survives as its own row (§7 A7) — and a published ranking can be frozen as a signed, offline-reproducible run (§7 A6). The one honest limit is the operator boundary (§7 A8): a chain and a signing key that both live on the operator's own host detect tampering only relative to a signed checkpoint that left the operator's control before the disputed change. Prevention against the operator remains out of reach by construction — a property of self-hosting, not a missing feature.
7. Threats
Each threat gives the mechanism, the control, its status, the residual, and the exact command a reviewer runs to check it (see §10 for how to run them).
A1 — Cross-judge ballot disclosure (the crux) · SHIPPED · PREVENT
Mechanism. A judge, or anyone, tries to read another judge's scores — e.g. by passing ?judge=judge_a while authenticated as someone else, or by hitting the judge API unauthenticated.
Control. /api/judge/scores derives the caller's judge membership for the event and returns only Ballot.objects.filter(assignment__judge=membership). The ?judge= parameter can only ever narrow to denial: if it names anyone but the caller, the response is 403; it never selects whose rows are returned. Ownership is a property of the data query, not of the URL. An unauthenticated caller is 401; a non-judge is 403. This is backend enforcement — no frontend check is involved, so it cannot be bypassed by calling the API directly (a frontend-only check would be an automatic disqualification).
Status. SHIPPED. Independently confirmed by an external security cold-read of the shipped code.
Residual. If two judge memberships were seeded with the same ext_id, the ?judge= denial check could mismatch — but the returned rows would still be the caller's own, so confidentiality holds; only the parameter echo would be wrong. Enforcing unique non-blank ext_id per event is DESIGN ONLY. ACCEPTED at fixture scale, where ext_ids are unique.
Verify. tools/replay.py checks 4/5/6; src/normalize/tests.py asserts the ownership matrix.
A2 — Role / authorization escalation · SHIPPED · PREVENT
Mechanism. A participant tries to read the judge API or hit the organizer-only CSV export; or a user tries to grant themselves a role.
Control. Roles are event-scoped EventMembership rows (organizer / judge / participant), not global flags. Every protected view checks the caller's membership for the current event: the judge API requires a judge membership (else 403), and the export requires an organizer membership (else 403). Memberships are created only by the organizer's seed/import path or by redeeming an organizer-minted, signed, single-use invitation (A12) — never by an open self-service role endpoint. The invite path cannot escalate: create_invite accepts only judge or participant, never organizer, so a shared link can add a member in a non-privileged role but can never mint an organizer, and minting itself requires an existing organizer membership. The seed/import path remains the offline sybil-resistance boundary from §5, and because an invitation is single-use, one link admits at most one account.
Status. SHIPPED (checks 4/5/6/7).
Residual. Roles are additive by design: one user may hold participant + judge + organizer in the same event if an organizer grants them. That is intended (a small event may need it) and is not escalation, because only an organizer can create the grant. Mutually exclusive roles, if an adopter needs them, are DESIGN ONLY (a cross-row constraint would enforce it).
Verify. tools/replay.py checks 6 and 7; src/normalize/tests.py.
A3 — Deadline gaming · SHIPPED · PREVENT
Mechanism. A participant submits, or edits, after the deadline to gain time.
Control. The deadline is a single canonical rule in the submission service, not the view or template: create_submission checks auth → participant role → the event's accepting-submissions window before any field validation, using server time (timezone.now()); the HTTP handler never accepts a client-supplied clock. A late POST is rejected for being late even if otherwise well-formed. Crucially, the acceptance checker's closed-event POST returns 4xx because of the deadline, not because of CSRF — the demo shim exempts CSRF precisely so the 4xx proves the rule under test (§7 A9).
Status. SHIPPED (check 3).
Residual. (1) Submissions are now editable and withdrawable (/submissions/<id>/edit, /submissions/<id>/withdraw), but every such write passes the same accepting_submissions(now) gate in the service that create_submission does, so "edit after close" is still unreachable: a revision or withdrawal after the deadline is rejected for being late exactly like a late create (verified — SubmissionEditWithdrawTests.test_edit_and_withdraw_refused_after_close). An edit overwrites the current fields rather than keeping a per-field history, so the chain records that a submission.revised occurred and which fields changed, not the prior text — weaker than ballot history (A7), and named as such here and in §9.5. (2) A theoretical TOCTOU race exists if an organizer closes the event in the millisecond between the state read and the row insert. ACCEPTED RISK — negligible at event scale; a row lock would close it and is DESIGN ONLY.
Verify. tools/replay.py check 3.
A4 — Submission ownership / IDOR · SHIPPED · PREVENT
Mechanism. A participant tries to submit for another team, or attach their submission to a foreign event's track, by supplying someone else's identifiers in the POST body.
Control. The write path never trusts a client-supplied owner. The team is derived server-side from the caller's own TeamMember membership (participant_team), so a team= field in the request body is simply ignored; the track is looked up scoped to the current event (Track.objects.filter(event=event, ext_id=…)), so a track id from another event resolves to nothing and the submission is rejected. There is no identifier a caller can inject to submit as, or for, another team.
Status. SHIPPED. An external cold-read flagged this as a potential IDOR against the then-unseen code; reading the shipped implementation refutes it — the owner is server-derived.
Residual. A participant who belongs to multiple teams submits under the deterministic first team, by id. At fixture scale each participant is on one team. ACCEPTED.
Verify. Code: submissions/services.py (participant_team, create_submission); submissions/views.py (track lookup). tools/replay.py check 3 exercises the write path.
A5 — Export integrity: spreadsheet formula injection · SHIPPED · PREVENT
Mechanism. A participant names a project — or a judge writes a comment — beginning with =, +, -, @, tab, or carriage return, e.g. =HYPERLINK("http://evil","click"). When the organizer opens the exported CSV in Excel or Sheets, the cell executes as a formula (CWE-1236), exfiltrating data or running a callback in the organizer's session.
Control. export_event_rows passes every data cell through _csv_safe, which prefixes an apostrophe to any string beginning with a formula-trigger character. Spreadsheets then display the literal text and do not evaluate it. Standard CSV quoting — which csv.writer already does — escapes delimiters but does not neutralise formulas, so this guard is separate and necessary. The header row is static and untouched, so the export still carries commas on line 1.
Status. SHIPPED — Increment 3 (commit 94d2e31).
Related hardening. The judge-scores and export responses now send Cache-Control: private, no-store and Vary: Cookie, so a reverse proxy or shared cache placed in front of the app cannot serve one caller's private rows to another.
Verify. tests/test_export_hardening.py (the guard, DB-free); tools/replay.py check 7 (export still valid).
A6 — Gaming the normalizer · estimator + signed reproducible run SHIPPED · collusion detectors DESIGN ONLY
Mechanism. A judge tries to move the ranking by scoring strategically — inflating an ally, tanking a rival, or exploiting leniency — rather than honestly.
What the design already bounds. The ranking is a ridge / partial-pooling fit that separates project quality q from judge severity b (y = q + b + e, penalty on b only; see JUDGING.md). Two structural facts limit a single judge's leverage: a judge influences a submission only in proportion to 1/R, the number of ballots on it, so on a well-connected panel one malicious ballot moves q little; and cross-submission comparison is only identified within a connected component of the judge–submission graph, so a judge cannot manufacture rank against projects they never share a co-judge with. λ is chosen by cross-validation on observed ballots only — never on ground truth — so it is a fit parameter, not a tunable lever and not a security parameter.
Status. The estimator and its determinism are SHIPPED (tests/test_normalize_engine.py, src/normalize/tests.py). Active collusion detectors — residual-outlier reports, exact-match ballot detection, zero-variance-judge exclusion — are DESIGN ONLY: the seams are understood but the code is not in this build.
Signed, reproducible run (SHIPPED — P2). Beyond the live leaderboard, an operator can now publish a signed, reproducible normalization run (src/normalize/runs.py, manage.py normalize_publish). It freezes the exact inputs the ranking consumed — the weighted ballots in a pinned order, each pinned to its BallotRevision version (A7), the rubric weights, and the pinned λ — hashes the inputs and the canonical result, signs the pair with the same Ed25519 operator key as the audit checkpoints (a distinct domain tag dogfood.normalize.run.v1 keeps a run signature from ever being replayed as a checkpoint, and vice-versa — proven both directions in tests/test_normalize_signing.py), and co-commits a normalization.published event onto the audit chain in one transaction, so a run can neither exist without its chain record nor leave a chain record without a run. An independent party runs python -m normalize.verify <bundle> on a machine that never touched the deployment: it re-runs the estimator from the pinned inputs through the same engine.compute_leaderboard code path the live view uses and confirms the ranking canonicalises to the signed result_hash (gauge_error is excluded from the hash — it is ~0 at machine precision and bit-volatile across BLAS backends — and re-asserted < 1e-6 separately). Honest scope mirrors A8: a PASS proves the published ranking is exactly what this engine version produces from those ballots and that a run signed by the pinned key committed to it — it is evidence against the operator only if the public key + fingerprint were pinned by an independent party before judging, since the operator holds the private key and could re-sign a different run.
Residual. A coordinated ring of judges sharing many submissions could still bias results within their component. Detecting that is the DESIGN-ONLY work above. ACCEPTED for this build, and named in §9.
Verify. tests/test_normalize_engine.py and tests/test_normalize_signing.py (DB-free); manage.py test (normalize service + run tests); manage.py normalize_publish --export <dir> then offline python -m normalize.verify <dir>; JUDGING.md for the derivation and the honest within-submission σ-reduction on the real fixture.
A7 — Silent ballot tampering / missing tamper-evidence · audit log + append-only ballot history SHIPPED · EVIDENT
Mechanism. A ballot is changed after the fact — by a judge revising quietly, or by anyone with app write access — with no record that it happened.
Control (shipped). Every write that matters now appends an immutable, hash-chained audit event. record_ballot and create_submission each call audit.service.record_event inside the same transaction.atomic() as the business write, so a score can never be persisted without its audit row, nor the reverse — the rollback is asserted by test_business_write_rolls_back_when_audit_append_raises. Each event carries a frozen schema-v1 header plus a payload_hash / prev_hash / row_hash triple; seq is issued from a single AuditHead row locked FOR UPDATE, never from an auto-increment PK (Postgres sequences gap on rollback and would silently hole the chain). audit.hashchain.verify_chain recomputes the whole chain and pinpoints the first seq where an insert, delete, reorder, or edit breaks the linkage. So a judge who revises a ballot leaves two chained events — the prior scores survive in the earlier one — and any edit to a stored audit row is detectable. Since Increment 5 the prior scores also survive as their own immutable row: record_ballot appends a BallotRevision (UNIQUE(ballot, version), DB CHECK scores 1..5) inside that same atomic block, keeping Ballot.* only as a denormalised latest-pointer, so ballot history is append-only and not merely evident in the chain. The organizer control room (commit cd44e76) extends this trail to judging configuration: assign_judge, unassign_judge, and set_rubric_weights each co-commit a chained event — judge.assigned, judge.unassigned, rubric.reweighted (the last carrying old→new weights) — inside the same transaction.atomic() as the write, exactly like record_ballot. The same increment closes the two direct assignment-removal footguns that would otherwise destroy ballot history by cascade (Ballot → BallotRevision cascade off JudgeAssignment): the control-room unassign refuses a scored assignment after taking select_for_update on the row and re-checking for a ballot inside the transaction — so a concurrent first-ever score cannot slip between the check and the delete — and JudgeAssignmentAdmin refuses it too (per-object has_delete_permission plus removal of the bulk delete_selected action). This is scoped precisely to the paths that remove a JudgeAssignment through the application — the two direct paths here, and, since the parent-cascade close, the parent admins above it (Event / EventMembership / Submission / Team / AppUser), which carry the same scored-cascade delete guard (A8). Cascade deletion through direct database access, beneath the app, remains A8's operator boundary — detectable, not prevented — stated there.
Status. SHIPPED (commit edb35c8) — hash-chained audit_event + singleton AuditHead, atomic co-commit with the business write, verify_chain, and the audit_verify / audit_export commands. Covered by src/audit/tests.py (6 DB-backed tests) and the offline verifier audit/verify.py. Append-only ballot history followed in Increment 5 (commit d1c60fc): the immutable BallotRevision table with a CHECK 1..5 on both it and Ballot, covered by src/judging/tests.py.
Residual — what is NOT yet shipped (do not read more into this). The append-only ballot history above closes the gap this section used to name (the BallotRevision history that was "the next build" now ships). The genuine residual is the operator boundary (A8): an out-of-band edit made directly in Postgres — bypassing record_ballot — writes no revision and emits no chained event, so it is caught only by comparing the live row against the audit log's last recorded value for that ballot, a manual reconciliation today rather than an automated one.
Verify. src/audit/models.py (the two audit tables) and src/judging/models.py (BallotRevision, the two CHECKs); judging/services.py record_ballot (appends a BallotRevision and the atomic record_event in one block); manage.py test audit judging; manage.py audit_verify.
A8 — Insider / operator tampering (adversary O) · prevention ACCEPTED RISK · detection SHIPPED-with-caveat
A self-hosted platform cannot prevent tampering by the party that runs it: the operator holds the database password, docker exec, the filesystem, and the signing key. They can edit a ballot, flip a role, or move the deadline directly in Postgres, beneath the application. We do not claim to prevent this, and any control implying otherwise would be dishonest. Prevention against adversary O stays ACCEPTED RISK by construction.
The in-app footguns on this boundary are now closed; the irreducible boundary beneath them is named rather than hidden. Closed (the admin UI): the Django admin will not delete a row whose cascade would reach a scored assignment. JudgeAssignmentAdmin refuses the direct row (per-object has_delete_permission, and get_actions drops the bulk delete_selected), mirroring the control-room unassign rule (A7); and because JudgeAssignment is CASCADE off EventMembership (the judge) and Submission — and those off Event / Team / AppUser above them — the parent admins (EventAdmin, EventMembershipAdmin, SubmissionAdmin, TeamAdmin, AppUserAdmin) carry the same guard through a shared ScoredCascadeDeleteGuard (src/portal/admin_mixins.py): each refuses to delete a row whose descendants include a scored assignment and drops bulk delete, so an operator can no longer cascade-destroy a ballot's append-only history by deleting a judge, a submission, a team, a user, or a whole event in admin. An unscored parent stays deletable, and Submission.track is PROTECT, so a Track cannot orphan a scored project either. Not closed — the operator boundary itself: none of this binds a party with direct database access. An operator holding the Postgres password can DELETE ... CASCADE beneath the application, edit a ballot, or drop a row — the admin guard is an application control and the database sits below it. We do not claim to prevent that; it remains ACCEPTED RISK by construction. It stays detectable rather than silent because the audit_event rows are not foreign-keyed to these tables — the judge.assigned and ballot.recorded events survive any cascade, so a party recomputing the chain and reconciling it against live rows sees history referring to ballots that no longer exist. That reconciliation is the same manual one A7's residual names; automating it is future work, not a shipped control.
What is now shipped is detection, with a caveat stated precisely so it is not mistaken for more. The hash-chained audit log (A7) plus an Ed25519-signed checkpoint (audit/receipts.py, audit_export) and an offline verifier (audit/verify.py, audit_verify) let an independent party recompute the chain and check the signed tip without trusting the running server. This catches insert / delete / reorder / edit within the stored rows and any tampering via the app or a DB role.
The caveat: a chain and a signing key that both live on the operator's own host do not bind the operator. An operator who rewrites history can re-sign a fresh, self-consistent checkpoint over it. Detection is therefore only as strong as a checkpoint that left the operator's control before the disputed change and is independently retained and later compared — e.g. a fingerprint published to participants, or a checkpoint bundle emailed out at freeze. Given such an external anchor, a later divergence is provable; without one, operator tampering is detectable only if the operator was careless. That is the honest posture, and the signed-checkpoint export exists precisely to make the external anchor cheap to produce.
That anchor now ships in its most complete form (commit fc648da): manage.py release_bundle <dir> writes one signed directory binding the published ranking.csv, the signed reproducible run (A6), the normalization.published event that finalized it, and the signed audit checkpoint (A7) — all under one operator key — and a single offline command, python -m normalize.release <dir>, re-runs both the audit and the run verifiers unchanged and then cross-links them: it confirms the checkpoint and run share one signer, that the run's finalization is an event actually committed in the signed chain (its audit_seq is unsigned, so it is validated here, never trusted), that this event sits at or below the signed checkpoint head, and that ranking.csv is byte-for-byte the signed result. The anchor an independent party pins before judging is thus a single artifact rather than three. The caveat is unchanged and load-bearing: because the operator holds the key, a PASS is decisive only against a public key + fingerprint pinned by an independent party before judging and a checkpoint they retained — the bundle recomputes and cross-checks; it cannot bind the operator by itself.
A9 — Account takeover, sessions, and secrets · mostly SHIPPED
- Admin credentials. The bootstrap generates a random admin password when none is supplied and prints it once; there is no baked-in default. SHIPPED.
- Secret key. Sourced from env, else generated to a private state volume with
O_EXCLand0o600, else ephemeral for a build-time step only. No secret is committed. SHIPPED. - Passwords. Django PBKDF2 with the stock validators; email is the identity, unique case-insensitively. SHIPPED.
- Transport.
SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE, SSL redirect and HSTS switch on behindDOGFOOD_TLSfor a real deployment. SHIPPED (flag-gated). - Demo auth (the one caveat). For the offline grader, a
DOGFOOD_DEMOshim maps asession=cookie to a seeded user and exempts CSRF so the stdlib checker can drive the API. This is a bearer-token shim: anyone holding a seeded token acts as that user, and CSRF is not enforced for it. It is gated entirely off in production (DOGFOOD_DEMO=0, the default in a real deploy), theDemoSessiontable is empty in production, and the middleware logs a loud warning when on. ACCEPTED RISK, scoped to the demo stack; never enable it on a public host. - Login throttling. SHIPPED.
POST /accounts/login/is throttled per client IP (REMOTE_ADDR) viaDOGFOOD_RATE_LIMITS['login'](default 10/min) over the shared cache; over the limit it returns 429 withRetry-After. This is a throttle, not a lockout: it slows credential stuffing without letting an attacker lock a victim out by guessing at them (the window resets on its own). The limiter fails open (§A10). Covered bysrc/accounts/tests.py. Persistent per-account lockout remains DESIGN ONLY — see §9. - Personal API tokens. SHIPPED. A user can mint a personal Bearer token (
manage.py mint_api_token); the raw string is shown once and only its sha256 hash is stored (api_token.token_hash), so a database dump yields hashes, not usable credentials. The token authenticates onlyGET /api/v1/me/(auth set on the view, not globally), which returns just the caller's own token metadata and event memberships — never another user's data, a ballot, a per-judge score, or PII. A missingAuthorization: Bearerheader leaves every public route anonymous and byte-identical; a malformed, unknown, or revoked token is a 401. Revocation is a soft flag (revoked_at), andresolve_tokenrefuses a revoked token or an inactive user. Covered bysrc/api/tests.py(MeEndpointAndBearerAuthTests) andtests/test_apitokens.py.
A10 — Resource exhaustion / denial of service · partial
What holds. There is no file-upload surface (grep-clean: no FileField / request.FILES), and no raw SQL anywhere (ORM-only), so two common amplification vectors are absent. The rate-limit cache is backed by the database (DatabaseCache), deliberately not per-process LocMemCache, so the limiter counts correctly across all workers rather than once per process. The read-only public API (/api/v1/) is page-bounded (PageNumberPagination, PAGE_SIZE = 50) and anon-throttled by a fail-open throttle (src/api/throttling.py; DOGFOOD_RATE_API, default 240/min), so — unlike the gallery below — it cannot be turned into an un-clamped, unauthenticated read amplifier; the fail-open design means a cache outage degrades the throttle, never the API's availability.
What does not. The gallery is intentionally un-paginated (checks 1/2 require the full list), so it is bounded by fixture size rather than by a clamp — acceptable at event scale, ACCEPTED RISK at large scale. Rate-limit enforcement now covers every write surface that exists for real users. DOGFOOD_RATE_LIMITS declares seven policies — login, invite_redeem, submission_write, ballot_write, vote_write, comment_write, webhook_write — and the fixed-window limiter src/portal/ratelimit.py (fail-open — a limiter outage must not become an availability outage) is called from nine sites across seven modules: login per client IP (§A9); submission_write on both the create and the edit/withdraw paths; ballot_write on the in-app judge-scoring route; invite_redeem on the invitee redeem route (A12); vote_write per (campaign, voter) on the community ballot (A13); comment_write per author (A16); webhook_write per (event, organizer) on endpoint registration (A15); and assignment_write per (event, organizer) on the auto-assign apply (A20). Each returns 429 + Retry-After over the limit, and each authenticated one exempts the DEMO/checker path so the five checker routes stay byte-exact. Only two of these throttle a route that is not among the five checker routes — ballot_write on GET/POST /judging/score and invite_redeem on POST /events/invite/<ext_id> — and the other five sit on routes the replay never touches, so wiring all of them left the replay untouched. One honest asymmetry: assignment_write has no key in DOGFOOD_RATE_LIMITS — the call site reads it with a hard-coded 60/h fallback, so it is enforced but not operator-tunable by environment variable like the other seven. Every declared policy key is consumed; not every consumed policy is declared. The residual DoS gap that keeps this section partial is the un-paginated gallery above, not an unwired policy.
Verify. grep for request.FILES / .raw( → none. The limiter and its nine live call sites: src/portal/ratelimit.py, and ratelimit.hit( in accounts/views.py, submissions/views.py (×2), judging/views.py (the score and auto_assign views), events/views.py (the redeem view), voting/views.py, comments/views.py, and webhooks/views.py; covered by tests/test_ratelimit.py (parser, fixed window, fail-open), src/accounts/tests.py, src/submissions/tests.py, src/judging/tests.py (ScoreEndpointTests.test_rate_limited_after_quota, including the DEMO-exempt byte-stability case), src/events/tests.py (InviteUITests.test_redeem_is_rate_limited_per_user), src/voting/tests.py (RateLimitTests.test_second_cast_rate_limited_429), and src/comments/tests.py (CommentThrottleTests.test_real_user_is_throttled_after_the_limit). The API clamp and fail-open throttle: src/portal/settings.py REST_FRAMEWORK (PAGE_SIZE, DEFAULT_THROTTLE_CLASSES / DEFAULT_THROTTLE_RATES) and src/api/throttling.py (FailOpenAnonThrottle).
A11 — Premature or unofficial results disclosure · SHIPPED · PREVENT (user boundary)
Mechanism. A participant or outsider tries to read the ranking before the organizer has designated it official — hitting the public results route or its JSON while judging is still in progress — or treats an organizer's live-leaderboard preview as the final outcome.
Control. The ranking is private until an organizer publishes it, and the shipped image boots with nothing published. The two public routes (/normalize/results, /normalize/results.json) serve only a "not published yet" state until an organizer acts; the live, recomputing leaderboard (/normalize/, /normalize/leaderboard.json) stays organizer-gated with A2's 401/403/200 shape. Publishing is an explicit, organizer-only governance step (/normalize/results/publish, POST, organizer-gated) that designates one signed, reproducible run (A6) as the official result; what the public then sees is that run's frozen result — not a live recompute — carrying its run_ext_id / result_hash / inputs_hash / fingerprint, so the published ranking cross-checks against the offline-verifiable bundle exactly like any signed run. The read-only public API is on the same contract: GET /api/v1/events/<ext_id>/results/ serves that frozen published run verbatim (never a live recompute) or {"published": false} before publication, and — like every public /api/v1/ route — carries no ballots, per-judge scores, judge identities, or user PII (src/api/views.py EventResultsView).
Append-only + atomic. A publish never overwrites. It appends the next-version ResultPublication row (UNIQUE(event_ext_id, version)) and co-commits a results.published event onto the hash chain (A7) and flips Event.results_published — all in the same transaction as the signed run itself (normalize/results.py publish_results). Because the run's own normalization.published event co-commits too, one publish advances the audit chain by exactly two linked events. If any step raises, the run, both audit events, the publication row, and the flag all roll back together — there is no half-published state — so the record of what was published, at which version, when, and by whom is itself on the tamper-evident chain.
Portable attestation (SHIPPED — the results certificate). Beyond serving the frozen run, an operator can emit a verifiable results certificate for the official published results (manage.py certificate, and read-only GET /api/v1/events/<ext_id>/certificate/). It is a rendering + attestation layer, not a new signature over the ranking: it copies the published run's result_hash, signature, signer fingerprint, public key, and ranking verbatim (src/normalize/certificate.py, CERTIFICATE_KIND = "dogfood.results-certificate.v1"). Before it issues anything it self-verifies with the same offline verifier a judge runs — normalize.verify.verify_bundle over a freshly exported bundle, which re-runs the estimator from the pinned ballots and checks the signature + hashes — and re-checks the certificate envelope's own Ed25519 signature, raising rather than emitting if either fails; an unpublished or unsigned run is refused. The public API serves it through an explicit output-only allowlist (ResultsCertificateSerializer) that carries no ballots, per-judge scores, judge identity, or PII. Its honest scope is exactly A6's: a PASS proves the certificate restates a run that reproduces and is validly signed by the pinned key — it is evidence against the operator only if the public key + fingerprint were pinned by an independent party before judging, since the operator holds the private key.
Status. SHIPPED — W1.
Residual. This is a user-boundary property, not an operator one. The organizer is also the operator (A8): they choose when to publish, may publish a provisional ranking, and — holding the signing key — a published run is evidence against them only under A6's caveat (public key + fingerprint pinned by an independent party before judging). "Private until published" therefore prevents a participant or outsider from seeing a ranking early; it does not, and does not claim to, constrain the operator.
Verify. src/normalize/tests.py ResultPublicationTests (private-until-published, organizer-gate 401/403/200, append-only versioning, and the atomic all-or-nothing rollback); a published run then verifies offline via A6's python -m normalize.verify.
A12 — Invitation forgery / replay / privilege escalation · SHIPPED · PREVENT (user boundary)
Threat. An organizer shares a link to let someone join an event. A non-key-holding attacker tries to (a) forge a link or tamper its role/event/expiry to join an event they were not invited to — or to join as organizer; (b) replay a link that was already consumed, so one invitation admits many accounts; or (c) brute-force the redeem endpoint.
Control. Each invitation carries an Ed25519 signature over its canonical (event_ext_id, invite_ext_id, role, expires_at) tuple under the domain tag dogfood.invite.v1, signed with the same /state key as the audit spine (src/events/invite_signing.py). Redeem re-derives that pre-image and verifies in a try/except → bool, so altering any covered field (role, event, expiry, or the id) makes the signature fail and the redeem 400s. Privilege escalation is structurally impossible: create_invite accepts only judge or participant — organizer is never a mintable role — so even a validly signed link can only ever grant a non-privileged membership (ties to A2), and minting co-commits an invite.created event onto the audit chain (A7) so issuance itself is recorded, not only redemption. Expiry is enforced at redeem time against the signed expires_at.
Single-use + atomic. Single use is a database property, not a signature one: redeem_invite locks the row FOR UPDATE, re-checks redeemed_at inside the transaction, get_or_creates the EventMembership, flips redeemed_at/redeemed_by, and co-commits a invite.redeemed event onto the hash chain (A7) — all in one transaction.atomic(). Two concurrent redemptions cannot both grant; a second redeem returns 409 and creates no second membership. The redeem POST is rate-limited per authenticated user (invite_redeem, §A10; 429 + Retry-After, DEMO/checker path exempt).
Status. SHIPPED — the Invite model + 0002_invite migration, signing, create/redeem services, views, and the invite_verify operator command.
Residual. This is a user-boundary property. A key-holding operator (A8/A9) can mint any invitation, so the guarantee is against a non-key-holding party, not the operator. And an invite link is a bearer credential: anyone who obtains the URL + sig before it is redeemed can consume it once, so a link should be shared with exactly one person (the UI says so). The invitation is single-use, signature-bound, and role-capped, but it is not bound to a specific identity until it is redeemed — the audit chain records who consumed it, after the fact.
Verify. tests/test_invite_signing.py (DB-free: golden fingerprint on the shared key path, frozen wire format, per-field tamper rejection, and domain separation from checkpoints and normalization runs); src/events/tests.py InviteUITests (organizer-gated audited minting, role rejection, the confirm→join redeem flow, single-use 409, bad-signature and expired 400s, the per-user redeem rate limit, and the invite_verify command — via manage.py test); and manage.py invite_verify <ext_id> re-checks a stored invite offline.
A13 — Community voting: ballot stuffing, sybils, and a second publication surface · identity accounting SHIPPED · sybil resistance ACCEPTED RISK
Mechanism. Community voting opens a write path to people who are not judges — in the strongest configuration, to anyone with an email address (adversary V). The attacks are: vote many times under many identities; pile a whole budget onto one project; vote after the window closes; read the tally while voting is still running; or have a judge vote in the popularity contest they are also judging.
What actually ships. src/voting/ is a real, migrated app (0001_initial, six tables) with six routes under /voting/. An organizer configures one campaign per event (OneToOne) in one of three modes: authenticated (a portal account), email_link (anyone who can receive mail at an address), email_gated (the same, restricted to an organizer allow-list). The controls, each read in the code:
- Identity accounting is a database constraint, not a view check.
Votercarries two partialUNIQUEconstraints —(campaign, user)where a user is set,(campaign, email_normalized)where an email is set — so a second identity for the same person in the same campaign cannot be inserted at all, by any code path. - Alias folding.
services.normalize_emaillowercases, strips+tagsubaddressing on every domain, and removes dots / foldsgooglemail.comontogmail.com, soa.b+x@gmail.comandab@gmail.comcollapse to one dedup key. A repeat under an alias is refused 409 and records avote.duplicate_refusedaudit event. - Confirmation precedes identity.
joinmints an unguessable randomVoteTokenand writes the confirm link into anOutboundEmailrow; the HTTP response never echoes the token. OnlyPOST /voting/confirm/<token>creates theVoter. The identity is therefore bound to control of the address, not to the claim of it. - Organizer allow-list. In
email_gatedmode, confirming an address absent fromEligibleVoteris 403 and creates no voter. - One allocation set per voter, quadratic budget.
cast_ballotdeletes and re-creates the voter's entire allocation set inside one transaction (UNIQUE(voter, submission)holds throughout), so re-casting replaces rather than accumulates; total quadratic cost (votes²per project) must stay insidecredit_budgetor the whole cast is 409 with nothing written. - Window and role separation. A cast outside
[opens_at, closes_at)is 409; a judge of that event is 403 on the ballot; an anonymous caller in authenticated mode is 401. - Throttled and audited. The cast is rate-limited per
(campaign, voter)undervote_write(§A10), andcampaign.configured/vote.cast/vote.duplicate_refused/results.publisheach co-commit onto the hash chain (A7). - Display order is per-voter. The ballot is ordered by a per-voter sha256 shuffle rather than by id, so ballot position is not a shared, gameable advantage.
Kept separate from the judged result — this is the crux. Community voting is a second, independent publication surface. It has its own tables, its own /voting/<event>/results page, and its own results_published flag on VotingCampaign. It feeds nothing into the normalizer: no vote is an input to compute_leaderboard, no vote is pinned into a signed run (A6), and voting.publish_results touches neither Event.results_published nor any ResultPublication. The two surfaces cannot be conflated by an attacker because the code provides no join between them. Stated as plainly: the vote tally is not signed and is not offline-reproducible. It is a popularity count. This document does not claim otherwise, and nothing here upgrades it into evidence about the ranking.
Disclosure. /voting/<event>/results answers 404 — not 403 — until an organizer publishes and the window has closed, so an in-progress campaign leaks nothing about standings, not even that a request was authorized. Publishing before the window closes is refused 409; publishing is idempotent.
Residual — and it is the large one. Sybil resistance in the email modes is not achieved, and cannot be, here. §5's constraint binds exactly: with no online identity oracle — no reputation service, no CAPTCHA, no deliverability check — an attacker who controls or can create many distinct mailboxes (a catch-all on their own domain, a disposable-mail service) registers many distinct normalized addresses, and every one passes every control above. Alias folding raises the cost of the laziest duplication; it does not raise the cost of new addresses. Collusion is likewise undefended: N coordinated people are indistinguishable from N independent ones, and a quadratic budget makes a bloc more efficient than one voter piling on, not less. The honest mitigations are organizational, not technical — run email_gated with an allow-list the organizer actually controls, or authenticated mode where §5's seed/invite boundary (A2/A12) is the sybil boundary. email_link mode is the weak one and should be treated as an engagement signal, never as an award input. Marking this DECLINED would now be false — the code ships; claiming sybil resistance would also be false. ACCEPTED RISK, scoped to the mode the organizer chooses, and named again in §9.
Two smaller residuals. OutboundEmail records the confirm link rather than sending it (no SMTP integration ships), and an organizer or admin can read that log — so in email_link mode an operator can see, and could consume, a pending confirm link. That is A8's operator boundary reappearing on a new surface, not a separate defect. And a published tally is a plain page: there is no signed artifact a third party could later re-verify, by design (see above).
Verify. src/voting/tests.py — AuthenticatedModeTests (test_anonymous_ballot_401, test_judge_forbidden_403, test_over_budget_409_writes_nothing, test_valid_cast_200_and_audit, test_recast_replaces_prior_ballot), EmailModeTests (test_email_link_join_records_outbound_without_echo, test_duplicate_email_refused_409_and_audited, test_gated_ineligible_confirm_403, test_gated_eligible_confirm_ok), ResultsWindowTests (test_results_hidden_while_open_404, test_results_hidden_after_close_until_published, test_publish_refused_while_open_409, test_publish_after_close_then_results_200_and_audit), ManageTests (test_manage_anonymous_redirects_to_login, test_manage_non_organizer_403, test_manage_configures_campaign_and_audits), RateLimitTests.test_second_cast_rate_limited_429; src/voting/test_pure.py (DB-free: quadratic cost, budget boundary, alias normalization, per-voter ballot-order stability and divergence). Via manage.py test voting.
A14 — Signed event bundle: export discloses, import writes · SHIPPED · PREVENT (gate + signature)
Mechanism. /bundles/ is the one authenticated write path that ingests externally-supplied data: POST /bundles/import accepts a JSON document and builds a whole event graph from it. The attacks are: reach it without authority; feed it a forged or doctored bundle; use it to overwrite or escalate an existing event; or pull an export to harvest participant data.
Control — who may call it. All three endpoints (<event>/export.json, <event>/export.csv, import) require an authenticated site administrator (request.user.is_superuser). An event-scoped organizer membership is deliberately not sufficient — these are cross-event, whole-graph operations. Anonymous is 401, authenticated non-superuser is 403, and the gate runs before event resolution, so a caller who may not use the endpoint cannot probe which events exist.
Control — the signature. sign_export folds the whole bundle into canonical bytes under the frozen domain tag dogfood.bundle.v1 and signs with the same /state Ed25519 operator key as the audit checkpoints, runs, records, and invites — one key, one implementation (audit.receipts), distinct tags, so a bundle signature can never be replayed as a checkpoint or a run, or the reverse. import_bundle calls verify_signed_bundle first: a missing, malformed, or non-verifying signature — or any doctored field anywhere in the document — is a 400 before a single row is written.
What it can overwrite: nothing. Import is create-only. An Event whose ext_id already exists is a 409 and the transaction aborts; there is no update path, no upsert, no merge. It never mints accounts — memberships and team memberships link only AppUser rows that already exist here (matched case-insensitively by email), and an unknown email is skipped and reported back in the response rather than silently created. And results_published is forced to False on the imported event, because a bundle carries no signed run or publication that could back a live published result.
What the export carries, and what it does not. The bundle is a structural snapshot — event config, tracks, teams and their members, submissions, rubric weights, and the voting window if one exists. It deliberately carries no ballots, no per-judge scores, and no audit chain. It does carry participant emails, display names, and event roles: that is PII, and it is precisely why the endpoint is site-admin-only rather than organizer-gated. export.csv is sent Cache-Control: private, no-store.
Residual. (1) The gate is the superuser flag, and import is csrf_exempt. The code states the tradeoff (a JSON operator API carries no form token, and a browser cannot silently cross-post application/json without a preflight this server will not approve), but the consequence is honest: an ambient admin session cookie alone authorizes the write. A stricter deployment should drive import with an explicit admin credential rather than a browser session. (2) The verifier checks the bundle against this deployment's own key — verify_signed_bundle re-derives the public key from /state and rejects a mismatched signer fingerprint, so in practice import accepts only bundles this instance itself exported. That is a sound restriction rather than a trust transfer: no third party's signature can authorize a write here, and migrating between deployments requires moving the key material too. (3) A member entry's role is copied verbatim into the new event's EventMembership and, unlike an invitation (A12), it is not capped to judge/participant — so a bundle can seat an organizer on the event it creates. Because only a site admin can import and only a key-holder can produce a verifying bundle, this sits inside the A8 operator boundary rather than being a user-boundary escalation; it is a real difference from A12 and is named here rather than glossed.
Verify. src/bundles/tests.py BundleEndpointTests — the gate (test_export_anonymous_401, test_export_organizer_forbidden_403, test_export_csv_gate, test_import_anonymous_401, test_import_organizer_403), the signature (test_export_superuser_ok_and_verifies, test_import_tampered_bundle_400, test_import_missing_signature_400, test_import_bad_json_400), and the write semantics (test_import_existing_ext_id_conflict_409, test_round_trip_import_reconstructs_counts, test_import_skips_unknown_users); src/bundles/test_signing.py (DB-free: frozen tag, round-trip, per-field tamper, wrong key, malformed signature, fingerprint mismatch). Via manage.py test bundles.
A15 — Outbound webhooks: SSRF, forged deliveries, replay · SHIPPED · PREVENT-with-caveat
Mechanism. An organizer registers a URL and the server fetches it. That is a server-side request under partial attacker control: point it at http://127.0.0.1:…, at 169.254.169.254, or at a public name whose DNS answers with an internal address, and the platform becomes a probe inside its own network. Separately, a receiver must be able to tell a genuine delivery from a forged or replayed one.
Control — SSRF. src/webhooks/ssrf.py validate_url is the single gate a URL passes before any socket opens. It requires http/https (anything else — file:, gopher:, schemeless — is refused), rejects embedded user:pass@ credentials, and requires a hostname. Then: an IP literal is checked directly, while a name is resolved to all of its addresses and every one must pass — a single blocked answer rejects the whole URL, so a split-horizon name cannot slip through on its second address. An address is blocked when it is loopback, private (RFC1918 and IPv6 ULA fc00::/7), link-local (which covers 169.254.169.254), reserved, multicast, or unspecified; the cloud-metadata addresses are additionally blocked by exact match so the intent survives a future range edit; and IPv4-mapped IPv6 (::ffff:a.b.c.d) is unwrapped and its v4 form re-checked. Resolution failure fails closed, and a hostname that parses as neither a name nor a valid IP is treated as blocked. A refused URL re-renders the form with 422 and writes no endpoint row. services._http_post then re-validates immediately before connecting and installs an opener that refuses to follow redirects, under a 5-second timeout.
What the SSRF gate does not do — precisely, because this is where over-claiming is easy. It narrows the resolve-then-connect window; it does not eliminate it. Validation and the actual connection are two separate lookups, so a TOCTOU rebind remains theoretically possible even with the re-validation, and nothing here pins the socket to the address that was validated. It does not pierce a proxy: if the deployment routes egress through an HTTP proxy, the proxy resolves the name, not us. It does not defend against a public host that itself forwards inward — the gate reasons about the address we connect to, not about what that server does next. And it constrains the URL only; it does not filter what a reachable public endpoint does with the body. The strongest honest statement is therefore: direct SSRF to private, loopback, link-local, and metadata ranges is blocked at registration; an attacker with control of authoritative DNS timing is made harder, not stopped.
Coverage caveat — the one part of this section that is not SHIPPED. validate_url itself is covered exhaustively (eight DB-free, network-free tests with an injected resolver), and the registration gate that calls it is covered end to end (a blocked URL is 422 with no row). But the two hardening steps inside _http_post — the re-validation immediately before connecting, and the opener that refuses redirects — have no covering test: every delivery test monkeypatches _http_post wholesale so that no real network is touched. That code is present and reviewable, but by this document's own rule it is enforced-but-not-covered, not SHIPPED, and it is named in §9 rather than counted as evidence. The consequence is scoped: an SSRF attempt must still pass validate_url at registration to be stored at all, so the untested steps are a second layer, not the only one.
Control — signing and replay. Each endpoint gets a fresh random secret, revealed exactly once via a one-shot session flash on the next GET. Every delivery carries X-Dogfood-Event, X-Dogfood-Timestamp, and X-Dogfood-Signature = sha256= + HMAC-SHA256 over "<timestamp>." + body under that secret. Binding the timestamp into the MAC — not merely alongside it in a header — is what makes replay detectable: a captured body cannot be re-presented under a fresh timestamp without invalidating the signature. The recorded signature is reproducible from the recorded payload, so a delivery-log entry can be re-checked after the fact. Registration, deletion, delivery, failure, and retry each co-commit an audit event (A7), and deletion is a soft active=False that retains the delivery history.
Residual. (1) Replay detection is the receiver's job. We sign the timestamp; we ship no nonce store, and nothing here stops a receiver that ignores the header from accepting a replayed body. A receiver must reject stale timestamps itself — the standard contract, stated so an integrator does not assume otherwise. (2) Delivery is best-effort and synchronous. There is no worker and no queue: deliver performs the POST inline, never raises, and records the attempt; retry is a manual organizer action. This is a durable attempt log, not an at-least-once queue. (3) The endpoint secret is stored in plaintext in webhook_endpoint.secret — it must be, to compute the MAC — so it lives inside the A8 operator boundary. (4) Registration is organizer-of-this-event only (anonymous → login redirect, non-organizer → 403) and throttled per (event, organizer) under webhook_write; but an organizer is also the operator (A8), so the SSRF gate meaningfully constrains a careless or hostile organizer-role account, not the party holding the shell. (5) The in-_http_post hardening is untested, per the coverage caveat above.
Verify. src/webhooks/test_ssrf.py (DB-free and network-free — the resolver is injected): blocked IP literals including 169.254.169.254, allowed public literals, non-IP strings blocked, test_scheme_credential_and_schemeless_rejected, test_name_resolving_to_internal_is_blocked, a public name allowed, and test_name_with_any_internal_address_is_blocked. src/webhooks/tests.py WebhookTests: test_register_ssrf_blocked_url_422_no_row, test_register_as_organizer_creates_row_with_secret, test_register_as_participant_403, test_register_anonymous_redirects_to_login, test_deliver_success_records_signature_and_audit (the recorded signature equals sign_body(secret, ts, canonical_body) and starts sha256=, plus a webhook.delivered audit event), test_deliver_failure_records_failed, test_retry_flips_failed_to_success, test_delete_endpoint_deactivates_and_audits — all with services._http_post monkeypatched, so read the coverage caveat above before treating the connect-time steps as covered. Via manage.py test webhooks.
A16 — User-generated comments: abuse, moderation, escaping · SHIPPED · PREVENT (injection, PII) / DETECT + REVERSE (abuse)
Mechanism. src/comments/ lets any authenticated user post free text against a project. The risks are the usual UGC set: script injection into whoever renders it, PII leakage through author identity, flooding, and having no way to handle an abusive post except by destroying evidence.
Control. Reading is public (GET /comments/projects/<ext_id> → 200, anonymous allowed); posting requires authentication (401 otherwise) and is throttled per author under comment_write (429 + Retry-After, DEMO/checker path exempt). The body is stripped, must be non-empty (400), and is capped at 2000 characters in the service. Moderation is organizer-of-the-comment's-event only (401 anonymous, 403 non-organizer, 404 unknown) and is a soft hidden=True — never a row delete — so a hidden comment leaves the public list while its row and its comment.hidden audit event survive; comment.posted and comment.hidden both co-commit onto the hash chain (A7). Author identity is surfaced as get_short_name() only, never the login email, and a comment whose author account is later deleted keeps its row (author is SET_NULL) rather than vanishing, so a thread is not silently rewritten.
Escaping posture — stated exactly, and no further. The comments surface is JSON-only: both endpoints return JsonResponse, there is no comments template, and no page in this build renders a comment body into HTML. So a body is never interpolated into markup by us — it is JSON-encoded, which escapes what would break out of a JSON string. That is the whole of the claim. The body is stored and returned verbatim (stripped and length-capped, not sanitised): we do not strip tags and we keep no HTML allow-list, because nothing here renders HTML. The repo-wide posture supports the claim — there is no mark_safe, no format_html, and no |safe or {% autoescape off %} anywhere in the templates, so Django's autoescaping is on throughout, and the site-wide CSP (script-src 'self') is a second layer. But the honest warning is forward-looking and belongs here rather than in a commit message: a future page that renders comment.body must rely on that autoescaping and must not mark it safe, and a client that injects the JSON body into innerHTML will have an XSS. We do not claim to sanitise for either case.
Residual. No content classification, no spam scoring, no per-project comment cap, and no edit history — a comment is posted once and can only be hidden. Flood control is the per-author throttle alone, which, like every limiter here, fails open on a cache outage (§A10). Hiding is organizer-only and the organizer is the operator (A8).
Verify. src/comments/tests.py ProjectCommentsTests: test_get_is_public_and_returns_empty_list, test_anonymous_post_is_401_and_writes_nothing, test_authenticated_post_creates_row_and_audits_without_pii (asserts the display name is returned and assertNotIn("@", data["author"]), plus the comment.posted audit event), test_empty_body_is_400_and_writes_nothing, test_public_get_excludes_hidden_comments, test_organizer_hide_succeeds_and_drops_from_public_get, test_non_organizer_moderate_is_403_and_stays_visible, test_anonymous_moderate_is_401; CommentThrottleTests.test_real_user_is_throttled_after_the_limit. Via manage.py test comments.
A17 — The embeddable widget: framing, data egress, caching · SHIPPED · PREVENT (scoped framing)
Mechanism. src/embed/ exists to be embedded by third-party pages, which means deliberately relaxing the clickjacking defence the rest of the site relies on. Two questions follow: does the relaxation leak past that one response, and what data crosses the origin when it does?
Control — the relaxation is scoped to a single response. The site-wide posture stays X-Frame-Options: DENY plus script-src 'self'. embed_gallery alone carries @xframe_options_exempt (dropping DENY for that response) and sets Content-Security-Policy: frame-ancestors *; script-src 'self' explicitly on the response. Because the CSP middleware uses response.setdefault, the explicit per-response value wins here while every other page keeps the default untouched — and a test asserts /projects still answers DENY, which is the actual proof the exemption did not leak. /embed.js is a static, request-independent loader that resolves its own origin client-side and injects an <iframe> back at the origin that served it, so the widget always talks to the deployment that published it regardless of which site embedded it.
What leaves the origin. Exactly what the public gallery already shows: for each SUBMITTED project, its title, track, summary, and team name. It reuses the gallery's SUBMITTED-only, event-scoped queryset, so drafts and withdrawn projects never appear. No email, no member name, no score, no ranking, no judge data. The widget document does not extend base.html, carries no inline <script>, and reads nothing from the session — so there is no per-caller state in it to leak into a frame.
Caching — a deliberate consequence, not an oversight. The widget response sets no Cache-Control header. The page is anonymous and its content is already public, so a shared cache or CDN serving it to another party discloses nothing /projects does not. The flip side is named honestly: an embedder or intermediary may cache the list, so a freshly withdrawn project can persist in someone's cache for a while. frame-ancestors * is likewise deliberate and unrestricted — any site may frame the widget; we do not offer a per-event allow-list of embedding origins, and an organizer who needs one does not have it here (DESIGN ONLY).
Residual. An unrestricted frame-ancestors means a hostile page can frame the widget inside misleading chrome. Because the framed document takes no authenticated action — no form, no button, no session-bearing request — there is no click for a clickjacker to steal, so the impact is confined to misrepresentation of public data. ACCEPTED.
Verify. src/embed/tests.py EmbedWidgetTests: test_embed_js_ok_and_javascript_content_type; test_embed_gallery_ok_frameable_and_lists_submitted (the SUBMITTED project with its track and team shown, the withdrawn project absent, X-Frame-Options no longer DENY, frame-ancestors * present); test_normal_page_still_denies_framing (/projects still DENY); test_unknown_event_404. Via manage.py test embed.
A18 — Participation records and the published signing key · SHIPPED · PREVENT (scope) · attestation caveat
Mechanism. src/records/ issues each judge and participant a signed statement of their participation, and publishes a key endpoint so anyone can check it. Two things could go wrong: a record could carry more than it should, and a route named "signing key" could be read as something it is not.
Control — what the key endpoint publishes, stated so it cannot be misread. /records/signing-key (also wired at /.well-known/dogfood-signing-key) returns the deployment's Ed25519 public verification key as PEM, with its sha256 fingerprint in X-Signing-Key-Fingerprint. It is the public half only. The private key is generated into the private /state volume, never leaves it, and is served by no route — a test asserts the string PRIVATE does not appear in the response body. What this endpoint is: the value A6 and A8 ask an independent party to pin before judging, published cheaply so producing that external anchor is easy. What it is not: a secret, a credential, or anything that authorizes an action. It is anonymous by design, because gating a verification key would only make the external anchor harder to obtain while protecting nothing.
Control — what a record carries. judge_record and participant_record are self-service only: anonymous → 401; a non-judge asking for a judge record → 403; a judge naming another judge via ?judge=<other> → 403; a non-participant asking for a participant record → 403. Authentication is checked before event resolution, so an anonymous caller cannot enumerate events through it. A record contains the event, the subject's role, and the items they were involved with — and no scores and no ballots, ever. The record body carries its own ATTESTATION string, so the artifact itself states that it attests participation rather than merit and is not fraud detection. /records/verify is csrf_exempt, and that is safe here for a stated reason rather than by assumption: it writes nothing, reads only the public key, takes no auth-sensitive action, and returns a boolean.
Residual. The same caveat as A6/A8, which is exactly why the fingerprint header exists: the operator holds the private key, so a record proves to a third party only that the key on that host signed those facts. It is decisive against the operator only if the public key and fingerprint were pinned by an independent party beforehand. A signed record is also a bearer artifact — anyone holding a copy can verify it, which is the intent, but it means a leaked record discloses the participation facts inside it.
Verify. src/records/tests.py RecordEndpointTests: test_signing_key_is_public_pem_with_fingerprint (the body contains BEGIN PUBLIC KEY, a 64-hex fingerprint header, and asserts PRIVATE is absent), test_judge_record_ok_and_verifies and test_participant_record_ok_no_scores (both assert no score or ballot token appears anywhere in the response, then round-trip it through /records/verify), test_judge_record_anonymous_401, test_judge_record_non_judge_403, test_judge_record_cross_judge_403, test_judge_record_own_judge_param_ok, test_participant_record_anonymous_401, test_participant_record_non_participant_403, test_verify_doctored_record_is_false, test_verify_malformed_body_is_false, test_unknown_event_404; src/records/test_signing.py (DB-free: frozen tag and field list, round-trip, per-field tamper, wrong key, malformed signature). Via manage.py test records.
A19 — Awards and the review top-up aid: a recognition layer that must not become a result · SHIPPED · PREVENT
Mechanism. src/awards/ adds prizes and a public podium. Three ways that could corrupt the thing the platform exists to protect: the public page could recompute a ranking instead of showing the frozen one; awarding a prize could feed back into the signed result; or a pre-finalization planning view could be mistaken for a ranking, a score, or a fraud signal.
Control — the public podium reads the frozen signed result, never a live recompute. GET /events/<ext>/awards is anonymous and calls services.public_awards, which asks normalize.results.current_results for the event's published result and returns {"published": False} when there is none. Before publication the page renders a neutral state with no ranking at all — not a 403, not a partial board, nothing to infer from. After publication every row is copied out of the frozen, signed run (A6/A11) — place, submission ext_id, public q, source rank — enriched only with public submission metadata (title, team name, track name) that the gallery and the public API already expose. The page carries no per-judge score, no judge identity, and no PII, and that is asserted rather than asserted-about: a test sweeps the rendered bytes for judge emails, criterion names, judge membership ext_ids, and judge/organizer display names and requires every one to be absent.
Control — a prize is a pointer, not a property. Prize is organizer-owned configuration in its own table. Assigning a winner sets awarded_submission and co-commits a prize.winner_assigned audit event — and that is all it does. It writes nothing into the normalization run, changes no q, adds no field to the signed result, and cannot alter result_hash. A podium-targeting prize with no explicit winner derives its winner from the frozen podium (within its track when track-scoped); an organizer-assigned winner overrides that derivation and is labelled assigned rather than derived, so the page distinguishes a curatorial choice from a derived place instead of blurring them. prize.created / prize.winner_assigned / prize.winner_cleared / prize.removed each co-commit onto the hash chain (A7). Prizes deliberately carry no append-only history — they are configuration, not integrity records — so remove_prize really deletes the row, and the audit event is what keeps the change traceable.
Control — the organizer console. manage, assign, clear, remove, and topup all resolve the event from the URL ext_id and require an organizer membership for that event (services.is_organizer, an event-scoped EventMembership query — not a global flag, per §8). Anonymous callers get a login redirect; authenticated non-organizers get a plain 403, and a POST from a non-organizer leaves the prize untouched.
The top-up aid, scoped as narrowly as the code scopes it. GET /events/<ext>/awards/topup is organizer-only and reads the live, unsigned standings — deliberately, because its whole purpose is to be useful before results are finalized. For each podium prize it shows the contenders sitting at or straddling that prize's rank cutoff and why they are flagged (thin review coverage, a bootstrap rank interval spanning the cutoff, a close q-margin across it). What it is: a review-planning aid — where a few more reviews would most reduce uncertainty. What it is not, stated as flatly as the code states it: it ranks nothing, assigns no score, is not the final ranking, and is not fraud detection. The official podium is always the frozen signed result. Its response carries Cache-Control: private, no-store and Vary: Cookie because it is per-caller organizer data that must never be cached or served to another user. And its honest limitation is load-bearing: because it reads live unsigned data, its output changes as reviews arrive and it is not reproducible from any signed artifact — so it must never be cited as evidence about a result, only used to decide where to look next.
Residual. The same-event invariant is enforced in the service layer, not by a database constraint. create_prize refuses a track belonging to another event and assign_winner refuses a submission belonging to another event (both ValueError, both tested), but Prize.track and Prize.awarded_submission carry no cross-event CHECK — the model's own docstring says so. A write that bypasses the service — direct SQL, or the Django admin — could therefore attach a foreign-event track or submission to a prize. That is A8's operator boundary again, narrowed to a concrete shape, and it is named rather than left for a reader to discover: the invariant is a service-layer rule, not a DB-enforced one. A cross-event DB constraint would close it and is DESIGN ONLY. Separately, an organizer may assign any same-event submission to any prize — that is the intended curatorial freedom, and the assigned label exists precisely so a reader can tell it apart from the derived podium.
Verify. src/awards/tests.py AwardsTests: test_public_awards_unpublished_is_neutral and test_public_podium_page_hidden_until_published (the neutral state, no ranking); test_public_awards_derives_from_frozen_result (places 1/2/3 out of the published signed run, the 4th project truncated) and test_public_podium_page_shows_topN_and_no_pii (the sensitive-token sweep over the rendered page); test_assigned_winner_overrides_derivation; test_create_prize_mints_ext_id_and_audits and test_clear_and_remove (the audit co-commits); test_assign_winner_and_cross_event_refused and test_create_prize_validation (the service-layer same-event invariant); test_manage_requires_organizer (302 / 403 / 200) and test_non_organizer_cannot_post_actions (403, winner unchanged). AwardsTopupViewTests: test_organizer_sees_plan_private_and_no_pii (asserts Cache-Control: private, no-store, Cookie in Vary, and the same sensitive-token sweep), test_anonymous_redirected_to_login, test_non_organizer_forbidden. DB-free: tests/test_podium.py (truncation, track scoping applied before truncation, rank-less rows last, input never mutated) and tests/test_topup.py (special awards skipped, the thin-coverage / straddling-interval / close-margin flags, determinism, inputs never mutated). Via manage.py test awards plus pytest tests/test_podium.py tests/test_topup.py.
A20 — Shaping the judge↔submission graph: recusal and the auto-assignment planner · SHIPPED · integrity consequence named
Mechanism. A6's leverage bound is a property of the graph: a judge influences a submission only in proportion to 1/R, and cross-submission comparison is identified only within a connected component. Anything that decides who reviews what therefore moves a security parameter, not merely an ergonomic one. Two features do: organizer-declared recusals, and the auto-assignment planner.
Recusal (conflict of interest). JudgeRecusal records an organizer-declared conflict between a judge and a team — deliberately team-level, because a conflict is with people and must cover that team's future submissions too, not only those filed when it was declared. UNIQUE(judge, team) makes a duplicate filing a database refusal rather than a silent second row. This is distinct from the planner's automatic own-team exclusion (derived from TeamMember): recusal covers what the graph cannot see — a former colleague, a mentor, a stake. At plan time each recusal expands to that team's currently-SUBMITTED projects (judging/recusal.py) and is fed to the planner as that judge's excluded set, so a recused judge is never planned onto that team's work. Removing a recusal is an organizer action and, like the assignment writes around it, is non-destructive: it changes only who may be planned, never a recorded ballot or its append-only history (A7).
Auto-assignment. GET/POST /judging/<event>/auto-assign previews and applies a connectivity-aware plan toward a per-project review target k (clamped 1..20). It is organizer-only and event-scoped by ext_id (anonymous → login redirect, non-organizer → 403). Applying is not a bulk bypass: it calls assign_judge once per planned edge, so every created assignment is the same atomic, audited judge.assigned write as a manual one, and the control-room rules (A7) — including the refusal to remove a scored assignment — apply unchanged. Re-applying the same plan adds nothing. The POST is throttled per (event, organizer) (§A10).
The integrity consequence, stated rather than implied. Both features change the graph A6's bound is computed over, and the effects run in opposite directions. Recusals remove edges, which can shrink a component or, at the limit, split one — and A6 is explicit that cross-submission comparison is identified only within a component, so heavy recusal weakens comparability rather than strengthening it. The planner pushes the other way: its objective is coverage toward k and a low component count, which is precisely the condition under which one malicious ballot moves q least. Neither is a security control and neither is labelled one here: a plan is a proposal an organizer applies, and the organizer is also the operator (A8), so nothing prevents an organizer from hand-assigning a deliberately sparse or partitioned graph. What is shipped is that every such change is an atomic, audited write, and that the resulting component count is surfaced in the planner's own summary rather than hidden from the organizer making the decision.
Residual. Recusal is declared by an organizer; the platform has no way to discover an undeclared conflict — that needs the online identity oracle §5 forbids. A judge with an undisclosed stake is indistinguishable from any other judge. DECLINED as a detection feature, for the same reason as A6's collusion detectors.
Verify. src/judging/tests.py AutoAssignRecusalTests.test_planner_honors_db_recusals_both_ways (the recused judge never lands on that team's projects, yet still covers another team's, and every SUBMITTED project still reaches k=1) and test_duplicate_recusal_is_refused_by_db (IntegrityError); AutoAssignViewTests (the organizer gate, the preview, the apply-once-per-edge path, DRAFT projects never planned); ControlRoomTests (each assignment write co-commits its audit event; a scored assignment is un-removable). DB-free: tests/test_assignment.py (the pure planner — k coverage, determinism, own-team and track exclusion, recusal exclusion, load balance, component merging) and tests/test_recusal.py (the team→submission expansion and its composition with the planner). Via manage.py test judging plus pytest tests/test_assignment.py tests/test_recusal.py.
8. Multi-event tenancy · DECLINED · and how "the current event" is actually resolved
The build is single-event by design; it does not implement multi-tenant isolation between concurrent events. An operator who needs to run two events at once should run two instances. This is DECLINED, not a bug: stated so an adopter isn't surprised, and so the authorization model above is read in its intended single-event context.
How a view resolves "the current event" has since split into two patterns, and because that resolution feeds the authorization check it is stated here rather than glossed:
- Oldest-event resolution (the original pattern).
Event.objects.order_by("id").first()— the single oldest event by primary key. This is what the checker-facing and original surfaces still do: the gallery (gallery/views.py), submissions (submissions/views.py), the judge-scores API / CSV export / in-app scoring (judging/views.py), and the normalizer's live and published views plus its management commands (normalize/services.py). It is why the five acceptance-checker routes need no event id in their paths. With one event in the database it is unambiguous; with two it silently picks the older, which is exactly the tenancy behaviour DECLINED above. ext_id-scoped resolution (every surface added since). The event is taken from anext_idin the URL path and the caller is then authorized against that event: awards (awards/views.py_organizer_or_response→services.is_organizer), community voting (voting/views.py), webhooks (webhooks/views.py), bundles (bundles/views.py, site-admin-gated), the organizer control room / progress / assignments / auto-assign (judging/views.py_organizer_event_or_response), event and invite management (events/views.py), the embed widget (embed/views.py), the read-only API (/api/v1/events/<ext_id>/…), participation records (records/views.py, via?event=<ext_id>), and comments (via the commented submission's own event).
The authorization rule is the same under both patterns, and that is the point: a caller's rights always come from an EventMembership row joining that caller to the event being acted on — never from the URL, a form field, or a global flag. Naming an event in the path does not grant anything; an unknown ext_id is a 404 and a non-member is a 403 (anonymous is a 401 or a login redirect, per surface). So the ext_id routes are not a partial multi-tenancy implementation and must not be read as one: they are per-event authorization on a single-event deployment. What is still missing for real tenancy — cross-event query isolation at the data layer, per-tenant key material, and a resolver that cannot fall back to "the oldest event" — is not built. DECLINED.
9. The honest list
The controls this build does not ship, stated plainly. This list is the point: a reviewer should trust the SHIPPED labels above precisely because these are not hidden among them.
Shipped since the first draft. Items once on this list are now built and tested, so they have left it: the append-only, hash-chained audit log (A7, A8; commit edb35c8); append-only ballot history — the immutable BallotRevision table (A7; commit d1c60fc); a signed, reproducible normalization run with an offline verifier (A6; src/normalize/runs.py, src/normalize/verify.py); binding all of these into one artifact, a signed release bundle — the published ranking, its signed run, and the signed audit checkpoint in a single directory — with one offline verifier for the whole chain of custody (A8; commit fc648da, manage.py release_bundle + python -m normalize.release); login throttling plus per-user submission-write throttling over a fail-open limiter (A9, A10); and submission edit / withdrawal — a team revises or soft-withdraws its own submission while the event is accepting writes (A3 residual; submissions/{services,views,urls}.py, migrations/0002); and signed, single-use invitations — an organizer-minted, Ed25519-signed, DB-enforced single-use link that admits one account as judge or participant and can never escalate to organizer (A2, A12; events/{models,invite_signing,services,views}.py, migrations/0002_invite). Later waves added whole surfaces that this list previously either ignored or, in one case, denied outright: community voting (A13), signed event bundles (A14), outbound signed webhooks (A15), project comments (A16), the embeddable gallery widget (A17), signed participation records and the published verification key (A18), awards / podium and the review top-up aid (A19), and judge recusal plus the auto-assignment planner (A20). This is why items 1, 2, 4, 5, and 6 below are scoped down, corrected, or marked SHIPPED rather than struck out — item 6 in particular was wrong, not merely stale, and the correction is kept visible rather than quietly deleted. What remains below is genuinely not in the build.
- Rate-limit enforcement — SHIPPED.
DOGFOOD_RATE_LIMITSdeclares seven policies —login,invite_redeem,submission_write,ballot_write,vote_write,comment_write,webhook_write— and the limiter is called from nine sites across seven modules, each returning 429 +Retry-Afterover a fail-open fixed window (§A9, §A10, §A12, §A13, §A15, §A16). Every declared policy key is consumed. The reverse does not hold, and saying so is the point of this list: an eighth policy name,assignment_write(the auto-assign apply, §A20), is read with a hard-coded60/hfallback and has no key inDOGFOOD_RATE_LIMITS, so it is enforced but not tunable by environment variable like the other seven. Adding the key is a one-line fix and is not yet in this build. - Account lockout — login throttling is SHIPPED (§A9); a persistent per-account lockout after N failures is not built (a throttle resets each window by design, so a victim cannot be locked out by an attacker guessing at them). DESIGN ONLY.
- Collusion & residual-outlier detection in judging — structural leverage limits exist, active detectors do not. DESIGN ONLY.
- Signed, single-use invitations — SHIPPED (was DESIGN ONLY). An organizer mints a signed, single-use invitation (
POST /events/<event>/invites/new); redeeming it (GET/POST /events/invite/<ext_id>) is Ed25519-verified, single-use (DB-enforced underselect_for_update), rate-limited, and atomically creates anEventMembership+ ainvite.redeemedaudit event. Roles are capped at judge/participant, so a link can never escalate to organizer (A2, A12). The organizer seed/import path still works alongside it. - Submission revisions / withdrawal — SHIPPED (was DESIGN ONLY). A team edits or soft-withdraws its own submission through
/submissions/<id>/editand/submissions/<id>/withdraw, owner- and deadline-gated in the service, atomic with asubmission.revised/submission.withdrawnaudit event; withdrawal flips state towithdrawnand drops the project from the gallery without deleting the row. Honest limit: an edit overwrites the fields — submissions do not keep a per-field revision history the way ballots do (A7), so the chain records that a revision happened and which fields changed, not the prior text. - Community / public voting — the feature is SHIPPED (was wrongly listed here as DECLINED;
src/voting/ships six models, a migration, six routes, and its own tests — §A13). What stays on this list is the control the feature does not have: sybil resistance in the email modes, which would need the online anti-sybil oracle §5 forbids. Identity accounting is real and DB-enforced (partialUNIQUEon the campaign+user and campaign+normalized-email pairs, Gmail-aware alias folding, email-confirmation before a voter exists, an organizer allow-list inemail_gatedmode, a quadratic budget, one replaceable allocation set per voter, window enforcement, judges refused, and per-voter throttling). Identity creation is not constrained: anyone who can receive mail at many addresses can become many voters. Collusion is likewise undetected, and a quadratic budget makes a bloc more efficient than a single piler-on. The vote tally is also not signed and not offline-reproducible, and it feeds nothing into the normalizer or any published result. ACCEPTED RISK, scoped to the mode the organizer picks —authenticatedinherits §5's seed/invite boundary,email_gatedinherits the organizer's allow-list, andemail_linkshould be read as an engagement signal, never as an award input. - Awards same-event invariant is service-layer, not DB-enforced —
create_prizeandassign_winnerrefuse a cross-event track or submission, butPrizecarries no cross-eventCHECKconstraint (§A19). A write that bypasses the service — direct SQL or the Django admin — can attach a foreign-event row to a prize. A DB constraint would close it. DESIGN ONLY. - Bundle import does not cap the imported role — unlike an invitation (A12, capped at judge/participant),
import_bundlecopies a member'sroleverbatim, so a signed bundle can seat an organizer on the event it creates (§A14). Import is site-admin-only and the bundle must verify under this deployment's own key, so this sits inside the A8 operator boundary — but it is a real asymmetry with A12 and is not fixed here. DESIGN ONLY. - Webhook SSRF is a reduction, not an elimination — and part of it is untested — the resolve-then-connect window is narrowed by re-validating immediately before connecting, not closed; the socket is not pinned to the validated address; a forwarding proxy or a public host that itself forwards inward is out of scope. Two of the hardening steps — the connect-time re-validation and the redirect-refusing opener, both inside
services._http_post— have no covering test, because every delivery test monkeypatches_http_postto keep the suite network-free. They are therefore enforced-but-not-covered, deliberately kept out of §10's evidence table rather than labelled SHIPPED. Separately, replay detection is the receiver's job (we bind the timestamp into the MAC but ship no nonce store), and delivery is synchronous best-effort with a durable attempt log and manual retry — not an at-least-once queue (§A15). ACCEPTED RISK. - The review top-up aid is not reproducible from a signed artifact — it reads live, unsigned standings by design, so its output changes as reviews arrive. It ranks nothing, assigns no score, is not the final ranking, and is not fraud detection (§A19). Named here so it is never cited as evidence about a result.
- Comments are not sanitised, because nothing renders them as HTML — the surface is JSON-only and the body is returned verbatim (stripped, length-capped). There is no content classification, no spam scoring, and no edit history. A future HTML renderer must rely on Django's autoescaping and must not mark the body safe (§A16). ACCEPTED RISK.
- The embed widget is framable by any origin and sets no
Cache-Control— both deliberate for a public, session-free, public-data widget, but there is no per-event allow-list of embedding origins, and an intermediary may cache the list past a withdrawal (§A17). DESIGN ONLY (allow-list) / ACCEPTED RISK (caching). - Undeclared judge conflicts of interest are undetectable — recusal is organizer-declared; discovering an undisclosed stake needs the identity oracle §5 forbids (§A20). DECLINED, for the same reason as the collusion detectors in item 3.
- Multi-event tenancy — §8. DECLINED.
- Operator-tampering prevention — impossible in a self-hosted model; only detection is achievable, and it now ships as a signed, offline-verifiable audit chain (A8), bounded by the external-checkpoint caveat there. The admin-UI cascade footguns that could destroy ballot history are closed (A8), but a party with direct database access is still only detectable, not prevented. ACCEPTED RISK (prevention).
- Demo-mode CSRF exemption — accepted in the grader stack, off in production. ACCEPTED RISK. The two other
csrf_exemptsurfaces are narrower and each states its rationale in code:/records/verifyis a pure stateless verifier that writes nothing (§A18), andPOST /bundles/importis a site-admin JSON operator endpoint — which means an ambient admin session cookie alone authorizes that write (§A14). - Deadline-close TOCTOU — sub-millisecond race, no row lock. ACCEPTED RISK.
10. How to verify every "SHIPPED" claim
Nothing here asks for trust. Bring up the stack and run the checks:
docker compose up --build --detach --wait
docker compose exec -T web python -m pytest tests/ -q # smoke, normalizer, export guard, rate-limit fail-open/window, podium + top-up planners, assignment + recusal, api-token hashing
docker compose exec -T -w /app/src web python manage.py test # every app: isolation, gates, audit chain, control room, throttles, voting, comments, records, embed, bundles, webhooks, awards
docker compose exec -T -w /app/src web python manage.py audit_verify # recompute the live audit chain in place
docker compose exec -T -w /app/src web python manage.py normalize_publish --export /tmp/nbundle # sign a reproducible run, then self-verify
docker compose exec -T -w /app/src web python -m normalize.verify /tmp/nbundle # re-run the estimator from pinned inputs, offline
docker compose exec -T -w /app/src web python manage.py release_bundle /tmp/release # ONE signed bundle: ranking + run + audit checkpoint
docker compose exec -T -w /app/src web python -m normalize.release /tmp/release # verify the whole chain of custody, offline
python3 tools/replay.py # the 7 acceptance checks, no redirects
| Claim | Evidence |
|---|---|
| A1 cross-judge isolation | replay.py 4/5/6; src/normalize/tests.py |
| A2 role gates | replay.py 6/7; src/normalize/tests.py |
| A3 server-side deadline | replay.py 3 |
| A4 ownership (server-derived team, event-scoped track) | submissions/services.py, submissions/views.py; replay.py 3 |
A3/A4 submission edit & soft-withdraw — owner-gated + same deadline gate as create; withdraw never deletes the row (ballot/audit history survives); atomic + submission.revised / submission.withdrawn |
src/submissions/tests.py SubmissionEditWithdrawTests (via manage.py test); src/submissions/{services,views,urls}.py, migrations/0002 |
| A5 CSV formula-injection guard + cache headers | tests/test_export_hardening.py; replay.py 7 |
A9 login throttle + A10 write throttles — seven declared policies (login, invite_redeem, submission_write, ballot_write, vote_write, comment_write, webhook_write) over nine call sites in seven modules; per-IP / per-user / per-(event,actor), DEMO-exempt, fail-open, 429 + Retry-After |
tests/test_ratelimit.py; src/accounts/tests.py, src/submissions/tests.py, src/judging/tests.py, src/events/tests.py, src/voting/tests.py RateLimitTests, src/comments/tests.py CommentThrottleTests (via manage.py test) |
A9 personal API tokens — sha256-only storage, one authenticated endpoint (GET /api/v1/me/), own identity only, soft revocation, malformed/unknown/revoked → 401, no header leaves public routes byte-identical |
src/api/tests.py MeEndpointAndBearerAuthTests (via manage.py test api); tests/test_apitokens.py (DB-free: prefix/length/distinctness, hash_token == direct sha256, determinism) |
| A6 estimator determinism + signed reproducible run | tests/test_normalize_engine.py, tests/test_normalize_signing.py; manage.py test; manage.py normalize_publish --export <dir> + offline python -m normalize.verify <dir> |
| A7/A8 tamper-evident audit chain + signed checkpoint | src/audit/tests.py (6, via manage.py test audit); manage.py audit_verify; manage.py audit_export + offline audit/verify.py |
| A7 append-only ballot history | src/judging/tests.py (via manage.py test judging); src/judging/models.py BallotRevision |
A7/A8 control-room writes co-commit an audit event (judge.assigned / judge.unassigned / rubric.reweighted); a scored assignment is un-removable via the app and admin |
src/judging/tests.py ControlRoomTests (via manage.py test judging); src/judging/{services,admin}.py |
A8 admin parent-cascade delete-guard — deleting an Event / EventMembership / Submission / Team / AppUser whose cascade reaches a scored assignment is refused and bulk delete dropped, so ballot history can't be destroyed via the admin UI (raw-DB access stays the A8 boundary) |
src/events/tests.py ParentCascadeAdminDeleteGuardTests, src/submissions/tests.py SubmissionAdminDeleteGuardTests (via manage.py test); src/portal/admin_mixins.py ScoredCascadeDeleteGuard |
| A6+A7+A8 unified signed release bundle (ranking + run + audit checkpoint, one dir) | src/normalize/tests.py ReleaseBundleTests / ReleaseVerifyPureTests (via manage.py test); manage.py release_bundle <dir> + offline python -m normalize.release <dir> |
| A11 official results: private until published + append-only versions | src/normalize/tests.py ResultPublicationTests (via manage.py test); published run verifies via A6 python -m normalize.verify |
A11 verifiable results certificate — restates a published signed run verbatim (no new signature over the ranking), self-verifies via verify.verify_bundle and its own envelope signature before emitting, refuses unpublished/unsigned, and the API serializes it through a PII-free output-only allowlist |
tests/test_certificate.py (DB-free: required non-PII fields, signature-verifies-and-tamper-fails, refuses unpublished/unsigned, offline PII-free render); src/api/test_certificate.py CertificateApiTests (via manage.py test api); src/normalize/certificate.py, src/api/{views,urls,serializers}.py; manage.py certificate --event <id> |
A12 signed single-use invitations — Ed25519-signed over canonical fields, role-capped at judge/participant (no organizer), single-use (DB, select_for_update), expiry- and rate-limited redeem; offline re-verify |
tests/test_invite_signing.py (DB-free golden/wire-format + domain separation); src/events/tests.py InviteUITests (via manage.py test); manage.py invite_verify <ext_id> |
A13 community voting — DB-enforced one-identity-per-campaign (two partial UNIQUEs), Gmail-aware alias dedup → 409 + audit, confirm-before-voter with the token never echoed, organizer allow-list in gated mode, quadratic budget refusing over-budget casts atomically, re-cast replaces, judges 403 / anonymous 401, results 404 until closed and published, publish-too-early 409. (Sybil resistance is not claimed — §9.6) |
src/voting/tests.py AuthenticatedModeTests / EmailModeTests / ResultsWindowTests / ManageTests / RateLimitTests (via manage.py test voting); src/voting/test_pure.py (DB-free: quadratic cost, budget boundary, alias normalization, per-voter ballot order) |
A14 signed event bundle — site-admin-only gate checked before event resolution (401/403), dogfood.bundle.v1 signature verified before any write (tamper / missing / malformed → 400), create-only (existing ext_id → 409), never mints users, results_published forced false, export carries no ballots or per-judge scores |
src/bundles/tests.py BundleEndpointTests (via manage.py test bundles); src/bundles/test_signing.py (DB-free: frozen tag, per-field tamper, wrong key, malformed sig, fingerprint mismatch) |
A15 webhooks — validate_url blocks loopback / private / link-local / metadata / reserved / multicast, resolves names to all addresses and requires every one to pass, rejects non-http(s), credentials and schemeless URLs, fails closed on DNS error; a blocked URL is 422 with no row; organizer-only registration; delivery signature is HMAC-SHA256 over "<ts>." + body with the timestamp bound into the MAC, reproducible from the log, and audited. (The connect-time re-validation and redirect refusal are not covered — §9.9) |
src/webhooks/test_ssrf.py (8 tests, DB-free + network-free, injected resolver); src/webhooks/tests.py WebhookTests (via manage.py test webhooks) |
A16 comments — public GET, anonymous POST 401 writing nothing, empty body 400, author surfaced as short name with no @ (no email), soft hidden moderation that drops from the public list while retaining the row + comment.hidden audit, non-organizer moderate 403 / anonymous 401, per-author throttle 429 |
src/comments/tests.py ProjectCommentsTests, CommentThrottleTests (via manage.py test comments) |
A17 embed widget — the framing exemption is scoped to one response (frame-ancestors * present there, /projects still X-Frame-Options: DENY), lists only SUBMITTED projects with a withdrawn one absent, no session data in the document, JS loader served as application/javascript |
src/embed/tests.py EmbedWidgetTests (via manage.py test embed) |
A18 participation records + published key — /records/signing-key returns the public PEM with a 64-hex fingerprint header and asserts PRIVATE is absent from the body; judge/participant records are self-service only (401 / 403 / cross-judge 403) and carry no scores or ballots; a doctored or malformed record verifies false |
src/records/tests.py RecordEndpointTests (via manage.py test records); src/records/test_signing.py (DB-free: frozen tag, per-field tamper, wrong key, malformed sig) |
A19 awards — public podium is neutral with no ranking until published, then derived from the frozen signed run (4th place truncated) with a sensitive-token sweep proving no judge identity / criterion / PII on the page; an assigned winner is labelled assigned vs derived; prize writes are audited and never touch the signed result; cross-event track/submission refused in the service; organizer gate 302 / 403 / 200; topup is organizer-only with Cache-Control: private, no-store + Vary: Cookie. (The same-event invariant is service-layer, not a DB constraint — §9.7; topup reads live unsigned data and is not reproducible — §9.10) |
src/awards/tests.py AwardsTests, AwardsTopupViewTests (via manage.py test awards); tests/test_podium.py, tests/test_topup.py (DB-free planners) |
A20 judge↔submission graph — organizer-declared JudgeRecusal is read from the DB, expanded to that team's submissions, and honored by the planner in both directions while other judges still reach k coverage; a duplicate recusal is refused by UNIQUE(judge, team); auto-assign is organizer-only and event-scoped and applies via assign_judge per edge so each assignment is audited like a manual one |
src/judging/tests.py AutoAssignRecusalTests, AutoAssignViewTests, ControlRoomTests (via manage.py test judging); tests/test_assignment.py, tests/test_recusal.py (DB-free) |
| CSP / middleware wired | tests/test_smoke.py |
| No raw SQL, no upload surface | grep \.raw( / request.FILES → none |
A control that appears in the table above with a passing check is SHIPPED. Everything else is in §9. If a future change claims a control, it adds the row and the test in the same commit — a label without evidence is not a label.