What this platform defends against, how, and what it deliberately does not defend
against. ARCHITECTURE.md explains the mechanisms; this document is about the adversary.
flowchart TD
V["Public\nsigned out"]
U["User\nsigned in, any account"]
P["Participant\nevent-scoped"]
J["Judge\nevent-scoped"]
A["Event admin\nowner or ADMIN membership"]
O["Organizer capability\nglobal: may create events"]
S["Instance operator\nsuper admin"]
V -->|creates an account| U
U -->|registers or joins a team| P
P -->|invited or promoted| J
A -->|granted per event| J
O -->|creates| A
Global account labels are Public (signed out), User (signed in) and Organizer (a user who may also create events). Participant, judge and event admin are event roles. None of these except identity itself and the organizer capability are global. A judge in
one event is a plain participant, or nothing at all, in another. See DATA-MODEL.md for
why that is a schema decision, not a UI convention.
- Judging integrity. Another judge's scores, or a submission a judge was never assigned. This is the thing the whole product exists to protect.
- Account credentials and sessions. Password hashes, JWTs, session tokens.
- Cross-event data. A private event's roster, submissions or results, visible to someone with no membership in it.
- Voting identity. Who voted for what, when a ballot was supposed to be anonymous to other participants.
- Availability. The platform staying reachable through its own submission and voting windows.
flowchart LR
subgraph Untrusted["Untrusted: anything the client sends"]
role["role"]
eventId["event_id"]
judgeId["judge_id / user_id"]
totals["computed totals, weights"]
idParam["ids in the URL or body"]
end
subgraph Trusted["Trusted: derived server-side"]
session["authenticated identity, from the verified JWT"]
membership["event_memberships lookup for THIS user, THIS event"]
server_totals["weighted totals, normalization, vote cost"]
end
Untrusted -. "never used for an authorization decision" .-> Trusted
session --> membership --> Decision["allow / deny / scope query"]
Every field in the "untrusted" box appears somewhere in a request. None of them cross
into an authorization decision or a stored score. role, judge_id and event_id in a
body or query string are read for routing, never for permission: the permission
question is always re-derived from the session and a fresh database lookup.
| Threat | Where it could happen | Mitigation |
|---|---|---|
| Spoofing | Claiming to be another judge or an organizer. | JWT signature verification, token_version and per-session revocation. Roles are never read from the token or the request, only looked up from event_memberships for the authenticated user. |
| Tampering | Client submits a weightedTotal, a vote credits figure, or a normalization result. |
All of these are computed server-side from stored inputs and never accepted from the client. See JUDGING.md section 1 and 9. |
| Repudiation | An organizer denies publishing results, or a judge denies a score they cast. | audit_logs with actor, action, target and timestamp, append-only by database trigger and hash-chained so a rewrite is detectable. normalization_runs snapshots the exact inputs behind a published result. |
| Information disclosure | Judge A reads Judge B's scores. A participant reads a private event they are not a member of. A voter's identity leaks to another participant. | Every event-scoped query is filtered by the caller's own event_memberships row, derived server-side (see API.md's worked example). Private events 404 rather than 403 for non-members. voterKey never appears in a response to another participant. |
| Denial of service | Vote or login flooding from one client. | Fixed-window rate limiting by hashed IP, and failed sign-ins by account and IP. The IP is the socket address unless TRUST_PROXY names a proxy, so a forged X-Forwarded-For cannot mint fresh IPs. Explicitly not a defense against a distributed attacker; see limits below. |
| Elevation of privilege | A participant calls a judge or organizer endpoint directly. | Middleware chain (requireAuth -> loadEventContext -> requireJudge / requireEventAdmin) runs before every handler; there is no code path that reaches a handler without it. Tested with curl-equivalent Supertest calls using a real, valid, wrong-role session, not by hiding a button. |
sequenceDiagram
participant JudgeA
participant API
participant DB
JudgeA->>API: GET /events/:id/judge/scores/:submissionId (their own session)
API->>DB: JudgeAssignment where judgeId = session user AND submissionId = param
alt assignment exists
DB-->>API: row found
API-->>JudgeA: this ballot only
else no assignment
DB-->>API: no row
API-->>JudgeA: 404, never another judge's data
end
An authenticated participant of Event A requests a resource in Event B. loadEventContext
resolves Event B, looks up event_memberships for (userId, eventB.id), finds nothing,
and the handler never runs with an authorization decision based on Event A's membership.
A private Event B returns 404; a public one returns whatever public visitors may see,
never more.
Disabling the submit button, or replaying an old page load, changes nothing: every
mutating submission route re-evaluates submissionWindow(event) against the server
clock on every request, and a write outside the window is refused and logged as
SUBMISSION_EDIT_REJECTED.
Five abuse cases that matter for an evaluation platform. Each is marked Stopped, Reduced (made harder or detectable, not impossible) or Not addressed, with the code that backs the claim.
| Threat | Verdict |
|---|---|
| Sybil accounts | Reduced |
| Ballot stuffing | Stopped for signed-in voting, Reduced otherwise |
| Submission scraping | Reduced (the gallery is public by design) |
| Judge collusion | Reduced |
| Deadline gaming | Stopped |
Attack: create many accounts to inflate community votes or fill a team board.
- What exists.
POST /auth/register,/auth/loginand/auth/magic-linkshare a ceiling of 300 attempts per 15 minutes per hashed client address (authRateLimit), and failed sign-ins are limited per account and address. Accounts are event-scoped for anything that matters: a new account holds no judge or admin role anywhere, because roles come only fromevent_membershipsrows an organizer grants. - What does not. Registration needs no email verification, because the platform must work offline with no mail server. One person with several addresses or IPs can hold several accounts. Signed-in voting therefore counts accounts, not people, and a quadratic budget is granted per account. The vote panel flags several voters behind one address for a human decision.
- Recommendation. For a vote that decides a prize, use signed-in voting, read the flags, and keep the credit budget small. The organizer can also limit voting by role (visitors, participants, judges, admins), for example to registered participants only.
Attack: cast many ballots as one voter, or replay a ballot.
- Stopped for identified voters. A voter is identified server-side (
resolveVoter): account id, an email address proved with a one-time code, or a browser cookie capped per address.votesis unique per(event, voter key, submission)and a new ballot replaces the previous one in a transaction, so re-submitting cannot stack (or is refused outright when the organizer made votes final). A client cannot nominate the identity it votes as. Own-team votes and cross-event submissions are refused. Weight, credits, budget and method are validated and priced on the server; a clientcreditsfigure is ignored. The method and budget lock once a ballot exists. - Reduced elsewhere. Ballots are limited to 60 per hour per hashed address, and open-link voting
admits a configurable number of new voters per address per hour. Email-gated voting proves
control of each address with a code, but one person can control several, so a determined
attacker with many addresses still succeeds. Tallies stay hidden until the
window closes, which removes the feedback an attacker needs to tune a campaign. Every rejected
ballot is audited (
VOTE_REJECTED).
Attack: bulk-harvest projects, descriptions and links.
- Reduced. The gallery is public by design, so there is nothing to protect from a visitor, but the gallery listing is limited to 240 requests a minute per address, which is far above a person browsing and a brake on harvesting. Drafts are never returned to the public, private events return 404, and results stay hidden until publication. A patient crawler across many addresses still gets the published gallery.
- If it matters. Put a reverse proxy with rate limiting in front, or make the event private or link only. The embeddable gallery exposes only what the public gallery already shows.
Attack: judges coordinate, or a judge favours a friend.
- Reduced. A judge is never assigned their own team's project (
assignmentrefuses it), and a judge can only score what the organizers assigned. Judges cannot see each other's scores (isolation is enforced in the API, see above), so they cannot copy a running total or react to it. Each project is read by several judges, and per-judge normalization limits how far one harsh or generous judge moves a ranking. The organizer can see each judge's mean and spread in the calibration table, and every score is audited. - Detected. The results screen's Panel integrity section (
GET /judging/integrity,algorithms/integrity.ts) lists judge pairs who scored three or more shared projects in lockstep (identical totals, or a correlation of 0.95 and up over four or more), single ballots two standard deviations away from the rest of the panel on the same project, flat judges, and judges assigned a team where someone shares their organization. It flags; the organizer decides. - Not addressed. Relationships beyond a shared organization are not modelled, and on a small panel two coordinating judges who vary their scores just enough can stay under every threshold.
Attack: an admin given one job (posting announcements, say) uses the API to change settings, read ballots, or promote themselves.
- Stopped. Each admin carries the organizer areas it was granted, or full access. Every organizer route requires its area on the server, and reads of hidden data (unpublished results, live vote tallies, drafts, hidden comments) require the matching area too. Only the owner or a full-access admin can add admins or change their access, so an admin cannot grant themselves more.
Attack: upload a file that runs script in the platform's origin, or fill the disk.
- Stopped. The type comes from the file's first bytes, not the client's header: only PNG, JPEG,
GIF and WebP are accepted, so SVG and HTML never get in. Served files carry
X-Content-Type-Options: nosniffandContent-Security-Policy: default-src 'none'; sandbox. - Reduced. Each file is at most 2 MB and each person 50 MB a day, behind the write limit. The browser shrinks images before upload, a repeat upload of the same file is reused, and uploads tied to no event are deleted after a day.
Attack: edit after the deadline, or replay a stale page.
- Stopped. Every mutating submission route re-evaluates
submissionWindow(event)against the server clock. A write outside the window is refused and audited (SUBMISSION_EDIT_REJECTED). Client time is never read. Locked submissions refuse writes from the team. - Remaining path. An event admin with the Event settings area can move a deadline or unlock, which is by design and is recorded in the audit log with the actor and the old and new values.
Stated plainly, rather than claiming coverage the code does not have.
- Rate limiting is per-process by default. A horizontally scaled deployment sets
RATE_LIMIT_STORE=postgresso replicas share windows through the database, with no Redis. Sign-in limits count failures per account and address, so a crowded venue behind one NAT address is not locked out; seeARCHITECTURE.md. - Email-gated and open-link voting do not prove personhood. A code proves control of an
address and a cookie identifies a browser; neither stops one person holding several. The honest recommendation
for anything that decides a prize is
AUTHENTICATEDvoting; seeJUDGING.mdsection 9. - Email needs an SMTP server. With
SMTP_URLset, sign-in links and voting codes are mailed. Without it the platform stays offline and writes them to the API log for the operator to relay, and team invite links are shown on-screen. - Webhook retries are bounded. A delivery is retried five times over about two and a half hours, then marked failed for a manual retry. Outbound requests refuse private and internal addresses (SSRF), and signatures bind a timestamp and delivery id (replay).
- The acceptance checker tokens in
.dogfood.tomlare public. The checker never signs in, so the seed stores four fixed API tokens for fixture accounts. Each is scoped to the fixture event: insidesample-hack-2026it acts as its account, anywhere else it authenticates as nobody, and it never carries the account's organizer capability, so it cannot create events.FIXTURE_TOKENS=falseskips them andDELETE /api/auth/tokens/:idrevokes one. Session and record-signing secrets are generated per instance on first boot and stored in the database, so knowing the repository does not let anyone forge a session from the outside. - The database is the trust anchor, so it is not exposed off-box. The session secret lives in
Postgres, which means anyone who can reach the database with its credentials can mint a session.
The default
POSTGRES_PASSWORDis a development convenience, sodocker-compose.ymlpublishes the Postgres port on127.0.0.1only: the API reaches it over the compose network and the host can reach it for local development and the test runner, but it is never on a public interface. To expose it deliberately (a separate database host, for example), setPOSTGRES_HOST_BINDand a strongPOSTGRES_PASSWORD. The application ports (API and web) stay published because they enforce authentication on every request; the raw database does not. - No anomaly detection. Flagged voting activity (shared IP across voter keys) is surfaced to the organizer for a human decision; nothing is auto-blocked, so a patient attacker below the flagging threshold is not caught by the platform itself.