feat(preview): serve each pull request's client at pr-N.<domain> #10

Merged
genji1024 merged 1 commit from feat/pr-previews into main 2026-10-12 10:12:59 +09:00
Collaborator

Closes #9

Changes

New docs/preview/, run by the demo's user next to the demo:

  • sync.sh (host, systemd timer every 2 minutes): runs the fetch container, unpacks each new build through the unpack container into data/.new-N, checks again that it holds only plain files and directories and an index.html, adds the preview's config.json, _preview.json and .source, and swaps it in for data/pr-N. Removes previews of closed pull requests (or beyond PREVIEW_MAX), then re-creates nginx with one Host(pr-N.…) per preview, or removes it when there are none. If the pull request listing fails, it stops before removing anything.
  • fetch.sh (container: Alpine + jq, read-only root, no capabilities; reads data/, writes only data/.incoming): lists open pull requests into main (optionally only PREVIEW_AUTHORS), finds the newest successful ci.yml run of each head commit and its client-dist artifact, and downloads it when the commit, run, artifact or upload time changed (a re-run replaces the artifact in place under the same ids). Refuses artifacts over PREVIEW_MAX_ZIP_MB.
  • unpack.sh (container: no network, read-only root, no capabilities, 256 MB tmpfs): unzips into the tmpfs, refuses anything but plain files and directories or a build without index.html, normalizes permissions and copies into /out, the only other thing it can see.
  • nginx.conf.template: serves pr-N read-only with the client's caching rules, disable_symlinks, no dotfiles, and headers the build cannot change: a CSP (connect-src 'self' <homeserver>, script-src 'self' 'wasm-unsafe-eval', worker-src 'self' blob:, form-action 'self', frame-ancestors 'none', …), X-Robots-Tag: noindex, Referrer-Policy: no-referrer.
  • compose.yaml (project koto-preview), Dockerfile, .env.example, and a README.md with the design, security notes, setup (DNS, front proxy, .env, systemd units) and limits.
  • ci.yml's shell syntax check covers docs/preview/*.sh; demo.md and development.md link the new guide.

Deviation from #9: the archive is downloaded in one container and unpacked in a second, network-less one that sees only that archive and the preview's own new directory, so even an unzip that followed a hostile symlink or path could only spoil that preview.

Verification

Docker is not available where this was written, so the scripts were run with a stand-in for docker compose that executes the container scripts directly, using the same busybox wget/unzip as Alpine, and jq 1.8.1:

  • Against forgejo.genji1024.com (with state=all, since no pull request is open): downloaded #8's client-dist (artifact 5) without a token, unpacked it, wrote config.json for the demo homeserver and _preview.json; the rule came out as Host(`pr-8.preview.genji1024.com`); a planted pr-99 was removed; a second run changed nothing.
  • Hostile archives through unpack.sh: a symlink entry and a symlink-then-file entry are refused; ../../x and /abs are stripped by busybox and land inside the output directory; an archive without index.html is refused.
  • sh -n passes for every script.

Not verified yet, to check on the host: nginx's config and headers, the client working under the CSP (wasm crypto, the image-compression worker falling back to the main thread), rootless Docker file ownership, and the tmpfs limit against a zip bomb.

Closes #9 ## Changes New `docs/preview/`, run by the demo's user next to the demo: - **`sync.sh`** (host, systemd timer every 2 minutes): runs the fetch container, unpacks each new build through the unpack container into `data/.new-N`, checks again that it holds only plain files and directories and an `index.html`, adds the preview's `config.json`, `_preview.json` and `.source`, and swaps it in for `data/pr-N`. Removes previews of closed pull requests (or beyond `PREVIEW_MAX`), then re-creates nginx with one `Host(pr-N.…)` per preview, or removes it when there are none. If the pull request listing fails, it stops before removing anything. - **`fetch.sh`** (container: Alpine + jq, read-only root, no capabilities; reads `data/`, writes only `data/.incoming`): lists open pull requests into `main` (optionally only `PREVIEW_AUTHORS`), finds the newest successful `ci.yml` run of each head commit and its `client-dist` artifact, and downloads it when the commit, run, artifact or upload time changed (a re-run replaces the artifact in place under the same ids). Refuses artifacts over `PREVIEW_MAX_ZIP_MB`. - **`unpack.sh`** (container: no network, read-only root, no capabilities, 256 MB tmpfs): unzips into the tmpfs, refuses anything but plain files and directories or a build without `index.html`, normalizes permissions and copies into `/out`, the only other thing it can see. - **`nginx.conf.template`**: serves `pr-N` read-only with the client's caching rules, `disable_symlinks`, no dotfiles, and headers the build cannot change: a CSP (`connect-src 'self' <homeserver>`, `script-src 'self' 'wasm-unsafe-eval'`, `worker-src 'self' blob:`, `form-action 'self'`, `frame-ancestors 'none'`, …), `X-Robots-Tag: noindex`, `Referrer-Policy: no-referrer`. - **`compose.yaml`** (project `koto-preview`), `Dockerfile`, `.env.example`, and a **`README.md`** with the design, security notes, setup (DNS, front proxy, `.env`, systemd units) and limits. - `ci.yml`'s shell syntax check covers `docs/preview/*.sh`; `demo.md` and `development.md` link the new guide. Deviation from #9: the archive is downloaded in one container and unpacked in a second, network-less one that sees only that archive and the preview's own new directory, so even an unzip that followed a hostile symlink or path could only spoil that preview. ## Verification Docker is not available where this was written, so the scripts were run with a stand-in for `docker compose` that executes the container scripts directly, using the same busybox `wget`/`unzip` as Alpine, and jq 1.8.1: - Against forgejo.genji1024.com (with `state=all`, since no pull request is open): downloaded #8's `client-dist` (artifact 5) without a token, unpacked it, wrote `config.json` for the demo homeserver and `_preview.json`; the rule came out as ``Host(`pr-8.preview.genji1024.com`)``; a planted `pr-99` was removed; a second run changed nothing. - Hostile archives through `unpack.sh`: a symlink entry and a symlink-then-file entry are refused; `../../x` and `/abs` are stripped by busybox and land inside the output directory; an archive without `index.html` is refused. - `sh -n` passes for every script. Not verified yet, to check on the host: nginx's config and headers, the client working under the CSP (wasm crypto, the image-compression worker falling back to the main thread), rootless Docker file ownership, and the tmpfs limit against a zip bomb.
feat(preview): serve each pull request's client at pr-N.<domain>
All checks were successful
ci / check (pull_request) Successful in 1m24s
eb1c7356f6
A timer on the demo's host fetches the client-dist artifact of every open
pull request's newest successful CI run through the API (no token) and
serves it as static files behind the demo's Traefik, talking to the
demo's homeserver (#9).

Nothing on the host runs from a workflow, and nothing from a pull request
runs on the host. The archives are untrusted: they are downloaded in one
container and unpacked in another without network, into a size-limited
tmpfs and the preview's own new directory; anything but plain files and
directories is refused, and checked again before nginx serves it. nginx
adds headers the build cannot change, including a CSP that limits
requests to the preview and the homeserver.

The CI syntax check now covers the new scripts.

Closes #9
genji1024 approved these changes 2026-10-12 10:12:37 +09:00
genji1024 left a comment

LGTM!

LGTM!
genji1024 deleted branch feat/pr-previews 2026-10-12 10:12:59 +09:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
genji1024/koto!10
No description provided.