DESIGN 0004: GitHub Webhook IP Allowlist Middleware
Status: Implemented Author: Donald Gifford Date: 2026-03-14
Overview
Add an HTTP middleware to the webhook handler that rejects requests from IP addresses outside GitHub's published webhook IP ranges. This provides defense-in-depth on top of the existing HMAC signature validation -- non-GitHub traffic is rejected before any payload processing occurs.
Goals and Non-Goals
Goals
- Reject webhook requests from non-GitHub source IPs with a 403 response.
- Fetch GitHub's webhook IP ranges from the
/metaAPI endpoint. - Cache the IP ranges and refresh periodically (they change infrequently).
- Handle
X-Forwarded-For/ proxy headers correctly for deployments behind a load balancer or Tailscale Funnel. - Make the middleware optional via configuration so it can be disabled in environments where IP filtering is not needed or handled externally.
Non-Goals
- Replace HMAC signature validation -- both layers work together.
- Block non-webhook routes (health checks, metrics should remain open).
- Support non-GitHub webhook sources.
Background
INV-0001: Tailscale Funnel for Webhook Testing identified that Tailscale Funnel exposes the webhook endpoint to the public internet with no IP-level filtering. While HMAC validation rejects unauthorized payloads, adding IP allowlisting reduces the attack surface by dropping non-GitHub traffic before it reaches application code.
GitHub publishes its webhook source IP ranges at https://api.github.com/meta
in the hooks field. These are CIDR blocks that can be used for allowlisting.
Detailed Design
Middleware Architecture
Incoming request
|
v
[IP Allowlist Middleware] -- 403 if source IP not in GitHub ranges
|
v
[Webhook Handler] -- 401 if HMAC signature invalid
|
v
[Event Processing]
The middleware wraps only the webhook route (/webhooks/github). Health and
metrics endpoints are not affected.
GitHub Meta API
GET https://api.github.com/meta
Response (relevant fields):
{
"hooks": [
"192.30.252.0/22",
"185.199.108.0/22",
"140.82.112.0/20",
"143.55.64.0/20",
...
]
}
IP Range Cache
type GitHubIPAllowlist struct {
mu sync.RWMutex
networks []*net.IPNet
logger *slog.Logger
}
- Initialization: Fetch from
/metaon startup. If the fetch fails, log a warning and allow all traffic (fail-open) until the first successful refresh. - Refresh: Background goroutine refreshes every 24 hours. GitHub's IP ranges change infrequently.
- Thread safety:
sync.RWMutexprotects the network list. Reads are concurrent; writes happen only during refresh.
Source IP Extraction
The middleware must handle multiple deployment scenarios:
- Direct connection (e.g., Tailscale Funnel): Use
r.RemoteAddr. - Behind a load balancer (e.g., ALB in EKS): Use
X-Forwarded-Forheader, taking the last trusted entry.
Configuration option: TRUST_PROXY_HEADERS (default: false). When true,
the middleware reads X-Forwarded-For. When false, it uses r.RemoteAddr
directly.
For Tailscale Funnel specifically, the RemoteAddr will be the Funnel proxy
IP (localhost/pod IP), not GitHub's IP. The actual client IP may be in
X-Forwarded-For or Tailscale-Forwarded-For. This needs to be verified
during implementation.
Middleware Function
func (a *GitHubIPAllowlist) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := a.extractIP(r)
if !a.IsAllowed(ip) {
a.logger.Warn("rejected request from non-GitHub IP",
"remote_ip", ip,
"path", r.URL.Path,
)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Configuration
| Variable | Default | Description |
|---|---|---|
WEBHOOK_IP_ALLOWLIST |
true |
Enable GitHub IP allowlist middleware |
TRUST_PROXY_HEADERS |
false |
Read client IP from X-Forwarded-For |
API / Interface Changes
No changes to the Client interface or public API. The middleware is wired
in main.go when setting up the webhook route.
Files to Change
| Action | File | Description |
|---|---|---|
| Create | internal/webhook/allowlist.go |
GitHubIPAllowlist struct, meta fetch, middleware |
| Create | internal/webhook/allowlist_test.go |
Unit tests |
| Modify | internal/config/config.go |
Add WebhookIPAllowlist and TrustProxyHeaders fields |
| Modify | cmd/repo-guardian/main.go |
Wire middleware on webhook route |
| Modify | internal/metrics/metrics.go |
Add webhook_rejected_total counter (label: reason) |
Testing Strategy
Unit tests (allowlist_test.go)
- Allowed IP: Request from a GitHub CIDR range -- passes through.
- Blocked IP: Request from a non-GitHub IP -- returns 403.
- IPv6 support: GitHub CIDRs include IPv6 ranges.
- X-Forwarded-For: With
TrustProxyHeaders=true, reads from header. - Cache refresh: Mock
/metaendpoint, verify refresh updates ranges. - Fetch failure:
/metaunreachable -- fails open, logs warning. - Disabled:
WebhookIPAllowlist=false-- all requests pass through.
Integration test
- Deploy to Talos cluster with middleware enabled.
- Verify GitHub webhooks still pass through.
curlthe webhook endpoint directly -- expect 403.
Open Questions
All resolved.
- ~~Tailscale Funnel and source IP~~: Resolved. Funnel sets
RemoteAddrto127.0.0.1(sidecar localhost proxy) and forwards the original client IP in theX-Forwarded-Forheader. It also setsTailscale-Funnel-Request: ?1to indicate traffic arrived via Funnel. The Tailscale overlay requiresTRUST_PROXY_HEADERS=true.
Decisions
- Fail-closed by default: If the
/metafetch fails on startup, the middleware rejects all webhook traffic until a successful refresh. This is the more secure default. Configurable viaWEBHOOK_IP_ALLOWLIST_FAIL_OPEN(default:false) for environments where availability is preferred over strict security.