feat(#123): add IP-level SSRF defense to Gitea client and action #129
@@ -9,7 +9,13 @@
|
|||||||
# token exfiltration. API calls use github.api_url; downloads use
|
# token exfiltration. API calls use github.api_url; downloads use
|
||||||
# github.server_url. Tokens are never sent to user-supplied URLs.
|
# github.server_url. Tokens are never sent to user-supplied URLs.
|
||||||
# - On Gitea (VCS_TYPE=gitea), inputs.vcs-url is validated (https scheme,
|
# - On Gitea (VCS_TYPE=gitea), inputs.vcs-url is validated (https scheme,
|
||||||
# no whitespace/newlines) before use.
|
# no whitespace/newlines, and DNS resolution to a public IP) before use.
|
||||||
|
# Python3 resolves the hostname and rejects RFC1918, RFC6598 (carrier-grade
|
||||||
|
# NAT), loopback, link-local, and other reserved addresses to prevent SSRF attacks.
|
||||||
|
# The installed review-bot binary additionally uses a safe HTTP transport
|
||||||
|
# (DialContext-level IP check) for all Gitea API calls at runtime.
|
||||||
|
# The binary also exposes a `validate-url` subcommand for use in any future
|
||||||
|
# shell steps that need to validate a URL before passing it to curl.
|
||||||
# - action-repo is validated against owner/repo pattern.
|
# - action-repo is validated against owner/repo pattern.
|
||||||
# - Tokens are passed via masked environment variables, not step outputs.
|
# - Tokens are passed via masked environment variables, not step outputs.
|
||||||
#
|
#
|
||||||
@@ -185,6 +191,36 @@ runs:
|
|||||||
echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2
|
echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2
|
||||||
|
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Additional IP-level SSRF defense: resolve the hostname and reject
|
||||||
|
# requests to RFC1918, RFC6598 (carrier-grade NAT), loopback, link-local,
|
||||||
|
# and other reserved addresses.
|
||||||
|
# python3 is required on ubuntu-* runners (see requirements comment above).
|
||||||
|
# Use printf to write the script to a temp file so the python lines are valid
|
||||||
|
# YAML (each indented line becomes a printf argument — no unindented code).
|
||||||
|
gpt-review-bot
commented
[NIT] The Python SSRF check uses a list comprehension with side effects ( **[NIT]** The Python SSRF check uses a list comprehension with side effects (`sys.exit(1)`), which hurts readability and can surprise maintainers. A simple for-loop with explicit exits would be clearer and easier to audit.
gpt-review-bot
commented
[NIT] The inline Python script uses a Unicode em dash (—) in an error message. While runners typically use UTF-8, non-ASCII characters in shell-embedded scripts can cause encoding issues in some environments. Consider using a plain hyphen '-' for maximum portability. **[NIT]** The inline Python script uses a Unicode em dash (—) in an error message. While runners typically use UTF-8, non-ASCII characters in shell-embedded scripts can cause encoding issues in some environments. Consider using a plain hyphen '-' for maximum portability.
|
|||||||
|
# SERVER_URL is passed via CHECK_URL env var, never interpolated into python code.
|
||||||
|
printf '%s\n' \
|
||||||
|
'import socket,ipaddress,sys,os' \
|
||||||
|
'from urllib.parse import urlparse' \
|
||||||
|
'u=os.environ["CHECK_URL"]; parsed=urlparse(u)' \
|
||||||
|
sonnet-review-bot
commented
[NIT] The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This is intentional (defense-in-depth re-check before each curl that sends ACTION_TOKEN) and is documented in the comments. The duplication is justified by the security goal, but a helper function or sourced script would reduce maintenance burden if the check logic ever needs to change. **[NIT]** The Python SSRF check script is duplicated verbatim in two steps ('Determine version' and 'Install review-bot'). This is intentional (defense-in-depth re-check before each curl that sends ACTION_TOKEN) and is documented in the comments. The duplication is justified by the security goal, but a helper function or sourced script would reduce maintenance burden if the check logic ever needs to change.
sonnet-review-bot
commented
[MINOR] The Python SSRF check script is duplicated verbatim in two places (Determine version step and Install review-bot step). The only difference is the temp file name (_ssrf_check.py vs _ssrf_check_install.py). This is a maintenance burden — a future change to the check logic must be applied twice. Consider extracting it into a shell function or a separate composite step. **[MINOR]** The Python SSRF check script is duplicated verbatim in two places (Determine version step and Install review-bot step). The only difference is the temp file name (_ssrf_check.py vs _ssrf_check_install.py). This is a maintenance burden — a future change to the check logic must be applied twice. Consider extracting it into a shell function or a separate composite step.
|
|||||||
|
'if parsed.username or parsed.password:' \
|
||||||
|
' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \
|
||||||
|
'h=parsed.hostname' \
|
||||||
|
gpt-review-bot
commented
[NIT] In the Python SSRF check, the CGN network object (ipaddress.ip_network("100.64.0.0/10")) is constructed inside the loop for each address. Consider moving it outside the loop for minor efficiency and readability. **[NIT]** In the Python SSRF check, the CGN network object (ipaddress.ip_network("100.64.0.0/10")) is constructed inside the loop for each address. Consider moving it outside the loop for minor efficiency and readability.
|
|||||||
|
'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else 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. **[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.
|
|||||||
|
'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. **[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.
|
|||||||
|
'except socket.gaierror as e: print(f"DNS error: {e}",file=sys.stderr); sys.exit(1)' \
|
||||||
|
'if not rs: print("Error: no addresses",file=sys.stderr); sys.exit(1)' \
|
||||||
|
'for _,_,_,_,(a,*_) in rs:' \
|
||||||
|
' ip=ipaddress.ip_address(a)' \
|
||||||
|
' if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped: ip=ip.ipv4_mapped' \
|
||||||
|
' cgn=ipaddress.ip_network("100.64.0.0/10")' \
|
||||||
|
' if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip in cgn:' \
|
||||||
|
' print(f"blocked: {a}",file=sys.stderr); sys.exit(1)' \
|
||||||
|
> /tmp/_ssrf_check.py
|
||||||
|
CHECK_URL="${SERVER_URL}" python3 /tmp/_ssrf_check.py || {
|
||||||
|
echo "Error: SERVER_URL '${SERVER_URL}' resolves to a private/reserved IP address" >&2
|
||||||
|
sonnet-review-bot
commented
[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.
|
|||||||
|
exit 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine auth token for release API requests
|
# Determine auth token for release API requests
|
||||||
@@ -305,6 +341,36 @@ runs:
|
|||||||
ACTION_TOKEN="${ACTION_TOKEN:-}"
|
ACTION_TOKEN="${ACTION_TOKEN:-}"
|
||||||
BINARY="review-bot-${OS}-${ARCH}"
|
BINARY="review-bot-${OS}-${ARCH}"
|
||||||
|
|
||||||
|
# SECURITY: Re-validate SERVER_URL at the start of this step to mitigate DNS
|
||||||
|
# rebinding attacks. A DNS TTL expiry between "Determine version" and here
|
||||||
|
# could allow an attacker to change the resolved IP to a private/reserved
|
||||||
|
# address, causing curl to send ACTION_TOKEN to an internal host.
|
||||||
|
# Only needed on Gitea path (VCS_TYPE=gitea); GitHub/GHES uses platform-controlled URLs.
|
||||||
|
if [ "$VCS_TYPE" = "gitea" ]; then
|
||||||
|
printf '%s\n' \
|
||||||
|
'import socket,ipaddress,sys,os' \
|
||||||
|
'from urllib.parse import urlparse' \
|
||||||
|
'u=os.environ["CHECK_URL"]; parsed=urlparse(u)' \
|
||||||
|
'if parsed.username or parsed.password:' \
|
||||||
|
[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.
|
|||||||
|
' 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)' \
|
||||||
|
gpt-review-bot
commented
[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.
|
|||||||
|
'except socket.gaierror as e: print(f"DNS error: {e}",file=sys.stderr); sys.exit(1)' \
|
||||||
|
'if not rs: print("Error: no addresses",file=sys.stderr); sys.exit(1)' \
|
||||||
|
'for _,_,_,_,(a,*_) in rs:' \
|
||||||
|
' ip=ipaddress.ip_address(a)' \
|
||||||
|
' if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped: ip=ip.ipv4_mapped' \
|
||||||
|
' cgn=ipaddress.ip_network("100.64.0.0/10")' \
|
||||||
|
' if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip in cgn:' \
|
||||||
|
' print(f"blocked: {a}",file=sys.stderr); sys.exit(1)' \
|
||||||
|
> /tmp/_ssrf_check_install.py
|
||||||
|
CHECK_URL="${SERVER_URL}" python3 /tmp/_ssrf_check_install.py || {
|
||||||
|
echo "Error: SERVER_URL '${SERVER_URL}' resolves to a private/reserved IP address" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$VCS_TYPE" = "github" ]; then
|
if [ "$VCS_TYPE" = "github" ]; then
|
||||||
# GitHub/GHES: Use REST API for release asset downloads.
|
# GitHub/GHES: Use REST API for release asset downloads.
|
||||||
# Web release URLs ({server}/.../releases/download/{tag}/{asset}) redirect
|
# Web release URLs ({server}/.../releases/download/{tag}/{asset}) redirect
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,6 +20,13 @@ import (
|
|||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
// outWriter and errWriter are the output and error writers for subcommands.
|
||||||
|
// They are variables so tests can capture output.
|
||||||
|
var (
|
||||||
|
outWriter io.Writer = os.Stdout
|
||||||
|
errWriter io.Writer = os.Stderr
|
||||||
|
)
|
||||||
|
|
||||||
// setupLogger configures the global slog default logger based on format and verbosity.
|
// setupLogger configures the global slog default logger based on format and verbosity.
|
||||||
func setupLogger(format, verbosity string) {
|
func setupLogger(format, verbosity string) {
|
||||||
var level slog.Level
|
var level slog.Level
|
||||||
@@ -49,6 +57,15 @@ func setupLogger(format, verbosity string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Dispatch subcommands before flag parsing so they get their own args.
|
||||||
|
// e.g. `review-bot validate-url <url>`
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "validate-url":
|
||||||
|
os.Exit(runValidateURL(os.Args[2:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
versionFlag := flag.Bool("version", false, "Print version and exit")
|
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||||
// Logging flags
|
// Logging flags
|
||||||
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runValidateURL implements the `review-bot validate-url <url>` subcommand.
|
||||||
|
//
|
||||||
|
// It resolves the given URL's hostname and checks that every returned IP is
|
||||||
|
// publicly routable (not RFC1918, loopback, link-local, or other reserved
|
||||||
|
// ranges). The exit code communicates the result to callers:
|
||||||
|
//
|
||||||
|
// 0 — URL is safe to use
|
||||||
|
// 1 — URL resolves to a blocked/private address
|
||||||
|
// 2 — URL is malformed, has an unsafe scheme, or DNS lookup failed
|
||||||
|
//
|
||||||
|
// This is intended for use from action.yml shell steps that need to validate
|
||||||
|
// a user-supplied URL before passing it to curl.
|
||||||
|
func runValidateURL(args []string) int {
|
||||||
|
if len(args) != 1 {
|
||||||
|
fmt.Fprintln(errWriter, "usage: review-bot validate-url <url>")
|
||||||
|
fmt.Fprintln(errWriter, "")
|
||||||
|
fmt.Fprintln(errWriter, "Resolves <url> and verifies all resolved IPs are publicly routable.")
|
||||||
|
fmt.Fprintln(errWriter, "Exit 0=safe, 1=blocked, 2=error")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
rawURL := args[0]
|
||||||
|
|
||||||
|
if err := validateURL(rawURL); err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: %v\n", err)
|
||||||
|
var ve *validateError
|
||||||
|
if isValidateError(err, &ve) {
|
||||||
|
return ve.code
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
fmt.Fprintf(outWriter, "OK: %s is safe\n", rawURL)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `isValidateError` helper is a thin wrapper around `errors.As(err, out)` with only a nil guard added. Since `errors.As` already handles nil targets gracefully (it panics only if target is nil pointer, which would be a programmer error), and the nil check on `err` is not strictly needed (errors.As handles nil err by returning false), this function adds little value and could just be `errors.As(err, out)` at the call sites. That said, the wrapper does improve readability of intent and is harmless.
|
|||||||
|
// validateError carries an exit code alongside a message.
|
||||||
|
type validateError struct {
|
||||||
|
code int
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *validateError) Error() string { return e.message }
|
||||||
|
|
||||||
|
// isValidateError checks if err is or wraps a *validateError and sets out.
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `isValidateError` helper wraps `errors.As` with a nil check, but `errors.As` already handles nil correctly (returns false for nil err). The nil guard is harmless but redundant per the documented error-handling patterns.
|
|||||||
|
// Uses errors.As so that wrapped *validateError values (e.g. from fmt.Errorf("...: %w", &validateError{...}))
|
||||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `isValidateError` does not use `errors.As` — it only checks for a direct type assertion. This means wrapped `*validateError` values (e.g., `fmt.Errorf("...: %w", &validateError{...})`) would not be detected. Since `validateURL` only ever returns bare `*validateError` values (never wrapped), this is safe in the current implementation. However, it deviates from the error-handling pattern in error-handling.md which prescribes `errors.As` for type extraction. A future refactor that wraps these errors would silently break the code-path. Consider using `errors.As(err, out)` instead.
|
|||||||
|
// are also detected, making the function robust against future wrapping.
|
||||||
|
func isValidateError(err error, out **validateError) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
sonnet-review-bot
commented
[NIT] isValidateError is a thin wrapper around errors.As with an extra nil check. The nil check is unnecessary because errors.As already handles nil (returns false). This doesn't affect correctness but adds a small amount of indirection. **[NIT]** isValidateError is a thin wrapper around errors.As with an extra nil check. The nil check is unnecessary because errors.As already handles nil (returns false). This doesn't affect correctness but adds a small amount of indirection.
|
|||||||
|
}
|
||||||
|
return errors.As(err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateURL checks that rawURL is safe for use as a Gitea server URL:
|
||||||
|
sonnet-review-bot
commented
[NIT] isValidateError is a thin wrapper around errors.As with a nil guard. The nil guard is unnecessary since errors.As(nil, target) returns false already (as documented). The function is also only used in two places; callers could call errors.As directly without the wrapper. **[NIT]** isValidateError is a thin wrapper around errors.As with a nil guard. The nil guard is unnecessary since errors.As(nil, target) returns false already (as documented). The function is also only used in two places; callers could call errors.As directly without the wrapper.
|
|||||||
|
// - Must be https:// (not http://)
|
||||||
|
// - Must have no user-info (user:pass@host)
|
||||||
|
// - Must resolve to at least one IP, all of which are publicly routable
|
||||||
|
func validateURL(rawURL string) error {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return &validateError{code: 2, message: fmt.Sprintf("malformed URL %q: %v", rawURL, err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheme check: only https is permitted.
|
||||||
|
if !strings.EqualFold(parsed.Scheme, "https") {
|
||||||
|
return &validateError{
|
||||||
|
code: 2,
|
||||||
|
message: fmt.Sprintf("URL scheme must be https (got %q)", parsed.Scheme),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject user-info (user:password@host) to prevent credential embedding.
|
||||||
|
if parsed.User != nil {
|
||||||
|
return &validateError{
|
||||||
|
code: 2,
|
||||||
|
message: "URL must not contain user-info (user:password@host)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return &validateError{code: 2, message: fmt.Sprintf("URL has no host: %q", rawURL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the hostname with a short timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return &validateError{
|
||||||
|
code: 2,
|
||||||
|
message: fmt.Sprintf("DNS lookup failed for %q: %v", host, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return &validateError{
|
||||||
|
code: 2,
|
||||||
|
message: fmt.Sprintf("DNS lookup returned no addresses for %q", host),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range addrs {
|
||||||
|
if gitea.IsBlockedIP(a.IP) {
|
||||||
|
return &validateError{
|
||||||
|
code: 1,
|
||||||
|
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunValidateURL_Usage(t *testing.T) {
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
origErr := errWriter
|
||||||
|
errWriter = &errBuf
|
||||||
|
defer func() { errWriter = origErr }()
|
||||||
|
|
||||||
|
code := runValidateURL(nil)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit code 2 for no args, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errBuf.String(), "usage") {
|
||||||
|
t.Errorf("expected usage in stderr, got %q", errBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
errBuf.Reset()
|
||||||
|
code = runValidateURL([]string{"arg1", "arg2"})
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit code 2 for too many args, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_MalformedURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{"empty", "", "must be https"},
|
||||||
|
{"http scheme", "http://example.com/", "must be https"},
|
||||||
|
{"ftp scheme", "ftp://example.com/", "must be https"},
|
||||||
|
{"no scheme", "example.com", "must be https"},
|
||||||
|
{"user info", "https://user:pass@example.com/", "user-info"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateURL(tc.url)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for URL %q, got nil", tc.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantMsg) {
|
||||||
|
t.Errorf("error %q does not contain %q", err.Error(), tc.wantMsg)
|
||||||
|
}
|
||||||
|
var ve *validateError
|
||||||
|
if !isValidateError(err, &ve) {
|
||||||
|
t.Fatalf("expected *validateError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.code != 2 {
|
||||||
|
t.Errorf("expected code 2, got %d", ve.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_BlockedPrivateIP(t *testing.T) {
|
||||||
|
// localhost always resolves to 127.0.0.1 (loopback).
|
||||||
|
err := validateURL("https://localhost/")
|
||||||
|
if err == nil {
|
||||||
|
t.Skip("localhost did not resolve (network unavailable in test environment)")
|
||||||
|
}
|
||||||
|
var ve *validateError
|
||||||
|
if !isValidateError(err, &ve) {
|
||||||
|
t.Fatalf("expected *validateError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.code != 1 && ve.code != 2 {
|
||||||
|
t.Errorf("expected code 1 (blocked) or 2 (dns fail), got %d: %s", ve.code, ve.message)
|
||||||
|
}
|
||||||
|
// If it resolved (code 1), the message must say "blocked".
|
||||||
|
if ve.code == 1 && !strings.Contains(ve.message, "blocked") {
|
||||||
|
t.Errorf("expected 'blocked' in message, got %q", ve.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_ExitCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{"http scheme", "http://example.com/", 2},
|
||||||
|
{"no scheme", "example.com", 2},
|
||||||
|
{"user info", "https://admin:secret@example.com/", 2},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateURL(tc.url)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for %q", tc.url)
|
||||||
|
}
|
||||||
|
var ve *validateError
|
||||||
|
if !isValidateError(err, &ve) {
|
||||||
|
t.Fatalf("expected *validateError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.code != tc.wantCode {
|
||||||
|
t.Errorf("code = %d, want %d (url=%q, msg=%s)", ve.code, tc.wantCode, tc.url, ve.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateURL_WithCapture(t *testing.T) {
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
origOut, origErr := outWriter, errWriter
|
||||||
|
outWriter = &outBuf
|
||||||
|
errWriter = &errBuf
|
||||||
|
defer func() {
|
||||||
|
outWriter = origOut
|
||||||
|
errWriter = origErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
// http:// scheme should fail with code 2.
|
||||||
|
code := runValidateURL([]string{"http://example.com/"})
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected code 2 for http:// URL, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errBuf.String(), "must be https") {
|
||||||
|
t.Errorf("expected error about https in stderr, got %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,34 +106,113 @@ func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safeDialContext is the default DialContext for NewClient.
|
||||||
|
// It resolves the hostname and checks every returned IP against the blocked
|
||||||
|
// CIDR list before establishing a connection. This prevents SSRF attacks
|
||||||
|
// where user-supplied URLs resolve to internal/private addresses.
|
||||||
|
//
|
||||||
|
// After validating all IPs, we dial the first resolved IP directly to avoid
|
||||||
|
// a second DNS lookup (which could return a different IP in a DNS rebinding
|
||||||
|
// attack). This narrows — but does not fully eliminate — the DNS rebinding
|
||||||
|
gpt-review-bot
commented
[MINOR] safeDialContext dials each resolved IP with a 10s timeout. For hosts with many IPs, total connection attempts could exceed the 30s http.Client Timeout and rely on request-level cancellation. Consider capping the aggregate dial duration or leveraging the context deadline to better align per-IP attempts with the overall request timeout. **[MINOR]** safeDialContext dials each resolved IP with a 10s timeout. For hosts with many IPs, total connection attempts could exceed the 30s http.Client Timeout and rely on request-level cancellation. Consider capping the aggregate dial duration or leveraging the context deadline to better align per-IP attempts with the overall request timeout.
|
|||||||
|
// window to the time between LookupIPAddr and DialContext.
|
||||||
|
//
|
||||||
|
// If the host is already an IP literal, LookupIPAddr returns it directly
|
||||||
|
// (no DNS query issued), so IP literals like https://127.0.0.1/ are blocked.
|
||||||
|
gpt-review-bot
commented
[NIT] safeDialContext dials only the first resolved IP. This intentionally narrows DNS rebinding but sacrifices resilience if the first IP is down while another is reachable. Consider iterating over resolved IPs in order if the first connection fails (still rejecting if any IP is in a blocked range). **[NIT]** safeDialContext dials only the first resolved IP. This intentionally narrows DNS rebinding but sacrifices resilience if the first IP is down while another is reachable. Consider iterating over resolved IPs in order if the first connection fails (still rejecting if any IP is in a blocked range).
gpt-review-bot
commented
[MINOR] safeDialContext dials only the first resolved IP address to narrow DNS rebinding risk. On dual-stack or multi-address hosts, the first IP may be unreachable (e.g., IPv6 preferred but not available), causing avoidable failures. Consider iterating over resolved addresses (after validating none are blocked) and attempting connection sequentially to preserve connectivity while keeping the SSRF checks. **[MINOR]** safeDialContext dials only the first resolved IP address to narrow DNS rebinding risk. On dual-stack or multi-address hosts, the first IP may be unreachable (e.g., IPv6 preferred but not available), causing avoidable failures. Consider iterating over resolved addresses (after validating none are blocked) and attempting connection sequentially to preserve connectivity while keeping the SSRF checks.
|
|||||||
|
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("safeDialContext: invalid address %q: %w", addr, err)
|
||||||
|
}
|
||||||
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("safeDialContext: DNS lookup %q: %w", host, err)
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return nil, fmt.Errorf("safeDialContext: no addresses returned for %q", host)
|
||||||
|
[MINOR] WithUnsafeDialer() is exported and removes IP-level SSRF protection. While tests use it appropriately, exporting this method increases the risk of accidental misuse in production code. Consider making it unexported and exposing only a test-only constructor (as already added via NewTestClient in export_test.go). **[MINOR]** WithUnsafeDialer() is exported and removes IP-level SSRF protection. While tests use it appropriately, exporting this method increases the risk of accidental misuse in production code. Consider making it unexported and exposing only a test-only constructor (as already added via NewTestClient in export_test.go).
|
|||||||
|
}
|
||||||
|
[MINOR] safeDialContext returns errors that include the resolved private/reserved IP (e.g., "blocked: host resolves to IP"). When logged in CI, this can disclose internal IPs. Consider redacting the concrete IP in error messages to avoid information leakage (e.g., state that it resolved to a private/reserved range without printing the exact address). **[MINOR]** safeDialContext returns errors that include the resolved private/reserved IP (e.g., "blocked: host resolves to IP"). When logged in CI, this can disclose internal IPs. Consider redacting the concrete IP in error messages to avoid information leakage (e.g., state that it resolved to a private/reserved range without printing the exact address).
|
|||||||
|
for _, a := range addrs {
|
||||||
|
if IsBlockedIP(a.IP) {
|
||||||
|
gpt-review-bot
commented
[MAJOR] newSafeHTTPClient constructs a zero-value http.Transport with only DialContext set, discarding DefaultTransport's tuned defaults (ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, MaxIdleConns, ExpectContinueTimeout, etc.). This can lead to hangs (e.g., unlimited TLS handshake) and degraded connection pooling compared to the standard defaults. Per patterns, prefer cloning DefaultTransport and overriding DialContext. **[MAJOR]** newSafeHTTPClient constructs a zero-value http.Transport with only DialContext set, discarding DefaultTransport's tuned defaults (ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, MaxIdleConns, ExpectContinueTimeout, etc.). This can lead to hangs (e.g., unlimited TLS handshake) and degraded connection pooling compared to the standard defaults. Per patterns, prefer cloning DefaultTransport and overriding DialContext.
|
|||||||
|
return nil, fmt.Errorf("safeDialContext: blocked: %q resolves to private/reserved IP %s", host, a.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try each resolved IP in order, returning the first successful connection.
|
||||||
|
// Fallback is important when a hostname resolves to multiple IPs and the first
|
||||||
|
gpt-review-bot
commented
[MAJOR] newSafeHTTPClient constructs a bare http.Transport with only DialContext set. This drops ProxyFromEnvironment and standard transport timeouts (IdleConnTimeout, TLSHandshakeTimeout, ExpectContinueTimeout, MaxIdleConns, ForceAttemptHTTP2) that http.DefaultTransport provides. This can break proxyed environments and lead to suboptimal or leaking connection pools. Prefer cloning http.DefaultTransport and overriding DialContext. **[MAJOR]** newSafeHTTPClient constructs a bare http.Transport with only DialContext set. This drops ProxyFromEnvironment and standard transport timeouts (IdleConnTimeout, TLSHandshakeTimeout, ExpectContinueTimeout, MaxIdleConns, ForceAttemptHTTP2) that http.DefaultTransport provides. This can break proxyed environments and lead to suboptimal or leaking connection pools. Prefer cloning http.DefaultTransport and overriding DialContext.
gpt-review-bot
commented
[MAJOR] By not setting Proxy: http.ProxyFromEnvironment on the custom transport, proxy environment variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) are ignored. Previous behavior (using default transport) respected proxies. This is a behavioral regression that can prevent the client from working behind corporate proxies. **[MAJOR]** By not setting Proxy: http.ProxyFromEnvironment on the custom transport, proxy environment variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) are ignored. Previous behavior (using default transport) respected proxies. This is a behavioral regression that can prevent the client from working behind corporate proxies.
|
|||||||
|
// 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
|
||||||
|
sonnet-review-bot
commented
[MINOR] The **[MINOR]** The `safeDialContext` creates a new `net.Dialer{}` on every call with no timeout configured. The outer `http.Client` has a 30s timeout, but the dial itself has no deadline beyond the context. In the original code (pre-PR), there was no DialContext either, so this is not a regression. However, the PLAN.md design snippet shows `d := &net.Dialer{Timeout: 10 * time.Second}` with an explicit timeout, but the implementation uses `&net.Dialer{}` (no timeout). This deviation from the design is minor since the context propagates cancellation, but it could allow a slow TCP connect to consume the full 30s.
[MAJOR] Safe dialer does not protect against proxy-assisted SSRF. newSafeHTTPClient clones http.DefaultTransport (retaining ProxyFromEnvironment). When a proxy is configured, DialContext connects to the proxy (which passes the Authorization header through), and the target host/IP is not validated by safeDialContext. An attacker could point baseURL to a private/reserved IP (e.g., 169.254.169.254) and rely on an environment proxy to reach it, bypassing the IP block. **[MAJOR]** Safe dialer does not protect against proxy-assisted SSRF. newSafeHTTPClient clones http.DefaultTransport (retaining ProxyFromEnvironment). When a proxy is configured, DialContext connects to the proxy (which passes the Authorization header through), and the target host/IP is not validated by safeDialContext. An attacker could point baseURL to a private/reserved IP (e.g., 169.254.169.254) and rely on an environment proxy to reach it, bypassing the IP block.
|
|||||||
|
// total timeout, but the per-dial timeout ensures a slow TCP connect on one IP
|
||||||
|
// doesn't consume the budget needed to try others.
|
||||||
|
d := &net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
var lastErr error
|
||||||
|
for _, a := range addrs {
|
||||||
|
conn, err := d.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
sonnet-review-bot
commented
[MINOR] safeDialContext creates a new net.Dialer on every call (one per HTTP connection). This is fine for correctness but allocates a struct per dial. A package-level or transport-level Dialer would be more efficient, though for this workload (non-hot-path API calls) the impact is negligible. **[MINOR]** safeDialContext creates a new net.Dialer on every call (one per HTTP connection). This is fine for correctness but allocates a struct per dial. A package-level or transport-level Dialer would be more efficient, though for this workload (non-hot-path API calls) the impact is negligible.
|
|||||||
|
}
|
||||||
|
return nil, fmt.Errorf("safeDialContext: all %d addresses for %q failed, last error: %w", len(addrs), host, lastErr)
|
||||||
|
sonnet-review-bot
commented
[MINOR] safeDialContext creates a new net.Dialer on every call (line 155: **[MINOR]** safeDialContext creates a new net.Dialer on every call (line 155: `d := &net.Dialer{Timeout: 10 * time.Second}`). While not a correctness issue, for a function called on every HTTP connection this allocates a new struct each time. The dialer could be a package-level variable or embedded in a struct to avoid repeated allocation.
|
|||||||
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[MINOR] In **[MINOR]** In `safeDialContext`, the comment says 'we dial the first resolved IP directly to narrow DNS rebinding window' but the implementation iterates through ALL resolved IPs with fallback. The code is actually correct (and better than the comment implies — it tries each IP in order), but the comment in the doc is slightly misleading since it says 'Dials first resolved IP directly' (visible in the PR description too). The comment at line 157 inside the function ('Try each resolved IP in order...') is accurate but the package-level doc comment should be updated to match.
|
|||||||
|
// 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. **[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.
|
|||||||
|
// We clone http.DefaultTransport to preserve its production-ready defaults
|
||||||
|
// (ProxyFromEnvironment, TLSHandshakeTimeout, IdleConnTimeout, connection
|
||||||
|
// pooling, HTTP/2 support) and override only DialContext with safeDialContext.
|
||||||
|
func newSafeHTTPClient() *http.Client {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.DialContext = safeDialContext
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
sonnet-review-bot
commented
[NIT] The comment references 'PLAN.md' ( **[NIT]** The comment references 'PLAN.md' (`// Timeout: 10s per the design (PLAN.md)`) but no such file is visible in the repository. This will be a dead reference for future readers.
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[MINOR] **[MINOR]** `WithUnsafeDialer()` is documented as 'MUST only be used in tests' but is an exported public method on `*Client`. This exposes a dangerous footgun to any package that imports `gitea`. The `export_test.go` pattern exists precisely for this purpose — `NewTestClient` in `export_test.go` correctly wraps it, but `WithUnsafeDialer` itself remains callable by production code in other packages. Consider making it unexported (`withUnsafeDialer`) and only exposing it via `export_test.go`, or accepting the trade-off if the API stability concern outweighs the risk.
|
|||||||
|
|
||||||
// NewClient creates a new Gitea API client.
|
// NewClient creates a new Gitea API client.
|
||||||
|
//
|
||||||
|
// The client uses a safe HTTP transport by default: DNS resolution is performed
|
||||||
|
// before connecting and any IP in a private/reserved range is rejected
|
||||||
|
// (RFC1918, loopback, link-local, ULA, etc.). Cross-host and HTTPS→HTTP
|
||||||
|
// redirects are also rejected.
|
||||||
|
//
|
||||||
|
// For tests that use httptest.NewServer (which listens on 127.0.0.1), call
|
||||||
|
// WithUnsafeDialer() to bypass the IP check.
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
http: &http.Client{
|
http: newSafeHTTPClient(),
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
CheckRedirect: defaultCheckRedirect,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithUnsafeDialer returns the client configured with a plain HTTP client that
|
||||||
|
// has no IP-level SSRF protection. It preserves the redirect-rejection policy.
|
||||||
|
//
|
||||||
|
// This MUST only be used in tests. Production code must never call this method.
|
||||||
|
func (c *Client) WithUnsafeDialer() *Client {
|
||||||
|
c.http = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// SetHTTPClient sets the underlying HTTP client used for requests.
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||||
// This is intended for test setup only to inject mock transports; it must be
|
// This is intended for test setup only to inject mock transports; it must be
|
||||||
// called before any goroutines issue requests.
|
// called before any goroutines issue requests.
|
||||||
//
|
//
|
||||||
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
// Passing nil restores the default safe client (30s timeout, IP-blocking
|
||||||
// CheckRedirect policy matching NewClient).
|
// safeDialContext, and redirect-rejecting CheckRedirect policy matching NewClient).
|
||||||
//
|
//
|
||||||
// Callers providing a non-nil client are responsible for configuring a safe
|
// Callers providing a non-nil client are responsible for configuring a safe
|
||||||
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
||||||
// redirects and may forward the Authorization header to untrusted hosts.
|
// redirects and may forward the Authorization header to untrusted hosts.
|
||||||
func (c *Client) SetHTTPClient(hc *http.Client) {
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = &http.Client{
|
hc = newSafeHTTPClient()
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
CheckRedirect: defaultCheckRedirect,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.http = hc
|
c.http = hc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestGetPullRequest(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
got, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
got, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -63,7 +63,7 @@ func TestGetPullRequestDiff(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 5)
|
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -88,7 +88,7 @@ func TestGetCommitStatuses(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
got, err := client.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
|
got, err := client.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -138,7 +138,7 @@ func TestPostReview(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "abc123def", nil)
|
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "abc123def", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -158,7 +158,7 @@ func TestGetPullRequest_Non200(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 999)
|
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 999)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 404, got nil")
|
t.Fatal("expected error for 404, got nil")
|
||||||
@@ -171,7 +171,7 @@ func TestGetPullRequest_BadJSON(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for bad JSON, got nil")
|
t.Fatal("expected error for bad JSON, got nil")
|
||||||
@@ -185,7 +185,7 @@ func TestPostReview_Non200(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil)
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403, got nil")
|
t.Fatal("expected error for 403, got nil")
|
||||||
@@ -208,7 +208,7 @@ func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "ok", "", nil)
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "ok", "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -226,7 +226,7 @@ func TestGetFileContent(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
got, err := client.GetFileContent(context.Background(), "owner", "repo", "CONVENTIONS.md")
|
got, err := client.GetFileContent(context.Background(), "owner", "repo", "CONVENTIONS.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -246,7 +246,7 @@ func TestGetPullRequestFiles(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
files, err := client.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
|
files, err := client.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -271,7 +271,7 @@ func TestGetFileContentRef(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
content, err := client.GetFileContentRef(context.Background(), "owner", "repo", "main.go", "feature-branch")
|
content, err := client.GetFileContentRef(context.Background(), "owner", "repo", "main.go", "feature-branch")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -291,7 +291,7 @@ func TestListContents(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
entries, err := client.ListContents(context.Background(), "owner", "repo", "docs")
|
entries, err := client.ListContents(context.Background(), "owner", "repo", "docs")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -318,7 +318,7 @@ func TestListContents_DotPath(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
entries, err := client.ListContents(context.Background(), "owner", "repo", ".")
|
entries, err := client.ListContents(context.Background(), "owner", "repo", ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -343,7 +343,7 @@ func TestListContents_FilePath(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
entries, err := client.ListContents(context.Background(), "owner", "repo", "README.md")
|
entries, err := client.ListContents(context.Background(), "owner", "repo", "README.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -375,7 +375,7 @@ func TestGetAllFilesInPath_File(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -428,7 +428,7 @@ func TestListReviews(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -468,7 +468,7 @@ func TestListReviews_Pagination(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -493,7 +493,7 @@ func TestDeleteReview(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -507,7 +507,7 @@ func TestDeleteReview_Forbidden(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403, got nil")
|
t.Fatal("expected error for 403, got nil")
|
||||||
@@ -536,7 +536,7 @@ func TestEditComment(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body")
|
err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EditComment() error = %v", err)
|
t.Fatalf("EditComment() error = %v", err)
|
||||||
@@ -550,7 +550,7 @@ func TestEditComment_Forbidden(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.EditComment(context.Background(), "owner", "repo", 42, "new body")
|
err := client.EditComment(context.Background(), "owner", "repo", 42, "new body")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403 response")
|
t.Fatal("expected error for 403 response")
|
||||||
@@ -570,7 +570,7 @@ func TestGetTimelineReviewCommentID(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetTimelineReviewCommentID() error = %v", err)
|
t.Fatalf("GetTimelineReviewCommentID() error = %v", err)
|
||||||
@@ -586,7 +586,7 @@ func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
_, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when sentinel not found")
|
t.Fatal("expected error when sentinel not found")
|
||||||
@@ -609,7 +609,7 @@ func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected fallback to file on 404, got error: %v", err)
|
t.Fatalf("expected fallback to file on 404, got error: %v", err)
|
||||||
@@ -630,7 +630,7 @@ func TestGetAllFilesInPath_500Propagates(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath")
|
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error to propagate for 500, got nil")
|
t.Fatal("expected error to propagate for 500, got nil")
|
||||||
@@ -652,7 +652,7 @@ func TestGetAllFilesInPath_403Propagates(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff")
|
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error to propagate for 403, got nil")
|
t.Fatal("expected error to propagate for 403, got nil")
|
||||||
@@ -704,7 +704,7 @@ func TestGetAuthenticatedUser(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
login, err := client.GetAuthenticatedUser(context.Background())
|
login, err := client.GetAuthenticatedUser(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetAuthenticatedUser() error = %v", err)
|
t.Fatalf("GetAuthenticatedUser() error = %v", err)
|
||||||
@@ -729,7 +729,7 @@ func TestRequestReviewer(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user")
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RequestReviewer() error = %v", err)
|
t.Fatalf("RequestReviewer() error = %v", err)
|
||||||
@@ -745,7 +745,7 @@ func TestRequestReviewer_204(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RequestReviewer() should accept 204, got error = %v", err)
|
t.Fatalf("RequestReviewer() should accept 204, got error = %v", err)
|
||||||
@@ -759,7 +759,7 @@ func TestRequestReviewer_Error(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403 response")
|
t.Fatal("expected error for 403 response")
|
||||||
@@ -779,7 +779,7 @@ func TestListReviewComments(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42)
|
comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListReviewComments() error = %v", err)
|
t.Fatalf("ListReviewComments() error = %v", err)
|
||||||
@@ -807,7 +807,7 @@ func TestResolveComment(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveComment() error = %v", err)
|
t.Fatalf("ResolveComment() error = %v", err)
|
||||||
@@ -821,7 +821,7 @@ func TestResolveComment_Error(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 404 response")
|
t.Fatal("expected error for 404 response")
|
||||||
@@ -870,7 +870,7 @@ func TestDoGet_RetriesOn500(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
// Use short backoff for fast tests
|
// Use short backoff for fast tests
|
||||||
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
||||||
|
|
||||||
@@ -895,7 +895,7 @@ func TestDoGet_FailsAfterMaxRetries(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
// Use short backoff for fast tests
|
// Use short backoff for fast tests
|
||||||
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
||||||
|
|
||||||
@@ -924,7 +924,7 @@ func TestDoGet_NoRetryOn4xx(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.doGet(context.Background(), server.URL+"/test")
|
_, err := client.doGet(context.Background(), server.URL+"/test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403")
|
t.Fatal("expected error for 403")
|
||||||
@@ -952,7 +952,7 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
// Use longer backoff to give us time to cancel during the wait
|
// Use longer backoff to give us time to cancel during the wait
|
||||||
client.RetryBackoff = []time.Duration{100 * time.Millisecond, 100 * time.Millisecond}
|
client.RetryBackoff = []time.Duration{100 * time.Millisecond, 100 * time.Millisecond}
|
||||||
|
|
||||||
@@ -1285,3 +1285,137 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
|||||||
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSafeDialContextBlocksPrivateIPs verifies that NewClient (which uses
|
||||||
|
// safeDialContext by default) refuses to connect to private/reserved IPs.
|
||||||
|
func TestSafeDialContextBlocksPrivateIPs(t *testing.T) {
|
||||||
|
// These servers listen on 127.0.0.1, so the safe dialer will block them.
|
||||||
|
// We use NewClient (NOT NewTestClient) to exercise the real safe dialer.
|
||||||
|
privateURLs := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
}{
|
||||||
|
{"loopback localhost", "http://localhost/"},
|
||||||
|
{"loopback 127.0.0.1", "http://127.0.0.1/"},
|
||||||
|
sonnet-review-bot
commented
[MINOR] TestSafeDialContextBlocksPrivateIPs has a soft assertion: when the error doesn't contain 'blocked'/'private'/'loopback'/'reserved', it only logs (t.Logf) instead of failing. This means the test passes even if safeDialContext is bypassed for an unexpected reason (e.g., the error is a plain TCP connection refused before the IP check fires). The comment 'Allow other errors (connection refused, DNS)' acknowledges this, but it means the test doesn't firmly assert the blocking behavior. **[MINOR]** TestSafeDialContextBlocksPrivateIPs has a soft assertion: when the error doesn't contain 'blocked'/'private'/'loopback'/'reserved', it only logs (t.Logf) instead of failing. This means the test passes even if safeDialContext is bypassed for an unexpected reason (e.g., the error is a plain TCP connection refused before the IP check fires). The comment 'Allow other errors (connection refused, DNS)' acknowledges this, but it means the test doesn't firmly assert the blocking behavior.
|
|||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range privateURLs {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := NewClient(tc.url, "token")
|
||||||
|
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error connecting to %s, got nil", tc.url)
|
||||||
|
}
|
||||||
|
// Error must mention SSRF/blocked, not a random network error.
|
||||||
|
if !strings.Contains(err.Error(), "blocked") &&
|
||||||
|
!strings.Contains(err.Error(), "private") &&
|
||||||
|
!strings.Contains(err.Error(), "loopback") &&
|
||||||
|
!strings.Contains(err.Error(), "reserved") {
|
||||||
|
sonnet-review-bot
commented
[NIT] In **[NIT]** In `TestSafeDialContextBlocksPrivateIPs`, when the error contains none of the expected keywords ('blocked', 'private', 'loopback', 'reserved'), the test only logs the error with `t.Logf` but does NOT fail. The comment 'Allow other errors' could mask regressions where the safe dialer is bypassed (e.g. connection refused from a real loopback listener). Since the test asserts `err != nil` already handles the success case, it should at minimum emit `t.Errorf` (not just `t.Logf`) when the error doesn't mention the SSRF block, making the test stricter.
|
|||||||
|
t.Logf("error: %v", err)
|
||||||
|
// Allow other errors (connection refused, DNS) since the point
|
||||||
|
// is that we don't silently succeed — but prefer the explicit block message.
|
||||||
|
sonnet-review-bot
commented
[NIT] **[NIT]** `TestSafeDialContextBlocksPrivateIPs` uses `t.Logf` to emit the error but doesn't call `t.Errorf` when the error message doesn't contain the expected keywords. The comment says 'Allow other errors... but prefer the explicit block message' — this means the test can pass even if the safe dialer is completely broken (e.g., if it returns a connection-refused error instead of a blocked error). The assertion should be strengthened to `t.Errorf` so that unexpected error messages are flagged, or the comment should be clearer about the intentional leniency.
|
|||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithUnsafeDialerAllowsLocalhost verifies that WithUnsafeDialer bypasses
|
||||||
|
// the IP check, allowing tests to connect to httptest.Server (127.0.0.1).
|
||||||
|
func TestWithUnsafeDialerAllowsLocalhost(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"title":"test","body":"","head":{"sha":"abc","ref":"main"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// WithUnsafeDialer should allow connecting to 127.0.0.1.
|
||||||
|
c := NewClient(server.URL, "token").WithUnsafeDialer()
|
||||||
|
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error with unsafe dialer: %v", err)
|
||||||
|
}
|
||||||
|
if pr.Title != "test" {
|
||||||
|
t.Errorf("expected title 'test', got %q", pr.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewClient_HasSafeTransport verifies that NewClient installs the
|
||||||
|
// SSRF-blocking transport (i.e. Transport is not nil and DialContext is set).
|
||||||
|
func TestNewClient_HasSafeTransport(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
if c.http.Transport == nil {
|
||||||
|
t.Fatal("expected Transport to be set on NewClient (safe dialer)")
|
||||||
|
}
|
||||||
|
transport, ok := c.http.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *http.Transport, got %T", c.http.Transport)
|
||||||
|
}
|
||||||
|
if transport.DialContext == nil {
|
||||||
|
t.Fatal("expected DialContext to be set on transport (safe dialer)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetHTTPClient_NilRestoresSafeTransport verifies that SetHTTPClient(nil)
|
||||||
|
// restores the safe transport (not just any client).
|
||||||
|
func TestSetHTTPClient_NilRestoresSafeTransport(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
c.SetHTTPClient(&http.Client{}) // replace with plain client
|
||||||
|
c.SetHTTPClient(nil) // restore
|
||||||
|
transport, ok := c.http.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *http.Transport after SetHTTPClient(nil), got %T", c.http.Transport)
|
||||||
|
}
|
||||||
|
if transport.DialContext == nil {
|
||||||
|
t.Fatal("expected DialContext to be restored after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewSafeHTTPClient_PreservesDefaultTransportSettings verifies that
|
||||||
|
// newSafeHTTPClient clones http.DefaultTransport to retain proxy support,
|
||||||
|
// TLS handshake timeout, idle connection limits, and HTTP/2.
|
||||||
|
func TestNewSafeHTTPClient_PreservesDefaultTransportSettings(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
transport, ok := c.http.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *http.Transport, got %T", c.http.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := http.DefaultTransport.(*http.Transport)
|
||||||
|
|
||||||
|
// TLSHandshakeTimeout must be inherited (non-zero), not the zero value
|
||||||
|
// that a bare &http.Transport{} would have.
|
||||||
|
if transport.TLSHandshakeTimeout == 0 {
|
||||||
|
t.Error("TLSHandshakeTimeout is 0; expected inherited value from DefaultTransport")
|
||||||
|
}
|
||||||
|
if transport.TLSHandshakeTimeout != defaults.TLSHandshakeTimeout {
|
||||||
|
t.Errorf("TLSHandshakeTimeout = %v, want %v", transport.TLSHandshakeTimeout, defaults.TLSHandshakeTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdleConnTimeout must be inherited.
|
||||||
|
if transport.IdleConnTimeout == 0 {
|
||||||
|
t.Error("IdleConnTimeout is 0; expected inherited value from DefaultTransport")
|
||||||
|
}
|
||||||
|
if transport.IdleConnTimeout != defaults.IdleConnTimeout {
|
||||||
|
t.Errorf("IdleConnTimeout = %v, want %v", transport.IdleConnTimeout, defaults.IdleConnTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxIdleConns must be inherited.
|
||||||
|
if transport.MaxIdleConns == 0 {
|
||||||
|
t.Error("MaxIdleConns is 0; expected inherited value from DefaultTransport")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceAttemptHTTP2 must be inherited.
|
||||||
|
if !transport.ForceAttemptHTTP2 {
|
||||||
|
t.Error("ForceAttemptHTTP2 is false; expected true from DefaultTransport")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy must be set (ProxyFromEnvironment).
|
||||||
|
if transport.Proxy == nil {
|
||||||
|
t.Error("Proxy is nil; expected ProxyFromEnvironment from DefaultTransport")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialContext must be our safe dialer, not the default.
|
||||||
|
if transport.DialContext == nil {
|
||||||
|
t.Error("DialContext is nil; expected safeDialContext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func TestGetPullRequestDiff_SizeLimits(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
client.MaxDiffSize = tt.maxDiffSize
|
client.MaxDiffSize = tt.maxDiffSize
|
||||||
client.RetryBackoff = []time.Duration{}
|
client.RetryBackoff = []time.Duration{}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Package gitea — export_test.go exposes test helpers to test files in this
|
||||||
|
sonnet-review-bot
commented
[MINOR] The package declaration is **[MINOR]** The package declaration is `package gitea` (not `package gitea_test`), which means `NewTestClient` is compiled into the `gitea` package during tests, making it accessible to all test files in the package. The comment says 'MUST only be called from _test.go files' but there is no enforcement beyond convention. Since it calls `WithUnsafeDialer()` which is an exported method on `*Client`, a caller in a non-test file could also call `client.WithUnsafeDialer()` directly. The naming (`WithUnsafeDialer`) and doc comment are sufficient deterrents, but the export_test.go approach only limits availability to the test binary, not to individual callers within the package. This is acceptable Go convention (the stdlib uses the same pattern in net/http/export_test.go).
|
|||||||
|
// package. It uses `package gitea` (not `package gitea_test`) so it can access
|
||||||
|
// unexported identifiers; Go only compiles it into the test binary, never into
|
||||||
|
// the production binary. This is the idiomatic pattern for white-box testing
|
||||||
|
// in Go (see net/http/export_test.go in the stdlib for the same approach).
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
// NewTestClient creates a Gitea client configured for use in unit tests.
|
||||||
|
// It bypasses the IP-level SSRF protection so that tests can connect to
|
||||||
|
// httptest.Server instances (which listen on 127.0.0.1).
|
||||||
|
//
|
||||||
|
// Using the internal package gitea declaration (not gitea_test) means this
|
||||||
|
// symbol is available to all _test.go files in this package. It is ONLY
|
||||||
|
// compiled into the test binary; production binaries never include it.
|
||||||
|
// Production code must use NewClient, which enables the safe dialer.
|
||||||
|
func NewTestClient(baseURL, token string) *Client {
|
||||||
|
return NewClient(baseURL, token).WithUnsafeDialer()
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Package gitea provides a client for the Gitea API.
|
||||||
|
gpt-review-bot
commented
[NIT] Duplicate package-level comments across multiple files (client.go and ipcheck.go). Per Go package documentation patterns, prefer a single package comment in a doc.go or primary file to avoid redundant doc output. **[NIT]** Duplicate package-level comments across multiple files (client.go and ipcheck.go). Per Go package documentation patterns, prefer a single package comment in a doc.go or primary file to avoid redundant doc output.
|
|||||||
|
// ipcheck.go implements IP-level SSRF protection by checking resolved addresses
|
||||||
|
// against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockedCIDRStrings is the canonical list of CIDR strings that should never
|
||||||
|
// be contacted by review-bot. See IsBlockedIP for the full list of covered
|
||||||
|
// address families.
|
||||||
|
//
|
||||||
|
// These are hard-coded literals: any parse failure is a programming error.
|
||||||
|
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
|
||||||
|
var blockedCIDRStrings = []string{
|
||||||
|
// IPv4 loopback
|
||||||
|
"127.0.0.0/8",
|
||||||
|
// IPv4 unspecified / "this network"
|
||||||
|
"0.0.0.0/8",
|
||||||
|
// RFC1918 private ranges
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
|
||||||
|
"169.254.0.0/16",
|
||||||
|
// IPv4 shared address space (RFC6598, carrier-grade NAT)
|
||||||
|
"100.64.0.0/10",
|
||||||
|
// IPv4 multicast
|
||||||
|
"224.0.0.0/4",
|
||||||
|
// IPv4 reserved / broadcast
|
||||||
|
"240.0.0.0/4",
|
||||||
|
gpt-review-bot
commented
[MINOR] Initialization panics on invalid built-in CIDR. While this guards programmer error, the repository convention says 'Return errors; never panic.' Consider handling this with a safer init-time check (e.g., pre-parse in tests) or making the failure explicit without a panic. **[MINOR]** Initialization panics on invalid built-in CIDR. While this guards programmer error, the repository convention says 'Return errors; never panic.' Consider handling this with a safer init-time check (e.g., pre-parse in tests) or making the failure explicit without a panic.
|
|||||||
|
// IPv6 loopback
|
||||||
|
"::1/128",
|
||||||
|
// IPv6 unspecified
|
||||||
|
"::/128",
|
||||||
|
// IPv6 link-local
|
||||||
|
"fe80::/10",
|
||||||
|
// IPv6 unique local (ULA) — RFC4193
|
||||||
|
"fc00::/7",
|
||||||
|
// IPv6 multicast
|
||||||
|
"ff00::/8",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
gpt-review-bot
commented
[MINOR] init() accumulates CIDR parse errors into blockedCIDRParseErrors without failing fast. If a future edit introduces an invalid CIDR, production builds could silently disable parts of SSRF protection. Consider panicking on parse error or logging a warning to avoid silent weakening of defenses. **[MINOR]** init() accumulates CIDR parse errors into blockedCIDRParseErrors without failing fast. If a future edit introduces an invalid CIDR, production builds could silently disable parts of SSRF protection. Consider panicking on parse error or logging a warning to avoid silent weakening of defenses.
|
|||||||
|
var (
|
||||||
|
blockedCIDRs []*net.IPNet
|
||||||
|
blockedCIDRParseErrors []string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
|
||||||
|
for _, r := range blockedCIDRStrings {
|
||||||
|
_, cidr, err := net.ParseCIDR(r)
|
||||||
|
sonnet-review-bot
commented
[MINOR] Using init() to parse CIDRs works but is slightly unusual when the data is pure compile-time constants. A panic on bad CIDR in init() would be more conventional (see configuration.md 'When NOT to Use' for init()). The chosen pattern (record errors, verify in tests) is an intentional trade-off documented in the code, but it means a typo in blockedCIDRStrings silently produces a weaker denylist at runtime if TestBlockedCIDRsValid is never run (e.g., in a binary without tests). A startup panic would catch this more reliably. However, given the CIDR list is stable and tests are run in CI, this is acceptable. **[MINOR]** Using init() to parse CIDRs works but is slightly unusual when the data is pure compile-time constants. A panic on bad CIDR in init() would be more conventional (see configuration.md 'When NOT to Use' for init()). The chosen pattern (record errors, verify in tests) is an intentional trade-off documented in the code, but it means a typo in blockedCIDRStrings silently produces a weaker denylist at runtime if TestBlockedCIDRsValid is never run (e.g., in a binary without tests). A startup panic would catch this more reliably. However, given the CIDR list is stable and tests are run in CI, this is acceptable.
sonnet-review-bot
commented
[MINOR] The **[MINOR]** The `init()` function is used here to parse CIDRs and populate `blockedCIDRs`. Per the package-design pattern, `init()` is appropriate for registration-style setup, and this usage is acceptable. However, the parse errors are deferred to test time rather than panicking at startup. Since these are hard-coded literals that can never fail at runtime (barring a programming error), an immediate `panic` in `init()` would be more idiomatic (as used in `regexp.MustCompile` for compile-time-known values). The current approach is a conscious design choice documented in the code, so this is minor — but it is a deviation from the 'Must pattern' documented in api-conventions.md.
|
|||||||
|
if err != nil {
|
||||||
|
// Record the error rather than panicking; TestBlockedCIDRsValid
|
||||||
|
// will catch this during tests, and the CI build will fail.
|
||||||
|
sonnet-review-bot
commented
[MINOR] Using **[MINOR]** Using `init()` to parse CIDRs rather than panicking. The CIDR strings are compile-time constants and parse errors are programming errors that can never occur in production. The deferred-error pattern (storing to `blockedCIDRParseErrors` and checking in a test) is more complex than a simple `var _ = func() { ... panic(...) }()` or `sync.OnceFunc` approach. More importantly, if `TestBlockedCIDRsValid` is somehow not run (e.g. in a non-test binary that skips test files), a typo in a CIDR string would silently reduce coverage. Since the CIDR list never changes at runtime and the strings are literals known at compile time, a direct `init()` that panics on parse failure would be idiomatic and safer. See `regexp.MustCompile` pattern: panicking on programmer errors caught at init time is the correct approach here.
|
|||||||
|
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
|
||||||
|
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
|
||||||
|
continue
|
||||||
|
sonnet-review-bot
commented
[MINOR] Using init() to parse CIDRs and silently recording errors in blockedCIDRParseErrors rather than panicking is an unusual pattern. The package-design.md pattern explicitly says init() should 'have no side effects beyond registration' and 'No errors possible (can't return error from init)'. Since these are compile-time-known string literals, a panic in init() on parse failure would be acceptable (it's a programmer error, not a runtime condition) and simpler than the deferred-error pattern. The TestBlockedCIDRsValid test catches the error only during testing — a production binary built without running tests would silently have incomplete CIDR protection. **[MINOR]** Using init() to parse CIDRs and silently recording errors in blockedCIDRParseErrors rather than panicking is an unusual pattern. The package-design.md pattern explicitly says init() should 'have no side effects beyond registration' and 'No errors possible (can't return error from init)'. Since these are compile-time-known string literals, a panic in init() on parse failure would be acceptable (it's a programmer error, not a runtime condition) and simpler than the deferred-error pattern. The TestBlockedCIDRsValid test catches the error only during testing — a production binary built without running tests would silently have incomplete CIDR protection.
|
|||||||
|
}
|
||||||
|
blockedCIDRs = append(blockedCIDRs, cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlockedIP reports whether ip is in a blocked address range.
|
||||||
|
// It is exported for use by the validate-url subcommand and tests outside
|
||||||
|
// this package.
|
||||||
|
//
|
||||||
|
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
|
||||||
|
// IPv4 form before checking so that IPv4 CIDRs catch them.
|
||||||
|
//
|
||||||
|
// Based on:
|
||||||
|
// - RFC1918 private ranges
|
||||||
|
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
|
||||||
|
// - RFC4291 IPv6 link-local / loopback
|
||||||
|
func IsBlockedIP(ip net.IP) bool {
|
||||||
|
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
ip = v4
|
||||||
|
}
|
||||||
|
for _, cidr := range blockedCIDRs {
|
||||||
|
if cidr.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsBlockedIP(t *testing.T) {
|
||||||
|
blocked := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
}{
|
||||||
|
// IPv4 loopback
|
||||||
|
{"loopback 127.0.0.1", "127.0.0.1"},
|
||||||
|
{"loopback 127.0.0.2", "127.0.0.2"},
|
||||||
|
{"loopback 127.255.255.255", "127.255.255.255"},
|
||||||
|
// IPv4 unspecified
|
||||||
|
{"unspecified 0.0.0.0", "0.0.0.0"},
|
||||||
|
{"unspecified 0.1.2.3", "0.1.2.3"},
|
||||||
|
// RFC1918
|
||||||
|
{"RFC1918 10.0.0.1", "10.0.0.1"},
|
||||||
|
{"RFC1918 10.255.255.255", "10.255.255.255"},
|
||||||
|
{"RFC1918 172.16.0.1", "172.16.0.1"},
|
||||||
|
{"RFC1918 172.31.255.255", "172.31.255.255"},
|
||||||
|
{"RFC1918 192.168.0.1", "192.168.0.1"},
|
||||||
|
{"RFC1918 192.168.255.255", "192.168.255.255"},
|
||||||
|
// Link-local (APIPA / AWS metadata)
|
||||||
|
{"link-local 169.254.0.1", "169.254.0.1"},
|
||||||
|
{"link-local 169.254.169.254", "169.254.169.254"},
|
||||||
|
// Shared address space (carrier-grade NAT)
|
||||||
|
{"CGN 100.64.0.1", "100.64.0.1"},
|
||||||
|
{"CGN 100.127.255.255", "100.127.255.255"},
|
||||||
|
// Multicast
|
||||||
|
{"multicast 224.0.0.1", "224.0.0.1"},
|
||||||
|
{"multicast 239.255.255.255", "239.255.255.255"},
|
||||||
|
// Reserved
|
||||||
|
{"reserved 240.0.0.1", "240.0.0.1"},
|
||||||
|
{"broadcast 255.255.255.255", "255.255.255.255"},
|
||||||
|
// IPv6 loopback
|
||||||
|
{"IPv6 loopback ::1", "::1"},
|
||||||
|
// IPv6 unspecified
|
||||||
|
{"IPv6 unspecified ::", "::"},
|
||||||
|
// IPv6 link-local
|
||||||
|
{"IPv6 link-local fe80::1", "fe80::1"},
|
||||||
|
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
|
||||||
|
// IPv6 ULA
|
||||||
|
{"IPv6 ULA fc00::1", "fc00::1"},
|
||||||
|
{"IPv6 ULA fd00::1", "fd00::1"},
|
||||||
|
// IPv6 multicast
|
||||||
|
{"IPv6 multicast ff02::1", "ff02::1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range blocked {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
if !IsBlockedIP(ip) {
|
||||||
|
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
}{
|
||||||
|
{"public 8.8.8.8", "8.8.8.8"},
|
||||||
|
{"public 1.1.1.1", "1.1.1.1"},
|
||||||
|
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
|
||||||
|
// not assigned to any real host, but intentionally left unblocked here because
|
||||||
|
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
|
||||||
|
// blocking it would require tracking every RFC5737 range without meaningful
|
||||||
|
// security benefit (no server should ever listen on a TEST-NET address).
|
||||||
|
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
||||||
|
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
||||||
|
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range allowed {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
if IsBlockedIP(ip) {
|
||||||
|
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[MINOR] 198.51.100.1 (TEST-NET-2, RFC5737) is a documentation/testing range that should arguably be blocked, not considered public. The comment acknowledges it as 'public-looking' but it is actually a reserved range per RFC5737. This won't cause a security issue in practice (no real server lives there) but the comment is misleading. Consider either blocking it or removing the comment that implies ambiguity. **[MINOR]** 198.51.100.1 (TEST-NET-2, RFC5737) is a documentation/testing range that should arguably be blocked, not considered public. The comment acknowledges it as 'public-looking' but it is actually a reserved range per RFC5737. This won't cause a security issue in practice (no real server lives there) but the comment is misleading. Consider either blocking it or removing the comment that implies ambiguity.
|
|||||||
|
|
||||||
|
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
|
||||||
|
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
|
||||||
|
sonnet-review-bot
commented
[NIT] The inline comment on the 198.51.100.1 test case is unusually long and misaligned — it uses a tab-based continuation that won't render cleanly in most viewers. The explanation is good but could be moved to a doc comment or shortened. **[NIT]** The inline comment on the 198.51.100.1 test case is unusually long and misaligned — it uses a tab-based continuation that won't render cleanly in most viewers. The explanation is good but could be moved to a doc comment or shortened.
|
|||||||
|
// Construct it manually as a 16-byte IP.
|
||||||
|
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
|
||||||
|
if !IsBlockedIP(mapped) {
|
||||||
|
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
|
||||||
|
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
|
||||||
|
if IsBlockedIP(mappedPublic) {
|
||||||
|
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBlockedIPEdgeCases(t *testing.T) {
|
||||||
|
// The boundary between RFC1918 and public ranges.
|
||||||
|
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
|
||||||
|
notPrivate := net.ParseIP("172.15.255.255")
|
||||||
|
if IsBlockedIP(notPrivate) {
|
||||||
|
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
|
||||||
|
}
|
||||||
|
// 172.32.0.0 is NOT private (just above 172.31.255.255).
|
||||||
|
notPrivate2 := net.ParseIP("172.32.0.0")
|
||||||
|
if IsBlockedIP(notPrivate2) {
|
||||||
|
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
|
||||||
|
}
|
||||||
|
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
|
||||||
|
notCGN := net.ParseIP("100.63.255.255")
|
||||||
|
if IsBlockedIP(notCGN) {
|
||||||
|
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
|
||||||
|
}
|
||||||
|
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
|
||||||
|
notCGN2 := net.ParseIP("100.128.0.0")
|
||||||
|
if IsBlockedIP(notCGN2) {
|
||||||
|
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
|
||||||
|
// successfully. This catches programming errors in the CIDR list without
|
||||||
|
// requiring a startup panic. The init() function records parse failures in
|
||||||
|
// blockedCIDRParseErrors rather than panicking; this test makes those failures
|
||||||
|
// visible as test failures during CI.
|
||||||
|
func TestBlockedCIDRsValid(t *testing.T) {
|
||||||
|
if len(blockedCIDRParseErrors) > 0 {
|
||||||
|
for _, msg := range blockedCIDRParseErrors {
|
||||||
|
t.Errorf("CIDR parse error: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestPostReview_WithComments(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
comments := []ReviewComment{
|
comments := []ReviewComment{
|
||||||
{Path: "main.go", NewPosition: 42, Body: "[MAJOR] Something bad"},
|
{Path: "main.go", NewPosition: 42, Body: "[MAJOR] Something bad"},
|
||||||
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
|
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
|
||||||
@@ -71,7 +71,7 @@ func TestPostReview_NilComments(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewTestClient(server.URL, "test-token")
|
||||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|||||||
[MINOR] The Python SSRF pre-check treats DNS errors and "no addresses" cases as a blocked private/reserved IP in the subsequent bash error message, which can mislead operators. Consider differentiating the error message for DNS failures vs blocked address ranges to improve diagnosability.
[MAJOR] The Python IP pre-check does not normalize IPv6-mapped IPv4 addresses (e.g., ::ffff:10.0.0.1). ipaddress.IPv6Address("::ffff:10.0.0.1").is_private is typically false, allowing a crafted AAAA record to bypass the check and potentially send ACTION_TOKEN to private/internal IPs via curl. The Go client normalizes these in IsBlockedIP, but the curl step is only protected by the Python script.
[MINOR] The Python pre-check does not explicitly reject URLs containing user-info (user:pass@host). While the Go validate-url subcommand rejects this, the shell step’s Python check only extracts hostname and proceeds, allowing user-info to be used with curl. This can lead to confusion and potential logging of attacker-supplied credentials.
[NIT] The Python IP classification (using ipaddress.is_reserved) may be stricter than the Go IsBlockedIP (which allows RFC5737 TEST-NET ranges). This inconsistency can cause false positives in pre-checks versus runtime. Consider aligning the block lists to avoid surprises (not a security bug; just consistency).