Shuffle/docs/adr/0003-ci-runner-sicherheit.md
cschlaefke ffa264f827
Some checks failed
CI / flutter (pull_request) Successful in 1m23s
CI / web-build (pull_request) Successful in 1m16s
CI / supabase (pull_request) Successful in 9s
CI / supabase-tests (pull_request) Successful in 33s
CI / e2e (pull_request) Failing after 5m50s
docs(adr): accept no-approval rule for fork PR runs (ADR-0003)
Refs #43

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:06:55 +01:00

9.9 KiB
Raw Permalink Blame History

ADR-0003: CI-Sicherheit – Fork-PRs und der Runner docker-host

  • Status: Teilweise angenommen (Chris, 2026-11-04): Betriebsregel unten gilt; Maßnahmen A und B sowie die Frage „öffentlich oder privat“ sind noch offen, Issue #43
  • Datum: 2026-11-03

Kontext

Das Repo ist öffentlich. Der Runner shuffle-mac-runner (Label docker-host) läuft auf Chris' Mac mit Docker-in-Docker; die Jobs supabase-tests und e2e sprechen den dind-Daemon per TCP an (DOCKER_HOST=tcp://<Gateway>:2375, ohne TLS). Wer dort Code ausführt, hat Root-Äquivalent im dind (Container mit Host-Mounts, Zugriff auf alles, was der dind sieht).

Bei on: pull_request wird die Workflow-Datei aus dem PR-Head gelesen. Ein Fork-PR kann Bedingungen wie head.repo.full_name == github.repository entfernen oder ein beliebiges runs-on: setzen. Ein if: in ci.yml schützt daher nicht und kein Inhalt des Repos kann einen Fork-PR daran hindern, runs-on: docker-host anzufordern.

Stand der Prüfung (Forgejo 8.0.3+gitea-1.22.0, Runner v5.0.4): Die Freigabe-Logik wurde im Quelltext von Forgejo v8.0.3 gelesen (services/actions/notifier_helper.go, Funktion ifNeedApproval). Es gab keinen Live-Test mit einem Fork-PR; das Verhalten des Runners und die Aussagen zu Runner-Konfiguration stammen aus Doku-Kenntnis. Prüfschritte stehen unten.

Was Forgejo 8.0.3 bietet

Mechanismus Verfügbarkeit Bewertung
Freigabe für Fork-PR-Workflows (need approval) Eingebaut, keine Repo-Einstellung zum Ein-/Ausschalten. Entscheidet über den ganzen Lauf. Laut Quelltext v8.0.3 braucht ein Fork-PR (Event pull_request) Freigabe, außer der Nutzer hat Schreibrecht oder es gibt im Repo schon einen freigegebenen Lauf dieses Nutzers. Hauptschutz mit Lücke, siehe Absatz unter der Tabelle.
Nutzer restricted (Administration -> Benutzerkonten; app.ini [service] DEFAULT_USER_IS_RESTRICTED für neue Konten, unverifiziert) Eingebaut Erzwingt die Freigabe bei jedem Lauf (Quelltext). Nebenwirkung vermutlich: keine Sicht auf öffentliche Repos ohne Zuordnung (unverifiziert), siehe C.2.
pull_request_target Läuft mit Workflow aus dem Base-Branch Nicht verwenden, um PR-Code auf docker-host zu bauen (klassische Pwn-Request-Lücke).
Runner-Scoping (Instanz/Organisation/Nutzer/Repo) Eingebaut Begrenzt, welche Repos den Runner nutzen, nicht welche Events. Hilft hier nicht gegen Fork-PRs im selben Repo.
Runner-Labels Eingebaut Jeder Workflow darf jedes Label anfordern. Kein Schutz.
Event-/Repo-Filter im Runner (runner.yaml) Nicht vorhanden --
Repo privat stellen Eingebaut Beseitigt das Problem (Fremde können keine PRs öffnen), kostet Öffentlichkeit.
Forks instanzweit verbieten (app.ini, Forgejo-Doku prüfen: [repository] DISABLE_FORKS/ähnlich) In 8.0.3 nicht verifiziert Nur als Prüfpunkt.

Vertrauenslücke der Freigabe: Eine einzige Freigabe vertraut dem Nutzer dauerhaft. Für alle künftigen PRs dieses Nutzers im Repo (nicht nur für diesen PR) laufen Fork-PR-Workflows ohne weitere Freigabe, auch nach einem Push mit manipuliertem Workflow. Eine Freigabe ist also faktisch die Entscheidung „diesem Nutzer vertraue ich den Runner an“.

Entscheidung (Vorschlag)

Verteidigung in drei Schichten; nur die erste lässt sich im Repo ausdrücken, der Rest braucht Chris:

  1. Repo (umgesetzt): Der if:-Guard an supabase-tests und e2e hält nur einen unveränderten, bereits freigegebenen Fork-PR von docker-host fern. Er verhindert weder das Warten auf Freigabe (die gilt für den ganzen Lauf, das Job-if wird erst danach ausgewertet) noch einen manipulierten Lauf. Die Kommentare in ci.yml sagen das.
  2. Forgejo (Chris): Fork-PR-Läufe auf diesem Runner nicht freigeben, solange er nicht isoliert ist (3.) oder das Repo nicht privat ist. Grund: Eine Freigabe gilt für alle künftigen PRs des Nutzers im Repo. Fremde Beiträge lokal prüfen oder als Branch ins Haupt-Repo übernehmen.
  3. Runner/Mac (Chris): Blast Radius des dind begrenzen (siehe Anleitung).

Solange 2. und 3. nicht erledigt sind, gilt docker-host als nicht für externe Beiträge geeignet. Empfehlung: vor dem ersten externen Beitrag, spätestens vor Release, entweder 3. umsetzen oder das Repo privat stellen.

Betriebsregel (angenommen, Chris, 2026-11-04)

Fork-PR-Läufe werden nie freigegeben. Fremde Beiträge werden lokal geprüft und als Branch ins Haupt-Repo übernommen (dort läuft die CI ohne Freigabe, weil Schreibrecht besteht). Das ist die einfachste wirksame Regel und kostet nichts; sie ist eine Disziplin-Regel, kein technischer Schutz: Eine einzige Freigabe vertraut dem Nutzer den Runner dauerhaft an (siehe Vertrauenslücke).

Weiterhin offen und vor dem ersten externen Beitrag, spätestens vor dem Release zu entscheiden: Prüfung A (Verhalten live prüfen), Maßnahme B (Runner isolieren) oder das Repo privat stellen. Issue #43 bleibt bis dahin offen.

Anleitung für Chris (nicht automatisiert; nichts davon wurde ausgeführt)

A. Verhalten prüfen (10 Minuten, empfohlen als Erstes)

  1. Zweites Forgejo-Konto ohne Schreibrecht anlegen (oder Test-Nutzer), Repo forken, im Fork ci.yml ändern (if:-Zeilen entfernen, harmlosen Schritt echo hallo ergänzen), PR gegen main öffnen.
  2. Erwartung: Lauf steht auf „Wartet auf Freigabe“ und startet erst nach Klick auf Freigeben (PR-Seite bzw. Actions-Lauf). Falls er ohne Freigabe startet (Quelltext sagt anderes): Das Repo sofort privat stellen (Einstellungen -> Repository -> Sichtbarkeit) und Runner pausieren (B.3), dann Forgejo-Version/Doku prüfen.
  3. Zweiten Push im selben PR machen: Läuft er jetzt ohne erneute Freigabe? Dann ist „einmal freigegeben, immer freigegeben“ bestätigt.
  4. Aufräumen: Testkonto und Fork löschen (dauerhaft vertrautes Konto!). Falls das nicht geht: mindestens restricted setzen und Anmeldung verbieten (Administration -> Benutzerkonten).

Hinweis: Der Test mit restricted (jeder Lauf einzeln freigegeben) ist eventuell nicht durchführbar, weil eingeschränkte Nutzer vermutlich keine öffentlichen Repos ohne Zuordnung sehen (unverifiziert, siehe C.2). Dann entfällt er; die Quelltext-Aussage bleibt.

B. Auf dem Mac (Runner/dind)

  1. Labels sind keine Grenze: Ein Fork-PR kann runs-on: docker-host selbst setzen. Eine Trennung docker / docker-host im Workflow schützt deshalb nichts, und container.network/valid_volumes der Runner-Konfiguration gelten für den ganzen Runner, nicht je Label. Schutz bieten nur Freigabe, ein Daemon/VM ohne Wertvolles (Punkt 2) oder ein privates Repo. Sollen docker und docker-host unterschiedlich exponiert sein, braucht es getrennte Runner-Registrierungen mit eigener Konfiguration, idealerweise auf getrennten Hosts. Prüfen kannst du, ob ein docker-Job den dind erreicht (curl -s -m 3 http://<Default-Gateway>:2375/version, Gateway wie in ci.yml aus /proc/net/route); antwortet er, gilt die Warnung für alle Jobs des Runners.
  2. Wirksam: Runner in eine Wegwerf-VM (z. B. Lima/Colima/UTM) oder auf einen separaten Rechner/Hetzner-Server verlagern, ohne Host-Mounts, ohne Zugriff aufs LAN, ohne Zugangsdaten und ohne persönliche Daten auf dem Mac. Ziel: Ein Ausbruch aus dem dind trifft nichts Wertvolles. Eigenes Registrierungs-Token verwenden; .runner und Token nicht ins Repo und nicht in Logs.
  3. Notbremse: Runner-Prozess auf dem Mac beenden (z. B. launchctl unload des Dienstes oder Prozess/Container stoppen; der genaue Weg hängt von der Einrichtung ab, unverifiziert). In Forgejo (Einstellungen -> Actions -> Runner) gibt es in 8.0.3 evtl. nur Bearbeiten/Löschen statt Deaktivieren (unverifiziert); Löschen macht den Runner unbrauchbar, bis er neu registriert wird.
  4. Runner nur für dieses Repo registrieren (Repo-Scope statt Instanz-/Nutzer-Scope), damit andere Repos ihn nicht ansprechen können.

C. In Forgejo

  1. Fork-PR-Läufe nicht freigeben (siehe Entscheidung 2.).
  2. Fremde Konten als restricted zu führen ist mit Vorsicht zu genießen: Eingeschränkte Nutzer sehen vermutlich keine öffentlichen Repos ohne explizite Zuordnung (unverifiziert). Dann könnten sie keinen Fork/PR anlegen, d. h. restricted sperrt externe Beiträge faktisch aus. Das ist eine Alternative zum Repo-privat-Stellen, kein Mittel für „jeder Lauf einzeln freigeben“. Erst mit dem Testkonto aus A prüfen.
  3. Optional: Branch-Schutz für main mit geschützten Dateimustern (UI-Bezeichnung in 8.0.3 unverifiziert, bei Gitea „Protected file patterns“) .forgejo/**;e2e/**;supabase/config.toml. Achtung, vermutlich blockiert das auch das Mergen von PRs, die solche Dateien ändern, also auch deine eigenen CI-PRs (unverifiziert); nur nutzen, wenn du dafür eine Ausnahme (Admin-Merge/Whitelist) hast, sonst weglassen. Es schützt nur main, nicht die PR-Läufe.
  4. Repo privat stellen, wenn externe Beiträge nicht gewollt sind (löst das Problem vollständig).
  5. Runner-Token rotieren (Registrierungs-Token ist kein Dauer-Geheimnis, das .runner auf dem Mac enthält das Laufzeit-Token): Runner in Forgejo (Einstellungen -> Actions -> Runner) löschen, im selben Dialog ein neues Registrierungs-Token erzeugen, den Runner auf dem Mac mit forgejo-runner register neu registrieren und die alte .runner löschen. Vorschlag: alle 3 Monate sowie sofort bei Verdacht. Token nie in Chats, Logs oder ins Repo kopieren.

Konsequenzen

  • Fork-PRs bekommen ohne Freigabe kein CI (der ganze Lauf wartet, mit allen Jobs). Eine Freigabe vertraut dem Nutzer den ganzen Runner dauerhaft an. Fremde Beiträge daher als Branch ins Haupt-Repo übernehmen (dort läuft CI ohne Freigabe, weil Schreibrecht).
  • Externe Beiträge laufen nicht auf docker-host, bis der Runner isoliert ist oder das Repo privat ist.
  • Der if:-Guard ändert daran nichts (siehe Entscheidung 1).
  • Offen: Ergebnis der Prüfung A und Umsetzung B. Bis dahin Issue #43 nicht schließen.