Skip to content

fix(orchestrator): refuse jobs whose run comes from a fork - #26

Merged
levivannoort merged 3 commits into
mainfrom
fix/refuse-fork-runs
Oct 13, 2026
Merged

levivannoort merged 3 commits into
mainfrom
fix/refuse-fork-runs

Conversation

@levivannoort

@levivannoort levivannoort commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

A pull request runs its own copy of the workflow, so a fork can rewrite runs-on to ask for any gcrunner label. The orchestrator checked the webhook signature and the label, then created a VM: on a public repository, anyone could run code on a VM in our project by opening a pull request. The runs-on expression appwrite/appwrite uses to send fork pull requests to ubuntu-24.04 only steers well-behaved ones; it lives in the fork's copy of the workflow. Raised by hansi-codes on appwrite/helm-chart#18 (thread), which moves that repository to gcrunner shortly before it goes public.

Before resolving the runner, provisionJob now looks up the job's run (GET /repos/{owner}/{repo}/actions/runs/{id}, already covered by actions: write) and refuses the job unless head_repository is the repository itself, compared case-insensitively. A run with no head repository (the fork was deleted) is refused too. The refusal goes through the existing configuration-error path: a failed gcrunner check on the commit saying why, and the run cancelled. A failed lookup is a normal GitHub error and is retried.

  • Applies to private repositories too: fork pull request workflows only run there if an admin turned them on, and then the same reasoning holds.
  • Same-repository pull requests, pushes, schedules and manual runs have the repository as their head repository and are unaffected.
  • One installation token and run lookup per run, not per job: the source is cached per run for six hours (it never changes for a run), and concurrent jobs of one run share a single request (singleflight). Failed lookups aren't cached. fix(orchestrator): count a VM that deleted itself as deleted #25's numbers put production at ~2,500 jobs an hour, so a per-job lookup would have added that many installation API calls.
  • website/content/docs/security.md gets a Jobs From Forks section with the fallback expression.

Testing

  • go vet and go test -count=1 ./... pass.
  • New tests through the fake GitHub: a fork run and a deleted-fork run get one failed check and one cancel and no VM, with a config and zones that would otherwise provision; a run from the repository itself passes (case-insensitive); a failed run lookup returns 500 for a retry without failing the run.
  • Cache: three jobs of one run make one lookup, an expired entry is looked up again, a failed lookup is retried rather than remembered. -race on the new tests is clean.
  • With the comparison disabled, TestARunFromAnotherRepositoryGetsNoVM fails.
  • Existing tests reach the lookup through runSource, which TestMain points at the job's own repository.

After deploying

  • Watch the orchestrator logs for comes from: a refused job logs Job N: configuration error: run R comes from <fork> …, failing the run. On appwrite/appwrite, fork pull requests already ask for ubuntu-24.04, so there should be none unless a fork changed runs-on.
  • /task/queued latency grows by one GitHub request for the first job of each run.

A pull request runs its own copy of the workflow, so a fork can rewrite
runs-on to ask for any gcrunner label, and gcrunner provisioned every queued
job with one. An expression in runs-on that sends fork pull requests to
GitHub-hosted runners only steered well-behaved ones.

Before resolving the runner, the orchestrator now looks up the job's run and
refuses it unless its head repository is the repository itself (a deleted
fork leaves none, and is refused too). The refusal goes through the
configuration-error path: a failed gcrunner check saying why, and the run
cancelled. A failed lookup is retried like any other GitHub error.

Tests reach the lookup through runSource, which TestMain points at the job's
own repository, so the tests that are not about it need no GitHub API.
@hansi-codes

hansi-codes Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🔵 Tier A · Mergeable after minor fixes

The new singleflight behavior is not exercised by concurrent calls, leaving the lookup-coalescing guarantee unverified.

The orchestrator checks each queued job’s workflow-run source and rejects runs originating from a different repository before runner resolution or VM provisioning. It caches run-source lookups and coalesces concurrent lookups, adds fake-GitHub tests for the protection and cache behavior, and documents the fork-run policy and fallback workflow expression.

Latest changes: The newest commits add a six-hour run-source cache with singleflight coalescing, plus tests for cache expiry and retrying failed lookups.

Verdict New comments Fixed Still open
✅ Approved 1 0 0
Finding Where
🟡 Exercise concurrent run-source lookups orchestrator/run_source_test.go:87
Fix with agent prompt
### Issue 1
orchestrator/run_source_test.go:87-90
**Exercise concurrent run-source lookups**

These calls are sequential, so they only verify cache hits; a regression that stops coalescing simultaneous jobs would still pass. The comparable config cache has a concurrent-miss test, so a blocked fake lookup with overlapping callers would verify this new guarantee.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 5
File Change
orchestrator/github.go Adds workflow-run source data and a six-hour, singleflight-backed cache for run-source lookups.
orchestrator/webhook.go Rejects queued jobs whose workflow run comes from another repository before resolving configuration or provisioning a VM.
orchestrator/preemption_test.go Extends the fake GitHub API with run-source responses, lookup failures, and lookup counting.
orchestrator/run_source_test.go Tests fork/deleted-fork rejection, same-repository acceptance, retryable lookup failures, and cache expiry/retry behavior.
website/content/docs/security.md Documents the fork-run restriction and a GitHub-hosted runner fallback expression.

Reviewed the commits since d074cfe · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

Production queues about 2,500 jobs an hour, many sharing a run. The head
repository of a run never changes, so lookups are cached per run for six
hours and concurrent jobs of one run share a single request. Failed lookups
are not cached.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment on lines +87 to +90
for id := int64(1); id <= 3; id++ {
event := WorkflowJobEvent{WorkflowJob: WorkflowJob{ID: id, RunID: 7}, Repository: cloudRepository}
if err := checkRunSource(context.Background(), event); err != nil {
t.Fatalf("job %d refused: %v", id, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exercise concurrent run-source lookups

These calls are sequential, so they only verify cache hits; a regression that stops coalescing simultaneous jobs would still pass. The comparable config cache has a concurrent-miss test, so a blocked fake lookup with overlapping callers would verify this new guarantee.

Prompt To Fix With AI
This is a comment left during a code review.
Path: orchestrator/run_source_test.go
Line: 87-90

Comment:
**Exercise concurrent run-source lookups**

These calls are sequential, so they only verify cache hits; a regression that stops coalescing simultaneous jobs would still pass. The comparable config cache has a concurrent-miss test, so a blocked fake lookup with overlapping callers would verify this new guarantee.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · testing · Reply if this doesn't apply.

@levivannoort
levivannoort merged commit a7a0c17 into main Oct 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant