This is a Jekyll site published by GitHub Pages. There is no server, no database and no user session: the whole site is static files, and every change to it arrives as a commit on the default branch.
What follows is the trust model — what the automation assumes about the people who use it, and what it does not assume.
One place: GitHub issues. Anyone with a GitHub account can open one, and
several workflows react to them. The submission form at /submit/ does not
send anything anywhere; it composes a prefilled issue URL and the submitter
presses the button on GitHub.
Everything below is about keeping that input from turning into anything more than text in a pull request.
Issue content never executes.
Issue bodies and titles reach a workflow step only through env:, never
interpolated into a run: script, so shell metacharacters in a submission are
inert. Nothing a submitter writes is passed to eval, a shell, or a template
that renders it as code.
The generated page is not run through Liquid.
A scaffolded entry carries render_with_liquid: false in its front matter.
Without it, Jekyll would evaluate the page body at build time and a Liquid tag
typed into a write-up would execute against the site's data. check_front_matter.rb
warns about any entry that is missing the flag.
The scaffolder writes inside one folder.
scripts/new_entry_from_issue.mjs derives a slug, re-checks it against
^[a-z0-9]+(?:-[a-z0-9]+)*$, and then resolves every path it is about to
write and refuses anything that does not sit under catalog/<slug>/ — file
writes and image downloads alike.
The issue-form parser is hard to spoof.
A GitHub issue form renders as ### <label> sections in template order. The
parser takes the first occurrence of each known heading and treats everything
after the final free-form section as prose, so a ### Contact email typed
inside someone's write-up cannot overwrite the answer GitHub collected.
Repeated headings are reported on the pull request, not silently applied.
This holds for a body GitHub rendered from the form; an issue written by hand
(or edited afterwards) is still parsed, so the pull request — not the parser —
is the trust boundary.
Images are fetched behind an SSRF guard.
Before each request — and again after every redirect hop, up to five — the
target host is resolved and refused if any resolved address is loopback,
link-local (including the cloud metadata address at 169.254.169.254),
private, unique-local, multicast or unspecified, or if the hostname ends in
.internal, .local or localhost. IP-literal URLs go through the same
check, and IPv6 literals are judged numerically — an IPv4-mapped,
IPv4-compatible or NAT64 address is decoded and held to the IPv4 rules,
whatever its spelling. Redirects are followed by hand so a credential is never
carried to a host it was not meant for. Downloads are capped by count, by total size and by
a request timeout that covers the response body, and each file must be a PNG,
JPEG, GIF or WebP by both its Content-Type and its magic bytes before it is
written.
The cohort and event automation holds to the same rules.
It reads issue bodies with the same first-occurrence-wins heading parser: only
a heading belonging to the relevant issue form starts a section, and
everything after the form's trailing free-text field is treated as prose, so a
### heading typed inside an answer does not replace the answer GitHub itself
collected. Every cohort year and event id is checked against ^\d{4}$ and
^[a-z0-9]+(?:-[a-z0-9]+)*$, and the resolved path is re-checked to be inside
cohorts/<year>/events/ before any file is read or written. The front matter
these scripts generate comes from the shared YAML emitter, so quotes, control
characters and Unicode line separators survive a round trip through both Psych
and js-yaml. Every $GITHUB_OUTPUT value is written as a heredoc whose
delimiter is random per write, so no answer can forge an additional step
output, and the four workflows declare permissions: {} at the top level and
honour the SUBMISSIONS_OPEN repository variable.
Values written into YAML are quoted unless provably safe.
scripts/lib/yaml.mjs emits a plain scalar only for prose-shaped values and
double-quotes everything else, escaping control characters. A submitted value
cannot change the type or the structure of the front matter it lands in.
Nothing publishes itself.
Every issue-driven workflow ends at a pull request. A maintainer reviews and
merges it, and only then does the site rebuild. The contents: write token is
repository-wide (GitHub has no branch-scoped token), so the workflows confine
themselves by construction: each pushes only to a branch it created for that
issue. The one workflow that commits to an existing branch is thumbnails.yml,
which renders PDF thumbnails onto a pull request's own head branch — or, when a
maintainer runs it by hand, onto the branch they chose — and adds only derived
.jpg files under the entry folder. Branch protection on the default branch is
the backstop; see "What you should still do" below.
The optional CONTENT_BOT_TOKEN is a deliberate delegation.
By default the content workflows use the built-in GITHUB_TOKEN, which cannot
trigger other workflows, so they dispatch the validation checks against the new
branch by hand. A maintainer may instead add a CONTENT_BOT_TOKEN secret so the
pull requests are opened as that token's user and trigger checks normally. That
token is a real user's credential rather than a per-run one: scope it to this
repository and to Contents: write and Pull requests: write only, give it
a short expiry, and prefer a machine account. The workflows read it as
secrets.CONTENT_BOT_TOKEN || secrets.GITHUB_TOKEN, so removing the secret
returns them to the default path with no other change.
See docs/admin-guide.md.
Third-party actions are pinned.
Every uses: in .github/workflows/ names a full commit SHA with the release
tag in a trailing comment. Dependabot proposes updates weekly.
Open by default: the catalog is meant to collect work from people without
write access. To restrict it, set the repository variable SUBMISSIONS_OPEN to
false (Settings → Secrets and variables → Actions → Variables). Every
issue-driven workflow (new-entry, new-event, new-year, update-schedule,
update-event-attachments) then runs only for issues opened by the repository
owner, an organization member or a collaborator. See
docs/admin-guide.md for the maintainer view.
apply-setup is the exception, and is not governed by that variable: the YAML
it applies becomes the site's configuration, so the workflow refuses to run
for anyone outside OWNER/MEMBER/COLLABORATOR whatever SUBMISSIONS_OPEN
says. Its blast radius is bounded the same way everything else is —
renderFiles() returns a fixed set of seven repo-relative paths, so the script
cannot write outside them, and the result is still only a pull request.
- Protect the default branch. Require a pull request before merging (Settings → Branches). The automation never targets the default branch, but the workflow token could, and branch protection is what turns "never does" into "cannot".
- Review the generated pull request as content. The checklist on it is the last look a submission gets before it is published.
- Watch the Actions tab occasionally. A workflow that fails on an issue posts a comment on that issue; one that fails before it can comment only shows up in Actions.
- DNS rebinding. The image fetcher resolves the host, checks every address,
and then lets
fetchresolve it again for the actual request. A DNS name that flips from a public to a private address between those two lookups could reach an internal host of the runner. GitHub-hosted runners have no interesting internal hosts, but a self-hosted runner does — put it on a network segment with nothing to reach, or drop theimagesfield from_data/schema.ymland let maintainers add screenshots on the pull request. - The runner's own network. The SSRF guard is an allow-list of address classes, not a proxy; anything routable and public is fetchable, which is the point.
- Content accuracy. Maintainers review submissions; the automation does not.
- Anything a maintainer merges by hand. The review checklist on each generated pull request is the control there.
- Denial of service against GitHub Actions minutes. The workflows use a
per-issue
concurrencygroup so re-runs replace each other rather than stacking, but a determined spammer can still consume minutes; disable the workflow or setSUBMISSIONS_OPEN=falseif that happens.
Please report privately rather than opening a public issue. Use GitHub's private vulnerability reporting: the Security tab of this repository → Report a vulnerability. (A repository admin enables it under Settings → Advanced Security → Private vulnerability reporting.) See GitHub's documentation for what the reporting form looks like.
If private reporting is not enabled, email the address in
_data/site.yml → organization.contact_email and say that the report is a
security issue, so it is not handled as a normal submission.
Expect an acknowledgement within a few working days. There is no bounty.
Something published that should not have been — a takedown request, a leaked email address, protected data in a screenshot — is not a vulnerability report; follow docs/incidents.md, which covers removing the page, purging it from git history, and asking GitHub Support to clear the caches.