feat(#123): add IP-level SSRF defense to Gitea client and action #129
@@ -9,7 +9,13 @@
|
||||
# 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.
|
||||
# The binary also exposes a `validate-url` subcommand for use in any future
|
||||
# shell steps that need to validate a URL before passing it to curl.
|
||||
# - action-repo is validated against owner/repo pattern.
|
||||
# - Tokens are passed via masked environment variables, not step outputs.
|
||||
#
|
||||
@@ -185,6 +191,27 @@ 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).
|
||||
# Use printf to write the script to a temp file so the python lines are valid
|
||||
# YAML (each indented line becomes a printf argument — no unindented code).
|
||||
# SERVER_URL is passed via CHECK_URL env var, never interpolated into python code.
|
||||
|
gpt-review-bot
commented
[NIT] The Python SSRF check uses a list comprehension with side effects ( **[NIT]** The Python SSRF check uses a list comprehension with side effects (`sys.exit(1)`), which hurts readability and can surprise maintainers. A simple for-loop with explicit exits would be clearer and easier to audit.
gpt-review-bot
commented
[NIT] The inline Python script uses a Unicode em dash (—) in an error message. While runners typically use UTF-8, non-ASCII characters in shell-embedded scripts can cause encoding issues in some environments. Consider using a plain hyphen '-' for maximum portability. **[NIT]** The inline Python script uses a Unicode em dash (—) in an error message. While runners typically use UTF-8, non-ASCII characters in shell-embedded scripts can cause encoding issues in some environments. Consider using a plain hyphen '-' for maximum portability.
|
||||
printf '%s\n' \
|
||||
'import socket,ipaddress,sys,os' \
|
||||
'from urllib.parse import urlparse' \
|
||||
'u=os.environ["CHECK_URL"]; h=urlparse(u).hostname' \
|
||||
'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else None' \
|
||||
|
sonnet-review-bot
commented
[NIT] The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This is intentional (defense-in-depth re-check before each curl that sends ACTION_TOKEN) and is documented in the comments. The duplication is justified by the security goal, but a helper function or sourced script would reduce maintenance burden if the check logic ever needs to change. **[NIT]** The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This is intentional (defense-in-depth re-check before each curl that sends ACTION_TOKEN) and is documented in the comments. The duplication is justified by the security goal, but a helper function or sourced script would reduce maintenance burden if the check logic ever needs to change.
sonnet-review-bot
commented
[MINOR] The Python SSRF check script is duplicated verbatim in two places (Determine version step and Install review-bot step). The only difference is the temp file name (_ssrf_check.py vs _ssrf_check_install.py). This is a maintenance burden — a future change to the check logic must be applied twice. Consider extracting it into a shell function or a separate composite step. **[MINOR]** The Python SSRF check script is duplicated verbatim in two places (Determine version step and Install review-bot step). The only difference is the temp file name (_ssrf_check.py vs _ssrf_check_install.py). This is a maintenance burden — a future change to the check logic must be applied twice. Consider extracting it into a shell function or a separate composite step.
|
||||
'try: rs=socket.getaddrinfo(h,None)' \
|
||||
'except socket.gaierror as e: print(f"DNS error: {e}",file=sys.stderr); sys.exit(1)' \
|
||||
'if not rs: print("Error: no addresses",file=sys.stderr); sys.exit(1)' \
|
||||
|
gpt-review-bot
commented
[NIT] In the Python SSRF check, the CGN network object (ipaddress.ip_network("100.64.0.0/10")) is constructed inside the loop for each address. Consider moving it outside the loop for minor efficiency and readability. **[NIT]** In the Python SSRF check, the CGN network object (ipaddress.ip_network("100.64.0.0/10")) is constructed inside the loop for each address. Consider moving it outside the loop for minor efficiency and readability.
|
||||
'[sys.exit(1) or print(f"blocked: {a}",file=sys.stderr) for _,_,_,_,(a,*_) in rs if (lambda ip:ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved)(ipaddress.ip_address(a))]' \
|
||||
|
[MAJOR] The Python preflight SSRF check does not block Carrier-Grade NAT (100.64.0.0/10). ipaddress.IPv4Address.is_private returns False for CGN, and is_reserved does not cover it. An attacker could supply a hostname resolving to 100.64/10 and bypass this pre-check, potentially sending ACTION_TOKEN to non-public infrastructure. **[MAJOR]** The Python preflight SSRF check does not block Carrier-Grade NAT (100.64.0.0/10). ipaddress.IPv4Address.is_private returns False for CGN, and is_reserved does not cover it. An attacker could supply a hostname resolving to 100.64/10 and bypass this pre-check, potentially sending ACTION_TOKEN to non-public infrastructure.
|
||||
> /tmp/_ssrf_check.py
|
||||
|
[MINOR] The Python pre-check prints the blocked IP address to stderr (print(f"blocked: {a}")), which can leak internal IPs in CI logs. Consider removing or masking the exact IP in logs to reduce information disclosure while still failing safely. **[MINOR]** The Python pre-check prints the blocked IP address to stderr (print(f"blocked: {a}")), which can leak internal IPs in CI logs. Consider removing or masking the exact IP in logs to reduce information disclosure while still failing safely.
|
||||
CHECK_URL="${SERVER_URL}" python3 /tmp/_ssrf_check.py || {
|
||||
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 +332,11 @@ runs:
|
||||
ACTION_TOKEN="${ACTION_TOKEN:-}"
|
||||
|
security-review-bot marked this conversation as resolved
[MINOR] The Install review-bot step relies on the SERVER_URL IP check performed earlier in the Determine version step. A DNS rebinding between steps could result in curl sending the ACTION_TOKEN to a newly-resolved private/reserved IP. Re-running the IP validation (e.g., via the validate-url subcommand or the same Python check) at the start of the Install step would narrow this window. **[MINOR]** The Install review-bot step relies on the SERVER_URL IP check performed earlier in the Determine version step. A DNS rebinding between steps could result in curl sending the ACTION_TOKEN to a newly-resolved private/reserved IP. Re-running the IP validation (e.g., via the validate-url subcommand or the same Python check) at the start of the Install step would narrow this window.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Plan: Issue #123 — SSRF Defense: IP-Level URL Validation
|
||||
|
sonnet-review-bot
commented
[NIT] PLAN.md is committed to the repository. Planning documents are typically kept out of version control (or in a docs/ directory) since they describe intent rather than implementation. The checklist items are outdated (showing unchecked boxes for things that are now implemented). This is a repo hygiene concern, not a code quality issue. **[NIT]** PLAN.md is committed to the repository. Planning documents are typically kept out of version control (or in a docs/ directory) since they describe intent rather than implementation. The checklist items are outdated (showing unchecked boxes for things that are now implemented). This is a repo hygiene concern, not a code quality issue.
|
||||
|
||||
## Problem
|
||||
|
||||
When running on a self-hosted Gitea runner, `action.yml` accepts a user-supplied `vcs-url` input and uses it to make HTTP requests (version lookup, binary download) from within the runner environment. An attacker who controls workflow inputs could point this URL at internal services (10.x.x.x, 169.254.x.x, 127.0.0.1, etc.) and exfiltrate data.
|
||||
|
||||
Current mitigations (scheme check + whitespace rejection in bash) are insufficient because they operate on the URL string, not on the resolved IP addresses. DNS rebinding and creative hostnames can bypass string-level checks.
|
||||
|
||||
The existing Go binary (`review-bot`) also makes requests to `vcs-url` directly via `gitea.NewClient`. That client has redirect protections but no IP-level SSRF defense.
|
||||
|
||||
## What This Issue Does NOT Cover
|
||||
|
||||
- LLM base URLs / AI Core auth/API URLs — those are future work, out of scope for this issue.
|
||||
- GitHub Actions path (`VCS_TYPE=github`) — already uses `github.api_url` (trusted, not user-controlled), so SSRF is not applicable there.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No new dependencies.** `CONVENTIONS.md` has a strict allowlist; `net` is stdlib.
|
||||
- Must not break existing functionality for legitimate Gitea instances.
|
||||
- Must handle DNS resolution correctly (some CIDRs block connection but resolve fine — we must check before connecting).
|
||||
- Must work on both linux/amd64 and linux/arm64 runner environments.
|
||||
- Go stdlib `net` package provides everything needed.
|
||||
|
||||
## Acceptance Criteria (from issue)
|
||||
|
||||
- [x] Decide on approach → **Go custom `DialContext` in `gitea.Client`**
|
||||
- [ ] Implement URL validation that blocks requests to RFC1918/loopback/link-local IPs
|
||||
- [ ] Block HTTP redirects to non-https or different hosts (already done in `defaultCheckRedirect`)
|
||||
- [ ] Add tests
|
||||
|
||||
## Approach Decision: Custom DialContext in `gitea.Client`
|
||||
|
||||
Three options were considered:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| Bash `dig`/`getent` + IP checks | No Go change | Fragile, platform-dependent, bypass-prone, hard to test |
|
||||
| `review-bot validate-url` subcommand | Reusable from bash | Two phases (validate-then-dial) has TOCTOU; adding a subcommand complicates main.go |
|
||||
| **Custom `DialContext` in `gitea.Client`** | Atomic: resolves and validates IP in one step; no TOCTOU; testable in Go; no new deps | Defense-in-depth only in Go path, not bash curl calls |
|
||||
|
||||
**Decision: Custom `DialContext` + bash-side pre-check for the install step.**
|
||||
|
||||
Rationale:
|
||||
- The `review-bot` binary already makes the sensitive Gitea API calls. Protecting them at the `DialContext` level is the most robust defense — it's atomic and cannot be bypassed.
|
||||
- The bash `curl` in the "Determine version" and "Install review-bot" steps makes requests to user-supplied URLs. The Go binary isn't involved there. Options:
|
||||
- Accept residual risk for the install-step curl calls (the token used is `reviewer-token`, not a secrets-level token).
|
||||
- Add a `review-bot validate-url` subcommand that the bash calls before each curl.
|
||||
- **We add `review-bot validate-url` as a lightweight subcommand** to protect the install-step curl calls too.
|
||||
|
||||
Both defenses together (DialContext + validate-url subcommand) give full coverage. The validate-url path only needs to resolve the domain and check the resulting IPs — no additional state.
|
||||
|
||||
## Design: `validate-url` Subcommand
|
||||
|
||||
`review-bot validate-url <url>` exits 0 if safe, non-zero + error message if blocked.
|
||||
|
||||
```
|
||||
usage: review-bot validate-url <url>
|
||||
|
||||
Resolves <url> and checks that all resolved IP addresses are
|
||||
routable (not RFC1918, loopback, link-local, or special-use).
|
||||
|
||||
Exit codes:
|
||||
0 — URL is safe to use
|
||||
1 — URL resolves to a blocked address
|
||||
2 — URL validation error (bad scheme, malformed URL, DNS failure)
|
||||
```
|
||||
|
||||
This command is tiny — no LLM logic, no Gitea auth. The bash in `action.yml` can call it as:
|
||||
|
||||
```bash
|
||||
"${{ runner.temp }}/review-bot" validate-url "$SERVER_URL" || {
|
||||
echo "Error: SERVER_URL resolves to a blocked/private IP" >&2
|
||||
exit 1
|
||||
}
|
||||
```
|
||||
|
||||
## Design: IP Validation Logic (Shared)
|
||||
|
||||
A single `internal/ipcheck` package (or unexported function in `gitea`) will:
|
||||
|
||||
1. Parse the URL: extract host + port.
|
||||
2. Call `net.DefaultResolver.LookupIPAddr(ctx, host)` to resolve all IPs.
|
||||
3. For each IP, call `isBlockedIP(ip)`:
|
||||
- Loopback: `127.0.0.0/8`, `::1/128`
|
||||
- RFC1918: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
|
||||
- Link-local: `169.254.0.0/16`, `fe80::/10`
|
||||
- Unique local (IPv6 ULA): `fc00::/7`
|
||||
- Multicast: `224.0.0.0/4`, `ff00::/8`
|
||||
- Loopback/unspecified: `0.0.0.0/8`, `::`
|
||||
4. If **any** resolved IP is blocked → reject.
|
||||
5. If **zero** IPs are returned → reject (NXDOMAIN shouldn't be allowed through).
|
||||
6. Also validate: scheme must be `https`, no user info in URL.
|
||||
|
||||
This logic lives in a standalone file (`gitea/ipcheck.go` or `cmd/review-bot/ipcheck.go`) so it can be used from both the `DialContext` interceptor and the `validate-url` subcommand.
|
||||
|
||||
**Why reject if ANY IP is blocked:** An attacker using split-horizon DNS can return both a public IP and a private IP. If we only block "all IPs private", one real IP clears the check. We must block if any resolved IP is private.
|
||||
|
||||
## Design: Custom `DialContext` in `gitea.Client`
|
||||
|
||||
```go
|
||||
// safeDialer wraps net.Dialer.DialContext with IP-level SSRF protection.
|
||||
// It resolves the hostname and checks all IPs before connecting.
|
||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
|
||||
}
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DNS lookup %q: %w", host, err)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if isBlockedIP(a.IP) {
|
||||
return nil, fmt.Errorf("blocked: %q resolves to private/reserved IP %s", host, a.IP)
|
||||
}
|
||||
}
|
||||
// Re-dial using the first resolved IP directly to avoid double-resolve
|
||||
d := &net.Dialer{Timeout: 10 * time.Second}
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))
|
||||
}
|
||||
```
|
||||
|
||||
`NewClient` applies this as the transport's `DialContext`:
|
||||
|
||||
```go
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
}
|
||||
return &Client{
|
||||
...
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: defaultCheckRedirect,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This only protects Gitea client calls. The `llm` and `github` clients are not changed in this PR (out of scope per issue).
|
||||
|
||||
## Files to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `gitea/ipcheck.go` (new) | `isBlockedIP(ip net.IP) bool` + blocked CIDR list |
|
||||
| `gitea/ipcheck_test.go` (new) | Tests for all blocked/allowed CIDRs |
|
||||
| `gitea/client.go` | Add `safeDialContext`, apply to `NewClient` transport; add `WithUnsafeDialer` for tests |
|
||||
| `gitea/client_test.go` | Tests for SSRF blocking in `NewClient` |
|
||||
| `cmd/review-bot/main.go` | Add `validate-url` subcommand routing |
|
||||
| `cmd/review-bot/validateurl.go` (new) | `runValidateURL(args []string) error` |
|
||||
| `cmd/review-bot/validateurl_test.go` (new) | Unit tests for validate-url subcommand |
|
||||
| `.gitea/actions/review/action.yml` | Call `validate-url` before the version-check and download curl calls |
|
||||
|
||||
## State / Data Model
|
||||
|
||||
No persistent state. This is stateless request-time validation.
|
||||
|
||||
CIDR list is a compile-time constant slice — no runtime config.
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| DNS resolution fails (NXDOMAIN) | Reject — do not connect |
|
||||
| DNS returns empty IP list | Reject — do not connect |
|
||||
| Any IP in blocked CIDR | Reject with log message showing hostname + IP |
|
||||
| URL has http:// scheme | Reject at string level (validate-url) / irrelevant for DialContext (scheme is checked at bash level already) |
|
||||
| User info in URL (user:pass@host) | Reject at string level in validate-url |
|
||||
| IPv6 mapped IPv4 (::ffff:10.0.0.1) | Blocked via `ip.To4()` normalization before CIDR check |
|
||||
| Unresolvable port | Handled by `net.SplitHostPort` error |
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **IPv6 mapped IPv4 addresses** (`::ffff:192.168.1.1`): `net.IP.To4()` returns the IPv4 form, which must be checked against IPv4 CIDRs. We normalize before checking.
|
||||
- **Multiple A/AAAA records**: One public + one private = blocked. Zero records = blocked.
|
||||
- **Default port (443 for https)**: URL may not include `:443`. The `validate-url` subcommand must use the URL's scheme to infer port for resolution (the hostname is enough for DNS check; port isn't needed for IP validation).
|
||||
- **DialContext addr format**: `net/http` passes `"host:port"` (already split), so `net.SplitHostPort` works without parsing the original URL.
|
||||
- **Test isolation**: Tests that hit local httptest servers need to bypass the safe dialer. Existing `SetHTTPClient` method allows injecting a plain HTTP client in tests. For the DialContext specifically, we add `WithUnsafeDialer() *Client` for test-only use.
|
||||
- **DNS rebinding**: The safeDialContext checks IPs after resolution, immediately before connection. This is not 100% immune to DNS rebinding (an attacker can change DNS response between LookupIPAddr and DialContext), but combined with the validate-url pre-check in bash (which also resolves), the window is extremely narrow. Full immunity requires pinning the IP for the duration of the request, which is not practical here without a larger refactor.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit tests (`gitea/ipcheck_test.go`)
|
||||
- `isBlockedIP`: test each CIDR (10.0.0.1, 172.16.0.1, 192.168.0.1, 169.254.1.1, 127.0.0.1, ::1, fe80::1, fc00::1)
|
||||
- Public IPs: 8.8.8.8, 1.1.1.1, 2001:4860:4860::8888 → not blocked
|
||||
- IPv6-mapped IPv4: `net.IP{0,0,0,0,0,0,0,0,0,0,0xff,0xff,192,168,1,1}` → blocked
|
||||
|
||||
### Unit tests (`gitea/client_test.go`)
|
||||
- `TestSafeDialerBlocksPrivateIP`: mock resolver returns 127.0.0.1 → client returns error, no connection made
|
||||
- `TestSafeDialerBlocksRFC1918`: mock resolver returns 10.0.0.1
|
||||
- `TestSafeDialerAllowsPublicIP`: public IP passes (connect to httptest server via 127.0.0.1 bypassed by WithUnsafeDialer)
|
||||
|
||||
### Unit tests (`cmd/review-bot/validateurl_test.go`)
|
||||
- Table-driven: various URLs (http://, https with private hostname, malformed) → expected exit/error
|
||||
- DNS resolution mocked via dependency injection or by testing against known-bad/good URLs
|
||||
|
||||
### Integration tests
|
||||
- Existing tests use `SetHTTPClient` with `httptest` — they pass a plain HTTP client that bypasses safe dialing. No change needed there.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- No flag changes, no env var changes, no action input changes.
|
||||
- `NewClient` now uses a safe HTTP transport by default — this is a breaking change in behavior for callers that were previously able to point the client at localhost. But callers that do so are tests — they already use `SetHTTPClient` to inject a test transport. The `WithUnsafeDialer` escape hatch for tests makes this clean.
|
||||
- The `validate-url` subcommand in `action.yml` runs before the binary is installed (in the "Determine version" step). We call it after the binary is installed (cache step) or at the start of "Install review-bot". Actually — the binary is installed in the "Install review-bot" step, but "Determine version" runs first and that's where the sensitive version-check curl happens. Therefore:
|
||||
- "Determine version" keeps existing bash scheme validation (pre-install, no binary available)
|
||||
- "Install review-bot" calls `validate-url` before any curl
|
||||
- "Run review" is protected by the Go binary's DialContext
|
||||
- This means version-check curl is not fully IP-protected — it uses the existing scheme-only validation. This is an acceptable residual risk: the attacker would only leak the Gitea API version response, not any secrets (no token is sent in the unauthenticated path; authenticated path sends reviewer-token which is the user's own token for their own server).
|
||||
|
||||
Actually, re-reading action.yml: the version check step DOES send `ACTION_TOKEN` to `API_URL`. So we should protect it. Options:
|
||||
- Embed a lightweight IP check in bash using `python3` (available on ubuntu runners per comments in action.yml)
|
||||
- Or restructure to do validate-url after install, skipping the cache step for the first call
|
||||
|
||||
**Decision**: Add a python3 IP check helper for the version-check curl. Python3's `socket.getaddrinfo` can resolve and check IPs without any external dep. Keep it minimal (just loopback + RFC1918 + link-local check). This is one more line in an already-bash-heavy step, documented clearly.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. The DNS rebinding window in `safeDialContext` can't be fully closed without IP pinning (binding the connection to the resolved IP). Is pinning worth the added complexity? Currently we dial `addrs[0]` directly after resolution which is a close approximation. This is our approach — worth noting in code comments.
|
||||
|
||||
2. Should we also protect the `llm` client? The issue says "action.yml Gitea path" so explicitly not in scope, but `llm-base-url` is also user-supplied. Suggest filing a follow-up. Not addressed here.
|
||||
|
||||
3. The python3 bash helper for the version-check step: should it also check IPv6? Yes — add `ipaddress.ip_address(ip).is_private` which handles both in Python 3.3+.
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
Generated for this specific task:
|
||||
|
||||
1. `isBlockedIP` covers all CIDR families: loopback, RFC1918, link-local, ULA, multicast, unspecified
|
||||
2. IPv6-mapped IPv4 addresses are normalized before CIDR check
|
||||
3. ANY blocked IP in the resolved set rejects (not just all-blocked)
|
||||
4. Zero resolved IPs rejects
|
||||
5. `safeDialContext` dials the resolved IP directly (avoids second DNS lookup that could be different)
|
||||
6. Test isolation: existing tests using httptest still pass (via `WithUnsafeDialer` or `SetHTTPClient`)
|
||||
7. `validate-url` subcommand exits non-zero for private IPs, malformed URLs, http:// scheme
|
||||
8. `action.yml` calls `validate-url` before each curl that uses user-supplied URL
|
||||
9. `go test ./...` passes
|
||||
10. `go vet ./...` passes
|
||||
11. `scripts/check-deps.sh` passes (no new deps)
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,6 +20,13 @@ import (
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// outWriter and errWriter are the output and error writers for subcommands.
|
||||
// They are variables so tests can capture output.
|
||||
var (
|
||||
outWriter io.Writer = os.Stdout
|
||||
errWriter io.Writer = os.Stderr
|
||||
)
|
||||
|
||||
// setupLogger configures the global slog default logger based on format and verbosity.
|
||||
func setupLogger(format, verbosity string) {
|
||||
var level slog.Level
|
||||
@@ -49,6 +57,15 @@ func setupLogger(format, verbosity string) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Dispatch subcommands before flag parsing so they get their own args.
|
||||
// e.g. `review-bot validate-url <url>`
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "validate-url":
|
||||
os.Exit(runValidateURL(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
|
||||
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||
// Logging flags
|
||||
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
||||
)
|
||||
|
||||
// runValidateURL implements the `review-bot validate-url <url>` subcommand.
|
||||
//
|
||||
// It resolves the given URL's hostname and checks that every returned IP is
|
||||
// publicly routable (not RFC1918, loopback, link-local, or other reserved
|
||||
// ranges). The exit code communicates the result to callers:
|
||||
//
|
||||
// 0 — URL is safe to use
|
||||
// 1 — URL resolves to a blocked/private address
|
||||
// 2 — URL is malformed, has an unsafe scheme, or DNS lookup failed
|
||||
//
|
||||
// This is intended for use from action.yml shell steps that need to validate
|
||||
// a user-supplied URL before passing it to curl.
|
||||
func runValidateURL(args []string) int {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintln(errWriter, "usage: review-bot validate-url <url>")
|
||||
fmt.Fprintln(errWriter, "")
|
||||
fmt.Fprintln(errWriter, "Resolves <url> and verifies all resolved IPs are publicly routable.")
|
||||
fmt.Fprintln(errWriter, "Exit 0=safe, 1=blocked, 2=error")
|
||||
return 2
|
||||
}
|
||||
rawURL := args[0]
|
||||
|
||||
if err := validateURL(rawURL); err != nil {
|
||||
fmt.Fprintf(errWriter, "Error: %v\n", err)
|
||||
var ve *validateError
|
||||
if isValidateError(err, &ve) {
|
||||
return ve.code
|
||||
}
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintf(outWriter, "OK: %s is safe\n", rawURL)
|
||||
return 0
|
||||
}
|
||||
|
||||
// validateError carries an exit code alongside a message.
|
||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `isValidateError` helper is a thin wrapper around `errors.As(err, out)` with only a nil guard added. Since `errors.As` already handles nil targets gracefully (it panics only if target is nil pointer, which would be a programmer error), and the nil check on `err` is not strictly needed (errors.As handles nil err by returning false), this function adds little value and could just be `errors.As(err, out)` at the call sites. That said, the wrapper does improve readability of intent and is harmless.
|
||||
type validateError struct {
|
||||
code int
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *validateError) Error() string { return e.message }
|
||||
|
||||
// isValidateError checks if err is or wraps a *validateError and sets out.
|
||||
func isValidateError(err error, out **validateError) bool {
|
||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `isValidateError` helper wraps `errors.As` with a nil check, but `errors.As` already handles nil correctly (returns false for nil err). The nil guard is harmless but redundant per the documented error-handling patterns.
|
||||
if err == nil {
|
||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `isValidateError` does not use `errors.As` — it only checks for a direct type assertion. This means wrapped `*validateError` values (e.g., `fmt.Errorf("...: %w", &validateError{...})`) would not be detected. Since `validateURL` only ever returns bare `*validateError` values (never wrapped), this is safe in the current implementation. However, it deviates from the error-handling pattern in error-handling.md which prescribes `errors.As` for type extraction. A future refactor that wraps these errors would silently break the code-path. Consider using `errors.As(err, out)` instead.
|
||||
return false
|
||||
}
|
||||
if ve, ok := err.(*validateError); ok {
|
||||
*out = ve
|
||||
|
sonnet-review-bot
commented
[NIT] isValidateError is a thin wrapper around errors.As with an extra nil check. The nil check is unnecessary because errors.As already handles nil (returns false). This doesn't affect correctness but adds a small amount of indirection. **[NIT]** isValidateError is a thin wrapper around errors.As with an extra nil check. The nil check is unnecessary because errors.As already handles nil (returns false). This doesn't affect correctness but adds a small amount of indirection.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
sonnet-review-bot
commented
[NIT] isValidateError is a thin wrapper around errors.As with a nil guard. The nil guard is unnecessary since errors.As(nil, target) returns false already (as documented). The function is also only used in two places; callers could call errors.As directly without the wrapper. **[NIT]** isValidateError is a thin wrapper around errors.As with a nil guard. The nil guard is unnecessary since errors.As(nil, target) returns false already (as documented). The function is also only used in two places; callers could call errors.As directly without the wrapper.
|
||||
// validateURL checks that rawURL is safe for use as a Gitea server URL:
|
||||
// - Must be https:// (not http://)
|
||||
// - Must have no user-info (user:pass@host)
|
||||
// - Must resolve to at least one IP, all of which are publicly routable
|
||||
func validateURL(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return &validateError{code: 2, message: fmt.Sprintf("malformed URL %q: %v", rawURL, err)}
|
||||
}
|
||||
|
||||
// Scheme check: only https is permitted.
|
||||
if !strings.EqualFold(parsed.Scheme, "https") {
|
||||
return &validateError{
|
||||
code: 2,
|
||||
message: fmt.Sprintf("URL scheme must be https (got %q)", parsed.Scheme),
|
||||
}
|
||||
}
|
||||
|
||||
// Reject user-info (user:password@host) to prevent credential embedding.
|
||||
if parsed.User != nil {
|
||||
return &validateError{
|
||||
code: 2,
|
||||
message: "URL must not contain user-info (user:password@host)",
|
||||
}
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return &validateError{code: 2, message: fmt.Sprintf("URL has no host: %q", rawURL)}
|
||||
}
|
||||
|
||||
// Resolve the hostname with a short timeout.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return &validateError{
|
||||
code: 2,
|
||||
message: fmt.Sprintf("DNS lookup failed for %q: %v", host, err),
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return &validateError{
|
||||
code: 2,
|
||||
message: fmt.Sprintf("DNS lookup returned no addresses for %q", host),
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range addrs {
|
||||
if gitea.IsBlockedIP(a.IP) {
|
||||
return &validateError{
|
||||
code: 1,
|
||||
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunValidateURL_Usage(t *testing.T) {
|
||||
var errBuf bytes.Buffer
|
||||
origErr := errWriter
|
||||
errWriter = &errBuf
|
||||
defer func() { errWriter = origErr }()
|
||||
|
||||
code := runValidateURL(nil)
|
||||
if code != 2 {
|
||||
t.Errorf("expected exit code 2 for no args, got %d", code)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "usage") {
|
||||
t.Errorf("expected usage in stderr, got %q", errBuf.String())
|
||||
}
|
||||
|
||||
errBuf.Reset()
|
||||
code = runValidateURL([]string{"arg1", "arg2"})
|
||||
if code != 2 {
|
||||
t.Errorf("expected exit code 2 for too many args, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL_MalformedURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
wantMsg string
|
||||
}{
|
||||
{"empty", "", "must be https"},
|
||||
{"http scheme", "http://example.com/", "must be https"},
|
||||
{"ftp scheme", "ftp://example.com/", "must be https"},
|
||||
{"no scheme", "example.com", "must be https"},
|
||||
{"user info", "https://user:pass@example.com/", "user-info"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateURL(tc.url)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for URL %q, got nil", tc.url)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantMsg) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tc.wantMsg)
|
||||
}
|
||||
var ve *validateError
|
||||
if !isValidateError(err, &ve) {
|
||||
t.Fatalf("expected *validateError, got %T", err)
|
||||
}
|
||||
if ve.code != 2 {
|
||||
t.Errorf("expected code 2, got %d", ve.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL_BlockedPrivateIP(t *testing.T) {
|
||||
// localhost always resolves to 127.0.0.1 (loopback).
|
||||
err := validateURL("https://localhost/")
|
||||
if err == nil {
|
||||
t.Skip("localhost did not resolve (network unavailable in test environment)")
|
||||
}
|
||||
var ve *validateError
|
||||
if !isValidateError(err, &ve) {
|
||||
t.Fatalf("expected *validateError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.code != 1 && ve.code != 2 {
|
||||
t.Errorf("expected code 1 (blocked) or 2 (dns fail), got %d: %s", ve.code, ve.message)
|
||||
}
|
||||
// If it resolved (code 1), the message must say "blocked".
|
||||
if ve.code == 1 && !strings.Contains(ve.message, "blocked") {
|
||||
t.Errorf("expected 'blocked' in message, got %q", ve.message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL_ExitCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
wantCode int
|
||||
}{
|
||||
{"http scheme", "http://example.com/", 2},
|
||||
{"no scheme", "example.com", 2},
|
||||
{"user info", "https://admin:secret@example.com/", 2},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateURL(tc.url)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", tc.url)
|
||||
}
|
||||
var ve *validateError
|
||||
if !isValidateError(err, &ve) {
|
||||
t.Fatalf("expected *validateError, got %T", err)
|
||||
}
|
||||
if ve.code != tc.wantCode {
|
||||
t.Errorf("code = %d, want %d (url=%q, msg=%s)", ve.code, tc.wantCode, tc.url, ve.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateURL_WithCapture(t *testing.T) {
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
origOut, origErr := outWriter, errWriter
|
||||
outWriter = &outBuf
|
||||
errWriter = &errBuf
|
||||
defer func() {
|
||||
outWriter = origOut
|
||||
errWriter = origErr
|
||||
}()
|
||||
|
||||
// http:// scheme should fail with code 2.
|
||||
code := runValidateURL([]string{"http://example.com/"})
|
||||
if code != 2 {
|
||||
t.Errorf("expected code 2 for http:// URL, got %d", code)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "must be https") {
|
||||
t.Errorf("expected error about https in stderr, got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
@@ -106,34 +106,95 @@ func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeDialContext is the default DialContext for NewClient.
|
||||
// It resolves the hostname and checks every returned IP against the blocked
|
||||
// CIDR list before establishing a connection. This prevents SSRF attacks
|
||||
// where user-supplied URLs resolve to internal/private addresses.
|
||||
//
|
||||
// After validating all IPs, we dial the first resolved IP directly to avoid
|
||||
// a second DNS lookup (which could return a different IP in a DNS rebinding
|
||||
// attack). This narrows — but does not fully eliminate — the DNS rebinding
|
||||
|
gpt-review-bot
commented
[MINOR] safeDialContext dials each resolved IP with a 10s timeout. For hosts with many IPs, total connection attempts could exceed the 30s http.Client Timeout and rely on request-level cancellation. Consider capping the aggregate dial duration or leveraging the context deadline to better align per-IP attempts with the overall request timeout. **[MINOR]** safeDialContext dials each resolved IP with a 10s timeout. For hosts with many IPs, total connection attempts could exceed the 30s http.Client Timeout and rely on request-level cancellation. Consider capping the aggregate dial duration or leveraging the context deadline to better align per-IP attempts with the overall request timeout.
|
||||
// window to the time between LookupIPAddr and DialContext.
|
||||
//
|
||||
// If the host is already an IP literal, LookupIPAddr returns it directly
|
||||
// (no DNS query issued), so IP literals like https://127.0.0.1/ are blocked.
|
||||
|
gpt-review-bot
commented
[NIT] safeDialContext dials only the first resolved IP. This intentionally narrows DNS rebinding but sacrifices resilience if the first IP is down while another is reachable. Consider iterating over resolved IPs in order if the first connection fails (still rejecting if any IP is in a blocked range). **[NIT]** safeDialContext dials only the first resolved IP. This intentionally narrows DNS rebinding but sacrifices resilience if the first IP is down while another is reachable. Consider iterating over resolved IPs in order if the first connection fails (still rejecting if any IP is in a blocked range).
gpt-review-bot
commented
[MINOR] safeDialContext dials only the first resolved IP address to narrow DNS rebinding risk. On dual-stack or multi-address hosts, the first IP may be unreachable (e.g., IPv6 preferred but not available), causing avoidable failures. Consider iterating over resolved addresses (after validating none are blocked) and attempting connection sequentially to preserve connectivity while keeping the SSRF checks. **[MINOR]** safeDialContext dials only the first resolved IP address to narrow DNS rebinding risk. On dual-stack or multi-address hosts, the first IP may be unreachable (e.g., IPv6 preferred but not available), causing avoidable failures. Consider iterating over resolved addresses (after validating none are blocked) and attempting connection sequentially to preserve connectivity while keeping the SSRF checks.
|
||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("safeDialContext: invalid address %q: %w", addr, err)
|
||||
}
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("safeDialContext: DNS lookup %q: %w", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("safeDialContext: no addresses returned for %q", host)
|
||||
|
[MINOR] WithUnsafeDialer() is exported and removes IP-level SSRF protection. While tests use it appropriately, exporting this method increases the risk of accidental misuse in production code. Consider making it unexported and exposing only a test-only constructor (as already added via NewTestClient in export_test.go). **[MINOR]** WithUnsafeDialer() is exported and removes IP-level SSRF protection. While tests use it appropriately, exporting this method increases the risk of accidental misuse in production code. Consider making it unexported and exposing only a test-only constructor (as already added via NewTestClient in export_test.go).
|
||||
}
|
||||
|
[MINOR] safeDialContext returns errors that include the resolved private/reserved IP (e.g., "blocked: host resolves to IP"). When logged in CI, this can disclose internal IPs. Consider redacting the concrete IP in error messages to avoid information leakage (e.g., state that it resolved to a private/reserved range without printing the exact address). **[MINOR]** safeDialContext returns errors that include the resolved private/reserved IP (e.g., "blocked: host resolves to IP"). When logged in CI, this can disclose internal IPs. Consider redacting the concrete IP in error messages to avoid information leakage (e.g., state that it resolved to a private/reserved range without printing the exact address).
|
||||
for _, a := range addrs {
|
||||
if IsBlockedIP(a.IP) {
|
||||
|
gpt-review-bot
commented
[MAJOR] newSafeHTTPClient constructs a zero-value http.Transport with only DialContext set, discarding DefaultTransport's tuned defaults (ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, MaxIdleConns, ExpectContinueTimeout, etc.). This can lead to hangs (e.g., unlimited TLS handshake) and degraded connection pooling compared to the standard defaults. Per patterns, prefer cloning DefaultTransport and overriding DialContext. **[MAJOR]** newSafeHTTPClient constructs a zero-value http.Transport with only DialContext set, discarding DefaultTransport's tuned defaults (ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, MaxIdleConns, ExpectContinueTimeout, etc.). This can lead to hangs (e.g., unlimited TLS handshake) and degraded connection pooling compared to the standard defaults. Per patterns, prefer cloning DefaultTransport and overriding DialContext.
|
||||
return nil, fmt.Errorf("safeDialContext: blocked: %q resolves to private/reserved IP %s", host, a.IP)
|
||||
}
|
||||
}
|
||||
// Dial the first resolved IP directly to avoid a second lookup.
|
||||
d := &net.Dialer{}
|
||||
|
gpt-review-bot
commented
[MAJOR] newSafeHTTPClient constructs a bare http.Transport with only DialContext set. This drops ProxyFromEnvironment and standard transport timeouts (IdleConnTimeout, TLSHandshakeTimeout, ExpectContinueTimeout, MaxIdleConns, ForceAttemptHTTP2) that http.DefaultTransport provides. This can break proxyed environments and lead to suboptimal or leaking connection pools. Prefer cloning http.DefaultTransport and overriding DialContext. **[MAJOR]** newSafeHTTPClient constructs a bare http.Transport with only DialContext set. This drops ProxyFromEnvironment and standard transport timeouts (IdleConnTimeout, TLSHandshakeTimeout, ExpectContinueTimeout, MaxIdleConns, ForceAttemptHTTP2) that http.DefaultTransport provides. This can break proxyed environments and lead to suboptimal or leaking connection pools. Prefer cloning http.DefaultTransport and overriding DialContext.
gpt-review-bot
commented
[MAJOR] By not setting Proxy: http.ProxyFromEnvironment on the custom transport, proxy environment variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) are ignored. Previous behavior (using default transport) respected proxies. This is a behavioral regression that can prevent the client from working behind corporate proxies. **[MAJOR]** By not setting Proxy: http.ProxyFromEnvironment on the custom transport, proxy environment variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) are ignored. Previous behavior (using default transport) respected proxies. This is a behavioral regression that can prevent the client from working behind corporate proxies.
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))
|
||||
}
|
||||
|
||||
// newSafeHTTPClient returns an *http.Client with the SSRF-blocking safeDialContext
|
||||
|
sonnet-review-bot
commented
[MINOR] The **[MINOR]** The `safeDialContext` creates a new `net.Dialer{}` on every call with no timeout configured. The outer `http.Client` has a 30s timeout, but the dial itself has no deadline beyond the context. In the original code (pre-PR), there was no DialContext either, so this is not a regression. However, the PLAN.md design snippet shows `d := &net.Dialer{Timeout: 10 * time.Second}` with an explicit timeout, but the implementation uses `&net.Dialer{}` (no timeout). This deviation from the design is minor since the context propagates cancellation, but it could allow a slow TCP connect to consume the full 30s.
[MAJOR] Safe dialer does not protect against proxy-assisted SSRF. newSafeHTTPClient clones http.DefaultTransport (retaining ProxyFromEnvironment). When a proxy is configured, DialContext connects to the proxy (which passes the Authorization header through), and the target host/IP is not validated by safeDialContext. An attacker could point baseURL to a private/reserved IP (e.g., 169.254.169.254) and rely on an environment proxy to reach it, bypassing the IP block. **[MAJOR]** Safe dialer does not protect against proxy-assisted SSRF. newSafeHTTPClient clones http.DefaultTransport (retaining ProxyFromEnvironment). When a proxy is configured, DialContext connects to the proxy (which passes the Authorization header through), and the target host/IP is not validated by safeDialContext. An attacker could point baseURL to a private/reserved IP (e.g., 169.254.169.254) and rely on an environment proxy to reach it, bypassing the IP block.
|
||||
// transport and the cross-host redirect rejection policy.
|
||||
func newSafeHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: defaultCheckRedirect,
|
||||
}
|
||||
|
sonnet-review-bot
commented
[MINOR] safeDialContext creates a new net.Dialer on every call (one per HTTP connection). This is fine for correctness but allocates a struct per dial. A package-level or transport-level Dialer would be more efficient, though for this workload (non-hot-path API calls) the impact is negligible. **[MINOR]** safeDialContext creates a new net.Dialer on every call (one per HTTP connection). This is fine for correctness but allocates a struct per dial. A package-level or transport-level Dialer would be more efficient, though for this workload (non-hot-path API calls) the impact is negligible.
|
||||
}
|
||||
|
||||
|
sonnet-review-bot
commented
[MINOR] safeDialContext creates a new net.Dialer on every call (line 155: **[MINOR]** safeDialContext creates a new net.Dialer on every call (line 155: `d := &net.Dialer{Timeout: 10 * time.Second}`). While not a correctness issue, for a function called on every HTTP connection this allocates a new struct each time. The dialer could be a package-level variable or embedded in a struct to avoid repeated allocation.
|
||||
// NewClient creates a new Gitea API client.
|
||||
//
|
||||
|
sonnet-review-bot
commented
[MINOR] In **[MINOR]** In `safeDialContext`, the comment says 'we dial the first resolved IP directly to narrow DNS rebinding window' but the implementation iterates through ALL resolved IPs with fallback. The code is actually correct (and better than the comment implies — it tries each IP in order), but the comment in the doc is slightly misleading since it says 'Dials first resolved IP directly' (visible in the PR description too). The comment at line 157 inside the function ('Try each resolved IP in order...') is accurate but the package-level doc comment should be updated to match.
|
||||
// The client uses a safe HTTP transport by default: DNS resolution is performed
|
||||
// before connecting and any IP in a private/reserved range is rejected
|
||||
// (RFC1918, loopback, link-local, ULA, etc.). Cross-host and HTTPS→HTTP
|
||||
|
[MINOR] The safe HTTP client clones http.DefaultTransport and thus preserves ProxyFromEnvironment. If HTTPS_PROXY/HTTP_PROXY are set to an attacker-controlled proxy in the runtime environment, Authorization headers could be exposed to that proxy despite SSRF IP checks. Consider disabling proxies (Transport.Proxy = nil) or providing an option to ignore proxies when connecting to user-supplied VCS URLs. **[MINOR]** The safe HTTP client clones http.DefaultTransport and thus preserves ProxyFromEnvironment. If HTTPS_PROXY/HTTP_PROXY are set to an attacker-controlled proxy in the runtime environment, Authorization headers could be exposed to that proxy despite SSRF IP checks. Consider disabling proxies (Transport.Proxy = nil) or providing an option to ignore proxies when connecting to user-supplied VCS URLs.
|
||||
// redirects are also rejected.
|
||||
//
|
||||
// For tests that use httptest.NewServer (which listens on 127.0.0.1), call
|
||||
// WithUnsafeDialer() to bypass the IP check.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: defaultCheckRedirect,
|
||||
},
|
||||
http: newSafeHTTPClient(),
|
||||
}
|
||||
|
sonnet-review-bot
commented
[NIT] The comment references 'PLAN.md' ( **[NIT]** The comment references 'PLAN.md' (`// Timeout: 10s per the design (PLAN.md)`) but no such file is visible in the repository. This will be a dead reference for future readers.
|
||||
}
|
||||
|
||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `WithUnsafeDialer()` is documented as 'MUST only be used in tests' but is an exported public method on `*Client`. This exposes a dangerous footgun to any package that imports `gitea`. The `export_test.go` pattern exists precisely for this purpose — `NewTestClient` in `export_test.go` correctly wraps it, but `WithUnsafeDialer` itself remains callable by production code in other packages. Consider making it unexported (`withUnsafeDialer`) and only exposing it via `export_test.go`, or accepting the trade-off if the API stability concern outweighs the risk.
|
||||
// WithUnsafeDialer returns the client configured with a plain HTTP client that
|
||||
// has no IP-level SSRF protection. It preserves the redirect-rejection policy.
|
||||
//
|
||||
// This MUST only be used in tests. Production code must never call this method.
|
||||
func (c *Client) WithUnsafeDialer() *Client {
|
||||
c.http = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: defaultCheckRedirect,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||
// This is intended for test setup only to inject mock transports; it must be
|
||||
// called before any goroutines issue requests.
|
||||
//
|
||||
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
||||
// CheckRedirect policy matching NewClient).
|
||||
// Passing nil restores the default safe client (30s timeout, IP-blocking
|
||||
// safeDialContext, and redirect-rejecting CheckRedirect policy matching NewClient).
|
||||
//
|
||||
// Callers providing a non-nil client are responsible for configuring a safe
|
||||
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
||||
// redirects and may forward the Authorization header to untrusted hosts.
|
||||
func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||
if hc == nil {
|
||||
hc = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: defaultCheckRedirect,
|
||||
}
|
||||
hc = newSafeHTTPClient()
|
||||
}
|
||||
c.http = hc
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestGetPullRequest(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
got, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -63,7 +63,7 @@ func TestGetPullRequestDiff(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -88,7 +88,7 @@ func TestGetCommitStatuses(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
got, err := client.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -138,7 +138,7 @@ func TestPostReview(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "abc123def", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -158,7 +158,7 @@ func TestGetPullRequest_Non200(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 999)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404, got nil")
|
||||
@@ -171,7 +171,7 @@ func TestGetPullRequest_BadJSON(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad JSON, got nil")
|
||||
@@ -185,7 +185,7 @@ func TestPostReview_Non200(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403, got nil")
|
||||
@@ -208,7 +208,7 @@ func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "ok", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -226,7 +226,7 @@ func TestGetFileContent(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
got, err := client.GetFileContent(context.Background(), "owner", "repo", "CONVENTIONS.md")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -246,7 +246,7 @@ func TestGetPullRequestFiles(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
files, err := client.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -271,7 +271,7 @@ func TestGetFileContentRef(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
content, err := client.GetFileContentRef(context.Background(), "owner", "repo", "main.go", "feature-branch")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -291,7 +291,7 @@ func TestListContents(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
entries, err := client.ListContents(context.Background(), "owner", "repo", "docs")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -318,7 +318,7 @@ func TestListContents_DotPath(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
entries, err := client.ListContents(context.Background(), "owner", "repo", ".")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -343,7 +343,7 @@ func TestListContents_FilePath(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
entries, err := client.ListContents(context.Background(), "owner", "repo", "README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -375,7 +375,7 @@ func TestGetAllFilesInPath_File(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -428,7 +428,7 @@ func TestListReviews(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -468,7 +468,7 @@ func TestListReviews_Pagination(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -493,7 +493,7 @@ func TestDeleteReview(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -507,7 +507,7 @@ func TestDeleteReview_Forbidden(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403, got nil")
|
||||
@@ -536,7 +536,7 @@ func TestEditComment(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body")
|
||||
if err != nil {
|
||||
t.Fatalf("EditComment() error = %v", err)
|
||||
@@ -550,7 +550,7 @@ func TestEditComment_Forbidden(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.EditComment(context.Background(), "owner", "repo", 42, "new body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 response")
|
||||
@@ -570,7 +570,7 @@ func TestGetTimelineReviewCommentID(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTimelineReviewCommentID() error = %v", err)
|
||||
@@ -586,7 +586,7 @@ func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when sentinel not found")
|
||||
@@ -609,7 +609,7 @@ func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to file on 404, got error: %v", err)
|
||||
@@ -630,7 +630,7 @@ func TestGetAllFilesInPath_500Propagates(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath")
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate for 500, got nil")
|
||||
@@ -652,7 +652,7 @@ func TestGetAllFilesInPath_403Propagates(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff")
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate for 403, got nil")
|
||||
@@ -704,7 +704,7 @@ func TestGetAuthenticatedUser(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
login, err := client.GetAuthenticatedUser(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthenticatedUser() error = %v", err)
|
||||
@@ -729,7 +729,7 @@ func TestRequestReviewer(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user")
|
||||
if err != nil {
|
||||
t.Fatalf("RequestReviewer() error = %v", err)
|
||||
@@ -745,7 +745,7 @@ func TestRequestReviewer_204(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
||||
if err != nil {
|
||||
t.Fatalf("RequestReviewer() should accept 204, got error = %v", err)
|
||||
@@ -759,7 +759,7 @@ func TestRequestReviewer_Error(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 response")
|
||||
@@ -779,7 +779,7 @@ func TestListReviewComments(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("ListReviewComments() error = %v", err)
|
||||
@@ -807,7 +807,7 @@ func TestResolveComment(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveComment() error = %v", err)
|
||||
@@ -821,7 +821,7 @@ func TestResolveComment_Error(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
@@ -870,7 +870,7 @@ func TestDoGet_RetriesOn500(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
// Use short backoff for fast tests
|
||||
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
||||
|
||||
@@ -895,7 +895,7 @@ func TestDoGet_FailsAfterMaxRetries(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
// Use short backoff for fast tests
|
||||
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
||||
|
||||
@@ -924,7 +924,7 @@ func TestDoGet_NoRetryOn4xx(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.doGet(context.Background(), server.URL+"/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403")
|
||||
@@ -952,7 +952,7 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
// Use longer backoff to give us time to cancel during the wait
|
||||
client.RetryBackoff = []time.Duration{100 * time.Millisecond, 100 * time.Millisecond}
|
||||
|
||||
@@ -1285,3 +1285,87 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeDialContextBlocksPrivateIPs verifies that NewClient (which uses
|
||||
// safeDialContext by default) refuses to connect to private/reserved IPs.
|
||||
func TestSafeDialContextBlocksPrivateIPs(t *testing.T) {
|
||||
// These servers listen on 127.0.0.1, so the safe dialer will block them.
|
||||
// We use NewClient (NOT NewTestClient) to exercise the real safe dialer.
|
||||
privateURLs := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"loopback localhost", "http://localhost/"},
|
||||
{"loopback 127.0.0.1", "http://127.0.0.1/"},
|
||||
|
sonnet-review-bot
commented
[MINOR] TestSafeDialContextBlocksPrivateIPs has a soft assertion: when the error doesn't contain 'blocked'/'private'/'loopback'/'reserved', it only logs (t.Logf) instead of failing. This means the test passes even if safeDialContext is bypassed for an unexpected reason (e.g., the error is a plain TCP connection refused before the IP check fires). The comment 'Allow other errors (connection refused, DNS)' acknowledges this, but it means the test doesn't firmly assert the blocking behavior. **[MINOR]** TestSafeDialContextBlocksPrivateIPs has a soft assertion: when the error doesn't contain 'blocked'/'private'/'loopback'/'reserved', it only logs (t.Logf) instead of failing. This means the test passes even if safeDialContext is bypassed for an unexpected reason (e.g., the error is a plain TCP connection refused before the IP check fires). The comment 'Allow other errors (connection refused, DNS)' acknowledges this, but it means the test doesn't firmly assert the blocking behavior.
|
||||
}
|
||||
|
||||
for _, tc := range privateURLs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := NewClient(tc.url, "token")
|
||||
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||
if err == nil {
|
||||
t.Errorf("expected error connecting to %s, got nil", tc.url)
|
||||
}
|
||||
// Error must mention SSRF/blocked, not a random network error.
|
||||
if !strings.Contains(err.Error(), "blocked") &&
|
||||
!strings.Contains(err.Error(), "private") &&
|
||||
!strings.Contains(err.Error(), "loopback") &&
|
||||
!strings.Contains(err.Error(), "reserved") {
|
||||
|
sonnet-review-bot
commented
[NIT] In **[NIT]** In `TestSafeDialContextBlocksPrivateIPs`, when the error contains none of the expected keywords ('blocked', 'private', 'loopback', 'reserved'), the test only logs the error with `t.Logf` but does NOT fail. The comment 'Allow other errors' could mask regressions where the safe dialer is bypassed (e.g. connection refused from a real loopback listener). Since the test asserts `err != nil` already handles the success case, it should at minimum emit `t.Errorf` (not just `t.Logf`) when the error doesn't mention the SSRF block, making the test stricter.
|
||||
t.Logf("error: %v", err)
|
||||
// Allow other errors (connection refused, DNS) since the point
|
||||
// is that we don't silently succeed — but prefer the explicit block message.
|
||||
|
sonnet-review-bot
commented
[NIT] **[NIT]** `TestSafeDialContextBlocksPrivateIPs` uses `t.Logf` to emit the error but doesn't call `t.Errorf` when the error message doesn't contain the expected keywords. The comment says 'Allow other errors... but prefer the explicit block message' — this means the test can pass even if the safe dialer is completely broken (e.g., if it returns a connection-refused error instead of a blocked error). The assertion should be strengthened to `t.Errorf` so that unexpected error messages are flagged, or the comment should be clearer about the intentional leniency.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUnsafeDialerAllowsLocalhost verifies that WithUnsafeDialer bypasses
|
||||
// the IP check, allowing tests to connect to httptest.Server (127.0.0.1).
|
||||
func TestWithUnsafeDialerAllowsLocalhost(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"title":"test","body":"","head":{"sha":"abc","ref":"main"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// WithUnsafeDialer should allow connecting to 127.0.0.1.
|
||||
c := NewClient(server.URL, "token").WithUnsafeDialer()
|
||||
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with unsafe dialer: %v", err)
|
||||
}
|
||||
if pr.Title != "test" {
|
||||
t.Errorf("expected title 'test', got %q", pr.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewClient_HasSafeTransport verifies that NewClient installs the
|
||||
// SSRF-blocking transport (i.e. Transport is not nil and DialContext is set).
|
||||
func TestNewClient_HasSafeTransport(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com", "token")
|
||||
if c.http.Transport == nil {
|
||||
t.Fatal("expected Transport to be set on NewClient (safe dialer)")
|
||||
}
|
||||
transport, ok := c.http.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", c.http.Transport)
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("expected DialContext to be set on transport (safe dialer)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetHTTPClient_NilRestoresSafeTransport verifies that SetHTTPClient(nil)
|
||||
// restores the safe transport (not just any client).
|
||||
func TestSetHTTPClient_NilRestoresSafeTransport(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com", "token")
|
||||
c.SetHTTPClient(&http.Client{}) // replace with plain client
|
||||
c.SetHTTPClient(nil) // restore
|
||||
transport, ok := c.http.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport after SetHTTPClient(nil), got %T", c.http.Transport)
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("expected DialContext to be restored after SetHTTPClient(nil)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestGetPullRequestDiff_SizeLimits(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
client.MaxDiffSize = tt.maxDiffSize
|
||||
client.RetryBackoff = []time.Duration{}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package gitea
|
||||
|
sonnet-review-bot
commented
[MINOR] The package declaration is **[MINOR]** The package declaration is `package gitea` (not `package gitea_test`), which means `NewTestClient` is compiled into the `gitea` package during tests, making it accessible to all test files in the package. The comment says 'MUST only be called from _test.go files' but there is no enforcement beyond convention. Since it calls `WithUnsafeDialer()` which is an exported method on `*Client`, a caller in a non-test file could also call `client.WithUnsafeDialer()` directly. The naming (`WithUnsafeDialer`) and doc comment are sufficient deterrents, but the export_test.go approach only limits availability to the test binary, not to individual callers within the package. This is acceptable Go convention (the stdlib uses the same pattern in net/http/export_test.go).
|
||||
|
||||
// NewTestClient creates a Gitea client configured for use in unit tests.
|
||||
// It bypasses the IP-level SSRF protection so that tests can connect to
|
||||
// httptest.Server instances (which listen on 127.0.0.1).
|
||||
//
|
||||
// This function MUST only be called from _test.go files.
|
||||
// Production code must use NewClient which uses the safe dialer.
|
||||
func NewTestClient(baseURL, token string) *Client {
|
||||
return NewClient(baseURL, token).WithUnsafeDialer()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package gitea provides a client for the Gitea API.
|
||||
|
gpt-review-bot
commented
[NIT] Duplicate package-level comments across multiple files (client.go and ipcheck.go). Per Go package documentation patterns, prefer a single package comment in a doc.go or primary file to avoid redundant doc output. **[NIT]** Duplicate package-level comments across multiple files (client.go and ipcheck.go). Per Go package documentation patterns, prefer a single package comment in a doc.go or primary file to avoid redundant doc output.
|
||||
// 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",
|
||||
|
gpt-review-bot
commented
[MINOR] Initialization panics on invalid built-in CIDR. While this guards programmer error, the repository convention says 'Return errors; never panic.' Consider handling this with a safer init-time check (e.g., pre-parse in tests) or making the failure explicit without a panic. **[MINOR]** Initialization panics on invalid built-in CIDR. While this guards programmer error, the repository convention says 'Return errors; never panic.' Consider handling this with a safer init-time check (e.g., pre-parse in tests) or making the failure explicit without a panic.
|
||||
// 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",
|
||||
}
|
||||
|
gpt-review-bot
commented
[MINOR] init() accumulates CIDR parse errors into blockedCIDRParseErrors without failing fast. If a future edit introduces an invalid CIDR, production builds could silently disable parts of SSRF protection. Consider panicking on parse error or logging a warning to avoid silent weakening of defenses. **[MINOR]** init() accumulates CIDR parse errors into blockedCIDRParseErrors without failing fast. If a future edit introduces an invalid CIDR, production builds could silently disable parts of SSRF protection. Consider panicking on parse error or logging a warning to avoid silent weakening of defenses.
|
||||
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)
|
||||
}
|
||||
|
sonnet-review-bot
commented
[MINOR] Using init() to parse CIDRs works but is slightly unusual when the data is pure compile-time constants. A panic on bad CIDR in init() would be more conventional (see configuration.md 'When NOT to Use' for init()). The chosen pattern (record errors, verify in tests) is an intentional trade-off documented in the code, but it means a typo in blockedCIDRStrings silently produces a weaker denylist at runtime if TestBlockedCIDRsValid is never run (e.g., in a binary without tests). A startup panic would catch this more reliably. However, given the CIDR list is stable and tests are run in CI, this is acceptable. **[MINOR]** Using init() to parse CIDRs works but is slightly unusual when the data is pure compile-time constants. A panic on bad CIDR in init() would be more conventional (see configuration.md 'When NOT to Use' for init()). The chosen pattern (record errors, verify in tests) is an intentional trade-off documented in the code, but it means a typo in blockedCIDRStrings silently produces a weaker denylist at runtime if TestBlockedCIDRsValid is never run (e.g., in a binary without tests). A startup panic would catch this more reliably. However, given the CIDR list is stable and tests are run in CI, this is acceptable.
sonnet-review-bot
commented
[MINOR] The **[MINOR]** The `init()` function is used here to parse CIDRs and populate `blockedCIDRs`. Per the package-design pattern, `init()` is appropriate for registration-style setup, and this usage is acceptable. However, the parse errors are deferred to test time rather than panicking at startup. Since these are hard-coded literals that can never fail at runtime (barring a programming error), an immediate `panic` in `init()` would be more idiomatic (as used in `regexp.MustCompile` for compile-time-known values). The current approach is a conscious design choice documented in the code, so this is minor — but it is a deviation from the 'Must pattern' documented in api-conventions.md.
|
||||
return nets
|
||||
}()
|
||||
|
||||
|
sonnet-review-bot
commented
[MINOR] Using **[MINOR]** Using `init()` to parse CIDRs rather than panicking. The CIDR strings are compile-time constants and parse errors are programming errors that can never occur in production. The deferred-error pattern (storing to `blockedCIDRParseErrors` and checking in a test) is more complex than a simple `var _ = func() { ... panic(...) }()` or `sync.OnceFunc` approach. More importantly, if `TestBlockedCIDRsValid` is somehow not run (e.g. in a non-test binary that skips test files), a typo in a CIDR string would silently reduce coverage. Since the CIDR list never changes at runtime and the strings are literals known at compile time, a direct `init()` that panics on parse failure would be idiomatic and safer. See `regexp.MustCompile` pattern: panicking on programmer errors caught at init time is the correct approach here.
|
||||
// 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.
|
||||
|
sonnet-review-bot
commented
[MINOR] Using init() to parse CIDRs and silently recording errors in blockedCIDRParseErrors rather than panicking is an unusual pattern. The package-design.md pattern explicitly says init() should 'have no side effects beyond registration' and 'No errors possible (can't return error from init)'. Since these are compile-time-known string literals, a panic in init() on parse failure would be acceptable (it's a programmer error, not a runtime condition) and simpler than the deferred-error pattern. The TestBlockedCIDRsValid test catches the error only during testing — a production binary built without running tests would silently have incomplete CIDR protection. **[MINOR]** Using init() to parse CIDRs and silently recording errors in blockedCIDRParseErrors rather than panicking is an unusual pattern. The package-design.md pattern explicitly says init() should 'have no side effects beyond registration' and 'No errors possible (can't return error from init)'. Since these are compile-time-known string literals, a panic in init() on parse failure would be acceptable (it's a programmer error, not a runtime condition) and simpler than the deferred-error pattern. The TestBlockedCIDRsValid test catches the error only during testing — a production binary built without running tests would silently have incomplete CIDR protection.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsBlockedIP(t *testing.T) {
|
||||
blocked := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
// IPv4 loopback
|
||||
{"loopback 127.0.0.1", "127.0.0.1"},
|
||||
{"loopback 127.0.0.2", "127.0.0.2"},
|
||||
{"loopback 127.255.255.255", "127.255.255.255"},
|
||||
// IPv4 unspecified
|
||||
{"unspecified 0.0.0.0", "0.0.0.0"},
|
||||
{"unspecified 0.1.2.3", "0.1.2.3"},
|
||||
// RFC1918
|
||||
{"RFC1918 10.0.0.1", "10.0.0.1"},
|
||||
{"RFC1918 10.255.255.255", "10.255.255.255"},
|
||||
{"RFC1918 172.16.0.1", "172.16.0.1"},
|
||||
{"RFC1918 172.31.255.255", "172.31.255.255"},
|
||||
{"RFC1918 192.168.0.1", "192.168.0.1"},
|
||||
{"RFC1918 192.168.255.255", "192.168.255.255"},
|
||||
// Link-local (APIPA / AWS metadata)
|
||||
{"link-local 169.254.0.1", "169.254.0.1"},
|
||||
{"link-local 169.254.169.254", "169.254.169.254"},
|
||||
// Shared address space (carrier-grade NAT)
|
||||
{"CGN 100.64.0.1", "100.64.0.1"},
|
||||
{"CGN 100.127.255.255", "100.127.255.255"},
|
||||
// Multicast
|
||||
{"multicast 224.0.0.1", "224.0.0.1"},
|
||||
{"multicast 239.255.255.255", "239.255.255.255"},
|
||||
// Reserved
|
||||
{"reserved 240.0.0.1", "240.0.0.1"},
|
||||
{"broadcast 255.255.255.255", "255.255.255.255"},
|
||||
// IPv6 loopback
|
||||
{"IPv6 loopback ::1", "::1"},
|
||||
// IPv6 unspecified
|
||||
{"IPv6 unspecified ::", "::"},
|
||||
// IPv6 link-local
|
||||
{"IPv6 link-local fe80::1", "fe80::1"},
|
||||
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
|
||||
// IPv6 ULA
|
||||
{"IPv6 ULA fc00::1", "fc00::1"},
|
||||
{"IPv6 ULA fd00::1", "fd00::1"},
|
||||
// IPv6 multicast
|
||||
{"IPv6 multicast ff02::1", "ff02::1"},
|
||||
}
|
||||
|
||||
for _, tc := range blocked {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||
}
|
||||
if !IsBlockedIP(ip) {
|
||||
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
allowed := []struct {
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{"public 8.8.8.8", "8.8.8.8"},
|
||||
{"public 1.1.1.1", "1.1.1.1"},
|
||||
{"public 198.51.100.1", "198.51.100.1"}, // TEST-NET-2, but not blocked — public-looking
|
||||
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
||||
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
||||
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
||||
}
|
||||
|
||||
for _, tc := range allowed {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||
}
|
||||
if IsBlockedIP(ip) {
|
||||
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
|
||||
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
|
||||
// Construct it manually as a 16-byte IP.
|
||||
|
sonnet-review-bot
commented
[MINOR] 198.51.100.1 (TEST-NET-2, RFC5737) is a documentation/testing range that should arguably be blocked, not considered public. The comment acknowledges it as 'public-looking' but it is actually a reserved range per RFC5737. This won't cause a security issue in practice (no real server lives there) but the comment is misleading. Consider either blocking it or removing the comment that implies ambiguity. **[MINOR]** 198.51.100.1 (TEST-NET-2, RFC5737) is a documentation/testing range that should arguably be blocked, not considered public. The comment acknowledges it as 'public-looking' but it is actually a reserved range per RFC5737. This won't cause a security issue in practice (no real server lives there) but the comment is misleading. Consider either blocking it or removing the comment that implies ambiguity.
|
||||
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
|
||||
if !IsBlockedIP(mapped) {
|
||||
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
|
||||
|
sonnet-review-bot
commented
[NIT] The inline comment on the 198.51.100.1 test case is unusually long and misaligned — it uses a tab-based continuation that won't render cleanly in most viewers. The explanation is good but could be moved to a doc comment or shortened. **[NIT]** The inline comment on the 198.51.100.1 test case is unusually long and misaligned — it uses a tab-based continuation that won't render cleanly in most viewers. The explanation is good but could be moved to a doc comment or shortened.
|
||||
}
|
||||
|
||||
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
|
||||
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
|
||||
if IsBlockedIP(mappedPublic) {
|
||||
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockedIPEdgeCases(t *testing.T) {
|
||||
// The boundary between RFC1918 and public ranges.
|
||||
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
|
||||
notPrivate := net.ParseIP("172.15.255.255")
|
||||
if IsBlockedIP(notPrivate) {
|
||||
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
|
||||
}
|
||||
// 172.32.0.0 is NOT private (just above 172.31.255.255).
|
||||
notPrivate2 := net.ParseIP("172.32.0.0")
|
||||
if IsBlockedIP(notPrivate2) {
|
||||
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
|
||||
}
|
||||
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
|
||||
notCGN := net.ParseIP("100.63.255.255")
|
||||
if IsBlockedIP(notCGN) {
|
||||
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
|
||||
}
|
||||
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
|
||||
notCGN2 := net.ParseIP("100.128.0.0")
|
||||
if IsBlockedIP(notCGN2) {
|
||||
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestPostReview_WithComments(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
comments := []ReviewComment{
|
||||
{Path: "main.go", NewPosition: 42, Body: "[MAJOR] Something bad"},
|
||||
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
|
||||
@@ -71,7 +71,7 @@ func TestPostReview_NilComments(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client := NewTestClient(server.URL, "test-token")
|
||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
[MINOR] The Python SSRF pre-check treats DNS errors and "no addresses" cases as a blocked private/reserved IP in the subsequent bash error message, which can mislead operators. Consider differentiating the error message for DNS failures vs blocked address ranges to improve diagnosability.
[MAJOR] The Python IP pre-check does not normalize IPv6-mapped IPv4 addresses (e.g., ::ffff:10.0.0.1). ipaddress.IPv6Address("::ffff:10.0.0.1").is_private is typically false, allowing a crafted AAAA record to bypass the check and potentially send ACTION_TOKEN to private/internal IPs via curl. The Go client normalizes these in IsBlockedIP, but the curl step is only protected by the Python script.
[MINOR] The Python pre-check does not explicitly reject URLs containing user-info (user:pass@host). While the Go validate-url subcommand rejects this, the shell step’s Python check only extracts hostname and proceeds, allowing user-info to be used with curl. This can lead to confusion and potential logging of attacker-supplied credentials.
[NIT] The Python IP classification (using ipaddress.is_reserved) may be stricter than the Go IsBlockedIP (which allows RFC5737 TEST-NET ranges). This inconsistency can cause false positives in pre-checks versus runtime. Consider aligning the block lists to avoid surprises (not a security bug; just consistency).