feat(#123): add IP-level SSRF defense to Gitea client and action

## 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
This commit is contained in:
2026-05-14 07:38:29 +00:00
parent 50facefdd6
commit 63e87d4b08
12 changed files with 963 additions and 51 deletions
+44 -1
View File
@@ -9,7 +9,11 @@
# token exfiltration. API calls use github.api_url; downloads use
# github.server_url. Tokens are never sent to user-supplied URLs.
# - On Gitea (VCS_TYPE=gitea), inputs.vcs-url is validated (https scheme,
# no whitespace/newlines) before use.
# no whitespace/newlines, and DNS resolution to a public IP) before use.
# Python3 resolves the hostname and rejects RFC1918, loopback, link-local,
# and other reserved addresses to prevent SSRF attacks.
# The installed review-bot binary additionally uses a safe HTTP transport
# (DialContext-level IP check) for all Gitea API calls at runtime.
# - action-repo is validated against owner/repo pattern.
# - Tokens are passed via masked environment variables, not step outputs.
#
@@ -185,6 +189,40 @@ runs:
echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2
exit 1
fi
# Additional IP-level SSRF defense: resolve the hostname and reject
# requests to RFC1918, loopback, link-local, and other reserved addresses.
# python3 is required on ubuntu-* runners (see requirements comment above).
# Using 'ipaddress.ip_address(addr).is_private' which covers both IPv4 and IPv6.
SERVER_HOST=$(python3 -c "from urllib.parse import urlparse; print(urlparse('${SERVER_URL}').hostname or '')")
if [ -z "$SERVER_HOST" ]; then
echo "Error: could not extract hostname from SERVER_URL '${SERVER_URL}'" >&2
exit 1
fi
python3 -c "
import socket, ipaddress, sys
host = sys.argv[1]
try:
results = socket.getaddrinfo(host, None)
except socket.gaierror as e:
print(f'Error: DNS lookup failed for {host!r}: {e}', file=sys.stderr)
sys.exit(2)
if not results:
print(f'Error: DNS lookup returned no addresses for {host!r}', file=sys.stderr)
sys.exit(2)
for r in results:
ip_str = r[4][0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
print(f'Error: {host!r} resolves to private/reserved IP {ip_str}', file=sys.stderr)
sys.exit(1)
" "$SERVER_HOST" || {
echo "Error: SERVER_URL '${SERVER_URL}' resolves to a private/reserved IP address" >&2
exit 1
}
fi
# Determine auth token for release API requests
@@ -305,6 +343,11 @@ runs:
ACTION_TOKEN="${ACTION_TOKEN:-}"
BINARY="review-bot-${OS}-${ARCH}"
# SECURITY: SERVER_URL was validated (scheme + IP check) in "Determine version".
# All curl calls in this step use the same SERVER_URL, so that pre-check covers them.
# The installed binary uses safeDialContext for additional defense-in-depth in
# the "Run review" step.
if [ "$VCS_TYPE" = "github" ]; then
# GitHub/GHES: Use REST API for release asset downloads.
# Web release URLs ({server}/.../releases/download/{tag}/{asset}) redirect