chore: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m3s
All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config (default: all minus six disabled linters, lll 88, tests included) and bump every golangci-lint pin to v2.12.2: - Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned) - script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new linux-amd64/arm64 release-archive sha256 pins Fix all 747 findings the stricter config surfaces, with no behavior changes: t.Parallel() throughout the test suite, static sentinel errors and errors.Is comparisons, checked error returns, context propagation (contextcheck/noctx), 88-column wrapping, extracted constants and helpers for goconst/dupl/funlen/cyclop, exhaustive switch cases replicating existing defaults, and white-box test files renamed to *_internal_test.go for testpackage. Three nolint:tagliatelle directives preserve the existing snake_case JSON wire and on-disk metadata formats.
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
neturl "net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -28,6 +29,23 @@ const (
|
||||
DefaultMaxConnectionsPerHost = 20
|
||||
)
|
||||
|
||||
// MIME content types.
|
||||
const (
|
||||
contentTypeJPEG = "image/jpeg"
|
||||
contentTypePNG = "image/png"
|
||||
contentTypeGIF = "image/gif"
|
||||
contentTypeWebP = "image/webp"
|
||||
contentTypeAVIF = "image/avif"
|
||||
contentTypeSVG = "image/svg+xml"
|
||||
contentTypeOctetStream = "application/octet-stream"
|
||||
)
|
||||
|
||||
// Loopback addresses blocked by SSRF protection.
|
||||
const (
|
||||
localhostIPv4 = "127.0.0.1"
|
||||
localhostIPv6 = "::1"
|
||||
)
|
||||
|
||||
// Fetcher errors.
|
||||
var (
|
||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||
@@ -39,6 +57,12 @@ var (
|
||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||
)
|
||||
|
||||
// Internal fetcher errors.
|
||||
var (
|
||||
errTooManyRedirects = errors.New("too many redirects")
|
||||
errConnectFailed = errors.New("failed to connect")
|
||||
)
|
||||
|
||||
// Fetcher retrieves content from upstream origins.
|
||||
type Fetcher interface {
|
||||
// Fetch retrieves content from the given URL.
|
||||
@@ -92,12 +116,12 @@ func DefaultConfig() *Config {
|
||||
MaxResponseSize: DefaultMaxResponseSize,
|
||||
UserAgent: "pixa/1.0",
|
||||
AllowedContentTypes: []string{
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/svg+xml",
|
||||
contentTypeJPEG,
|
||||
contentTypePNG,
|
||||
contentTypeGIF,
|
||||
contentTypeWebP,
|
||||
contentTypeAVIF,
|
||||
contentTypeSVG,
|
||||
},
|
||||
AllowHTTP: false,
|
||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||
@@ -132,10 +156,12 @@ func New(config *Config) *HTTPFetcher {
|
||||
// Don't follow redirects automatically - we need to validate each hop
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= DefaultMaxRedirects {
|
||||
return errors.New("too many redirects")
|
||||
return errTooManyRedirects
|
||||
}
|
||||
|
||||
// Validate the redirect target
|
||||
if err := validateURL(req.URL.String(), config.AllowHTTP); err != nil {
|
||||
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("redirect blocked: %w", err)
|
||||
}
|
||||
|
||||
@@ -150,24 +176,11 @@ func New(config *Config) *HTTPFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||
f.hostSemMu.Lock()
|
||||
defer f.hostSemMu.Unlock()
|
||||
|
||||
sem, ok := f.hostSems[host]
|
||||
if !ok {
|
||||
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
||||
f.hostSems[host] = sem
|
||||
}
|
||||
|
||||
return sem
|
||||
}
|
||||
|
||||
// Fetch retrieves content from the given URL with SSRF protection.
|
||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||
// Validate URL before making request
|
||||
if err := validateURL(url, f.config.AllowHTTP); err != nil {
|
||||
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -201,7 +214,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
URL: parsedURL,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
req.Header.Set("User-Agent", f.config.UserAgent)
|
||||
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
||||
@@ -216,11 +228,10 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
}
|
||||
},
|
||||
}
|
||||
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
|
||||
req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
//nolint:gosec // G704: URL validated by validateURL() above
|
||||
resp, err := f.client.Do(req)
|
||||
|
||||
fetchDuration := time.Since(startTime)
|
||||
@@ -233,6 +244,39 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
|
||||
result, err := f.buildResult(resp, remoteAddr, fetchDuration, sem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark success so defer doesn't release the semaphore
|
||||
success = true
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||
f.hostSemMu.Lock()
|
||||
defer f.hostSemMu.Unlock()
|
||||
|
||||
sem, ok := f.hostSems[host]
|
||||
if !ok {
|
||||
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
||||
f.hostSems[host] = sem
|
||||
}
|
||||
|
||||
return sem
|
||||
}
|
||||
|
||||
// buildResult validates the upstream response and assembles a FetchResult
|
||||
// whose Content releases the host semaphore slot when closed.
|
||||
func (f *HTTPFetcher) buildResult(
|
||||
resp *http.Response,
|
||||
remoteAddr string,
|
||||
fetchDuration time.Duration,
|
||||
sem chan struct{},
|
||||
) (*FetchResult, error) {
|
||||
// Extract HTTP version (strip "HTTP/" prefix)
|
||||
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
||||
|
||||
@@ -265,9 +309,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
remaining: f.config.MaxResponseSize,
|
||||
}
|
||||
|
||||
// Mark success so defer doesn't release the semaphore
|
||||
success = true
|
||||
|
||||
return &FetchResult{
|
||||
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
|
||||
ContentLength: resp.ContentLength,
|
||||
@@ -297,7 +338,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||
}
|
||||
|
||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||
func validateURL(rawURL string, allowHTTP bool) error {
|
||||
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
||||
return ErrUnsupportedScheme
|
||||
}
|
||||
@@ -309,7 +350,8 @@ func validateURL(rawURL string, allowHTTP bool) error {
|
||||
}
|
||||
|
||||
// Remove port if present
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
@@ -319,15 +361,16 @@ func validateURL(rawURL string, allowHTTP bool) error {
|
||||
}
|
||||
|
||||
// Resolve the host to check IP addresses
|
||||
ips, err := net.LookupIP(host)
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return ErrSSRFBlocked
|
||||
}
|
||||
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
||||
return isPrivateIP(addr.IP)
|
||||
})
|
||||
if private {
|
||||
return ErrSSRFBlocked
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,9 +383,11 @@ func extractHost(rawURL string) string {
|
||||
if idx := strings.Index(url, "://"); idx != -1 {
|
||||
url = url[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.Index(url, "/"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
|
||||
if idx := strings.Index(url, "?"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
@@ -355,8 +400,8 @@ func isLocalhost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
return host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
host == localhostIPv4 ||
|
||||
host == localhostIPv6 ||
|
||||
host == "[::1]" ||
|
||||
strings.HasSuffix(host, ".localhost") ||
|
||||
strings.HasSuffix(host, ".local")
|
||||
@@ -422,23 +467,23 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Check all resolved IPs
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return nil, ErrSSRFBlocked
|
||||
}
|
||||
if slices.ContainsFunc(ips, isPrivateIP) {
|
||||
return nil, ErrSSRFBlocked
|
||||
}
|
||||
|
||||
// Connect using the first valid IP
|
||||
var dialer net.Dialer
|
||||
|
||||
for _, ip := range ips {
|
||||
addr := net.JoinHostPort(ip.String(), port)
|
||||
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to %s", host)
|
||||
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||
}
|
||||
|
||||
// limitedReader wraps a reader and limits the number of bytes read.
|
||||
@@ -465,6 +510,7 @@ func (r *limitedReader) Read(p []byte) (int, error) {
|
||||
// semaphoreReleasingReadCloser releases a semaphore slot when closed.
|
||||
type semaphoreReleasingReadCloser struct {
|
||||
*limitedReader
|
||||
|
||||
closer io.Closer
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user