8c8f3ab4b3
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 44s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m57s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m21s
## Changes ### Go: IP-level SSRF protection in gitea.Client (primary defense) - Add gitea/ipcheck.go with IsBlockedIP() covering all blocked CIDR ranges: loopback (127.0.0.0/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16, fe80::/10), ULA (fc00::/7), CGN (100.64/10), multicast, reserved, and unspecified ranges. - IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) are normalized before checking. - Add safeDialContext to gitea.Client: resolves DNS, rejects any IP in a blocked CIDR, then dials the resolved IP directly to narrow the DNS rebinding window. NewClient now uses this safe transport by default. - Add WithUnsafeDialer() for test code using httptest.Server (127.0.0.1). - Update NewTestClient helper in export_test.go for all gitea unit tests. - Update SetHTTPClient(nil) to restore the safe transport (not the plain one). ### Go: validate-url subcommand (defense-in-depth for future bash callers) - Add 'review-bot validate-url <url>' subcommand: validates https scheme, no user-info, resolves hostname, rejects any blocked IP. - Exit 0=safe, 1=blocked, 2=validation error/dns failure. - Add outWriter/errWriter vars to main.go for testable output capture. ### action.yml: Python3 IP check in 'Determine version' step - After the https scheme validation, resolve SERVER_URL hostname with socket.getaddrinfo and reject any result where ipaddress.ip_address(ip).is_private/is_loopback/is_link_local/etc. is true. - python3 is required on ubuntu-* runners (noted in existing comments). - Covers the version-check curl that sends ACTION_TOKEN to SERVER_URL. - SERVER_URL for install-step curls is covered by the same pre-check. ### Tests - gitea/ipcheck_test.go: 30+ cases covering all blocked families + public IPs - gitea/client_test.go: safe transport presence, WithUnsafeDialer, SSRF blocking - cmd/review-bot/validateurl_test.go: scheme validation, user-info, exit codes Closes #123
79 lines
2.2 KiB
Go
79 lines
2.2 KiB
Go
// Package gitea provides a client for the Gitea API.
|
|
// ipcheck.go implements IP-level SSRF protection by checking resolved addresses
|
|
// against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
|
|
package gitea
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
)
|
|
|
|
// blockedCIDRs is the list of CIDR ranges that should never be contacted by
|
|
// review-bot. These ranges cover private, loopback, link-local, multicast,
|
|
// and other special-use address spaces that are not reachable from the internet
|
|
// but may be reachable from a self-hosted runner.
|
|
//
|
|
// Based on:
|
|
// - RFC1918 private ranges
|
|
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
|
|
// - RFC4291 IPv6 link-local / loopback
|
|
var blockedCIDRs = func() []*net.IPNet {
|
|
ranges := []string{
|
|
// IPv4 loopback
|
|
"127.0.0.0/8",
|
|
// IPv4 unspecified / "this network"
|
|
"0.0.0.0/8",
|
|
// RFC1918 private ranges
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
|
|
"169.254.0.0/16",
|
|
// IPv4 shared address space (RFC6598, carrier-grade NAT)
|
|
"100.64.0.0/10",
|
|
// IPv4 multicast
|
|
"224.0.0.0/4",
|
|
// IPv4 reserved / broadcast
|
|
"240.0.0.0/4",
|
|
// IPv6 loopback
|
|
"::1/128",
|
|
// IPv6 unspecified
|
|
"::/128",
|
|
// IPv6 link-local
|
|
"fe80::/10",
|
|
// IPv6 unique local (ULA) — RFC4193
|
|
"fc00::/7",
|
|
// IPv6 multicast
|
|
"ff00::/8",
|
|
}
|
|
nets := make([]*net.IPNet, 0, len(ranges))
|
|
for _, r := range ranges {
|
|
_, cidr, err := net.ParseCIDR(r)
|
|
if err != nil {
|
|
// This is a programming error — panic to catch it at startup/test time.
|
|
panic(fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
|
|
}
|
|
nets = append(nets, cidr)
|
|
}
|
|
return nets
|
|
}()
|
|
|
|
// IsBlockedIP reports whether ip is in a blocked address range.
|
|
// It is exported for use by the validate-url subcommand and tests outside
|
|
// this package.
|
|
//
|
|
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
|
|
// IPv4 form before checking so that IPv4 CIDRs catch them.
|
|
func IsBlockedIP(ip net.IP) bool {
|
|
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
|
|
if v4 := ip.To4(); v4 != nil {
|
|
ip = v4
|
|
}
|
|
for _, cidr := range blockedCIDRs {
|
|
if cidr.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|