8c8f3ab4b3
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 44s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m57s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m21s
## Changes ### Go: IP-level SSRF protection in gitea.Client (primary defense) - Add gitea/ipcheck.go with IsBlockedIP() covering all blocked CIDR ranges: loopback (127.0.0.0/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16, fe80::/10), ULA (fc00::/7), CGN (100.64/10), multicast, reserved, and unspecified ranges. - IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) are normalized before checking. - Add safeDialContext to gitea.Client: resolves DNS, rejects any IP in a blocked CIDR, then dials the resolved IP directly to narrow the DNS rebinding window. NewClient now uses this safe transport by default. - Add WithUnsafeDialer() for test code using httptest.Server (127.0.0.1). - Update NewTestClient helper in export_test.go for all gitea unit tests. - Update SetHTTPClient(nil) to restore the safe transport (not the plain one). ### Go: validate-url subcommand (defense-in-depth for future bash callers) - Add 'review-bot validate-url <url>' subcommand: validates https scheme, no user-info, resolves hostname, rejects any blocked IP. - Exit 0=safe, 1=blocked, 2=validation error/dns failure. - Add outWriter/errWriter vars to main.go for testable output capture. ### action.yml: Python3 IP check in 'Determine version' step - After the https scheme validation, resolve SERVER_URL hostname with socket.getaddrinfo and reject any result where ipaddress.ip_address(ip).is_private/is_loopback/is_link_local/etc. is true. - python3 is required on ubuntu-* runners (noted in existing comments). - Covers the version-check curl that sends ACTION_TOKEN to SERVER_URL. - SERVER_URL for install-step curls is covered by the same pre-check. ### Tests - gitea/ipcheck_test.go: 30+ cases covering all blocked families + public IPs - gitea/client_test.go: safe transport presence, WithUnsafeDialer, SSRF blocking - cmd/review-bot/validateurl_test.go: scheme validation, user-info, exit codes Closes #123
89 lines
2.5 KiB
Go
89 lines
2.5 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestPostReview_WithComments(t *testing.T) {
|
|
var gotPayload struct {
|
|
Body string `json:"body"`
|
|
Event string `json:"event"`
|
|
Comments []struct {
|
|
Path string `json:"path"`
|
|
NewPosition int64 `json:"new_position"`
|
|
Body string `json:"body"`
|
|
} `json:"comments"`
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewDecoder(r.Body).Decode(&gotPayload)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 99,
|
|
"body": gotPayload.Body,
|
|
"user": map[string]any{"login": "bot"},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewTestClient(server.URL, "test-token")
|
|
comments := []ReviewComment{
|
|
{Path: "main.go", NewPosition: 42, Body: "[MAJOR] Something bad"},
|
|
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
|
|
}
|
|
|
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "", comments)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(gotPayload.Comments) != 2 {
|
|
t.Fatalf("expected 2 comments, got %d", len(gotPayload.Comments))
|
|
}
|
|
if gotPayload.Comments[0].Path != "main.go" {
|
|
t.Errorf("expected path main.go, got %s", gotPayload.Comments[0].Path)
|
|
}
|
|
if gotPayload.Comments[0].NewPosition != 42 {
|
|
t.Errorf("expected new_position 42, got %d", gotPayload.Comments[0].NewPosition)
|
|
}
|
|
if gotPayload.Comments[1].Body != "[MINOR] Style issue" {
|
|
t.Errorf("unexpected body: %s", gotPayload.Comments[1].Body)
|
|
}
|
|
}
|
|
|
|
func TestPostReview_NilComments(t *testing.T) {
|
|
var gotPayload map[string]any
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewDecoder(r.Body).Decode(&gotPayload)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 100,
|
|
"body": "test",
|
|
"user": map[string]any{"login": "bot"},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewTestClient(server.URL, "test-token")
|
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// With nil comments, the field should be omitted (omitempty)
|
|
comments, ok := gotPayload["comments"]
|
|
if ok && comments != nil {
|
|
arr, isArr := comments.([]any)
|
|
if isArr && len(arr) > 0 {
|
|
t.Error("expected no comments in payload when nil passed")
|
|
}
|
|
}
|
|
}
|