fix(github): address review findings from rounds 2867/2870
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 41s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m20s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m43s

- Extract duplicated CheckRedirect lambda to defaultCheckRedirect function
  (sonnet #1: eliminate duplication between NewClient and SetHTTPClient)
- Remove unnecessary int64 cast in response size check (sonnet #3)
- Validate fallback unmarshal in ListContents to reject zero-value entries
  (sonnet #5: prevent accepting unexpected JSON formats silently)
- Rename strPtr to stringPtr for consistency (sonnet #6)
- Add doc comment about APIError.Error body exposure (security #3)

Deferred to separate issues:
- #95: Reject cross-host redirects entirely (security #1)
- #96: Add safeguards for AllowInsecureHTTP (security #2)
This commit is contained in:
claw
2026-05-12 17:30:24 -07:00
parent 1fcc0b738a
commit 491df7cb1f
3 changed files with 35 additions and 32 deletions
+24 -24
View File
@@ -26,6 +26,10 @@ const (
// APIError represents an HTTP error response from the GitHub API. // APIError represents an HTTP error response from the GitHub API.
// It carries the status code so callers can distinguish between // It carries the status code so callers can distinguish between
// different failure modes (e.g. 404 vs 500). // different failure modes (e.g. 404 vs 500).
//
// Note: Error() includes up to 200 bytes of the response body for debugging.
// Callers should avoid logging raw error messages in production if the upstream
// server may return sensitive details in error responses.
type APIError struct { type APIError struct {
StatusCode int StatusCode int
Body string Body string
@@ -97,6 +101,21 @@ type Client struct {
retryBackoff []time.Duration retryBackoff []time.Duration
} }
// defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil).
// It strips the Authorization header on cross-host redirects or protocol downgrades
// (HTTPS→HTTP) to prevent credential leakage, while still following the redirect.
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
// Strip Authorization on cross-host redirect or protocol downgrade (https→http).
prev := via[len(via)-1]
if req.URL.Host != prev.URL.Host || (prev.URL.Scheme == "https" && req.URL.Scheme == "http") {
req.Header.Del("Authorization")
}
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).
@@ -115,18 +134,8 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
allowInsecureHTTP: cfg.allowInsecureHTTP, allowInsecureHTTP: cfg.allowInsecureHTTP,
token: token, token: token,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: defaultCheckRedirect,
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
// Strip Authorization on cross-host redirect or protocol downgrade (https→http).
prev := via[len(via)-1]
if req.URL.Host != prev.URL.Host || (prev.URL.Scheme == "https" && req.URL.Scheme == "http") {
req.Header.Del("Authorization")
}
return nil
},
}, },
} }
} }
@@ -138,17 +147,8 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
func (c *Client) SetHTTPClient(hc *http.Client) { func (c *Client) SetHTTPClient(hc *http.Client) {
if hc == nil { if hc == nil {
hc = &http.Client{ hc = &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: defaultCheckRedirect,
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
prev := via[len(via)-1]
if req.URL.Host != prev.URL.Host || (prev.URL.Scheme == "https" && req.URL.Scheme == "http") {
req.Header.Del("Authorization")
}
return nil
},
} }
} }
c.httpClient = hc c.httpClient = hc
@@ -235,7 +235,7 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
if err != nil { if err != nil {
return nil, fmt.Errorf("read response body: %w", err) return nil, fmt.Errorf("read response body: %w", err)
} }
if int64(len(body)) >= maxResponseBytes { if len(body) >= maxResponseBytes {
return nil, fmt.Errorf("response body exceeded %d bytes (truncated)", maxResponseBytes) return nil, fmt.Errorf("response body exceeded %d bytes (truncated)", maxResponseBytes)
} }
return body, nil return body, nil
+3
View File
@@ -48,6 +48,9 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]
if err2 := json.Unmarshal(body, &single); err2 != nil { if err2 := json.Unmarshal(body, &single); err2 != nil {
return nil, fmt.Errorf("parse contents JSON: %w", err2) return nil, fmt.Errorf("parse contents JSON: %w", err2)
} }
if single.Name == "" && single.Path == "" && single.Type == "" {
return nil, fmt.Errorf("parse contents JSON: unexpected response format")
}
entries = []entry{single} entries = []entry{single}
} }
+8 -8
View File
@@ -528,13 +528,13 @@ func TestGetCommitStatuses_CheckRunConclusions(t *testing.T) {
status string status string
want string want string
}{ }{
{strPtr("success"), "completed", "success"}, {stringPtr("success"), "completed", "success"},
{strPtr("failure"), "completed", "failure"}, {stringPtr("failure"), "completed", "failure"},
{strPtr("action_required"), "completed", "failure"}, {stringPtr("action_required"), "completed", "failure"},
{strPtr("timed_out"), "completed", "failure"}, {stringPtr("timed_out"), "completed", "failure"},
{strPtr("cancelled"), "completed", "success"}, {stringPtr("cancelled"), "completed", "success"},
{strPtr("skipped"), "completed", "success"}, {stringPtr("skipped"), "completed", "success"},
{strPtr("neutral"), "completed", "success"}, {stringPtr("neutral"), "completed", "success"},
{nil, "in_progress", "pending"}, {nil, "in_progress", "pending"},
{nil, "queued", "pending"}, {nil, "queued", "pending"},
} }
@@ -632,6 +632,6 @@ func TestGetCommitStatuses_MalformedJSON(t *testing.T) {
} }
} }
func strPtr(s string) *string { func stringPtr(s string) *string {
return &s return &s
} }