Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2647da385e | |||
| 06b92a6834 | |||
| 91f31ff2d7 | |||
| ce48dc0ec6 | |||
| dc2e1ca5de | |||
| 7de6fdd9ec | |||
| 1e0959b077 | |||
| 67c3db70cb | |||
| a845ce32eb | |||
| 49d6ca77a3 | |||
| 6ebf66aefb | |||
| 004343d05f | |||
| 92b84976cf | |||
| 881ce232eb | |||
| bf52fceea0 |
+1
-3
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
| Package | Use Case | Scope |
|
| Package | Use Case | Scope |
|
||||||
|---------|----------|-------|
|
|---------|----------|-------|
|
||||||
| `github.com/goccy/go-yaml` | YAML parsing (persona files, config) | production |
|
| `github.com/goccy/go-yaml` | YAML parsing and AST inspection (subpkgs: `ast`, `parser`) | production |
|
||||||
| `github.com/google/go-cmp` | Test comparisons (`cmp.Diff`) | test only |
|
| `github.com/google/go-cmp` | Test comparisons (`cmp.Diff`) | test only |
|
||||||
|
|
||||||
**Any import not in this table or the Go standard library is forbidden.**
|
**Any import not in this table or the Go standard library is forbidden.**
|
||||||
@@ -21,8 +21,6 @@ To request a new dependency:
|
|||||||
2. Requires explicit approval from Aaron
|
2. Requires explicit approval from Aaron
|
||||||
3. After merge, a separate PR may use the package
|
3. After merge, a separate PR may use the package
|
||||||
|
|
||||||
<!-- Deviation from step 1+3 for go-yaml migration: see #91 for rationale. -->
|
|
||||||
|
|
||||||
*Enforcement: `scripts/check-deps.sh` parses this table — update only here.*
|
*Enforcement: `scripts/check-deps.sh` parses this table — update only here.*
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
+127
-15
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -47,6 +48,12 @@ func IsServerError(err error) bool {
|
|||||||
return errors.As(err, &apiErr) && apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
return errors.As(err, &apiErr) && apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultMaxDiffSize is the default maximum diff size in bytes (10 MB).
|
||||||
|
const DefaultMaxDiffSize = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// ErrDiffTooLarge is returned when a PR diff exceeds the configured MaxDiffSize.
|
||||||
|
var ErrDiffTooLarge = errors.New("diff size exceeds maximum allowed size")
|
||||||
|
|
||||||
// Client interacts with the Gitea API.
|
// Client interacts with the Gitea API.
|
||||||
// A Client is safe for concurrent use by multiple goroutines.
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -61,6 +68,42 @@ type Client struct {
|
|||||||
// This field must be configured before the first request is made.
|
// This field must be configured before the first request is made.
|
||||||
// Modifying it while requests are in flight is not safe.
|
// Modifying it while requests are in flight is not safe.
|
||||||
RetryBackoff []time.Duration
|
RetryBackoff []time.Duration
|
||||||
|
|
||||||
|
// MaxDiffSize is the maximum number of bytes allowed when fetching a PR diff.
|
||||||
|
// If zero, defaults to DefaultMaxDiffSize (10 MB). Set to any negative value
|
||||||
|
// (or math.MaxInt64) to disable the limit.
|
||||||
|
//
|
||||||
|
// This field must be configured before the first request is made.
|
||||||
|
// Modifying it while requests are in flight is not safe.
|
||||||
|
MaxDiffSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
||||||
|
// NOTE: This function is intentionally duplicated in github/client.go (and vice versa)
|
||||||
|
// because the packages are separate. Changes here must be mirrored there.
|
||||||
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
||||||
|
// and cross-host redirects (to prevent following responses from untrusted
|
||||||
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
||||||
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
// Guard for direct invocation in tests and any future callers;
|
||||||
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
||||||
|
if len(via) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prev := via[len(via)-1]
|
||||||
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
||||||
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
||||||
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
// Reject cross-host redirect entirely to avoid consuming responses
|
||||||
|
// from untrusted endpoints.
|
||||||
|
if req.URL.Host != prev.URL.Host {
|
||||||
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Gitea API client.
|
// NewClient creates a new Gitea API client.
|
||||||
@@ -68,13 +111,30 @@ 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{Timeout: 30 * time.Second},
|
http: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHTTPClient sets the underlying HTTP client used for requests.
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||||
// This is intended for testing to inject mock transports.
|
// This is intended for test setup only to inject mock transports; it must be
|
||||||
|
// called before any goroutines issue requests.
|
||||||
|
//
|
||||||
|
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
||||||
|
// CheckRedirect policy matching NewClient).
|
||||||
|
//
|
||||||
|
// Callers providing a non-nil client are responsible for configuring a safe
|
||||||
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
||||||
|
// redirects and may forward the Authorization header to untrusted hosts.
|
||||||
func (c *Client) SetHTTPClient(hc *http.Client) {
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
}
|
||||||
|
}
|
||||||
c.http = hc
|
c.http = hc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +185,20 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPullRequestDiff fetches the unified diff for a PR.
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
||||||
|
// It enforces MaxDiffSize to prevent unbounded memory allocation.
|
||||||
|
// Returns ErrDiffTooLarge if the diff exceeds the configured limit.
|
||||||
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
|
|
||||||
|
maxSize := c.MaxDiffSize
|
||||||
|
if maxSize == 0 {
|
||||||
|
maxSize = DefaultMaxDiffSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the limit is disabled (negative) or set to math.MaxInt64 (which
|
||||||
|
// would overflow the +1 detection and silently disable enforcement),
|
||||||
|
// use the standard unlimited doGet path.
|
||||||
|
if maxSize < 0 || maxSize == math.MaxInt64 {
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch diff: %w", err)
|
return "", fmt.Errorf("fetch diff: %w", err)
|
||||||
@@ -134,6 +206,13 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, num
|
|||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body, err := c.doGetLimited(ctx, reqURL, maxSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch diff: %w", err)
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPullRequestFiles fetches the list of files changed in a PR.
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
||||||
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
@@ -292,9 +371,9 @@ func isRetriableSyscallError(err error) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// redactURL strips query parameters from a URL for safe logging.
|
// redactURL strips query parameters and userinfo credentials from a URL for
|
||||||
// This prevents accidental exposure of sensitive data that future callers
|
// safe logging. This prevents accidental exposure of sensitive data (tokens in
|
||||||
// might pass via query strings.
|
// query strings, or user:pass in the authority) in log output.
|
||||||
func redactURL(rawURL string) string {
|
func redactURL(rawURL string) string {
|
||||||
parsed, err := url.Parse(rawURL)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,6 +381,9 @@ func redactURL(rawURL string) string {
|
|||||||
// potentially logging something sensitive.
|
// potentially logging something sensitive.
|
||||||
return "[invalid URL]"
|
return "[invalid URL]"
|
||||||
}
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
parsed.User = url.User("REDACTED")
|
||||||
|
}
|
||||||
if parsed.RawQuery != "" {
|
if parsed.RawQuery != "" {
|
||||||
parsed.RawQuery = "[redacted]"
|
parsed.RawQuery = "[redacted]"
|
||||||
}
|
}
|
||||||
@@ -322,10 +404,12 @@ func sanitizeErrorForLog(err error) string {
|
|||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// doGet performs an HTTP GET request with retry on 5xx errors and temporary
|
// doGetWithReader performs an HTTP GET request with retry on 5xx errors and
|
||||||
// network errors. Retries up to 3 times with exponential backoff (1s, 2s delays
|
// temporary network errors. Retries up to 3 times with exponential backoff
|
||||||
// by default; configurable via Client.RetryBackoff for testing).
|
// (1s, 2s delays by default; configurable via Client.RetryBackoff for testing).
|
||||||
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
// The readBody function is called with the response body on success (2xx) and
|
||||||
|
// is responsible for reading and closing it.
|
||||||
|
func (c *Client) doGetWithReader(ctx context.Context, reqURL string, readBody func(io.ReadCloser) ([]byte, error)) ([]byte, error) {
|
||||||
const maxAttempts = 3
|
const maxAttempts = 3
|
||||||
// backoff[i] is the delay before attempt i+1 (i.e., after attempt i fails).
|
// backoff[i] is the delay before attempt i+1 (i.e., after attempt i fails).
|
||||||
// First attempt (i=0) has no delay; retries wait 1s then 2s by default.
|
// First attempt (i=0) has no delay; retries wait 1s then 2s by default.
|
||||||
@@ -390,12 +474,7 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
body, err := io.ReadAll(resp.Body)
|
return readBody(resp.Body)
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error path: limit how much we read from potentially malicious server
|
// Error path: limit how much we read from potentially malicious server
|
||||||
@@ -413,6 +492,39 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doGet performs an HTTP GET request with retry, reading the full response body.
|
||||||
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
||||||
|
return c.doGetWithReader(ctx, reqURL, func(body io.ReadCloser) ([]byte, error) {
|
||||||
|
defer body.Close()
|
||||||
|
return io.ReadAll(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// doGetLimited performs an HTTP GET request with retry but enforces a maximum
|
||||||
|
// response body size. Returns ErrDiffTooLarge if the response exceeds maxBytes.
|
||||||
|
// It reads maxBytes+1 (clamped to avoid overflow) to detect truncation without
|
||||||
|
// buffering the entire body.
|
||||||
|
func (c *Client) doGetLimited(ctx context.Context, reqURL string, maxBytes int64) ([]byte, error) {
|
||||||
|
return c.doGetWithReader(ctx, reqURL, func(body io.ReadCloser) ([]byte, error) {
|
||||||
|
defer body.Close()
|
||||||
|
// Read up to maxBytes+1 to detect overflow.
|
||||||
|
// Clamp to prevent integer overflow when maxBytes == math.MaxInt64.
|
||||||
|
limitBytes := maxBytes + 1
|
||||||
|
if limitBytes <= 0 {
|
||||||
|
limitBytes = math.MaxInt64
|
||||||
|
}
|
||||||
|
limited := io.LimitReader(body, limitBytes)
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxBytes {
|
||||||
|
return nil, fmt.Errorf("%w: response exceeds %d bytes", ErrDiffTooLarge, maxBytes)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// escapePath escapes each segment of a relative file path for use in URLs.
|
// escapePath escapes each segment of a relative file path for use in URLs.
|
||||||
// Slashes are preserved as path separators; other special characters are escaped.
|
// Slashes are preserved as path separators; other special characters are escaped.
|
||||||
// Input should be a relative path (no leading slash). Already-encoded segments
|
// Input should be a relative path (no leading slash). Already-encoded segments
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -1092,6 +1093,21 @@ func TestRedactURL(t *testing.T) {
|
|||||||
input: "",
|
input: "",
|
||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "with userinfo - redacts credentials",
|
||||||
|
input: "https://admin:secret@gitea.example.com/api/v1/repos",
|
||||||
|
want: "https://REDACTED@gitea.example.com/api/v1/repos",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with userinfo and query params",
|
||||||
|
input: "https://user:pass@example.com/path?token=abc",
|
||||||
|
want: "https://REDACTED@example.com/path?[redacted]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username only - no password",
|
||||||
|
input: "https://user@example.com/path",
|
||||||
|
want: "https://REDACTED@example.com/path",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -1144,3 +1160,104 @@ func TestSanitizeErrorForLog(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewClient_HasCheckRedirect(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
if c.http.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "gitea.example.com", Path: "/foo"},
|
||||||
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on HTTPS->HTTP redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HTTPS to HTTP downgrade") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsCrossHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "cdn.example.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on cross-host redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cross-host") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if auth := req.Header.Get("Authorization"); auth != "token abc" {
|
||||||
|
t.Errorf("expected Authorization to be preserved, got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHostHTTPToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "http", Host: "localhost:3000", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "localhost:3000", Path: "/bar"},
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsTooManyRedirects(t *testing.T) {
|
||||||
|
via := make([]*http.Request, 10)
|
||||||
|
for i := range via {
|
||||||
|
via[i] = &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/"}}
|
||||||
|
}
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/final"}}
|
||||||
|
err := defaultCheckRedirect(req, via)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after 10 redirects")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "10 redirects") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_EmptyViaAllowed(t *testing.T) {
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
err := defaultCheckRedirect(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error with empty via: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
c.SetHTTPClient(nil)
|
||||||
|
if c.http == nil {
|
||||||
|
t.Fatal("expected non-nil http client after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
if c.http.Timeout != 30*time.Second {
|
||||||
|
t.Errorf("expected 30s timeout, got %v", c.http.Timeout)
|
||||||
|
}
|
||||||
|
if c.http.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetPullRequestDiff_SizeLimits(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
diff string
|
||||||
|
maxDiffSize int64
|
||||||
|
wantErr error
|
||||||
|
wantDiff string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exceeds max size",
|
||||||
|
diff: strings.Repeat("+ added line\n", 1000), // ~13 KB
|
||||||
|
maxDiffSize: 100,
|
||||||
|
wantErr: ErrDiffTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "within max size",
|
||||||
|
diff: "diff --git a/f.go b/f.go\n--- a/f.go\n+++ b/f.go\n@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
maxDiffSize: 1024,
|
||||||
|
wantDiff: "diff --git a/f.go b/f.go\n--- a/f.go\n+++ b/f.go\n@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exactly at limit",
|
||||||
|
diff: strings.Repeat("x", 50),
|
||||||
|
maxDiffSize: 50,
|
||||||
|
wantDiff: strings.Repeat("x", 50),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte over limit",
|
||||||
|
diff: strings.Repeat("x", 51),
|
||||||
|
maxDiffSize: 50,
|
||||||
|
wantErr: ErrDiffTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled limit",
|
||||||
|
diff: strings.Repeat("x", 10000),
|
||||||
|
maxDiffSize: -1,
|
||||||
|
wantDiff: strings.Repeat("x", 10000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "math.MaxInt64 treated as disabled",
|
||||||
|
diff: strings.Repeat("x", 10000),
|
||||||
|
maxDiffSize: math.MaxInt64,
|
||||||
|
wantDiff: strings.Repeat("x", 10000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default limit",
|
||||||
|
diff: "diff content",
|
||||||
|
maxDiffSize: 0, // zero means use DefaultMaxDiffSize
|
||||||
|
wantDiff: "diff content",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(tt.diff)) //nolint:errcheck // test handler
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
client.MaxDiffSize = tt.maxDiffSize
|
||||||
|
client.RetryBackoff = []time.Duration{}
|
||||||
|
|
||||||
|
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
|
||||||
|
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("expected %v, got: %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.wantDiff {
|
||||||
|
t.Errorf("diff mismatch: got length %d, want length %d", len(got), len(tt.wantDiff))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-3
@@ -8,7 +8,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +33,9 @@ const (
|
|||||||
// maxResponseBodyBytes limits how much of a successful response body we read
|
// maxResponseBodyBytes limits how much of a successful response body we read
|
||||||
// for defense-in-depth against servers returning excessively large payloads.
|
// for defense-in-depth against servers returning excessively large payloads.
|
||||||
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
|
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
|
// envAllowInsecure is the environment variable that gates the WithAllowInsecureHTTP option.
|
||||||
|
envAllowInsecure = "REVIEW_BOT_ALLOW_INSECURE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIError represents an HTTP error response from the GitHub API.
|
// APIError represents an HTTP error response from the GitHub API.
|
||||||
@@ -84,6 +89,27 @@ func asAPIError(err error) (*APIError, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientConfig holds optional configuration for NewClient.
|
||||||
|
type clientConfig struct {
|
||||||
|
allowInsecureHTTP bool
|
||||||
|
testBypass bool // skip env gate; only WithAllowInsecureHTTPForTest (export_test.go) should set this
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOption configures optional behavior of NewClient.
|
||||||
|
type ClientOption func(*clientConfig)
|
||||||
|
|
||||||
|
// WithAllowInsecureHTTP permits the client to send credentials over HTTP (non-TLS) URLs.
|
||||||
|
// This is gated by the REVIEW_BOT_ALLOW_INSECURE=1 environment variable as a
|
||||||
|
// defense-in-depth safeguard. If the env var is not set, the option is ignored
|
||||||
|
// and a warning is logged.
|
||||||
|
//
|
||||||
|
// For production use on trusted internal networks only.
|
||||||
|
func WithAllowInsecureHTTP() ClientOption {
|
||||||
|
return func(c *clientConfig) {
|
||||||
|
c.allowInsecureHTTP = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Client interacts with the GitHub API.
|
// Client interacts with the GitHub API.
|
||||||
// A Client is safe for concurrent use by multiple goroutines.
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
|
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
|
||||||
@@ -95,6 +121,7 @@ type Client struct {
|
|||||||
// accepting full URLs instead.
|
// accepting full URLs instead.
|
||||||
baseURL string
|
baseURL string
|
||||||
token string
|
token string
|
||||||
|
allowInsecureHTTP bool
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
|
||||||
// retryBackoff defines the delays between retry attempts for 429 responses.
|
// retryBackoff defines the delays between retry attempts for 429 responses.
|
||||||
@@ -107,24 +134,92 @@ type Client struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
||||||
|
// NOTE: This function is intentionally duplicated in gitea/client.go (and vice versa)
|
||||||
|
// because the packages are separate. Changes here must be mirrored there.
|
||||||
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
||||||
|
// and cross-host redirects (to prevent following responses from untrusted
|
||||||
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
||||||
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
// Guard for direct invocation in tests and any future callers;
|
||||||
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
||||||
|
if len(via) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prev := via[len(via)-1]
|
||||||
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
||||||
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
||||||
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
// Reject cross-host redirect entirely to avoid consuming responses
|
||||||
|
// from untrusted endpoints.
|
||||||
|
if req.URL.Host != prev.URL.Host {
|
||||||
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient creates a new GitHub API client.
|
// NewClient creates a new GitHub API client.
|
||||||
// If baseURL is empty, it defaults to https://api.github.com.
|
// If baseURL is empty, it defaults to https://api.github.com.
|
||||||
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
|
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
|
||||||
func NewClient(token, baseURL string) *Client {
|
// The baseURL must use HTTPS unless WithAllowInsecureHTTP() or WithAllowInsecureHTTPForTest() (test-only)
|
||||||
|
// is passed as an option.
|
||||||
|
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = defaultBaseURL
|
baseURL = defaultBaseURL
|
||||||
}
|
}
|
||||||
|
var cfg clientConfig
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment gate for WithAllowInsecureHTTP (defense-in-depth).
|
||||||
|
// WithAllowInsecureHTTPForTest bypasses this gate for test convenience.
|
||||||
|
allowInsecure := false
|
||||||
|
if cfg.allowInsecureHTTP {
|
||||||
|
if cfg.testBypass {
|
||||||
|
allowInsecure = true
|
||||||
|
} else if os.Getenv(envAllowInsecure) == "1" {
|
||||||
|
allowInsecure = true
|
||||||
|
slog.Warn("WithAllowInsecureHTTP enabled — credentials may be sent over plaintext",
|
||||||
|
"env", envAllowInsecure+"=1")
|
||||||
|
} else {
|
||||||
|
slog.Warn("WithAllowInsecureHTTP option ignored", "hint", "set "+envAllowInsecure+"=1 to enable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
allowInsecureHTTP: allowInsecure,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
},
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHTTPClient sets the underlying HTTP client used for requests.
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||||
// This is intended for testing to inject mock transports.
|
// This is intended for test setup only to inject mock transports; it must be
|
||||||
|
// called before any goroutines issue requests.
|
||||||
|
//
|
||||||
|
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
||||||
|
// CheckRedirect policy matching NewClient).
|
||||||
|
//
|
||||||
|
// Callers providing a non-nil client are responsible for configuring a safe
|
||||||
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
||||||
|
// redirects and may forward the Authorization header to untrusted hosts.
|
||||||
func (c *Client) SetHTTPClient(hc *http.Client) {
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
}
|
||||||
|
}
|
||||||
c.httpClient = hc
|
c.httpClient = hc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,10 +265,30 @@ func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasHTTPSScheme reports whether rawURL starts with "https://" (case-insensitive).
|
||||||
|
// It avoids the allocation of url.Parse for a simple scheme check.
|
||||||
|
func hasHTTPSScheme(rawURL string) bool {
|
||||||
|
const prefix = "https://"
|
||||||
|
return len(rawURL) >= len(prefix) && strings.EqualFold(rawURL[:len(prefix)], prefix)
|
||||||
|
}
|
||||||
|
|
||||||
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
||||||
// It respects the Retry-After header when present, supporting both integer
|
// It respects the Retry-After header when present, supporting both integer
|
||||||
// seconds and HTTP-date formats (capped at maxRetryAfter).
|
// seconds and HTTP-date formats (capped at maxRetryAfter).
|
||||||
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
||||||
|
// Reject non-HTTPS URLs when credentials are present and insecure mode is not enabled.
|
||||||
|
// Uses a string prefix check instead of url.Parse to avoid per-request allocations.
|
||||||
|
if c.token != "" && !c.allowInsecureHTTP {
|
||||||
|
if !hasHTTPSScheme(reqURL) {
|
||||||
|
// Redact query parameters to avoid leaking sensitive data in error messages.
|
||||||
|
sanitized := reqURL
|
||||||
|
if i := strings.Index(reqURL, "?"); i >= 0 {
|
||||||
|
sanitized = reqURL[:i] + "?[REDACTED]"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use WithAllowInsecureHTTP option for trusted networks)", sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var backoff []time.Duration
|
var backoff []time.Duration
|
||||||
if c.retryBackoff != nil {
|
if c.retryBackoff != nil {
|
||||||
backoff = append([]time.Duration(nil), c.retryBackoff...)
|
backoff = append([]time.Duration(nil), c.retryBackoff...)
|
||||||
|
|||||||
+240
-9
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -33,7 +35,7 @@ func TestDoRequest_Success(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("test-token", srv.URL)
|
c := NewClient("test-token", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -58,7 +60,7 @@ func TestDoRequest_429_RetryAfter_IntegerSeconds(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
@@ -92,7 +94,7 @@ func TestDoRequest_429_RetryAfter_HTTPDate(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
c.now = func() time.Time { return fixedNow }
|
c.now = func() time.Time { return fixedNow }
|
||||||
// Initial backoff is 0; the HTTP-date parser will compute 1s and override.
|
// Initial backoff is 0; the HTTP-date parser will compute 1s and override.
|
||||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
@@ -128,7 +130,7 @@ func TestDoRequest_429_RetryAfter_HTTPDate_InPast(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
c.now = func() time.Time { return fixedNow }
|
c.now = func() time.Time { return fixedNow }
|
||||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ func TestDoRequest_429_NoRetryAfter_UsesDefaultBackoff(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
@@ -185,7 +187,7 @@ func TestDoRequest_429_InvalidRetryAfter_UsesDefaultBackoff(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
c.SetRetryBackoff([]time.Duration{0, 0})
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
@@ -206,7 +208,7 @@ func TestDoRequest_404_NoRetry(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
@@ -228,7 +230,7 @@ func TestDoRequest_401_NoRetry(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
@@ -258,7 +260,7 @@ func TestDoRequest_ContextCanceled(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient("tok", srv.URL)
|
c := NewClient("tok", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})
|
c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -407,3 +409,232 @@ func TestAPIError_Error_NewlineSanitized(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewClient_HasCheckRedirect(t *testing.T) {
|
||||||
|
c := NewClient("secret-token", "https://api.github.com")
|
||||||
|
if c.httpClient.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "api.github.com", Path: "/foo"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on HTTPS->HTTP redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HTTPS to HTTP downgrade") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsCrossHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "objects.githubusercontent.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on cross-host redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cross-host") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Auth should be preserved on same-host redirect
|
||||||
|
if auth := req.Header.Get("Authorization"); auth != "Bearer token" {
|
||||||
|
t.Errorf("expected Authorization to be preserved, got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHostHTTPToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/bar"},
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsTooManyRedirects(t *testing.T) {
|
||||||
|
via := make([]*http.Request, 10)
|
||||||
|
for i := range via {
|
||||||
|
via[i] = &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/"}}
|
||||||
|
}
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/final"}}
|
||||||
|
err := defaultCheckRedirect(req, via)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after 10 redirects")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "10 redirects") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_EmptyViaAllowed(t *testing.T) {
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
err := defaultCheckRedirect(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error with empty via: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||||
|
c := NewClient("token", "https://api.github.com")
|
||||||
|
c.SetHTTPClient(nil)
|
||||||
|
if c.httpClient == nil {
|
||||||
|
t.Fatal("expected non-nil httpClient after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
if c.httpClient.Timeout != 30*time.Second {
|
||||||
|
t.Errorf("expected 30s timeout, got %v", c.httpClient.Timeout)
|
||||||
|
}
|
||||||
|
if c.httpClient.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_RejectsHTTPWithToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Without WithAllowInsecureHTTP, should refuse to send token over HTTP
|
||||||
|
c := NewClient("secret-token", srv.URL)
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when sending token over HTTP")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing to send credentials") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_RejectsHTTPWithToken_RedactsQueryParams(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte("{}"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("secret-token", srv.URL)
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test?secret=hunter2&token=abc")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when sending token over HTTP")
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "hunter2") || strings.Contains(errMsg, "token=abc") {
|
||||||
|
t.Errorf("error message should not contain query params, got: %v", errMsg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "?[REDACTED]") {
|
||||||
|
t.Errorf("error message should contain redacted marker, got: %v", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_AllowsHTTPWithoutToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Without token, HTTP should be fine (no credentials to leak)
|
||||||
|
c := NewClient("", srv.URL)
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_AllowsHTTPWithInsecureTestOption(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTPForTest())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAllowInsecureHTTP_RequiresEnvVar(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Without the env var, AllowInsecureHTTP should be ignored
|
||||||
|
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "")
|
||||||
|
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTP())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error: WithAllowInsecureHTTP without env var should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing to send credentials") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAllowInsecureHTTP_WithEnvVar(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "1")
|
||||||
|
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTP())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAllowInsecureHTTP_EnvVarMustBeExactlyOne(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// "true" is not "1" — should be rejected
|
||||||
|
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "true")
|
||||||
|
c := NewClient("secret-token", srv.URL, WithAllowInsecureHTTP())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error: env var must be exactly \"1\" to satisfy gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
// WithAllowInsecureHTTPForTest permits the client to send credentials over HTTP
|
||||||
|
// without requiring the REVIEW_BOT_ALLOW_INSECURE environment variable.
|
||||||
|
// This is intended exclusively for tests using httptest.Server.
|
||||||
|
//
|
||||||
|
// This function lives in export_test.go so it is only available to test
|
||||||
|
// binaries and does not appear in the production API surface.
|
||||||
|
func WithAllowInsecureHTTPForTest() ClientOption {
|
||||||
|
return func(c *clientConfig) {
|
||||||
|
c.allowInsecureHTTP = true
|
||||||
|
c.testBypass = true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user