Build1 publisher3 min readPublished
Seven passing acceptance checks missed a team history log that named a hackathon's judges
Shipshape's team-only history log would have shown hackathon teams their judges, scoring times and conflict notes despite seven passing acceptance checks. Its role tests checked who could open each page, while the leak was in what that page printed for the team members allowed in.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- The history panel sits on each team's team page and submission page and logs who joined, who edited the project and when it was submitted.
- Only a team's own members can open those pages, so the people reading the judge activity were the people being judged.
- The log also showed whether a judge changed a submitted score, plus the first 120 characters of a judge's private reason for stepping aside.
- The organizers' deadline check passes on any status from 400 to 499 and, per its spec, does not inspect why the portal refused.
- Shipshape runs on Django 5.2 and SQLite, built in 72 hours for a brief requiring a seeded portal to come up with the network off.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint A suite that asserts which user may reach which URL cannot fail on a permitted page that prints another role's data, so catching it takes a test that reads the page as its real audience.
- exposure Recusal reasons written for organizers, and the record of revised scores, become material the judged team can read and use to dispute a result.
- decision Builders facing a checker that accepts any 4xx have to define and prove what their refusal means themselves, because a pass is equally consistent with a 404.
The page's access check did its job. The leak was in the rows behind it. A history feed built from everything that touches a team's project will pick up a judge's score on that project as readily as a teammate's edit. The post does not show the query behind the panel. The symptom fits a feed selected by project, with no filter on who may read each kind of event. One line from the test the developer wrote afterwards reads: Jules Judge declared a conflict with "Quiet Hours": I mentored them [2].
"Role isolation was the part of the portal I had tested hardest," the developer wrote [16]. None of those tests opened a team page, and the organizers' checker does not open one either [5]. A role test asks whether a given user can reach a given URL. It gets the same answer whether the page prints an edit history or a recusal note. The test that catches this signs in as a team member after a judge has scored, then reads the rendered panel.
The checker's deadline probe has a related weakness. A 404 passes it, and so would a CSRF failure or a complaint about missing fields [9]. By that standard, failing to find the event counts as enforcing its deadline [9]. "A portal could pass that check for the wrong reason, so the order of checks is the design," the developer wrote [17]. Ordering the refusals is the right response to a check that reads only the status code. The view refuses in a fixed sequence [10]:
1. Not signed in: 401. 2. No such event: 404. 3. Not JSON from this site: 415, 403 cross_origin, or 400. 4. Past the deadline: 403 submissions_closed. 5. No team: 403 no_team. 6. Bad fields: 400.
The deadline test runs with Django's CSRF enforcement switched on, so its 403 cannot be a missing token [11]. Every write path re-reads the event row inside its transaction and checks the server clock. The deadline instant itself counts as closed, and a refusal is logged after the rollback so its audit line survives [12].
The offline requirement also failed only when someone else ran it. For two days the developer read "with the network off" as a runtime rule, until another machine ran the python:3.12-slim build and it went looking for downloads [13]. The base image and wheels now live in the repo, built FROM scratch and installed with --no-index [14]. The image ships as bzip2 at 36.9 MB, 9.8 MB more than the xz version [14][1]. Docker unpacks xz by calling an external xz program wherever the engine runs, but unpacks bzip2 itself with Go's standard library [14]. An offline Docker-in-Docker box then caught compose trying to pull the image from Docker Hub before building it, and pull_policy: never fixed that [15].
What to watch
- Whether the developer publishes the change to the history panel, such as a per-event audience filter or a separate judge-only log.
- Whether the DOGFOOD 2026 organizers add an acceptance check that renders a team page as a team member after judges have scored.
- Whether the checker's deadline probe starts asserting the error code, such as submissions_closed, instead of accepting any 4xx.