Skip to content

Security: crypticpy/phct

SECURITY.md

Security

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.

Where untrusted input enters

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.

What the automation guarantees

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.

Who can submit

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.

What you should still do

  • 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.

Known limitations

  • DNS rebinding. The image fetcher resolves the host, checks every address, and then lets fetch resolve 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 the images field from _data/schema.yml and 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.

What is out of scope

  • 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 concurrency group so re-runs replace each other rather than stacking, but a determined spammer can still consume minutes; disable the workflow or set SUBMISSIONS_OPEN=false if that happens.

Reporting a vulnerability

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.ymlorganization.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.

There aren't any published security advisories