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
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:
+24
-24
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user