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.
// It carries the status code so callers can distinguish between
// 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 {
StatusCode int
Body string
@@ -97,6 +101,21 @@ type Client struct {
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.
// 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).
@@ -115,18 +134,8 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
allowInsecureHTTP: cfg.allowInsecureHTTP,
token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(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
},
Timeout: 30 * time.Second,
CheckRedirect: defaultCheckRedirect,
},
}
}
@@ -138,17 +147,8 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
func (c *Client) SetHTTPClient(hc *http.Client) {
if hc == nil {
hc = &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
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
},
Timeout: 30 * time.Second,
CheckRedirect: defaultCheckRedirect,
}
}
c.httpClient = hc
@@ -235,7 +235,7 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
if err != nil {
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 body, nil