Repository navigation
Harden webhook ingestion against outsiders - #2097
Merged
Merged
Conversation
Anyone who could reach the anonymous webhook endpoints could start runs, replay deliveries and bury the refusal log: - A fork PR from someone outside a public repository started a run like a member's, with nothing in the payload to tell them apart, and every close/reopen started another run. - GitHub signs the body only, so a captured delivery posted again under a fresh (or no) X-GitHub-Delivery started new runs for as long as the secret was valid. - Every forged-signature post wrote its own audit row with unbounded headers, so fifty of them pushed every genuine refusal off the panel. - The whole body was read before the hook was looked up, with no size or rate limit on the webhook and callback endpoints. PR trigger payloads now carry authorAssociation, isFork and headRepositoryFullName. GitLab sends no standing, so the author's project access level is looked up once per delivery (Developer and above is a member). trigger.pr.opened and trigger.pr.updated gain an 'authors' filter (any | members). Members means whoever made the code what it is: the author, and for new commits on a fork head whoever pushed them. A member can open a PR from an outsider's fork branch, and the outsider's later pushes must not run on the member's standing. GitLab's pusher is looked up like the author; GitHub has no lookup here, so another user's push to a fork head is not admitted. Existing activations set no 'authors'. They admit only members when the delivery says the repository is public or internal, anyone when it says private, and, when it does not say, only members for a fork PR. Internal counts with public because every user of a GitLab instance or GitHub enterprise can fork it and open a PR. So existing activations on public and internal repositories stop running outsiders' PRs; private ones behave as before. A run holds off the next one from the same activation for the same PR at the same head commit for 60 seconds. A close/reopen loop re-sends a head that already ran; a push moves the head, so a member's follow-up push always gets its own run. A delivery that already started its run is skipped as a duplicate before admission, so posting it again (an operator's Redeliver, or a captured body with its own id) cannot restart that window. GitHub deliveries without X-GitHub-Delivery are refused with 400, and a signed body is claimed per hook for 7 days, so the same body under another id starts nothing while a redelivery, which keeps its id, still reaches dispatch. Refusals anyone can cause collapse to one row per hook and reason per day, with headers capped. Webhook bodies are capped at 25 MiB and callbacks at 1 MiB, and the hook is resolved before its body is read. Each remote address gets 10,000 ingress requests a minute across every hook and callback, then 2,000 per (hook or token, address). The address budget is checked first because the per-target key is a value the sender chooses: a sender changing it on every request was never limited and left a limiter behind each time. The callback route admits only the engine's 32-character token shape, so a key can no longer be kilobytes long. Claims live in a new webhook_claim table (migration 0244), taken inside the delivery's transaction so a delivery that rolls back releases them. Expired claims are taken over in place and deleted by a recurring sweep every five minutes that skips rows a delivery holds; purging them inside a delivery held their locks until it committed, and two deliveries could deadlock. Activations are visited in id order so concurrent deliveries take their debounce claims in the same order.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
authorAssociation,isForkandheadRepositoryFullName. For GitLab, the author's project access level is looked up once per delivery; Developer and above counts as a member.trigger.pr.openedandtrigger.pr.updatedgain anauthorsfilter (any|members). Undermembers, new commits on a fork head must also be pushed by the author or by a member. Otherwise a member's PR opened from an outsider's fork would run the outsider's later pushes. GitLab's pusher is looked up; GitHub's cannot be, so another user's push to a fork head is refused.authorsvalue (every existing one) admits only members when the delivery says the repository is public or internal, anyone when it says private, and only members for a fork PR when it doesn't say.RunSourceDispatcher.FireIfMatchesAsync).X-GitHub-Deliveryare refused with 400.{token:length(32)}).webhook_claim(migration 0244) and are taken in the delivery's transaction. Expired rows are deleted byWebhookClaimPurgeRecurringJobevery 5 minutes, usingSKIP LOCKED, in batches that each commit on their own. Before this, each delivery purged expired rows inside its own transaction, and two deliveries could deadlock (40P01).Test plan
SKIP LOCKEDgoes red);pnpm lint,tsc --noEmit -p tsconfig.app.json, vitest (2,366 passed),pnpm build.author_not_memberandpull_request_debouncedrefusal rates on public and internal repositories, and 429s on/api/webhooks/*behind the production proxy.