feat(github): implement PRReader + FileReader client (#80) #93
@@ -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")
|
||||||
|
}
|
||||||
|
[MINOR] defaultCheckRedirect follows HTTPS→HTTP redirects after stripping Authorization. While credentials are protected, this still permits plaintext requests to proceed, which can leak metadata and expands attack surface if a misconfigured or compromised server issues such redirects. Prefer failing closed on protocol downgrades. **[MINOR]** defaultCheckRedirect follows HTTPS→HTTP redirects after stripping Authorization. While credentials are protected, this still permits plaintext requests to proceed, which can leak metadata and expands attack surface if a misconfigured or compromised server issues such redirects. Prefer failing closed on protocol downgrades.
|
|||||||
|
return nil
|
||||||
|
sonnet-review-bot
commented
[MINOR] The doc comment on **[MINOR]** The doc comment on `defaultCheckRedirect` says it "strips the Authorization header on cross-host redirects or protocol downgrades (HTTPS→HTTP) to prevent credential leakage, while still following the redirect." However, a protocol downgrade from HTTPS to HTTP is a genuine security issue — stripping the header and still following is debatable. Consider returning an error on HTTPS→HTTP downgrade rather than silently following. This is a design choice that has security implications, not a bug per se, but worth flagging.
gpt-review-bot
commented
[MINOR] defaultCheckRedirect indexes via[len(via)-1] without guarding for len(via) == 0. net/http currently guarantees at least one prior request in via, but adding a len(via) check would make this more robust against misuse. **[MINOR]** defaultCheckRedirect indexes via[len(via)-1] without guarding for len(via) == 0. net/http currently guarantees at least one prior request in via, but adding a len(via) check would make this more robust against misuse.
|
|||||||
|
}
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -116,17 +135,7 @@ func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
|||||||
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
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,16 +148,7 @@ 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)
|
||||||
|
sonnet-review-bot
commented
[MINOR] After **[MINOR]** After `c.httpClient.Do(req)` returns an error, the function returns immediately with a wrapped transport error. However, the body is not closed because `resp` is nil on transport error — this is correct, but deserves a comment for future readers since `handleResponse` uses defer for body close, it's not obvious why there's no defer here.
|
|||||||
}
|
}
|
||||||
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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
|||||||
}
|
}
|
||||||
|
sonnet-review-bot
commented
[NIT] strPtr helper is defined in pr_test.go but files_test.go is in the same package and could theoretically need it. Both are in **[NIT]** strPtr helper is defined in pr_test.go but files_test.go is in the same package and could theoretically need it. Both are in `package github` (white-box tests), so there's no duplication issue here. However, there is a duplicate strPtr in neither file — only pr_test.go has it. Fine as-is.
sonnet-review-bot
commented
[NIT] **[NIT]** `strPtr` is defined in `pr_test.go` but `files_test.go` and `client_test.go` are in the same package. If any other test file ever needs this helper, it will collide. It's already fine since both are in `package github`, but naming it something more descriptive (e.g., `stringPtr`) would match the codebase's convention of meaningful names over abbreviations per the style conventions.
sonnet-review-bot
commented
[NIT] **[NIT]** `stringPtr` helper is defined in `pr_test.go`. If future tests in `files_test.go` or `client_test.go` need similar helpers, there could be duplication. Consider moving to a shared test helper file, though for now it's fine since it's only used in `pr_test.go`.
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[NIT] **[NIT]** `strPtr` helper is defined in `pr_test.go` but `files_test.go` is in the same package (`package github`). If `files_test.go` ever needs this helper, there would be a duplicate definition. Consider putting shared test helpers in a `helpers_test.go` file. Not a current problem since `files_test.go` doesn't use it, but worth considering for consistency.
|
|||||||
func strPtr(s string) *string {
|
func stringPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|||||||
[MINOR] defaultCheckRedirect allows cross-host redirects (with Authorization stripped). Although token leakage is mitigated, following cross-host redirects can facilitate SSRF-like behavior if baseURL is misconfigured or points to a compromised server. Consider rejecting cross-host redirects by default or enforcing an allowlist of trusted hosts.