IMPL 0003: GitHub Webhook IP Allowlist Middleware
Status: Completed Author: Donald Gifford Date: 2026-03-14
Objective
Implement an HTTP middleware that rejects webhook requests from IP addresses outside GitHub's published webhook CIDR ranges. Provides defense-in-depth on top of existing HMAC signature validation.
Implements: DESIGN-0004: GitHub Webhook IP Allowlist Middleware
Scope
In Scope
- Fetch and cache GitHub webhook IP ranges from
/metaAPI - HTTP middleware that checks source IP against cached ranges
- Fail-closed by default, configurable fail-open
X-Forwarded-Forsupport for proxy deployments- Configuration via environment variables
- Prometheus metric for rejected requests
- Determine how Tailscale Funnel forwards client IPs
Out of Scope
- Blocking non-webhook routes (health, metrics stay open)
- Replacing HMAC validation (both layers coexist)
- Supporting non-GitHub webhook sources
Implementation Phases
Each phase builds on the previous one. A phase is complete when all its tasks are checked off and its success criteria are met.
Phase 1: Configuration
Add three new environment variables to the config package.
Tasks
- [x]
internal/config/config.go: - [x] Add
WebhookIPAllowlist boolfield- Env var:
WEBHOOK_IP_ALLOWLIST, default:true
- Env var:
- [x] Add
WebhookIPAllowlistFailOpen boolfield- Env var:
WEBHOOK_IP_ALLOWLIST_FAIL_OPEN, default:false
- Env var:
- [x] Add
TrustProxyHeaders boolfield- Env var:
TRUST_PROXY_HEADERS, default:false
- Env var:
- [x] Load all three in
Load()using existingenvOrDefaultBoolhelper - [x]
internal/config/config_test.go: - [x] Test default values: allowlist=true, fail-open=false, trust-proxy=false
- [x] Test env var overrides for each field
- [x] Test invalid boolean values return parse errors
Success Criteria
go test -v -race ./internal/config/...passes with new test cases- All three fields default correctly when env vars are unset
make checkpasses
Phase 2: Metrics
Add a Prometheus counter for rejected webhook requests.
Tasks
- [x]
internal/metrics/metrics.go: - [x] Add
WebhookRejectedTotalcounter vec withreasonlabel- Name:
repo_guardian_webhook_rejected_total - Help:
"Webhook requests rejected by IP allowlist." - Label values:
"ip_not_allowed","allowlist_unavailable"
- Name:
Success Criteria
make buildcompiles without errorsmake lintpasses- Metric follows
repo_guardian_naming convention
Phase 3: Core Allowlist
Implement the GitHubIPAllowlist struct with IP range fetching, caching, and
IP matching. This phase does not include the HTTP middleware.
Tasks
- [x]
internal/webhook/allowlist.go(new file): - [x] Define
GitHubIPAllowliststruct with fields:mu sync.RWMutexfor thread-safe accessnetworks []*net.IPNetfor parsed CIDR rangesloaded boolto track whether initial fetch succeededfailOpen boolfor fail-open/fail-closed behaviortrustProxy boolforX-Forwarded-Forsupportlogger *slog.LoggermetaURL string(defaults tohttps://api.github.com/meta, overridable for tests)
- [x] Define
metaResponsestruct for JSON unmarshaling:Hooks []string \json:"hooks"``
- [x] Constructor:
NewGitHubIPAllowlist(failOpen, trustProxy bool, logger *slog.Logger) *GitHubIPAllowlist - [x]
fetchRanges(ctx context.Context) ([]*net.IPNet, error):- HTTP GET to
metaURLwith 10s timeout - Parse JSON, extract
hooksfield - Parse each CIDR via
net.ParseCIDR, skip invalid with warning log - Return parsed networks
- HTTP GET to
- [x]
Refresh(ctx context.Context) error:- Call
fetchRanges - On success: write-lock, update
networks, setloaded = true - On failure: log error, keep previous ranges intact
- Call
- [x]
StartRefresh(ctx context.Context):- Call
Refreshonce synchronously (startup fetch) - Launch background goroutine with 24h ticker for periodic refresh
- Respect
ctx.Done()for shutdown
- Call
- [x]
IsAllowed(ip net.IP) bool:- Read-lock
- If not loaded: return
failOpenvalue - Iterate networks, return
trueon match - Return
falseif no match
- [x]
extractIP(r *http.Request) net.IP:- If
trustProxyandX-Forwarded-Forpresent: parse leftmost IP - Otherwise: parse from
r.RemoteAddrvianet.SplitHostPort - Return
nilon parse failure
- If
Success Criteria
make buildcompiles without errorsmake lintpassesIsAllowedcorrectly matches IPs against CIDR rangesextractIPhandles both direct and proxied scenarios- Thread-safe: read lock for checks, write lock for refresh
Phase 4: HTTP Middleware
Add the Middleware method that wraps an http.Handler.
Tasks
- [x]
internal/webhook/allowlist.go(add to existing file): - [x]
Middleware(next http.Handler) http.Handler:- Extract IP via
extractIP(r) - If IP is nil: log warning, increment
WebhookRejectedTotal("ip_not_allowed"), return 403 - If
!IsAllowed(ip): - Choose label:
"allowlist_unavailable"if not loaded, else"ip_not_allowed" - Increment
WebhookRejectedTotalwith label - Log warning with
remote_ipandpath - Return 403
"forbidden" - Otherwise: call
next.ServeHTTP(w, r)
- Extract IP via
Success Criteria
- Middleware returns 403 for non-GitHub IPs
- Middleware passes through for GitHub IPs
- Correct metric labels used for each rejection reason
- Health/metrics endpoints not affected (middleware only wraps webhook route)
Phase 5: Wiring
Wire the middleware into main.go on the webhook route only.
Tasks
- [x]
cmd/repo-guardian/main.go: - [x] If
cfg.WebhookIPAllowlist:- Create
webhook.NewGitHubIPAllowlist(cfg.WebhookIPAllowlistFailOpen, cfg.TrustProxyHeaders, logger) - Call
allowlist.StartRefresh(ctx) - Wrap:
webhookHandler = allowlist.Middleware(webhookHandler)before passing tonewMainServer - Log:
"webhook IP allowlist enabled"withfail_openandtrust_proxy
- Create
- [x] If
!cfg.WebhookIPAllowlist:- Log:
"webhook IP allowlist disabled" - Pass unwrapped
webhookHandlertonewMainServer
- Log:
Success Criteria
make buildcompiles- App starts with middleware enabled by default
- App starts without middleware when
WEBHOOK_IP_ALLOWLIST=false - Startup logs show allowlist configuration
make checkpasses
Phase 6: Tests
Comprehensive test coverage for the allowlist.
Tasks
- [x]
internal/webhook/allowlist_test.go(new file): - [x]
TestIsAllowed_GitHubIP-- IP within range returns true - [x]
TestIsAllowed_NonGitHubIP-- IP outside ranges returns false - [x]
TestIsAllowed_IPv6-- IPv6 CIDR matching works - [x]
TestIsAllowed_NotLoaded_FailClosed-- returns false when not loaded, failOpen=false - [x]
TestIsAllowed_NotLoaded_FailOpen-- returns true when not loaded, failOpen=true - [x]
TestRefresh_Success-- mock/meta, verify ranges loaded - [x]
TestRefresh_Failure_KeepsPrevious-- failed refresh keeps old ranges - [x]
TestRefresh_InvalidCIDR_Skipped-- invalid CIDRs logged and skipped - [x]
TestExtractIP_RemoteAddr-- extracts IP from RemoteAddr - [x]
TestExtractIP_XForwardedFor-- extracts leftmost IP when trustProxy=true - [x]
TestExtractIP_XForwardedFor_Ignored-- uses RemoteAddr when trustProxy=false - [x]
TestMiddleware_AllowedIP-- 200 response for GitHub IP - [x]
TestMiddleware_BlockedIP-- 403 response for non-GitHub IP - [x]
TestMiddleware_NotLoaded_FailClosed-- 403 when ranges not loaded
Success Criteria
go test -v -race ./internal/webhook/...passes all test cases- Coverage includes fail-open and fail-closed paths
- Coverage includes trustProxy true and false paths
- No flaky tests (httptest.Server mocks, no timing dependencies)
make checkpasses
Phase 7: Tailscale Funnel IP Investigation
Determine how Tailscale Funnel forwards the original client IP. Resolves DESIGN-0004 Open Question 1.
Tasks
- [x] Deploy to Talos cluster with middleware enabled and
WEBHOOK_IP_ALLOWLIST_FAIL_OPEN=true - [x] Add temporary debug logging in middleware to dump
RemoteAddrand all request headers - [x] Trigger a GitHub webhook and inspect the logs
- [x] Determine correct
TRUST_PROXY_HEADERSsetting for Tailscale overlay - [x] Update Tailscale overlay
deployment-patch.yamlwith the finding - [x] Update DESIGN-0004 to close Open Question 1
Success Criteria
- We know how Funnel forwards client IPs
- Tailscale overlay is configured correctly
- DESIGN-0004 Open Question 1 is answered
Phase 8: Documentation
Update documentation and observability assets.
Tasks
- [x]
CLAUDE.md: addallowlist.goto webhook package description, update metric count - [x] Grafana dashboard (
contrib/grafana/repo-guardian-dashboard.json): add "Webhook Rejected" stat panel - [x] DESIGN-0004: update status from
DrafttoImplemented - [x] This doc (IMPL-0003): update status to
Completed
Success Criteria
- Documentation reflects the implemented feature
make checkpasses
File Changes
| File | Action | Phase | Description |
|---|---|---|---|
internal/config/config.go |
Modify | 1 | Add 3 new config fields |
internal/config/config_test.go |
Modify | 1 | Add config tests |
internal/metrics/metrics.go |
Modify | 2 | Add rejected counter |
internal/webhook/allowlist.go |
Create | 3-4 | Core allowlist + middleware |
cmd/repo-guardian/main.go |
Modify | 5 | Wire middleware |
internal/webhook/allowlist_test.go |
Create | 6 | Test coverage |
Open Questions
-
X-Forwarded-Forparsing -- leftmost vs rightmost: The leftmost IP inX-Forwarded-Foris the original client but can be spoofed. Behind a single trusted proxy (Tailscale Funnel), leftmost is correct. For EKS/ALB with multiple proxy hops, the rightmost-before-ALB entry is more trustworthy. Decision for now: implement leftmost, document the assumption. Revisit for EKS production if needed. -
HTTP client for
/metafetch: Use a plainhttp.Clientwith 10s timeout. The/metaendpoint is unauthenticated and rate-limited separately from the GitHub App API. No need to couple to the GitHub App auth flow. Fetches happen once every 24h. -
Refresh interval configurability: Hardcode 24h. GitHub's IP ranges change very rarely. Can be made configurable later if needed.