feat(github): add safeguards against accidental AllowInsecureHTTP use (#96) #113

Merged
aweiker merged 6 commits from review-bot-issue-96 into main 2026-05-13 20:21:42 +00:00
2 changed files with 63 additions and 8 deletions
Showing only changes of commit db7b7e66bf - Show all commits
+23 -8
View File
@@ -10,6 +10,7 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
28
@@ -268,21 +269,36 @@ func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
return 0, false
}
Outdated
Review

[NIT] redactURL uses strings.IndexByte which is perfectly fine, but since the project already imports net/url, using url.Parse + clearing RawQuery would handle edge cases like fragment identifiers (#) and malformed URLs more robustly. Not a bug given the controlled usage context (internal URLs), just a note.

**[NIT]** `redactURL` uses `strings.IndexByte` which is perfectly fine, but since the project already imports `net/url`, using `url.Parse` + clearing `RawQuery` would handle edge cases like fragment identifiers (`#`) and malformed URLs more robustly. Not a bug given the controlled usage context (internal URLs), just a note.
Review

[NIT] redactURL redacts userinfo and query parameters; consider also clearing URL fragments (u.Fragment) to avoid leaking anchor components in logs, even though they’re generally not sent to servers.

**[NIT]** redactURL redacts userinfo and query parameters; consider also clearing URL fragments (u.Fragment) to avoid leaking anchor components in logs, even though they’re generally not sent to servers.
// redactURL redacts query parameters from a URL for safe inclusion in error
// messages and log output.
// redactURL redacts sensitive components from a URL for safe inclusion in error
// messages and log output. It removes userinfo (e.g., user:pass@) and replaces
Outdated
Review

[MINOR] hasHTTPSScheme uses strings.EqualFold for the scheme prefix check. Since "https://" contains only ASCII characters, EqualFold is correct but adds a small amount of unnecessary overhead compared to a direct strings.ToLower or a case-sensitive check (HTTP schemes per RFC 3986 are case-insensitive but in practice always lowercase in this codebase). This is purely a performance NIT on a non-hot path and does not affect correctness.

**[MINOR]** hasHTTPSScheme uses strings.EqualFold for the scheme prefix check. Since "https://" contains only ASCII characters, EqualFold is correct but adds a small amount of unnecessary overhead compared to a direct strings.ToLower or a case-sensitive check (HTTP schemes per RFC 3986 are case-insensitive but in practice always lowercase in this codebase). This is purely a performance NIT on a non-hot path and does not affect correctness.
// query parameters with a placeholder.
security-review-bot marked this conversation as resolved Outdated
Outdated
Review

[MINOR] The refusal error includes the full request URL (including query string) which may carry sensitive data in some edge cases; if upstream logs this error verbatim, it could leak information. Consider redacting query parameters or logging a sanitized URL.

**[MINOR]** The refusal error includes the full request URL (including query string) which may carry sensitive data in some edge cases; if upstream logs this error verbatim, it could leak information. Consider redacting query parameters or logging a sanitized URL.
func redactURL(rawURL string) string {
if idx := strings.IndexByte(rawURL, '?'); idx != -1 {
return rawURL[:idx] + "?<redacted>"
u, err := url.Parse(rawURL)
Outdated
Review

[MAJOR] The HTTP-scheme guard in doRequest uses a case-sensitive prefix check (strings.HasPrefix(reqURL, "http://"). URI schemes are case-insensitive (RFC 3986), so a URL like "HTTP://..." bypasses the guard and may send credentials over plaintext. Parse the URL and compare scheme case-insensitively (e.g., url.Parse + strings.EqualFold(u.Scheme, "http")).

**[MAJOR]** The HTTP-scheme guard in doRequest uses a case-sensitive prefix check (strings.HasPrefix(reqURL, "http://"). URI schemes are case-insensitive (RFC 3986), so a URL like "HTTP://..." bypasses the guard and may send credentials over plaintext. Parse the URL and compare scheme case-insensitively (e.g., url.Parse + strings.EqualFold(u.Scheme, "http")).
if err != nil {
return "<unparseable URL>"
}
Outdated
Review

[NIT] The redactURL function has a dead code path: if u.User != nil { u.User = nil }. The nil check is unnecessary because assigning nil to a nil pointer is a no-op. This works correctly but is slightly misleading. Could simplify to u.User = nil.

**[NIT]** The `redactURL` function has a dead code path: `if u.User != nil { u.User = nil }`. The nil check is unnecessary because assigning nil to a nil pointer is a no-op. This works correctly but is slightly misleading. Could simplify to `u.User = nil`.
Review

[NIT] The HTTP rejection error message says 'use HTTPS or set AllowInsecureHTTP option'. For users who call AllowInsecureHTTP() and see it silently fall back (env var not set), this message may be confusing — they did set the option but it was ignored. A slightly more helpful message might mention the REVIEW_BOT_ALLOW_INSECURE env var. Minor UX nit.

**[NIT]** The HTTP rejection error message says 'use HTTPS or set AllowInsecureHTTP option'. For users who call AllowInsecureHTTP() and see it silently fall back (env var not set), this message may be confusing — they did set the option but it was ignored. A slightly more helpful message might mention the REVIEW_BOT_ALLOW_INSECURE env var. Minor UX nit.
return rawURL
if u.User != nil {
u.User = nil
}
Outdated
Review

[MAJOR] The insecure-HTTP guard uses strings.HasPrefix(reqURL, "http://") which is case-sensitive. URL schemes are case-insensitive; a URL like "HTTP://..." would bypass this check and send credentials over plaintext. Parse the URL and compare the scheme case-insensitively (e.g., url.Parse + strings.EqualFold(u.Scheme, "http")) or normalize to lower-case before checking.

**[MAJOR]** The insecure-HTTP guard uses strings.HasPrefix(reqURL, "http://") which is case-sensitive. URL schemes are case-insensitive; a URL like "HTTP://..." would bypass this check and send credentials over plaintext. Parse the URL and compare the scheme case-insensitively (e.g., url.Parse + strings.EqualFold(u.Scheme, "http")) or normalize to lower-case before checking.
if u.RawQuery != "" {
u.RawQuery = "<redacted>"
}
u.Fragment = ""
return u.String()
}
Review

[MINOR] The error message includes user-influenced URL data via redactURL(reqURL) without explicit newline/carriage-return sanitization. If upstream code logs this error directly, it could allow limited log injection if a crafted URL with control characters is accepted by url.Parse. Consider sanitizing \n/\r in the formatted error or ensuring redactURL strips such characters.

**[MINOR]** The error message includes user-influenced URL data via redactURL(reqURL) without explicit newline/carriage-return sanitization. If upstream code logs this error directly, it could allow limited log injection if a crafted URL with control characters is accepted by url.Parse. Consider sanitizing \n/\r in the formatted error or ensuring redactURL strips such characters.
// doRequest performs an HTTP request with retry on 429 rate limit responses.
Review

[NIT] The HTTP scheme check in doRequest parses the URL twice when the URL is also valid for the request — once here in the guard and implicitly again by http.NewRequestWithContext. This is a minor inefficiency in the hot path but not a correctness issue.

**[NIT]** The HTTP scheme check in `doRequest` parses the URL twice when the URL is also valid for the request — once here in the guard and implicitly again by `http.NewRequestWithContext`. This is a minor inefficiency in the hot path but not a correctness issue.
// It respects the Retry-After header when present, supporting both integer
// seconds and HTTP-date formats (capped at maxRetryAfter).
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
if !c.allowInsecureHTTP && strings.HasPrefix(reqURL, "http://") {
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
if !c.allowInsecureHTTP {
parsed, err := url.Parse(reqURL)
if err != nil {
return nil, fmt.Errorf("parse request URL: %w", err)
}
if strings.EqualFold(parsed.Scheme, "http") {
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
}
}
var backoff []time.Duration
1
@@ -303,7 +319,6 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
timer := time.NewTimer(delay)
Review

[MINOR] The diff removes timer.Stop() from the case <-timer.C: branch. When the timer fires normally, the timer's goroutine is already done, but calling timer.Stop() after it fires is a no-op and not harmful — the original code was actually correct in calling it (the resource is already freed, but it's a safe call). The real concern is the missing call on the successful timer path: after <-timer.C fires, the GC will eventually collect the timer, but timer.Stop() on the fired case is idiomatic cleanup. This is extremely minor since a fired timer has no goroutine leak, only a small GC delay. Consider adding timer.Stop() back on both branches for explicitness, or using defer timer.Stop() before the select.

**[MINOR]** The diff removes `timer.Stop()` from the `case <-timer.C:` branch. When the timer fires normally, the timer's goroutine is already done, but calling `timer.Stop()` after it fires is a no-op and not harmful — the original code was actually correct in calling it (the resource is already freed, but it's a safe call). The real concern is the missing call on the successful timer path: after `<-timer.C` fires, the GC will eventually collect the timer, but `timer.Stop()` on the fired case is idiomatic cleanup. This is extremely minor since a fired timer has no goroutine leak, only a small GC delay. Consider adding `timer.Stop()` back on both branches for explicitness, or using `defer timer.Stop()` before the select.
select {
case <-timer.C:
timer.Stop()
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
+40
View File
5
@@ -544,6 +544,30 @@ func TestNoInsecureOption_RejectsHTTP(t *testing.T) {
t.Errorf("unexpected error message: %v", err)
}
}
func TestNoInsecureOption_RejectsUppercaseHTTP(t *testing.T) {
// Verify case-insensitive scheme check (RFC 3986).
c := NewClient("tok", "HTTP://example.com")
_, err := c.doGet(context.Background(), "HTTP://example.com/test")
if err == nil {
t.Fatal("expected error for uppercase HTTP scheme")
}
if !strings.Contains(err.Error(), "refusing HTTP request") {
Review

[NIT] Missing blank line between TestNoInsecureOption_RejectsHTTP and TestNoInsecureOption_RejectsUppercaseHTTP — minor formatting inconsistency compared to the rest of the file, but gofmt doesn't enforce blank lines between top-level declarations in the same way.

**[NIT]** Missing blank line between `TestNoInsecureOption_RejectsHTTP` and `TestNoInsecureOption_RejectsUppercaseHTTP` — minor formatting inconsistency compared to the rest of the file, but `gofmt` doesn't enforce blank lines between top-level declarations in the same way.
t.Errorf("unexpected error message: %v", err)
}
}
func TestNoInsecureOption_RejectsMixedCaseHTTP(t *testing.T) {
// Verify mixed case like "Http://" is also rejected.
c := NewClient("tok", "Http://example.com")
_, err := c.doGet(context.Background(), "Http://example.com/test")
if err == nil {
t.Fatal("expected error for mixed-case HTTP scheme")
}
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestAllowInsecureHTTP_WithoutEnvVar_Rejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1
@@ -616,3 +640,19 @@ func TestRedactURL_NoQuery(t *testing.T) {
t.Errorf("redactURL = %q, want %q", got, want)
}
}
func TestRedactURL_Userinfo(t *testing.T) {
got := redactURL("http://user:pass@localhost:1234/path")
want := "http://localhost:1234/path"
if got != want {
t.Errorf("redactURL = %q, want %q", got, want)
}
}
func TestRedactURL_UserinfoWithQuery(t *testing.T) {
got := redactURL("http://user:pass@localhost:1234/path?secret=token")
want := "http://localhost:1234/path?<redacted>"
if got != want {
t.Errorf("redactURL = %q, want %q", got, want)
}
}