f84cc3bbcf
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m25s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m30s
MAJOR fixes: - gitea/ipcheck.go: replace startup panic with init()+error list pattern Hard-coded CIDRs that fail to parse now recorded in blockedCIDRParseErrors instead of panicking. TestBlockedCIDRsValid catches programming errors in CI without violating CONVENTIONS.md 'never panic' rule. - .gitea/actions/review/action.yml: re-validate SERVER_URL at start of 'Install review-bot' step to close DNS rebinding window between 'Determine version' and install-step curl calls. MINOR fixes: - gitea/client.go: add Timeout: 10*time.Second to net.Dialer per PLAN.md spec - cmd/review-bot/validateurl.go: switch isValidateError to errors.As so wrapped *validateError values are also detected - gitea/ipcheck_test.go: clarify 198.51.100.1 (RFC5737 TEST-NET-2) comment; add TestBlockedCIDRsValid to surface CIDR parse errors as test failures NIT fixes: - .gitea/actions/review/action.yml: refactor Python list comprehension in SSRF check to for-loop (avoids side-effect-only comprehension, runner compat) - gitea/export_test.go: expand comment explaining white-box test pattern (why package gitea not gitea_test, Go stdlib precedent) Remove PLAN.md (implementation complete)
126 lines
3.5 KiB
Go
126 lines
3.5 KiB
Go
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
|
|
}
|
|
|
|
// 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.
|
|
// Uses errors.As so that wrapped *validateError values (e.g. from fmt.Errorf("...: %w", &validateError{...}))
|
|
// are also detected, making the function robust against future wrapping.
|
|
func isValidateError(err error, out **validateError) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return errors.As(err, out)
|
|
}
|
|
|
|
// validateURL checks that rawURL is safe for use as a Gitea server URL:
|
|
// - Must be https:// (not http://)
|
|
// - Must have no user-info (user:pass@host)
|
|
// - Must resolve to at least one IP, all of which are publicly routable
|
|
func validateURL(rawURL string) error {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return &validateError{code: 2, message: fmt.Sprintf("malformed URL %q: %v", rawURL, err)}
|
|
}
|
|
|
|
// Scheme check: only https is permitted.
|
|
if !strings.EqualFold(parsed.Scheme, "https") {
|
|
return &validateError{
|
|
code: 2,
|
|
message: fmt.Sprintf("URL scheme must be https (got %q)", parsed.Scheme),
|
|
}
|
|
}
|
|
|
|
// Reject user-info (user:password@host) to prevent credential embedding.
|
|
if parsed.User != nil {
|
|
return &validateError{
|
|
code: 2,
|
|
message: "URL must not contain user-info (user:password@host)",
|
|
}
|
|
}
|
|
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return &validateError{code: 2, message: fmt.Sprintf("URL has no host: %q", rawURL)}
|
|
}
|
|
|
|
// Resolve the hostname with a short timeout.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return &validateError{
|
|
code: 2,
|
|
message: fmt.Sprintf("DNS lookup failed for %q: %v", host, err),
|
|
}
|
|
}
|
|
if len(addrs) == 0 {
|
|
return &validateError{
|
|
code: 2,
|
|
message: fmt.Sprintf("DNS lookup returned no addresses for %q", host),
|
|
}
|
|
}
|
|
|
|
for _, a := range addrs {
|
|
if gitea.IsBlockedIP(a.IP) {
|
|
return &validateError{
|
|
code: 1,
|
|
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|