Adds IP-level SSRF protection to block requests to RFC1918, loopback, link-local, and other reserved address ranges when vcs-url is user-supplied (Gitea path).
Changes
gitea.Client: SSRF-safe HTTP transport (primary defense)
gitea/ipcheck.go: IsBlockedIP(ip net.IP) covers 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, and unspecified. IPv6-mapped IPv4 addresses are normalized before checking.
gitea/client.go: safeDialContext resolves DNS and checks every returned IP before connecting. Rejects if any resolved IP is blocked (not just all), and if zero IPs are returned. Dials first resolved IP directly to narrow DNS rebinding window.
NewClient uses safe transport by default. WithUnsafeDialer() escapes for tests. SetHTTPClient(nil) restores safe transport.
All existing gitea unit tests updated to use NewTestClient (which calls WithUnsafeDialer()).
validate-url subcommand (future use / defense-in-depth)
review-bot validate-url <url>: validates https scheme, no user-info, resolves hostname, rejects any blocked IP.
Exit 0=safe, 1=blocked, 2=validation error.
Available for any future shell steps that need to validate a URL before curl.
action.yml: Python3 IP check in "Determine version" step
Writes a python3 script via printf to a temp file (injection-safe: URL passed as env var, never interpolated into code) and runs it after the existing https scheme check.
Blocks requests to RFC1918, loopback, link-local, multicast, and reserved addresses.
Covers the version-check curl that sends ACTION_TOKEN to SERVER_URL.
Install-step curls are covered by the same pre-check (same SERVER_URL).
Test Coverage
gitea/ipcheck_test.go: 30+ cases across all blocked CIDR families + public IPs
gitea/client_test.go: safe transport presence, WithUnsafeDialer, loopback/localhost SSRF blocking
DNS rebinding: not fully immune, but window is extremely narrow after both pre-checks.
GitHub path (VCS_TYPE=github) not affected — uses trusted github.api_url.
LLM/AI Core URLs not covered — separate issue per scope.
Closes #123
## Summary
Adds IP-level SSRF protection to block requests to RFC1918, loopback, link-local, and other reserved address ranges when `vcs-url` is user-supplied (Gitea path).
## Changes
### `gitea.Client`: SSRF-safe HTTP transport (primary defense)
- `gitea/ipcheck.go`: `IsBlockedIP(ip net.IP)` covers 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, and unspecified. IPv6-mapped IPv4 addresses are normalized before checking.
- `gitea/client.go`: `safeDialContext` resolves DNS and checks every returned IP before connecting. Rejects if **any** resolved IP is blocked (not just all), and if zero IPs are returned. Dials first resolved IP directly to narrow DNS rebinding window.
- `NewClient` uses safe transport by default. `WithUnsafeDialer()` escapes for tests. `SetHTTPClient(nil)` restores safe transport.
- All existing gitea unit tests updated to use `NewTestClient` (which calls `WithUnsafeDialer()`).
### `validate-url` subcommand (future use / defense-in-depth)
- `review-bot validate-url <url>`: validates https scheme, no user-info, resolves hostname, rejects any blocked IP.
- Exit 0=safe, 1=blocked, 2=validation error.
- Available for any future shell steps that need to validate a URL before curl.
### `action.yml`: Python3 IP check in "Determine version" step
- Writes a python3 script via `printf` to a temp file (injection-safe: URL passed as env var, never interpolated into code) and runs it after the existing https scheme check.
- Blocks requests to RFC1918, loopback, link-local, multicast, and reserved addresses.
- Covers the version-check curl that sends `ACTION_TOKEN` to `SERVER_URL`.
- Install-step curls are covered by the same pre-check (same `SERVER_URL`).
## Test Coverage
- `gitea/ipcheck_test.go`: 30+ cases across all blocked CIDR families + public IPs
- `gitea/client_test.go`: safe transport presence, `WithUnsafeDialer`, loopback/localhost SSRF blocking
- `cmd/review-bot/validateurl_test.go`: scheme validation, user-info rejection, exit codes
## Threat Model
| Threat | Defense |
|--------|---------|
| RFC1918 IP direct | `IsBlockedIP` |
| Loopback (127.x, ::1) | `IsBlockedIP` |
| Link-local (169.254.x, fe80::) | `IsBlockedIP` |
| Split-horizon DNS (one public + one private) | ANY-IP-blocked policy |
| IPv6-mapped IPv4 (`::ffff:10.x.x.x`) | `ip.To4()` normalization |
| DNS rebinding | Dial first-resolved-IP directly (narrow window) |
| http:// scheme | `validate-url` + existing bash scheme check |
| user:pass@host | `validate-url` user-info check |
## Limitations (acknowledged)
- DNS rebinding: not fully immune, but window is extremely narrow after both pre-checks.
- GitHub path (`VCS_TYPE=github`) not affected — uses trusted `github.api_url`.
- LLM/AI Core URLs not covered — separate issue per scope.
## 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
Gates: go test ./..., go vet ./..., scripts/check-deps.sh — all pass
Coherence: Single purpose — SSRF defense for Gitea URLs. Three complementary layers (Go DialContext, validate-url subcommand, python3 action.yml check) serving the same threat model.
Fit: Follows codebase conventions — early returns, table-driven tests, stdlib-only, error wrapping. IsBlockedIP exported cleanly. WithUnsafeDialer() clearly named for risk. NewTestClient follows export_test.go pattern.
Solution: Correct threat model — ANY-IP-blocked (not all), zero-IPs-blocked, IPv6-mapped-IPv4 normalized, first-resolved-IP dialed directly to narrow rebinding window.
Completeness: All CIDR families tested. Transport presence verified. SetHTTPClient(nil) restores safe transport. YAML injection issue in action.yml caught and fixed (env-var approach). validate-url documented in header comment as available for future steps.
No instinct flags: Initially missed YAML injection (python code embedded in -c "..."), caught in self-review and fixed.
Known limitations (out of scope):
DNS rebinding not fully immune (acknowledged in comments)
GitHub path not affected — uses trusted github.api_url
LLM/AI Core URLs out of scope per issue
## Self-review: PASS
No issues found — ready for human review.
### Checklist
- [x] **Gates**: `go test ./...`, `go vet ./...`, `scripts/check-deps.sh` — all pass
- [x] **Coherence**: Single purpose — SSRF defense for Gitea URLs. Three complementary layers (Go DialContext, validate-url subcommand, python3 action.yml check) serving the same threat model.
- [x] **Fit**: Follows codebase conventions — early returns, table-driven tests, stdlib-only, error wrapping. `IsBlockedIP` exported cleanly. `WithUnsafeDialer()` clearly named for risk. `NewTestClient` follows export_test.go pattern.
- [x] **Solution**: Correct threat model — ANY-IP-blocked (not all), zero-IPs-blocked, IPv6-mapped-IPv4 normalized, first-resolved-IP dialed directly to narrow rebinding window.
- [x] **Completeness**: All CIDR families tested. Transport presence verified. `SetHTTPClient(nil)` restores safe transport. YAML injection issue in action.yml caught and fixed (env-var approach). `validate-url` documented in header comment as available for future steps.
- [x] **No instinct flags**: Initially missed YAML injection (python code embedded in `-c "..."`), caught in self-review and fixed.
**Known limitations (out of scope):**
- DNS rebinding not fully immune (acknowledged in comments)
- GitHub path not affected — uses trusted `github.api_url`
- LLM/AI Core URLs out of scope per issue
This PR implements a well-designed, defense-in-depth SSRF protection for the Gitea client. The Go implementation is clean, idiomatic, and thoroughly tested. A few minor issues worth noting but none are blocking.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck_test.go
92
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.
2
[MINOR]
gitea/client.go
143
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.
3
[MINOR]
gitea/export_test.go
1
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).
4
[MINOR]
cmd/review-bot/validateurl.go
58
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.
5
[NIT]
action.yml
217
The Python SSRF check script uses sys.exit(1) inside a list comprehension to exit on the first blocked IP. While this works in CPython, using a list comprehension for side effects (exit/print) is an unconventional Python anti-pattern and reduces readability. A for-loop would be clearer. Since this is a one-off shell script embedded in YAML, it's a minor style concern only.
6
[NIT]
PLAN.md
1
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.
Recommendation
APPROVE — Approve. The security implementation is sound and correct: CIDR coverage is comprehensive, IPv6-mapped IPv4 normalization is handled, the ANY-blocked-IP policy correctly defends against split-horizon DNS, and the DNS rebinding window is narrowed by dialing the first resolved IP directly. The test coverage is thorough with boundary cases. The export_test.go / NewTestClient / WithUnsafeDialer pattern for test isolation is idiomatic Go (matches the stdlib pattern in net/http/export_test.go). The Python fallback for the pre-install bash step is a pragmatic and injection-safe solution. The two minor findings (no dial timeout, non-errors.As in isValidateError) are low risk in the current codebase and can be addressed in follow-up PRs if desired.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3716) for up-to-date findings.
<details><summary>Previous findings (commit 8c8f3ab4)</summary>
# Sonnet Review
## Summary
This PR implements a well-designed, defense-in-depth SSRF protection for the Gitea client. The Go implementation is clean, idiomatic, and thoroughly tested. A few minor issues worth noting but none are blocking.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck_test.go` | 92 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 143 | 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. |
| 3 | [MINOR] | `gitea/export_test.go` | 1 | 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). |
| 4 | [MINOR] | `cmd/review-bot/validateurl.go` | 58 | `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. |
| 5 | [NIT] | `action.yml` | 217 | The Python SSRF check script uses `sys.exit(1)` inside a list comprehension to exit on the first blocked IP. While this works in CPython, using a list comprehension for side effects (exit/print) is an unconventional Python anti-pattern and reduces readability. A for-loop would be clearer. Since this is a one-off shell script embedded in YAML, it's a minor style concern only. |
| 6 | [NIT] | `PLAN.md` | 1 | 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. |
## Recommendation
**APPROVE** — Approve. The security implementation is sound and correct: CIDR coverage is comprehensive, IPv6-mapped IPv4 normalization is handled, the ANY-blocked-IP policy correctly defends against split-horizon DNS, and the DNS rebinding window is narrowed by dialing the first resolved IP directly. The test coverage is thorough with boundary cases. The `export_test.go` / `NewTestClient` / `WithUnsafeDialer` pattern for test isolation is idiomatic Go (matches the stdlib pattern in net/http/export_test.go). The Python fallback for the pre-install bash step is a pragmatic and injection-safe solution. The two minor findings (no dial timeout, non-`errors.As` in isValidateError) are low risk in the current codebase and can be addressed in follow-up PRs if desired.
---
*Review by sonnet*
<!-- review-bot:sonnet -->
---
*Evaluated against 8c8f3ab4*
</details>
<!-- review-bot:sonnet -->
[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.
[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.
**[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.
[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.
**[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.
[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).
**[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).
[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.
security-review-bot
requested review from security-review-bot 2026-05-14 07:47:46 +00:00
Strong SSRF defenses were added at both the Go client transport level and in the action workflow, including IP-based allowlisting and redirect hardening. The approach is sound and materially improves security without introducing obvious new vulnerabilities.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
.gitea/actions/review/action.yml
332
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.
Recommendation
APPROVE — The changes substantially strengthen SSRF protections by validating resolved IPs in the Gitea client (DialContext) and adding a preflight IP check in the action. As a small hardening improvement, also invoke the URL/IP validation at the beginning of the Install review-bot step to mitigate DNS rebinding across steps before curl requests that include Authorization headers. Otherwise, the implementation follows secure patterns (block private/link-local/loopback/multicast/reserved ranges, reject cross-host and downgrade redirects, normalize IPv6-mapped IPv4) and is appropriate to approve.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3721) for up-to-date findings.
<details><summary>Previous findings (commit 8c8f3ab4)</summary>
# Security Review
## Summary
Strong SSRF defenses were added at both the Go client transport level and in the action workflow, including IP-based allowlisting and redirect hardening. The approach is sound and materially improves security without introducing obvious new vulnerabilities.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `.gitea/actions/review/action.yml` | 332 | 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. |
## Recommendation
**APPROVE** — The changes substantially strengthen SSRF protections by validating resolved IPs in the Gitea client (DialContext) and adding a preflight IP check in the action. As a small hardening improvement, also invoke the URL/IP validation at the beginning of the Install review-bot step to mitigate DNS rebinding across steps before curl requests that include Authorization headers. Otherwise, the implementation follows secure patterns (block private/link-local/loopback/multicast/reserved ranges, reject cross-host and downgrade redirects, normalize IPv6-mapped IPv4) and is appropriate to approve.
---
*Review by security*
<!-- review-bot:security -->
---
*Evaluated against 8c8f3ab4*
</details>
<!-- review-bot:security -->
[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.
security-review-bot marked this conversation as resolved
gpt-review-bot
approved these changes 2026-05-14 07:48:23 +00:00
Solid, well-scoped SSRF mitigations were added across the Gitea client and action with good test coverage. The implementation follows idiomatic Go patterns and repository conventions, with only minor nits related to initialization panic and a readability concern in the action’s Python helper.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck.go
33
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.
2
[NIT]
gitea/client.go
120
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).
3
[NIT]
.gitea/actions/review/action.yml
200
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.
Recommendation
APPROVE — The SSRF defense is thoughtfully implemented and well tested: IP-level checks in the Gitea client via a custom DialContext, an exported IsBlockedIP for reuse, a validate-url subcommand, and an additional bash-side Python check in the action. Redirect policies remain strict and existing tests were adapted via NewTestClient/WithUnsafeDialer to avoid localhost blocking. CI is green.
Before merging, consider the minor improvements:
Replace the init-time panic in gitea/ipcheck.go with a safer initialization pattern that aligns with the repository’s 'never panic' rule, or document and justify the exception since it defends against a pure programmer error.
Optionally add a fallback to try other resolved IPs if the first one fails to connect, while still rejecting the hostname if any resolved IP is blocked.
Refactor the Python helper’s list comprehension to a more readable loop.
These are not blockers; the current changes are correct and beneficial. Approve.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3717) for up-to-date findings.
<details><summary>Previous findings (commit 8c8f3ab4)</summary>
# Gpt Review
## Summary
Solid, well-scoped SSRF mitigations were added across the Gitea client and action with good test coverage. The implementation follows idiomatic Go patterns and repository conventions, with only minor nits related to initialization panic and a readability concern in the action’s Python helper.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck.go` | 33 | 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. |
| 2 | [NIT] | `gitea/client.go` | 120 | 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). |
| 3 | [NIT] | `.gitea/actions/review/action.yml` | 200 | 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. |
## Recommendation
**APPROVE** — The SSRF defense is thoughtfully implemented and well tested: IP-level checks in the Gitea client via a custom DialContext, an exported IsBlockedIP for reuse, a validate-url subcommand, and an additional bash-side Python check in the action. Redirect policies remain strict and existing tests were adapted via NewTestClient/WithUnsafeDialer to avoid localhost blocking. CI is green.
Before merging, consider the minor improvements:
- Replace the init-time panic in gitea/ipcheck.go with a safer initialization pattern that aligns with the repository’s 'never panic' rule, or document and justify the exception since it defends against a pure programmer error.
- Optionally add a fallback to try other resolved IPs if the first one fails to connect, while still rejecting the hostname if any resolved IP is blocked.
- Refactor the Python helper’s list comprehension to a more readable loop.
These are not blockers; the current changes are correct and beneficial. Approve.
---
*Review by gpt*
<!-- review-bot:gpt -->
---
*Evaluated against 8c8f3ab4*
</details>
<!-- review-bot:gpt -->
[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.
**[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.
[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).
[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.
MAJOR fixes:
- gitea/ipcheck.go: replace startup panic with init()+error list pattern
Hard-coded CIDRs that fail to parse now recorded in blockedCIDRParseErrors
instead of panicking. TestBlockedCIDRsValid catches programming errors
in CI without violating CONVENTIONS.md 'never panic' rule.
- .gitea/actions/review/action.yml: re-validate SERVER_URL at start of
'Install review-bot' step to close DNS rebinding window between
'Determine version' and install-step curl calls.
MINOR fixes:
- gitea/client.go: add Timeout: 10*time.Second to net.Dialer per PLAN.md spec
- cmd/review-bot/validateurl.go: switch isValidateError to errors.As so
wrapped *validateError values are also detected
- gitea/ipcheck_test.go: clarify 198.51.100.1 (RFC5737 TEST-NET-2) comment;
add TestBlockedCIDRsValid to surface CIDR parse errors as test failures
NIT fixes:
- .gitea/actions/review/action.yml: refactor Python list comprehension in
SSRF check to for-loop (avoids side-effect-only comprehension, runner compat)
- gitea/export_test.go: expand comment explaining white-box test pattern
(why package gitea not gitea_test, Go stdlib precedent)
Remove PLAN.md (implementation complete)
This PR adds well-implemented SSRF defense to the Gitea client and action. The Go code follows established patterns correctly: export_test.go pattern for white-box testing, sentinel errors, proper error wrapping, safe defaults, and comprehensive test coverage. CI passed.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck.go
57
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.
2
[MINOR]
gitea/client.go
153
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.
3
[NIT]
cmd/review-bot/validateurl.go
62
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.
4
[NIT]
.gitea/actions/review/action.yml
205
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.
Recommendation
APPROVE — Approve. The implementation is correct, well-tested, and follows established Go patterns. The SSRF defense is multi-layered (Python check in shell steps + DialContext-level check in the Go client), properly documented, and the threat model acknowledgements are honest. The export_test.go pattern is exactly the right approach for exposing NewTestClient without polluting production API. All findings are minor/nit and do not block approval.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3718) for up-to-date findings.
<details><summary>Previous findings (commit f84cc3bb)</summary>
# Sonnet Review
## Summary
This PR adds well-implemented SSRF defense to the Gitea client and action. The Go code follows established patterns correctly: export_test.go pattern for white-box testing, sentinel errors, proper error wrapping, safe defaults, and comprehensive test coverage. CI passed.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck.go` | 57 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 153 | 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. |
| 3 | [NIT] | `cmd/review-bot/validateurl.go` | 62 | 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. |
| 4 | [NIT] | `.gitea/actions/review/action.yml` | 205 | 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. |
## Recommendation
**APPROVE** — Approve. The implementation is correct, well-tested, and follows established Go patterns. The SSRF defense is multi-layered (Python check in shell steps + DialContext-level check in the Go client), properly documented, and the threat model acknowledgements are honest. The export_test.go pattern is exactly the right approach for exposing NewTestClient without polluting production API. All findings are minor/nit and do not block approval.
---
*Review by sonnet*
<!-- review-bot:sonnet -->
---
*Evaluated against f84cc3bb*
</details>
<!-- review-bot:sonnet -->
[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.
[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.
[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.
[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.
Solid SSRF defenses added at both transport and CLI/action layers with good tests and documentation. However, the new HTTP transport discards important defaults from net/http's DefaultTransport, which can cause hangs and degraded behavior.
Findings
#
Severity
File
Line
Finding
1
[MAJOR]
gitea/client.go
134
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.
2
[MINOR]
gitea/client.go
120
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.
3
[MINOR]
.gitea/actions/review/action.yml
191
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.
Recommendation
REQUEST_CHANGES — Overall the SSRF protections and validate-url subcommand are well-implemented and tested. To address the critical transport issue, update newSafeHTTPClient to preserve the standard library's DefaultTransport defaults by cloning and overriding DialContext, for example:
This maintains the recommended timeouts and pooling behavior while enforcing the SSRF dial guard. Additionally, enhance safeDialContext to attempt dialing each validated IP in order if the first connection fails, to avoid connectivity regressions on dual-stack hosts. Finally, refine the action.yml SSRF pre-check messaging to distinguish DNS errors from blocked ranges for clearer operator feedback. After these adjustments, the changes would be ready to merge.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3719) for up-to-date findings.
<details><summary>Previous findings (commit f84cc3bb)</summary>
# Gpt Review
## Summary
Solid SSRF defenses added at both transport and CLI/action layers with good tests and documentation. However, the new HTTP transport discards important defaults from net/http's DefaultTransport, which can cause hangs and degraded behavior.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MAJOR] | `gitea/client.go` | 134 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 120 | 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. |
| 3 | [MINOR] | `.gitea/actions/review/action.yml` | 191 | 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. |
## Recommendation
**REQUEST_CHANGES** — Overall the SSRF protections and validate-url subcommand are well-implemented and tested. To address the critical transport issue, update newSafeHTTPClient to preserve the standard library's DefaultTransport defaults by cloning and overriding DialContext, for example:
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = safeDialContext
client := &http.Client{Timeout: 30*time.Second, Transport: transport, CheckRedirect: defaultCheckRedirect}
This maintains the recommended timeouts and pooling behavior while enforcing the SSRF dial guard. Additionally, enhance safeDialContext to attempt dialing each validated IP in order if the first connection fails, to avoid connectivity regressions on dual-stack hosts. Finally, refine the action.yml SSRF pre-check messaging to distinguish DNS errors from blocked ranges for clearer operator feedback. After these adjustments, the changes would be ready to merge.
---
*Review by gpt*
<!-- review-bot:gpt -->
---
*Evaluated against f84cc3bb*
</details>
<!-- review-bot:gpt -->
[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.
**[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.
[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.
[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.
Previously safeDialContext only dialed the first resolved IP. If the
connection failed, it returned an error without trying other IPs.
Now it iterates all validated IPs and returns the first successful
connection, or the last error if all fail. This matches the resilience
behavior of a plain net.Dialer on multi-IP hostnames.
Addresses review finding: safeDialContext only dials first resolved IP.
All IPs are still validated before any dial attempt is made.
gitea/ipcheck.go: replaced init-time panic with init() + blockedCIDRParseErrors list — TestBlockedCIDRsValid catches programming errors in CI without violating CONVENTIONS.md
action.yml: added DNS rebinding re-validation at start of Install step (closes the window between Determine version and install-step curl calls)
MINOR:
gitea/client.go: net.Dialer{Timeout: 10 * time.Second} per PLAN.md spec
gitea/client.go: fallback dialing — now tries all validated IPs, not just the first
cmd/review-bot/validateurl.go: isValidateError uses errors.As instead of direct type assertion
action.yml: Python list comprehension → for-loop in both SSRF check locations
gitea/export_test.go: expanded comment explaining white-box test pattern
Gates: scripts/check-deps.sh✅, go vet ./...✅, go test -race ./...✅
## Self-review: PASS
All 8 review findings addressed:
**MAJOR:**
- `gitea/ipcheck.go`: replaced init-time `panic` with `init()` + `blockedCIDRParseErrors` list — `TestBlockedCIDRsValid` catches programming errors in CI without violating CONVENTIONS.md
- `action.yml`: added DNS rebinding re-validation at start of Install step (closes the window between Determine version and install-step curl calls)
**MINOR:**
- `gitea/client.go`: `net.Dialer{Timeout: 10 * time.Second}` per PLAN.md spec
- `gitea/client.go`: fallback dialing — now tries all validated IPs, not just the first
- `cmd/review-bot/validateurl.go`: `isValidateError` uses `errors.As` instead of direct type assertion
- `gitea/ipcheck_test.go`: RFC5737 TEST-NET-2 comment clarified; `TestBlockedCIDRsValid` added
**NIT:**
- `action.yml`: Python list comprehension → for-loop in both SSRF check locations
- `gitea/export_test.go`: expanded comment explaining white-box test pattern
Gates: `scripts/check-deps.sh` ✅, `go vet ./...` ✅, `go test -race ./...` ✅
Well-structured SSRF defense implementation with appropriate depth-in-defense layering (Python3 pre-check in bash, DialContext-level IP blocking in Go, validate-url subcommand). The code follows established Go patterns, is well-tested, and CI passes. Minor issues exist but none are blockers.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck.go
63
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.
2
[MINOR]
gitea/client.go
155
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.
3
[MINOR]
gitea/client_test.go
1299
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.
4
[MINOR]
.gitea/actions/review/action.yml
205
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.
5
[NIT]
gitea/client.go
170
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.
6
[NIT]
cmd/review-bot/validateurl.go
67
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.
7
[NIT]
gitea/ipcheck_test.go
95
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.
Recommendation
APPROVE — This is a solid, well-thought-out security feature. CI passes, the implementation follows Go patterns (export_test.go pattern, interface-level testing, table-driven tests), and the threat model documentation is thorough. The findings above are minor: the most notable is that init() silently swallowing CIDR parse errors means a production binary could theoretically have incomplete blocking if tests weren't run — a panic in init() would be more appropriate for compile-time-known literals. The duplicated Python script in action.yml is a maintenance concern. None of these are blockers. Approve with the recommendation to address the init() pattern and script duplication in a follow-up.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3734) for up-to-date findings.
<details><summary>Previous findings (commit 5ac93bea)</summary>
# Sonnet Review
## Summary
Well-structured SSRF defense implementation with appropriate depth-in-defense layering (Python3 pre-check in bash, DialContext-level IP blocking in Go, validate-url subcommand). The code follows established Go patterns, is well-tested, and CI passes. Minor issues exist but none are blockers.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck.go` | 63 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 155 | 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. |
| 3 | [MINOR] | `gitea/client_test.go` | 1299 | 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. |
| 4 | [MINOR] | `.gitea/actions/review/action.yml` | 205 | 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. |
| 5 | [NIT] | `gitea/client.go` | 170 | 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. |
| 6 | [NIT] | `cmd/review-bot/validateurl.go` | 67 | 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. |
| 7 | [NIT] | `gitea/ipcheck_test.go` | 95 | 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. |
## Recommendation
**APPROVE** — This is a solid, well-thought-out security feature. CI passes, the implementation follows Go patterns (export_test.go pattern, interface-level testing, table-driven tests), and the threat model documentation is thorough. The findings above are minor: the most notable is that init() silently swallowing CIDR parse errors means a production binary could theoretically have incomplete blocking if tests weren't run — a panic in init() would be more appropriate for compile-time-known literals. The duplicated Python script in action.yml is a maintenance concern. None of these are blockers. Approve with the recommendation to address the init() pattern and script duplication in a follow-up.
---
*Review by sonnet*
<!-- review-bot:sonnet -->
---
*Evaluated against 5ac93bea*
</details>
<!-- review-bot:sonnet -->
[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.
[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.
[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.
**[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.
[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.
**[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.
[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.
[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.
[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.
Security additions and validate-url subcommand look solid and well-tested. However, the new safe HTTP transport in gitea.Client regresses important http.Transport defaults (proxy support and timeouts), which can break environments and cause resource issues.
Findings
#
Severity
File
Line
Finding
1
[MAJOR]
gitea/client.go
139
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.
2
[MAJOR]
gitea/client.go
139
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.
Recommendation
REQUEST_CHANGES — Overall the SSRF defenses are thoughtfully implemented: safeDialContext checks resolved IPs, action.yml adds a pre-flight Python IP check, and the validate-url subcommand provides a CLI validator. Tests comprehensively cover these paths. However, please address the http.Transport regression introduced by newSafeHTTPClient.
Recommended fix:
In newSafeHTTPClient, start from the default transport configuration and only override DialContext. For example:
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = safeDialContext
// Optionally ensure ForceAttemptHTTP2 remains true (default) and other defaults intact
return &http.Client{Timeout: 30 * time.Second, Transport: t, CheckRedirect: defaultCheckRedirect}
This preserves ProxyFromEnvironment and sane defaults (IdleConnTimeout=90s, TLSHandshakeTimeout=10s, ExpectContinueTimeout=1s, MaxIdleConns=100, ForceAttemptHTTP2=true) while adding the SSRF protection.
Alternatively, explicitly set the same defaults on a new http.Transport, including:
Proxy: http.ProxyFromEnvironment
ForceAttemptHTTP2: true
MaxIdleConns: 100
IdleConnTimeout: 90*time.Second
TLSHandshakeTimeout: 10*time.Second
ExpectContinueTimeout: 1*time.Second
After updating newSafeHTTPClient, keep the CheckRedirect policy as-is. With these changes, you maintain the new security guarantees without breaking existing behavior in proxied environments or degrading transport performance.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3737) for up-to-date findings.
<details><summary>Previous findings (commit 5ac93bea)</summary>
# Gpt Review
## Summary
Security additions and validate-url subcommand look solid and well-tested. However, the new safe HTTP transport in gitea.Client regresses important http.Transport defaults (proxy support and timeouts), which can break environments and cause resource issues.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MAJOR] | `gitea/client.go` | 139 | 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. |
| 2 | [MAJOR] | `gitea/client.go` | 139 | 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. |
## Recommendation
**REQUEST_CHANGES** — Overall the SSRF defenses are thoughtfully implemented: safeDialContext checks resolved IPs, action.yml adds a pre-flight Python IP check, and the validate-url subcommand provides a CLI validator. Tests comprehensively cover these paths. However, please address the http.Transport regression introduced by newSafeHTTPClient.
Recommended fix:
- In newSafeHTTPClient, start from the default transport configuration and only override DialContext. For example:
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = safeDialContext
// Optionally ensure ForceAttemptHTTP2 remains true (default) and other defaults intact
return &http.Client{Timeout: 30 * time.Second, Transport: t, CheckRedirect: defaultCheckRedirect}
This preserves ProxyFromEnvironment and sane defaults (IdleConnTimeout=90s, TLSHandshakeTimeout=10s, ExpectContinueTimeout=1s, MaxIdleConns=100, ForceAttemptHTTP2=true) while adding the SSRF protection.
Alternatively, explicitly set the same defaults on a new http.Transport, including:
- Proxy: http.ProxyFromEnvironment
- ForceAttemptHTTP2: true
- MaxIdleConns: 100
- IdleConnTimeout: 90*time.Second
- TLSHandshakeTimeout: 10*time.Second
- ExpectContinueTimeout: 1*time.Second
After updating newSafeHTTPClient, keep the CheckRedirect policy as-is. With these changes, you maintain the new security guarantees without breaking existing behavior in proxied environments or degrading transport performance.
---
*Review by gpt*
<!-- review-bot:gpt -->
---
*Evaluated against 5ac93bea*
</details>
<!-- review-bot:gpt -->
[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.
[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.
security-review-bot
requested review from security-review-bot 2026-05-14 08:48:07 +00:00
Strong SSRF defenses were added in both the Go client and the action, but the Python pre-check in action.yml can be bypassed with IPv6-mapped IPv4 addresses. Also, the action’s pre-check doesn’t reject URLs with embedded user-info. Addressing these will close important gaps before secrets are sent via curl.
Findings
#
Severity
File
Line
Finding
1
[MAJOR]
.gitea/actions/review/action.yml
191
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.
2
[MINOR]
.gitea/actions/review/action.yml
191
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.
Recommendation
REQUEST_CHANGES — Fix the action pre-check to fully align with the Go client’s SSRF defenses and the validate-url subcommand:
In the Python script, normalize IPv6-mapped IPv4 addresses before checks. For example: if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: ip = ip.ipv4_mapped. Then apply the private/loopback/link-local/multicast/reserved checks. Also explicitly block ip.is_unspecified. Alternatively, reject any address for which not ip.is_global (but validate semantics across Python versions) or adopt both explicit checks and ipv4_mapped normalization.
Explicitly reject URLs with user-info in the Python check before DNS resolution (e.g., if parsed.userinfo is present, exit with validation error). This mirrors the validate-url subcommand’s behavior and avoids accepting attacker-supplied credentials in URLs.
With these adjustments, the token-bearing curl calls will be protected from IPv6-mapped bypasses and unsafe URL forms. The Go client and new subcommand look solid and significantly improve SSRF resistance; once the action pre-check is corrected, the PR can be approved.
~~Original review~~
**Superseded** — [see current review](https://gitea.weiker.me/rodin/review-bot/pulls/129#pullrequestreview-3736) for up-to-date findings.
<details><summary>Previous findings (commit 5ac93bea)</summary>
# Security Review
## Summary
Strong SSRF defenses were added in both the Go client and the action, but the Python pre-check in action.yml can be bypassed with IPv6-mapped IPv4 addresses. Also, the action’s pre-check doesn’t reject URLs with embedded user-info. Addressing these will close important gaps before secrets are sent via curl.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MAJOR] | `.gitea/actions/review/action.yml` | 191 | 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. |
| 2 | [MINOR] | `.gitea/actions/review/action.yml` | 191 | 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. |
## Recommendation
**REQUEST_CHANGES** — Fix the action pre-check to fully align with the Go client’s SSRF defenses and the validate-url subcommand:
- In the Python script, normalize IPv6-mapped IPv4 addresses before checks. For example: if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: ip = ip.ipv4_mapped. Then apply the private/loopback/link-local/multicast/reserved checks. Also explicitly block ip.is_unspecified. Alternatively, reject any address for which not ip.is_global (but validate semantics across Python versions) or adopt both explicit checks and ipv4_mapped normalization.
- Explicitly reject URLs with user-info in the Python check before DNS resolution (e.g., if parsed.userinfo is present, exit with validation error). This mirrors the validate-url subcommand’s behavior and avoids accepting attacker-supplied credentials in URLs.
With these adjustments, the token-bearing curl calls will be protected from IPv6-mapped bypasses and unsafe URL forms. The Go client and new subcommand look solid and significantly improve SSRF resistance; once the action pre-check is corrected, the PR can be approved.
---
*Review by security*
<!-- review-bot:security -->
---
*Evaluated against 5ac93bea*
</details>
<!-- review-bot:security -->
[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.
**[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.
**[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.
security-review-bot marked this conversation as resolved
- Clone http.DefaultTransport instead of bare &http.Transport{} to preserve
ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, connection
pooling, and HTTP/2 support (fixes transport regression).
- Add IPv6-mapped IPv4 normalization in action.yml Python SSRF checks to
prevent bypass via ::ffff:10.0.0.1 style AAAA records.
- Reject URLs with user-info (user:pass@host) in action.yml Python checks
to match validate-url subcommand behavior.
- Add test verifying DefaultTransport settings are preserved.
This PR adds well-structured IP-level SSRF protection to the Gitea client with defense-in-depth at three layers: DNS-resolve-before-dial in the Go transport, a Python pre-check in action.yml, and a validate-url subcommand. The implementation is correct, thoroughly tested, and follows established patterns.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck.go
60
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.
2
[MINOR]
gitea/client.go
157
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.
3
[MINOR]
action.yml
214
The Python SSRF check script is duplicated verbatim in two places in action.yml (the 'Determine version' step and the 'Install review-bot' step). While the duplication is intentional and commented, there is no mechanism to keep them in sync. A future maintainer who fixes a bug in one copy might miss the other. Consider extracting to a shared helper file in the repo (e.g. .gitea/actions/review/ssrf_check.py) and calling it from both steps, which would also be easier to test independently.
4
[NIT]
cmd/review-bot/validateurl.go
48
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.
5
[NIT]
gitea/client_test.go
1313
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.
Recommendation
APPROVE — Approve with minor observations. The core security implementation is correct and well-designed: CIDR list is comprehensive, IPv6-mapped IPv4 normalization is handled, the ANY-IP-blocked policy for split-horizon DNS is appropriate, and the defense-in-depth layering (transport + action.yml + subcommand) is thoughtful. The export_test.go pattern for NewTestClient is idiomatic. Test coverage is thorough. The findings above are minor quality issues that don't affect correctness or security: (1) the init()-vs-panic design choice for CIDR parsing is a style preference, (2) a slightly misleading doc comment in safeDialContext, (3) duplicated Python script in action.yml, (4) an overly weak assertion in one test. None of these warrant blocking the PR.
# Sonnet Review
## Summary
This PR adds well-structured IP-level SSRF protection to the Gitea client with defense-in-depth at three layers: DNS-resolve-before-dial in the Go transport, a Python pre-check in action.yml, and a validate-url subcommand. The implementation is correct, thoroughly tested, and follows established patterns.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck.go` | 60 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 157 | 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. |
| 3 | [MINOR] | `action.yml` | 214 | The Python SSRF check script is duplicated verbatim in two places in action.yml (the 'Determine version' step and the 'Install review-bot' step). While the duplication is intentional and commented, there is no mechanism to keep them in sync. A future maintainer who fixes a bug in one copy might miss the other. Consider extracting to a shared helper file in the repo (e.g. `.gitea/actions/review/ssrf_check.py`) and calling it from both steps, which would also be easier to test independently. |
| 4 | [NIT] | `cmd/review-bot/validateurl.go` | 48 | 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. |
| 5 | [NIT] | `gitea/client_test.go` | 1313 | 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. |
## Recommendation
**APPROVE** — Approve with minor observations. The core security implementation is correct and well-designed: CIDR list is comprehensive, IPv6-mapped IPv4 normalization is handled, the ANY-IP-blocked policy for split-horizon DNS is appropriate, and the defense-in-depth layering (transport + action.yml + subcommand) is thoughtful. The export_test.go pattern for NewTestClient is idiomatic. Test coverage is thorough. The findings above are minor quality issues that don't affect correctness or security: (1) the init()-vs-panic design choice for CIDR parsing is a style preference, (2) a slightly misleading doc comment in safeDialContext, (3) duplicated Python script in action.yml, (4) an overly weak assertion in one test. None of these warrant blocking the PR.
---
*Review by sonnet*
<!-- review-bot:sonnet -->
---
*Evaluated against 934c6728*
[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.
**[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.
[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.
**[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.
[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.
**[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.
[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.
**[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.
security-review-bot
requested review from security-review-bot 2026-05-14 11:51:56 +00:00
Excellent SSRF-hardening overall (scheme/userinfo checks, IP allowlisting, safe Dialer, redirect policy). However, two gaps create exploitable paths: the Python preflight check in action.yml misses CGN (100.64.0.0/10), and the Go client's safe dial does not account for HTTP(S) proxies, allowing proxy-assisted SSRF to internal IPs.
Findings
#
Severity
File
Line
Finding
1
[MAJOR]
.gitea/actions/review/action.yml
209
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.
2
[MAJOR]
.gitea/actions/review/action.yml
354
Same CGN gap in the re-validation block before downloads: the Python IP checks omit 100.64.0.0/10, enabling a DNS rebind or split-horizon scenario to CGN addresses even after initial validation.
3
[MAJOR]
gitea/client.go
143
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.
4
[MINOR]
gitea/client.go
131
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).
Recommendation
REQUEST_CHANGES — Address the two primary SSRF gaps before merging:
Action preflight Python IP checks: explicitly block CGN. Options:
Add an explicit check for 100.64.0.0/10 (e.g., using ip_network("100.64.0.0/10").supernet_of or manual range match), or
Prefer a positive allow only of ip.is_global, which returns False for private, loopback, link-local, multicast, reserved, unspecified, and CGN. Example: if not ip.is_global: block.
Apply this in both validation blocks in action.yml to ensure tokens are never sent to CGN or other non-global ranges.
Proxy-assisted SSRF: ensure the Go client does not use HTTP(S) proxies for untrusted, user-supplied baseURL. Approaches:
Disable proxies for the Gitea client HTTP transport (set Transport.Proxy = nil) when baseURL is user-controlled (e.g., VCS_TYPE=gitea path). Since you already implement IP-level validation and redirect checks, removing proxy use eliminates the proxy bypass. Alternatively,
Detect when a proxy is configured and reject untrusted baseURL outright, or implement additional validation that enforces the target host’s resolved IPs are safe even when a proxy is used (noting that with an HTTP proxy, target resolution/connection happens on the proxy side and cannot be verified client-side). The safest course is to bypass proxies for these requests.
Defense-in-depth: Consider making WithUnsafeDialer unexported to avoid accidental production use, keeping NewTestClient as the supported test-only path.
With these changes, the SSRF protections will be robust across direct and proxied connections, including CGN edge cases, and reduce the risk of token exfiltration. CI is passing; once these issues are fixed, the PR looks strong to approve.
# Security Review
## Summary
Excellent SSRF-hardening overall (scheme/userinfo checks, IP allowlisting, safe Dialer, redirect policy). However, two gaps create exploitable paths: the Python preflight check in action.yml misses CGN (100.64.0.0/10), and the Go client's safe dial does not account for HTTP(S) proxies, allowing proxy-assisted SSRF to internal IPs.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MAJOR] | `.gitea/actions/review/action.yml` | 209 | 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. |
| 2 | [MAJOR] | `.gitea/actions/review/action.yml` | 354 | Same CGN gap in the re-validation block before downloads: the Python IP checks omit 100.64.0.0/10, enabling a DNS rebind or split-horizon scenario to CGN addresses even after initial validation. |
| 3 | [MAJOR] | `gitea/client.go` | 143 | 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. |
| 4 | [MINOR] | `gitea/client.go` | 131 | 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). |
## Recommendation
**REQUEST_CHANGES** — Address the two primary SSRF gaps before merging:
1) Action preflight Python IP checks: explicitly block CGN. Options:
- Add an explicit check for 100.64.0.0/10 (e.g., using ip_network("100.64.0.0/10").supernet_of or manual range match), or
- Prefer a positive allow only of ip.is_global, which returns False for private, loopback, link-local, multicast, reserved, unspecified, and CGN. Example: if not ip.is_global: block.
Apply this in both validation blocks in action.yml to ensure tokens are never sent to CGN or other non-global ranges.
2) Proxy-assisted SSRF: ensure the Go client does not use HTTP(S) proxies for untrusted, user-supplied baseURL. Approaches:
- Disable proxies for the Gitea client HTTP transport (set Transport.Proxy = nil) when baseURL is user-controlled (e.g., VCS_TYPE=gitea path). Since you already implement IP-level validation and redirect checks, removing proxy use eliminates the proxy bypass. Alternatively,
- Detect when a proxy is configured and reject untrusted baseURL outright, or implement additional validation that enforces the target host’s resolved IPs are safe even when a proxy is used (noting that with an HTTP proxy, target resolution/connection happens on the proxy side and cannot be verified client-side). The safest course is to bypass proxies for these requests.
3) Defense-in-depth: Consider making WithUnsafeDialer unexported to avoid accidental production use, keeping NewTestClient as the supported test-only path.
With these changes, the SSRF protections will be robust across direct and proxied connections, including CGN edge cases, and reduce the risk of token exfiltration. CI is passing; once these issues are fixed, the PR looks strong to approve.
---
*Review by security*
<!-- review-bot:security -->
---
*Evaluated against 934c6728*
[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.
[MAJOR] Same CGN gap in the re-validation block before downloads: the Python IP checks omit 100.64.0.0/10, enabling a DNS rebind or split-horizon scenario to CGN addresses even after initial validation.
**[MAJOR]** Same CGN gap in the re-validation block before downloads: the Python IP checks omit 100.64.0.0/10, enabling a DNS rebind or split-horizon scenario to CGN addresses even after initial validation.
[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).
[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.
gpt-review-bot
approved these changes 2026-05-14 11:52:20 +00:00
Security-focused changes add an SSRF-safe transport for the Gitea client, a URL validation subcommand, and defense-in-depth checks in the action script. The implementation is idiomatic, well-tested, and aligns with repository conventions; CI has passed.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck.go
48
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.
2
[NIT]
gitea/ipcheck.go
1
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.
3
[NIT]
.gitea/actions/review/action.yml
200
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.
4
[MINOR]
gitea/client.go
116
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.
Recommendation
APPROVE — The additions significantly improve SSRF defenses by validating resolved IPs in both the action and the client transport, and they introduce a helpful validate-url subcommand. Tests are comprehensive and adapt to the new safe dialer with a clear NewTestClient escape hatch. Minor refinements include: (1) fail fast or log on CIDR parse errors in ipcheck.go to avoid silent degradation of security, (2) consolidate package documentation into a single comment (doc.go) to follow Go package docs patterns, (3) replace the Unicode em dash in the action's Python script with a simple hyphen for broader portability, and (4) consider aligning safeDialContext’s per-IP timeouts with the overall client timeout. None of these block merging; approve as is.
# Gpt Review
## Summary
Security-focused changes add an SSRF-safe transport for the Gitea client, a URL validation subcommand, and defense-in-depth checks in the action script. The implementation is idiomatic, well-tested, and aligns with repository conventions; CI has passed.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck.go` | 48 | 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. |
| 2 | [NIT] | `gitea/ipcheck.go` | 1 | 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. |
| 3 | [NIT] | `.gitea/actions/review/action.yml` | 200 | 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. |
| 4 | [MINOR] | `gitea/client.go` | 116 | 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. |
## Recommendation
**APPROVE** — The additions significantly improve SSRF defenses by validating resolved IPs in both the action and the client transport, and they introduce a helpful validate-url subcommand. Tests are comprehensive and adapt to the new safe dialer with a clear NewTestClient escape hatch. Minor refinements include: (1) fail fast or log on CIDR parse errors in ipcheck.go to avoid silent degradation of security, (2) consolidate package documentation into a single comment (doc.go) to follow Go package docs patterns, (3) replace the Unicode em dash in the action's Python script with a simple hyphen for broader portability, and (4) consider aligning safeDialContext’s per-IP timeouts with the overall client timeout. None of these block merging; approve as is.
---
*Review by gpt*
<!-- review-bot:gpt -->
---
*Evaluated against 934c6728*
[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.
[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.
[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.
[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.
Python's ipaddress module does not classify 100.64.0.0/10 (RFC6598 Carrier-Grade NAT / Shared Address Space) as any of those properties — confirmed on Python 3.12:
Impact: The Go IsBlockedIP() correctly blocks 100.64.0.0/10 via an explicit CIDR entry. However, the curl commands in action.yml (binary download, version check) are protected by the Python pre-check — not by safeDialContext. A SERVER_URL resolving to a CGN address (e.g. 100.64.x.x) would pass the Python check and reach curl with ACTION_TOKEN attached.
Affected code: Both Python snippets in action.yml — the one in Determine version step and the re-validation in Install review-bot step.
Suggested fix: Add explicit CGN check to both Python scripts:
Alternatively, add ipaddress to the imports already present and use the network check. The ipaddress module is already imported for ipaddress.ip_address() so no new import needed.
All other checks passed:
go test ./... -count=1 — ✅ all packages pass
go vet ./... — ✅ clean
Logic correctness in IsBlockedIP() — ✅ covers all CIDR families including IPv6-mapped IPv4 normalization
action.yml injection safety — ✅CHECK_URL passed via env var, not interpolated into Python
## Self-review: NEEDS_WORK
### Finding: action.yml Python SSRF check misses CGN range (100.64.0.0/10)
**Severity: MINOR** (but correctness gap in the defense-in-depth layer)
**Root cause**: The Python check in `action.yml` uses:
```python
ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
```
Python's `ipaddress` module does **not** classify `100.64.0.0/10` (RFC6598 Carrier-Grade NAT / Shared Address Space) as any of those properties — confirmed on Python 3.12:
```
>>> ipaddress.ip_address('100.64.0.1').is_private
False
>>> ipaddress.ip_address('100.64.0.1').is_global
False
```
**Impact**: The Go `IsBlockedIP()` correctly blocks `100.64.0.0/10` via an explicit CIDR entry. However, the `curl` commands in `action.yml` (binary download, version check) are protected by the Python pre-check — **not** by `safeDialContext`. A `SERVER_URL` resolving to a CGN address (e.g. `100.64.x.x`) would pass the Python check and reach `curl` with `ACTION_TOKEN` attached.
**Affected code**: Both Python snippets in `action.yml` — the one in `Determine version` step and the re-validation in `Install review-bot` step.
**Suggested fix**: Add explicit CGN check to both Python scripts:
```python
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved \
or ip in ipaddress.ip_network('100.64.0.0/10'):
print(f"blocked: {a}", file=sys.stderr); sys.exit(1)
```
Alternatively, add `ipaddress` to the imports already present and use the network check. The `ipaddress` module is already imported for `ipaddress.ip_address()` so no new import needed.
---
**All other checks passed:**
- `go test ./... -count=1` — ✅ all packages pass
- `go vet ./...` — ✅ clean
- Logic correctness in `IsBlockedIP()` — ✅ covers all CIDR families including IPv6-mapped IPv4 normalization
- `safeDialContext` — ✅ correct any-blocked-rejects-all semantic, IP literal handling, connection fallback loop
- DNS rebinding window — ✅ acknowledged in comments, narrowed by dialing resolved IP directly
- `validateURL` subcommand — ✅ correctly validates scheme, user-info, DNS resolution
- Test coverage — ✅ 30+ cases including boundary tests, edge cases, IPv6-mapped IPv4
- `export_test.go` / `NewTestClient` pattern — ✅ idiomatic Go test isolation
- GitHub path unaffected — ✅ uses `github.api_url` (platform-controlled)
- `action.yml` injection safety — ✅ `CHECK_URL` passed via env var, not interpolated into Python
Python's ipaddress module does NOT classify 100.64.0.0/10 (RFC6598
carrier-grade NAT) as private/loopback/link_local/multicast/reserved.
This means a SERVER_URL resolving to a CGN address would bypass the
Python SSRF check and reach curl with ACTION_TOKEN.
Add an explicit network membership check for 100.64.0.0/10 to both
Python validation blocks in action.yml:
- _ssrf_check.py (VCS URL pre-flight check)
- _ssrf_check_install.py (binary download URL check)
The Go-level IsBlockedIP already covers this range correctly (ipcheck.go);
this fix closes the gap in the action.yml Python layer.
Also update comments to mention RFC6598 explicitly.
Pre-review action taken:
Found a security gap (RFC6598/CGN bypass in Python SSRF checks) during review. Python's ipaddress module does NOT classify 100.64.0.0/10 (RFC6598 carrier-grade NAT) as is_private. A SERVER_URL resolving to a CGN address would bypass the Python check and reach curl with ACTION_TOKEN. Added explicit ip in ipaddress.ip_network("100.64.0.0/10") check to both Python validation blocks. Pushed as additional commit c349986. The Go-level IsBlockedIP already covered this range correctly.
Checks completed:
✅go build ./cmd/review-bot — PASS
✅go vet ./... — PASS
✅go test ./... — all 6 packages PASS
✅ Coherence: single purpose, tight scope
✅ Fit: follows Go patterns (export_test.go, errors.As, http.Transport.Clone)
✅ Tests cover all critical paths including edge cases and CIDR validity
✅ All public functions documented
## Self-review: PASS
No issues found — ready for human review.
**Pre-review action taken:**
Found a security gap (RFC6598/CGN bypass in Python SSRF checks) during review. Python's `ipaddress` module does NOT classify 100.64.0.0/10 (RFC6598 carrier-grade NAT) as `is_private`. A SERVER_URL resolving to a CGN address would bypass the Python check and reach curl with ACTION_TOKEN. Added explicit `ip in ipaddress.ip_network("100.64.0.0/10")` check to both Python validation blocks. Pushed as additional commit `c349986`. The Go-level `IsBlockedIP` already covered this range correctly.
**Checks completed:**
- ✅ `go build ./cmd/review-bot` — PASS
- ✅ `go vet ./...` — PASS
- ✅ `go test ./...` — all 6 packages PASS
- ✅ Coherence: single purpose, tight scope
- ✅ Fit: follows Go patterns (export_test.go, errors.As, http.Transport.Clone)
- ✅ Solution: defense-in-depth (Python action layer + Go safeDialContext + validate-url CLI)
- ✅ DNS rebinding window acknowledged and narrowed
- ✅ IPv6-mapped IPv4 normalization in both layers
- ✅ RFC6598 CGN gap closed in Python layer
- ✅ Tests cover all critical paths including edge cases and CIDR validity
- ✅ All public functions documented
Well-engineered SSRF defense layer with correct Go idioms throughout. The implementation follows established patterns: export_test.go for white-box test helpers, init() for CIDR registration with deferred error surfacing, safe transport via DefaultTransport.Clone(), and comprehensive test coverage. CI passes.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/ipcheck.go
57
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.
2
[MINOR]
gitea/client.go
172
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.
3
[NIT]
.gitea/actions/review/action.yml
221
The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This increases maintenance burden — a future bug fix must be applied in two places. Consider extracting it to a shared function or a persistent helper script written once and sourced in both steps.
4
[NIT]
gitea/client_test.go
1316
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.
5
[NIT]
cmd/review-bot/validateurl.go
57
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.
Recommendation
APPROVE — Approve. The PR correctly implements a meaningful security feature (SSRF protection at the transport layer and in action steps). The Go patterns are largely idiomatic: the export_test.go bridge, cloning DefaultTransport, the init()-based CIDR registry, and the safeDialContext design are all sound. The most significant finding is that WithUnsafeDialer() is exported, creating a reachable bypass in production code — worth discussing whether to make it unexported. The duplicated Python snippet in action.yml is a maintenance smell. Neither rises to a blocking issue given that CI passes and the security model is substantially improved.
# Sonnet Review
## Summary
Well-engineered SSRF defense layer with correct Go idioms throughout. The implementation follows established patterns: `export_test.go` for white-box test helpers, `init()` for CIDR registration with deferred error surfacing, safe transport via `DefaultTransport.Clone()`, and comprehensive test coverage. CI passes.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/ipcheck.go` | 57 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 172 | `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. |
| 3 | [NIT] | `.gitea/actions/review/action.yml` | 221 | The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This increases maintenance burden — a future bug fix must be applied in two places. Consider extracting it to a shared function or a persistent helper script written once and sourced in both steps. |
| 4 | [NIT] | `gitea/client_test.go` | 1316 | `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. |
| 5 | [NIT] | `cmd/review-bot/validateurl.go` | 57 | 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. |
## Recommendation
**APPROVE** — Approve. The PR correctly implements a meaningful security feature (SSRF protection at the transport layer and in action steps). The Go patterns are largely idiomatic: the `export_test.go` bridge, cloning `DefaultTransport`, the `init()`-based CIDR registry, and the `safeDialContext` design are all sound. The most significant finding is that `WithUnsafeDialer()` is exported, creating a reachable bypass in production code — worth discussing whether to make it unexported. The duplicated Python snippet in `action.yml` is a maintenance smell. Neither rises to a blocking issue given that CI passes and the security model is substantially improved.
---
*Review by sonnet*
<!-- review-bot:sonnet -->
---
*Evaluated against c3499861*
[NIT] The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This increases maintenance burden — a future bug fix must be applied in two places. Consider extracting it to a shared function or a persistent helper script written once and sourced in both steps.
**[NIT]** The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This increases maintenance burden — a future bug fix must be applied in two places. Consider extracting it to a shared function or a persistent helper script written once and sourced in both steps.
[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.
**[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.
[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.
**[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.
[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.
**[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.
[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.
**[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.
gpt-review-bot
approved these changes 2026-05-14 13:44:02 +00:00
Well-implemented IP-level SSRF defenses across both the Gitea client and the action workflow. The safe DialContext, redirect policy, and comprehensive tests are idiomatic and align with error-handling and testing patterns; CI passed.
Findings
#
Severity
File
Line
Finding
1
[NIT]
.gitea/actions/review/action.yml
208
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.
2
[NIT]
.gitea/actions/review/action.yml
358
The same Python SSRF check is duplicated in the install step; similarly, constructing the CGN network once outside the loop would be cleaner.
Recommendation
APPROVE — The additions provide robust SSRF protection: the Gitea client now uses a safe transport that resolves and validates all resolved IPs before connecting (rejecting private/reserved ranges) and enforces a strict redirect policy, with clear escape hatches for tests. The action adds defense-in-depth with preflight URL validation using Python, and a validate-url subcommand offers reuse. Tests are comprehensive and idiomatic. Consider the minor cleanup in the Python snippets (constructing the CGN network outside the loop), but otherwise this is ready to merge.
# Gpt Review
## Summary
Well-implemented IP-level SSRF defenses across both the Gitea client and the action workflow. The safe DialContext, redirect policy, and comprehensive tests are idiomatic and align with error-handling and testing patterns; CI passed.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [NIT] | `.gitea/actions/review/action.yml` | 208 | 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. |
| 2 | [NIT] | `.gitea/actions/review/action.yml` | 358 | The same Python SSRF check is duplicated in the install step; similarly, constructing the CGN network once outside the loop would be cleaner. |
## Recommendation
**APPROVE** — The additions provide robust SSRF protection: the Gitea client now uses a safe transport that resolves and validates all resolved IPs before connecting (rejecting private/reserved ranges) and enforces a strict redirect policy, with clear escape hatches for tests. The action adds defense-in-depth with preflight URL validation using Python, and a validate-url subcommand offers reuse. Tests are comprehensive and idiomatic. Consider the minor cleanup in the Python snippets (constructing the CGN network outside the loop), but otherwise this is ready to merge.
---
*Review by gpt*
<!-- review-bot:gpt -->
---
*Evaluated against c3499861*
[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.
[NIT] The same Python SSRF check is duplicated in the install step; similarly, constructing the CGN network once outside the loop would be cleaner.
**[NIT]** The same Python SSRF check is duplicated in the install step; similarly, constructing the CGN network once outside the loop would be cleaner.
security-review-bot
requested review from security-review-bot 2026-05-14 13:44:22 +00:00
This PR significantly hardens the system against SSRF by adding IP-level blocking in both the Go Gitea client (dialer) and the composite action (preflight checks), along with a validate-url subcommand. The implementations follow good practices (DNS resolution, IPv6-mapped IPv4 normalization, redirect policy) and CI passed. I have only minor defense-in-depth suggestions and a small info-leak concern in error messages.
Findings
#
Severity
File
Line
Finding
1
[MINOR]
gitea/client.go
160
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.
2
[MINOR]
gitea/client.go
132
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).
3
[MINOR]
.gitea/actions/review/action.yml
210
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.
4
[NIT]
.gitea/actions/review/action.yml
191
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).
Recommendation
APPROVE — Overall, the SSRF defenses are thoughtfully implemented and significantly improve security. The Go client’s safeDialContext validates all resolved IPs, blocks private/reserved ranges (including IPv6-mapped IPv4), and avoids cross-host or HTTPS→HTTP redirects. The action adds robust pre-validation and re-validation to reduce DNS rebinding risk, and the new validate-url subcommand is useful for future use. To further harden the system, consider: (1) disabling or making opt-in the use of environment proxies (ProxyFromEnvironment) when dealing with user-supplied VCS URLs to prevent token exposure to untrusted proxies; and (2) redacting specific IP addresses from error outputs/logs to avoid internal IP leakage in CI logs. The slight difference between Python and Go block lists can be documented or harmonized. With these minor suggestions noted, the PR is ready to merge.
# Security Review
## Summary
This PR significantly hardens the system against SSRF by adding IP-level blocking in both the Go Gitea client (dialer) and the composite action (preflight checks), along with a validate-url subcommand. The implementations follow good practices (DNS resolution, IPv6-mapped IPv4 normalization, redirect policy) and CI passed. I have only minor defense-in-depth suggestions and a small info-leak concern in error messages.
## Findings
| # | Severity | File | Line | Finding |
|---|----------|------|------|--------|
| 1 | [MINOR] | `gitea/client.go` | 160 | 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. |
| 2 | [MINOR] | `gitea/client.go` | 132 | 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). |
| 3 | [MINOR] | `.gitea/actions/review/action.yml` | 210 | 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. |
| 4 | [NIT] | `.gitea/actions/review/action.yml` | 191 | 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). |
## Recommendation
**APPROVE** — Overall, the SSRF defenses are thoughtfully implemented and significantly improve security. The Go client’s safeDialContext validates all resolved IPs, blocks private/reserved ranges (including IPv6-mapped IPv4), and avoids cross-host or HTTPS→HTTP redirects. The action adds robust pre-validation and re-validation to reduce DNS rebinding risk, and the new validate-url subcommand is useful for future use. To further harden the system, consider: (1) disabling or making opt-in the use of environment proxies (ProxyFromEnvironment) when dealing with user-supplied VCS URLs to prevent token exposure to untrusted proxies; and (2) redacting specific IP addresses from error outputs/logs to avoid internal IP leakage in CI logs. The slight difference between Python and Go block lists can be documented or harmonized. With these minor suggestions noted, the PR is ready to merge.
---
*Review by security*
<!-- review-bot:security -->
---
*Evaluated against c3499861*
[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).
**[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).
[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.
[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).
[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.
rodin
added wip and removed ready labels 2026-05-14 14:00:08 +00:00
rodin
added ready and removed wip labels 2026-05-14 14:14:37 +00:00
rodin
removed their assignment 2026-05-14 14:14:37 +00:00
aweiker
was assigned by rodin2026-05-14 14:14:37 +00:00
rodin
removed the ready label 2026-05-14 14:49:33 +00:00
aweiker
was unassigned by rodin2026-05-14 14:49:34 +00:00
rodin
self-assigned this 2026-05-14 14:49:34 +00:00
rodin
added the wip label 2026-05-14 14:49:44 +00:00
rodin
removed the wip label 2026-05-14 16:40:06 +00:00
rodin
added the wip label 2026-05-14 16:47:00 +00:00
rodin
removed the wip label 2026-05-14 17:10:15 +00:00
rodin
added the ready label 2026-05-14 17:45:33 +00:00
rodin
added wip and removed ready labels 2026-05-14 18:01:24 +00:00
rodin
added ready and removed wip labels 2026-05-14 18:05:50 +00:00
rodin
added wip and removed ready labels 2026-05-14 18:11:21 +00:00
rodin
added ready and removed wip labels 2026-05-14 18:15:36 +00:00
rodin
merged commit 4440823571 into main2026-05-14 19:10:20 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #123
Summary
Adds IP-level SSRF protection to block requests to RFC1918, loopback, link-local, and other reserved address ranges when
vcs-urlis user-supplied (Gitea path).Changes
gitea.Client: SSRF-safe HTTP transport (primary defense)gitea/ipcheck.go:IsBlockedIP(ip net.IP)covers 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, and unspecified. IPv6-mapped IPv4 addresses are normalized before checking.gitea/client.go:safeDialContextresolves DNS and checks every returned IP before connecting. Rejects if any resolved IP is blocked (not just all), and if zero IPs are returned. Dials first resolved IP directly to narrow DNS rebinding window.NewClientuses safe transport by default.WithUnsafeDialer()escapes for tests.SetHTTPClient(nil)restores safe transport.NewTestClient(which callsWithUnsafeDialer()).validate-urlsubcommand (future use / defense-in-depth)review-bot validate-url <url>: validates https scheme, no user-info, resolves hostname, rejects any blocked IP.action.yml: Python3 IP check in "Determine version" stepprintfto a temp file (injection-safe: URL passed as env var, never interpolated into code) and runs it after the existing https scheme check.ACTION_TOKENtoSERVER_URL.SERVER_URL).Test Coverage
gitea/ipcheck_test.go: 30+ cases across all blocked CIDR families + public IPsgitea/client_test.go: safe transport presence,WithUnsafeDialer, loopback/localhost SSRF blockingcmd/review-bot/validateurl_test.go: scheme validation, user-info rejection, exit codesThreat Model
IsBlockedIPIsBlockedIPIsBlockedIP::ffff:10.x.x.x)ip.To4()normalizationvalidate-url+ existing bash scheme checkvalidate-urluser-info checkLimitations (acknowledged)
VCS_TYPE=github) not affected — uses trustedgithub.api_url.Self-review: PASS
No issues found — ready for human review.
Checklist
go test ./...,go vet ./...,scripts/check-deps.sh— all passIsBlockedIPexported cleanly.WithUnsafeDialer()clearly named for risk.NewTestClientfollows export_test.go pattern.SetHTTPClient(nil)restores safe transport. YAML injection issue in action.yml caught and fixed (env-var approach).validate-urldocumented in header comment as available for future steps.-c "..."), caught in self-review and fixed.Known limitations (out of scope):
github.api_urlOriginal reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
8c8f3ab4)Sonnet Review
Summary
This PR implements a well-designed, defense-in-depth SSRF protection for the Gitea client. The Go implementation is clean, idiomatic, and thoroughly tested. A few minor issues worth noting but none are blocking.
Findings
gitea/ipcheck_test.gogitea/client.gosafeDialContextcreates a newnet.Dialer{}on every call with no timeout configured. The outerhttp.Clienthas 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 showsd := &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.gitea/export_test.gopackage gitea(notpackage gitea_test), which meansNewTestClientis compiled into thegiteapackage 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 callsWithUnsafeDialer()which is an exported method on*Client, a caller in a non-test file could also callclient.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).cmd/review-bot/validateurl.goisValidateErrordoes not useerrors.As— it only checks for a direct type assertion. This means wrapped*validateErrorvalues (e.g.,fmt.Errorf("...: %w", &validateError{...})) would not be detected. SincevalidateURLonly ever returns bare*validateErrorvalues (never wrapped), this is safe in the current implementation. However, it deviates from the error-handling pattern in error-handling.md which prescribeserrors.Asfor type extraction. A future refactor that wraps these errors would silently break the code-path. Consider usingerrors.As(err, out)instead.action.ymlsys.exit(1)inside a list comprehension to exit on the first blocked IP. While this works in CPython, using a list comprehension for side effects (exit/print) is an unconventional Python anti-pattern and reduces readability. A for-loop would be clearer. Since this is a one-off shell script embedded in YAML, it's a minor style concern only.PLAN.mdRecommendation
APPROVE — Approve. The security implementation is sound and correct: CIDR coverage is comprehensive, IPv6-mapped IPv4 normalization is handled, the ANY-blocked-IP policy correctly defends against split-horizon DNS, and the DNS rebinding window is narrowed by dialing the first resolved IP directly. The test coverage is thorough with boundary cases. The
export_test.go/NewTestClient/WithUnsafeDialerpattern for test isolation is idiomatic Go (matches the stdlib pattern in net/http/export_test.go). The Python fallback for the pre-install bash step is a pragmatic and injection-safe solution. The two minor findings (no dial timeout, non-errors.Asin isValidateError) are low risk in the current codebase and can be addressed in follow-up PRs if desired.Review by sonnet
Evaluated against
8c8f3ab4@@ -0,0 +1,238 @@# Plan: Issue #123 — SSRF Defense: IP-Level URL Validation[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.
@@ -0,0 +55,4 @@// isValidateError checks if err is or wraps a *validateError and sets out.func isValidateError(err error, out **validateError) bool {if err == nil {[MINOR]
isValidateErrordoes not useerrors.As— it only checks for a direct type assertion. This means wrapped*validateErrorvalues (e.g.,fmt.Errorf("...: %w", &validateError{...})) would not be detected. SincevalidateURLonly ever returns bare*validateErrorvalues (never wrapped), this is safe in the current implementation. However, it deviates from the error-handling pattern in error-handling.md which prescribeserrors.Asfor type extraction. A future refactor that wraps these errors would silently break the code-path. Consider usingerrors.As(err, out)instead.@@ -109,0 +140,4 @@return d.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))}// newSafeHTTPClient returns an *http.Client with the SSRF-blocking safeDialContext[MINOR] The
safeDialContextcreates a newnet.Dialer{}on every call with no timeout configured. The outerhttp.Clienthas 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 showsd := &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.@@ -0,0 +1,11 @@package gitea[MINOR] The package declaration is
package gitea(notpackage gitea_test), which meansNewTestClientis compiled into thegiteapackage 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 callsWithUnsafeDialer()which is an exported method on*Client, a caller in a non-test file could also callclient.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).@@ -0,0 +89,4 @@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.[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
8c8f3ab4)Security Review
Summary
Strong SSRF defenses were added at both the Go client transport level and in the action workflow, including IP-based allowlisting and redirect hardening. The approach is sound and materially improves security without introducing obvious new vulnerabilities.
Findings
.gitea/actions/review/action.ymlRecommendation
APPROVE — The changes substantially strengthen SSRF protections by validating resolved IPs in the Gitea client (DialContext) and adding a preflight IP check in the action. As a small hardening improvement, also invoke the URL/IP validation at the beginning of the Install review-bot step to mitigate DNS rebinding across steps before curl requests that include Authorization headers. Otherwise, the implementation follows secure patterns (block private/link-local/loopback/multicast/reserved ranges, reject cross-host and downgrade redirects, normalize IPv6-mapped IPv4) and is appropriate to approve.
Review by security
Evaluated against
8c8f3ab4@@ -305,6 +332,11 @@ runs:ACTION_TOKEN="${ACTION_TOKEN:-}"[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
8c8f3ab4)Gpt Review
Summary
Solid, well-scoped SSRF mitigations were added across the Gitea client and action with good test coverage. The implementation follows idiomatic Go patterns and repository conventions, with only minor nits related to initialization panic and a readability concern in the action’s Python helper.
Findings
gitea/ipcheck.gogitea/client.go.gitea/actions/review/action.ymlsys.exit(1)), which hurts readability and can surprise maintainers. A simple for-loop with explicit exits would be clearer and easier to audit.Recommendation
APPROVE — The SSRF defense is thoughtfully implemented and well tested: IP-level checks in the Gitea client via a custom DialContext, an exported IsBlockedIP for reuse, a validate-url subcommand, and an additional bash-side Python check in the action. Redirect policies remain strict and existing tests were adapted via NewTestClient/WithUnsafeDialer to avoid localhost blocking. CI is green.
Before merging, consider the minor improvements:
These are not blockers; the current changes are correct and beneficial. Approve.
Review by gpt
Evaluated against
8c8f3ab4@@ -188,0 +197,4 @@# 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.[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.@@ -109,0 +117,4 @@// 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.[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).
@@ -0,0 +30,4 @@// 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",[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
f84cc3bb)Sonnet Review
Summary
This PR adds well-implemented SSRF defense to the Gitea client and action. The Go code follows established patterns correctly: export_test.go pattern for white-box testing, sentinel errors, proper error wrapping, safe defaults, and comprehensive test coverage. CI passed.
Findings
gitea/ipcheck.gogitea/client.gocmd/review-bot/validateurl.go.gitea/actions/review/action.ymlRecommendation
APPROVE — Approve. The implementation is correct, well-tested, and follows established Go patterns. The SSRF defense is multi-layered (Python check in shell steps + DialContext-level check in the Go client), properly documented, and the threat model acknowledgements are honest. The export_test.go pattern is exactly the right approach for exposing NewTestClient without polluting production API. All findings are minor/nit and do not block approval.
Review by sonnet
Evaluated against
f84cc3bb@@ -188,0 +202,4 @@'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' \[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.
@@ -0,0 +59,4 @@// are also detected, making the function robust against future wrapping.func isValidateError(err error, out **validateError) bool {if err == nil {return false[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.
@@ -109,0 +150,4 @@DialContext: safeDialContext,}return &http.Client{Timeout: 30 * time.Second,[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.
@@ -0,0 +54,4 @@func init() {blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))for _, r := range blockedCIDRStrings {_, cidr, err := net.ParseCIDR(r)[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
f84cc3bb)Gpt Review
Summary
Solid SSRF defenses added at both transport and CLI/action layers with good tests and documentation. However, the new HTTP transport discards important defaults from net/http's DefaultTransport, which can cause hangs and degraded behavior.
Findings
gitea/client.gogitea/client.go.gitea/actions/review/action.ymlRecommendation
REQUEST_CHANGES — Overall the SSRF protections and validate-url subcommand are well-implemented and tested. To address the critical transport issue, update newSafeHTTPClient to preserve the standard library's DefaultTransport defaults by cloning and overriding DialContext, for example:
transport := http.DefaultTransport.(http.Transport).Clone()
transport.DialContext = safeDialContext
client := &http.Client{Timeout: 30time.Second, Transport: transport, CheckRedirect: defaultCheckRedirect}
This maintains the recommended timeouts and pooling behavior while enforcing the SSRF dial guard. Additionally, enhance safeDialContext to attempt dialing each validated IP in order if the first connection fails, to avoid connectivity regressions on dual-stack hosts. Finally, refine the action.yml SSRF pre-check messaging to distinguish DNS errors from blocked ranges for clearer operator feedback. After these adjustments, the changes would be ready to merge.
Review by gpt
Evaluated against
f84cc3bb@@ -185,6 +191,30 @@ runs:echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2[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.
@@ -109,0 +117,4 @@// 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.[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.
@@ -109,0 +131,4 @@return nil, fmt.Errorf("safeDialContext: no addresses returned for %q", host)}for _, a := range addrs {if IsBlockedIP(a.IP) {[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.
Self-review: PASS
All 8 review findings addressed:
MAJOR:
gitea/ipcheck.go: replaced init-timepanicwithinit()+blockedCIDRParseErrorslist —TestBlockedCIDRsValidcatches programming errors in CI without violating CONVENTIONS.mdaction.yml: added DNS rebinding re-validation at start of Install step (closes the window between Determine version and install-step curl calls)MINOR:
gitea/client.go:net.Dialer{Timeout: 10 * time.Second}per PLAN.md specgitea/client.go: fallback dialing — now tries all validated IPs, not just the firstcmd/review-bot/validateurl.go:isValidateErroruseserrors.Asinstead of direct type assertiongitea/ipcheck_test.go: RFC5737 TEST-NET-2 comment clarified;TestBlockedCIDRsValidaddedNIT:
action.yml: Python list comprehension → for-loop in both SSRF check locationsgitea/export_test.go: expanded comment explaining white-box test patternGates:
scripts/check-deps.sh✅,go vet ./...✅,go test -race ./...✅Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
5ac93bea)Sonnet Review
Summary
Well-structured SSRF defense implementation with appropriate depth-in-defense layering (Python3 pre-check in bash, DialContext-level IP blocking in Go, validate-url subcommand). The code follows established Go patterns, is well-tested, and CI passes. Minor issues exist but none are blockers.
Findings
gitea/ipcheck.gogitea/client.god := &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.gitea/client_test.go.gitea/actions/review/action.ymlgitea/client.go// 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.cmd/review-bot/validateurl.gogitea/ipcheck_test.goRecommendation
APPROVE — This is a solid, well-thought-out security feature. CI passes, the implementation follows Go patterns (export_test.go pattern, interface-level testing, table-driven tests), and the threat model documentation is thorough. The findings above are minor: the most notable is that init() silently swallowing CIDR parse errors means a production binary could theoretically have incomplete blocking if tests weren't run — a panic in init() would be more appropriate for compile-time-known literals. The duplicated Python script in action.yml is a maintenance concern. None of these are blockers. Approve with the recommendation to address the init() pattern and script duplication in a follow-up.
Review by sonnet
Evaluated against
5ac93bea@@ -188,0 +202,4 @@'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' \[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.
@@ -0,0 +64,4 @@return errors.As(err, out)}// validateURL checks that rawURL is safe for use as a Gitea server URL:[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.
@@ -109,0 +152,4 @@}lastErr = err}return nil, fmt.Errorf("safeDialContext: all %d addresses for %q failed, last error: %w", len(addrs), host, lastErr)[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.@@ -109,0 +167,4 @@CheckRedirect: defaultCheckRedirect,}}[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.@@ -1288,0 +1296,4 @@url string}{{"loopback localhost", "http://localhost/"},{"loopback 127.0.0.1", "http://127.0.0.1/"},[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.
@@ -0,0 +60,4 @@// will catch this during tests, and the CI build will fail.blockedCIDRParseErrors = append(blockedCIDRParseErrors,fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))continue[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.
@@ -0,0 +92,4 @@}func TestIsBlockedIPv6MappedIPv4(t *testing.T) {// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.[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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
5ac93bea)Gpt Review
Summary
Security additions and validate-url subcommand look solid and well-tested. However, the new safe HTTP transport in gitea.Client regresses important http.Transport defaults (proxy support and timeouts), which can break environments and cause resource issues.
Findings
gitea/client.gogitea/client.goRecommendation
REQUEST_CHANGES — Overall the SSRF defenses are thoughtfully implemented: safeDialContext checks resolved IPs, action.yml adds a pre-flight Python IP check, and the validate-url subcommand provides a CLI validator. Tests comprehensively cover these paths. However, please address the http.Transport regression introduced by newSafeHTTPClient.
Recommended fix:
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = safeDialContext
// Optionally ensure ForceAttemptHTTP2 remains true (default) and other defaults intact
return &http.Client{Timeout: 30 * time.Second, Transport: t, CheckRedirect: defaultCheckRedirect}
This preserves ProxyFromEnvironment and sane defaults (IdleConnTimeout=90s, TLSHandshakeTimeout=10s, ExpectContinueTimeout=1s, MaxIdleConns=100, ForceAttemptHTTP2=true) while adding the SSRF protection.
Alternatively, explicitly set the same defaults on a new http.Transport, including:
After updating newSafeHTTPClient, keep the CheckRedirect policy as-is. With these changes, you maintain the new security guarantees without breaking existing behavior in proxied environments or degrading transport performance.
Review by gpt
Evaluated against
5ac93bea@@ -109,0 +136,4 @@}}// Try each resolved IP in order, returning the first successful connection.// Fallback is important when a hostname resolves to multiple IPs and the first[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] 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.
Original reviewSuperseded — see current review for up-to-date findings.
Previous findings (commit
5ac93bea)Security Review
Summary
Strong SSRF defenses were added in both the Go client and the action, but the Python pre-check in action.yml can be bypassed with IPv6-mapped IPv4 addresses. Also, the action’s pre-check doesn’t reject URLs with embedded user-info. Addressing these will close important gaps before secrets are sent via curl.
Findings
.gitea/actions/review/action.yml.gitea/actions/review/action.ymlRecommendation
REQUEST_CHANGES — Fix the action pre-check to fully align with the Go client’s SSRF defenses and the validate-url subcommand:
With these adjustments, the token-bearing curl calls will be protected from IPv6-mapped bypasses and unsafe URL forms. The Go client and new subcommand look solid and significantly improve SSRF resistance; once the action pre-check is corrected, the PR can be approved.
Review by security
Evaluated against
5ac93bea@@ -185,6 +191,30 @@ runs:echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2[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.
- Clone http.DefaultTransport instead of bare &http.Transport{} to preserve ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, connection pooling, and HTTP/2 support (fixes transport regression). - Add IPv6-mapped IPv4 normalization in action.yml Python SSRF checks to prevent bypass via ::ffff:10.0.0.1 style AAAA records. - Reject URLs with user-info (user:pass@host) in action.yml Python checks to match validate-url subcommand behavior. - Add test verifying DefaultTransport settings are preserved.Sonnet Review
Summary
This PR adds well-structured IP-level SSRF protection to the Gitea client with defense-in-depth at three layers: DNS-resolve-before-dial in the Go transport, a Python pre-check in action.yml, and a validate-url subcommand. The implementation is correct, thoroughly tested, and follows established patterns.
Findings
gitea/ipcheck.goinit()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 toblockedCIDRParseErrorsand checking in a test) is more complex than a simplevar _ = func() { ... panic(...) }()orsync.OnceFuncapproach. More importantly, ifTestBlockedCIDRsValidis 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 directinit()that panics on parse failure would be idiomatic and safer. Seeregexp.MustCompilepattern: panicking on programmer errors caught at init time is the correct approach here.gitea/client.gosafeDialContext, 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.action.yml.gitea/actions/review/ssrf_check.py) and calling it from both steps, which would also be easier to test independently.cmd/review-bot/validateurl.goisValidateErrorhelper is a thin wrapper arounderrors.As(err, out)with only a nil guard added. Sinceerrors.Asalready handles nil targets gracefully (it panics only if target is nil pointer, which would be a programmer error), and the nil check onerris not strictly needed (errors.As handles nil err by returning false), this function adds little value and could just beerrors.As(err, out)at the call sites. That said, the wrapper does improve readability of intent and is harmless.gitea/client_test.goTestSafeDialContextBlocksPrivateIPs, when the error contains none of the expected keywords ('blocked', 'private', 'loopback', 'reserved'), the test only logs the error witht.Logfbut 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 assertserr != nilalready handles the success case, it should at minimum emitt.Errorf(not justt.Logf) when the error doesn't mention the SSRF block, making the test stricter.Recommendation
APPROVE — Approve with minor observations. The core security implementation is correct and well-designed: CIDR list is comprehensive, IPv6-mapped IPv4 normalization is handled, the ANY-IP-blocked policy for split-horizon DNS is appropriate, and the defense-in-depth layering (transport + action.yml + subcommand) is thoughtful. The export_test.go pattern for NewTestClient is idiomatic. Test coverage is thorough. The findings above are minor quality issues that don't affect correctness or security: (1) the init()-vs-panic design choice for CIDR parsing is a style preference, (2) a slightly misleading doc comment in safeDialContext, (3) duplicated Python script in action.yml, (4) an overly weak assertion in one test. None of these warrant blocking the PR.
Review by sonnet
Evaluated against
934c6728@@ -0,0 +45,4 @@fmt.Fprintf(outWriter, "OK: %s is safe\n", rawURL)return 0}[NIT] The
isValidateErrorhelper is a thin wrapper arounderrors.As(err, out)with only a nil guard added. Sinceerrors.Asalready handles nil targets gracefully (it panics only if target is nil pointer, which would be a programmer error), and the nil check onerris not strictly needed (errors.As handles nil err by returning false), this function adds little value and could just beerrors.As(err, out)at the call sites. That said, the wrapper does improve readability of intent and is harmless.@@ -109,0 +154,4 @@}return nil, fmt.Errorf("safeDialContext: all %d addresses for %q failed, last error: %w", len(addrs), host, lastErr)}[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.@@ -1288,0 +1310,4 @@if !strings.Contains(err.Error(), "blocked") &&!strings.Contains(err.Error(), "private") &&!strings.Contains(err.Error(), "loopback") &&!strings.Contains(err.Error(), "reserved") {[NIT] In
TestSafeDialContextBlocksPrivateIPs, when the error contains none of the expected keywords ('blocked', 'private', 'loopback', 'reserved'), the test only logs the error witht.Logfbut 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 assertserr != nilalready handles the success case, it should at minimum emitt.Errorf(not justt.Logf) when the error doesn't mention the SSRF block, making the test stricter.@@ -0,0 +57,4 @@_, cidr, err := net.ParseCIDR(r)if err != nil {// Record the error rather than panicking; TestBlockedCIDRsValid// will catch this during tests, and the CI build will fail.[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 toblockedCIDRParseErrorsand checking in a test) is more complex than a simplevar _ = func() { ... panic(...) }()orsync.OnceFuncapproach. More importantly, ifTestBlockedCIDRsValidis 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 directinit()that panics on parse failure would be idiomatic and safer. Seeregexp.MustCompilepattern: panicking on programmer errors caught at init time is the correct approach here.Security Review
Summary
Excellent SSRF-hardening overall (scheme/userinfo checks, IP allowlisting, safe Dialer, redirect policy). However, two gaps create exploitable paths: the Python preflight check in action.yml misses CGN (100.64.0.0/10), and the Go client's safe dial does not account for HTTP(S) proxies, allowing proxy-assisted SSRF to internal IPs.
Findings
.gitea/actions/review/action.yml.gitea/actions/review/action.ymlgitea/client.gogitea/client.goRecommendation
REQUEST_CHANGES — Address the two primary SSRF gaps before merging:
Apply this in both validation blocks in action.yml to ensure tokens are never sent to CGN or other non-global ranges.
With these changes, the SSRF protections will be robust across direct and proxied connections, including CGN edge cases, and reduce the risk of token exfiltration. CI is passing; once these issues are fixed, the PR looks strong to approve.
Review by security
Evaluated against
934c6728@@ -188,0 +206,4 @@' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \'h=parsed.hostname' \'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else None' \'try: rs=socket.getaddrinfo(h,None)' \[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.
@@ -308,0 +351,4 @@'u=os.environ["CHECK_URL"]; parsed=urlparse(u)' \'if parsed.username or parsed.password:' \' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \'h=parsed.hostname' \[MAJOR] Same CGN gap in the re-validation block before downloads: the Python IP checks omit 100.64.0.0/10, enabling a DNS rebind or split-horizon scenario to CGN addresses even after initial validation.
@@ -109,0 +128,4 @@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).
@@ -109,0 +140,4 @@// is temporarily unreachable. All IPs were already validated above, so dialing// any of them is safe.//// Timeout: 10s per the design (PLAN.md); the outer http.Client has a 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.
Gpt Review
Summary
Security-focused changes add an SSRF-safe transport for the Gitea client, a URL validation subcommand, and defense-in-depth checks in the action script. The implementation is idiomatic, well-tested, and aligns with repository conventions; CI has passed.
Findings
gitea/ipcheck.gogitea/ipcheck.go.gitea/actions/review/action.ymlgitea/client.goRecommendation
APPROVE — The additions significantly improve SSRF defenses by validating resolved IPs in both the action and the client transport, and they introduce a helpful validate-url subcommand. Tests are comprehensive and adapt to the new safe dialer with a clear NewTestClient escape hatch. Minor refinements include: (1) fail fast or log on CIDR parse errors in ipcheck.go to avoid silent degradation of security, (2) consolidate package documentation into a single comment (doc.go) to follow Go package docs patterns, (3) replace the Unicode em dash in the action's Python script with a simple hyphen for broader portability, and (4) consider aligning safeDialContext’s per-IP timeouts with the overall client timeout. None of these block merging; approve as is.
Review by gpt
Evaluated against
934c6728@@ -188,0 +197,4 @@# 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.[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.
@@ -109,0 +113,4 @@//// 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[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.
@@ -0,0 +1,91 @@// Package gitea provides a client for the Gitea API.[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.
@@ -0,0 +45,4 @@// blockedCIDRs is the parsed form of blockedCIDRStrings.// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.[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.
Self-review: NEEDS_WORK
Finding: action.yml Python SSRF check misses CGN range (100.64.0.0/10)
Severity: MINOR (but correctness gap in the defense-in-depth layer)
Root cause: The Python check in
action.ymluses:Python's
ipaddressmodule does not classify100.64.0.0/10(RFC6598 Carrier-Grade NAT / Shared Address Space) as any of those properties — confirmed on Python 3.12:Impact: The Go
IsBlockedIP()correctly blocks100.64.0.0/10via an explicit CIDR entry. However, thecurlcommands inaction.yml(binary download, version check) are protected by the Python pre-check — not bysafeDialContext. ASERVER_URLresolving to a CGN address (e.g.100.64.x.x) would pass the Python check and reachcurlwithACTION_TOKENattached.Affected code: Both Python snippets in
action.yml— the one inDetermine versionstep and the re-validation inInstall review-botstep.Suggested fix: Add explicit CGN check to both Python scripts:
Alternatively, add
ipaddressto the imports already present and use the network check. Theipaddressmodule is already imported foripaddress.ip_address()so no new import needed.All other checks passed:
go test ./... -count=1— ✅ all packages passgo vet ./...— ✅ cleanIsBlockedIP()— ✅ covers all CIDR families including IPv6-mapped IPv4 normalizationsafeDialContext— ✅ correct any-blocked-rejects-all semantic, IP literal handling, connection fallback loopvalidateURLsubcommand — ✅ correctly validates scheme, user-info, DNS resolutionexport_test.go/NewTestClientpattern — ✅ idiomatic Go test isolationgithub.api_url(platform-controlled)action.ymlinjection safety — ✅CHECK_URLpassed via env var, not interpolated into PythonSelf-review: PASS
No issues found — ready for human review.
Pre-review action taken:
Found a security gap (RFC6598/CGN bypass in Python SSRF checks) during review. Python's
ipaddressmodule does NOT classify 100.64.0.0/10 (RFC6598 carrier-grade NAT) asis_private. A SERVER_URL resolving to a CGN address would bypass the Python check and reach curl with ACTION_TOKEN. Added explicitip in ipaddress.ip_network("100.64.0.0/10")check to both Python validation blocks. Pushed as additional commitc349986. The Go-levelIsBlockedIPalready covered this range correctly.Checks completed:
go build ./cmd/review-bot— PASSgo vet ./...— PASSgo test ./...— all 6 packages PASSSonnet Review
Summary
Well-engineered SSRF defense layer with correct Go idioms throughout. The implementation follows established patterns:
export_test.gofor white-box test helpers,init()for CIDR registration with deferred error surfacing, safe transport viaDefaultTransport.Clone(), and comprehensive test coverage. CI passes.Findings
gitea/ipcheck.goinit()function is used here to parse CIDRs and populateblockedCIDRs. 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 immediatepanicininit()would be more idiomatic (as used inregexp.MustCompilefor 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.gitea/client.goWithUnsafeDialer()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 importsgitea. Theexport_test.gopattern exists precisely for this purpose —NewTestClientinexport_test.gocorrectly wraps it, butWithUnsafeDialeritself remains callable by production code in other packages. Consider making it unexported (withUnsafeDialer) and only exposing it viaexport_test.go, or accepting the trade-off if the API stability concern outweighs the risk..gitea/actions/review/action.ymlgitea/client_test.goTestSafeDialContextBlocksPrivateIPsusest.Logfto emit the error but doesn't callt.Errorfwhen 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 tot.Errorfso that unexpected error messages are flagged, or the comment should be clearer about the intentional leniency.cmd/review-bot/validateurl.goisValidateErrorhelper wrapserrors.Aswith a nil check, buterrors.Asalready handles nil correctly (returns false for nil err). The nil guard is harmless but redundant per the documented error-handling patterns.Recommendation
APPROVE — Approve. The PR correctly implements a meaningful security feature (SSRF protection at the transport layer and in action steps). The Go patterns are largely idiomatic: the
export_test.gobridge, cloningDefaultTransport, theinit()-based CIDR registry, and thesafeDialContextdesign are all sound. The most significant finding is thatWithUnsafeDialer()is exported, creating a reachable bypass in production code — worth discussing whether to make it unexported. The duplicated Python snippet inaction.ymlis a maintenance smell. Neither rises to a blocking issue given that CI passes and the security model is substantially improved.Review by sonnet
Evaluated against
c3499861@@ -188,0 +218,4 @@' print(f"blocked: {a}",file=sys.stderr); sys.exit(1)' \> /tmp/_ssrf_check.pyCHECK_URL="${SERVER_URL}" python3 /tmp/_ssrf_check.py || {echo "Error: SERVER_URL '${SERVER_URL}' resolves to a private/reserved IP address" >&2[NIT] The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This increases maintenance burden — a future bug fix must be applied in two places. Consider extracting it to a shared function or a persistent helper script written once and sourced in both steps.
@@ -0,0 +54,4 @@func (e *validateError) Error() string { return e.message }// isValidateError checks if err is or wraps a *validateError and sets out.[NIT] The
isValidateErrorhelper wrapserrors.Aswith a nil check, buterrors.Asalready handles nil correctly (returns false for nil err). The nil guard is harmless but redundant per the documented error-handling patterns.@@ -109,0 +169,4 @@Transport: transport,CheckRedirect: defaultCheckRedirect,}}[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 importsgitea. Theexport_test.gopattern exists precisely for this purpose —NewTestClientinexport_test.gocorrectly wraps it, butWithUnsafeDialeritself remains callable by production code in other packages. Consider making it unexported (withUnsafeDialer) and only exposing it viaexport_test.go, or accepting the trade-off if the API stability concern outweighs the risk.@@ -1288,0 +1313,4 @@!strings.Contains(err.Error(), "reserved") {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.[NIT]
TestSafeDialContextBlocksPrivateIPsusest.Logfto emit the error but doesn't callt.Errorfwhen 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 tot.Errorfso that unexpected error messages are flagged, or the comment should be clearer about the intentional leniency.@@ -0,0 +54,4 @@func init() {blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))for _, r := range blockedCIDRStrings {_, cidr, err := net.ParseCIDR(r)[MINOR] The
init()function is used here to parse CIDRs and populateblockedCIDRs. 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 immediatepanicininit()would be more idiomatic (as used inregexp.MustCompilefor 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.Gpt Review
Summary
Well-implemented IP-level SSRF defenses across both the Gitea client and the action workflow. The safe DialContext, redirect policy, and comprehensive tests are idiomatic and align with error-handling and testing patterns; CI passed.
Findings
.gitea/actions/review/action.yml.gitea/actions/review/action.ymlRecommendation
APPROVE — The additions provide robust SSRF protection: the Gitea client now uses a safe transport that resolves and validates all resolved IPs before connecting (rejecting private/reserved ranges) and enforces a strict redirect policy, with clear escape hatches for tests. The action adds defense-in-depth with preflight URL validation using Python, and a validate-url subcommand offers reuse. Tests are comprehensive and idiomatic. Consider the minor cleanup in the Python snippets (constructing the CGN network outside the loop), but otherwise this is ready to merge.
Review by gpt
Evaluated against
c3499861@@ -188,0 +205,4 @@'u=os.environ["CHECK_URL"]; parsed=urlparse(u)' \'if parsed.username or parsed.password:' \' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \'h=parsed.hostname' \[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.
@@ -308,0 +355,4 @@' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \'h=parsed.hostname' \'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else None' \'try: rs=socket.getaddrinfo(h,None)' \[NIT] The same Python SSRF check is duplicated in the install step; similarly, constructing the CGN network once outside the loop would be cleaner.
Security Review
Summary
This PR significantly hardens the system against SSRF by adding IP-level blocking in both the Go Gitea client (dialer) and the composite action (preflight checks), along with a validate-url subcommand. The implementations follow good practices (DNS resolution, IPv6-mapped IPv4 normalization, redirect policy) and CI passed. I have only minor defense-in-depth suggestions and a small info-leak concern in error messages.
Findings
gitea/client.gogitea/client.go.gitea/actions/review/action.yml.gitea/actions/review/action.ymlRecommendation
APPROVE — Overall, the SSRF defenses are thoughtfully implemented and significantly improve security. The Go client’s safeDialContext validates all resolved IPs, blocks private/reserved ranges (including IPv6-mapped IPv4), and avoids cross-host or HTTPS→HTTP redirects. The action adds robust pre-validation and re-validation to reduce DNS rebinding risk, and the new validate-url subcommand is useful for future use. To further harden the system, consider: (1) disabling or making opt-in the use of environment proxies (ProxyFromEnvironment) when dealing with user-supplied VCS URLs to prevent token exposure to untrusted proxies; and (2) redacting specific IP addresses from error outputs/logs to avoid internal IP leakage in CI logs. The slight difference between Python and Go block lists can be documented or harmonized. With these minor suggestions noted, the PR is ready to merge.
Review by security
Evaluated against
c3499861@@ -185,6 +191,36 @@ runs:echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2[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).
@@ -188,0 +207,4 @@' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \'h=parsed.hostname' \'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else None' \'try: rs=socket.getaddrinfo(h,None)' \[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.
@@ -109,0 +129,4 @@}if len(addrs) == 0 {return nil, fmt.Errorf("safeDialContext: no addresses returned for %q", host)}[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).
@@ -109,0 +157,4 @@// newSafeHTTPClient returns an *http.Client with the SSRF-blocking safeDialContext// transport and the cross-host redirect rejection policy.//[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.