check / check (push) Successful in 12s
max_concurrent_processing (default: the number of CPUs Go uses) bounds the images processed at once, and upstream_connections (default 64) the fetches from all upstream hosts together, beside the per-host limit. A request that finds either full waits up to 10 seconds, then gets 503 "server busy, try again later". The processor holds its slot from before it reads the input until it returns; a fetch holds its connection until the response body is closed, after its image is processed. libvips now starts with one worker thread per image and no operation cache. Both settings have PIXA_ variables and are in README.md and config.example.yml. Model: opus-5-5
642 lines
17 KiB
Go
642 lines
17 KiB
Go
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
|
// protection, connection limits per host and for all hosts together, and
|
|
// content-type validation.
|
|
package httpfetcher
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptrace"
|
|
"net/netip"
|
|
neturl "net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Fetcher configuration constants.
|
|
const (
|
|
DefaultFetchTimeout = 30 * time.Second
|
|
DefaultMaxResponseSize = 50 << 20 // 50MB
|
|
DefaultTLSTimeout = 10 * time.Second
|
|
DefaultMaxIdleConns = 100
|
|
DefaultIdleConnTimeout = 90 * time.Second
|
|
DefaultMaxRedirects = 10
|
|
DefaultMaxConnectionsPerHost = 20
|
|
DefaultMaxConnections = 64
|
|
)
|
|
|
|
// ConnectionWaitTimeout is how long Fetch waits for a free connection when
|
|
// MaxConnections fetches are already in progress.
|
|
const ConnectionWaitTimeout = 10 * time.Second
|
|
|
|
// 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"
|
|
)
|
|
|
|
// builtinBlockedPrefixes are internal or special-use ranges that Go's
|
|
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
|
|
// already cover. They are always blocked, in addition to any
|
|
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
|
|
// before matching, so these IPv4 ranges are caught in both forms.
|
|
//
|
|
//nolint:gochecknoglobals // immutable built-in blocklist
|
|
var builtinBlockedPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
|
|
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
|
|
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
|
|
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
|
|
}
|
|
|
|
// Fetcher errors.
|
|
var (
|
|
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
|
ErrInvalidHost = errors.New("invalid or unresolvable host")
|
|
ErrUnsupportedScheme = errors.New("only HTTPS is supported")
|
|
ErrResponseTooLarge = errors.New("response exceeds maximum size")
|
|
ErrInvalidContentType = errors.New("invalid or unsupported content type")
|
|
ErrUpstreamError = errors.New("upstream server error")
|
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
|
ErrTooManyConnections = errors.New("too many concurrent upstream connections")
|
|
)
|
|
|
|
// 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.
|
|
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
|
}
|
|
|
|
// FetchResult contains the result of fetching from upstream.
|
|
type FetchResult struct {
|
|
// Content is the raw image data.
|
|
Content io.ReadCloser
|
|
// ContentLength is the size in bytes (-1 if unknown).
|
|
ContentLength int64
|
|
// ContentType is the MIME type from upstream.
|
|
ContentType string
|
|
// Headers contains all response headers from upstream.
|
|
Headers map[string][]string
|
|
// StatusCode is the HTTP status code from upstream.
|
|
StatusCode int
|
|
// FetchDurationMs is how long the fetch took in milliseconds.
|
|
FetchDurationMs int64
|
|
// RemoteAddr is the IP:port of the upstream server.
|
|
RemoteAddr string
|
|
// HTTPVersion is the protocol version (e.g., "1.1", "2.0").
|
|
HTTPVersion string
|
|
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3").
|
|
TLSVersion string
|
|
// TLSCipherSuite is the negotiated cipher suite name.
|
|
TLSCipherSuite string
|
|
}
|
|
|
|
// Config holds configuration for the upstream fetcher.
|
|
type Config struct {
|
|
// Timeout for upstream requests.
|
|
Timeout time.Duration
|
|
// MaxResponseSize is the maximum allowed response body size.
|
|
MaxResponseSize int64
|
|
// UserAgent to send to upstream servers.
|
|
UserAgent string
|
|
// AllowedContentTypes is an allow list of MIME types to accept.
|
|
AllowedContentTypes []string
|
|
// AllowHTTP allows non-TLS connections (for testing only).
|
|
AllowHTTP bool
|
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
|
MaxConnectionsPerHost int
|
|
// MaxConnections limits concurrent connections to all upstream hosts
|
|
// together.
|
|
MaxConnections int
|
|
// BlockedNetworks are operator-supplied CIDR ranges refused by the
|
|
// dialer, in addition to the always-enforced built-in ranges.
|
|
BlockedNetworks []netip.Prefix
|
|
}
|
|
|
|
// DefaultConfig returns a Config with sensible defaults.
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
Timeout: DefaultFetchTimeout,
|
|
MaxResponseSize: DefaultMaxResponseSize,
|
|
UserAgent: "pixa/1.0",
|
|
AllowedContentTypes: []string{
|
|
contentTypeJPEG,
|
|
contentTypePNG,
|
|
contentTypeGIF,
|
|
contentTypeWebP,
|
|
contentTypeAVIF,
|
|
contentTypeSVG,
|
|
},
|
|
AllowHTTP: false,
|
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
|
MaxConnections: DefaultMaxConnections,
|
|
}
|
|
}
|
|
|
|
// HTTPFetcher implements Fetcher with SSRF protection and connection limits
|
|
// per host and for all hosts together.
|
|
type HTTPFetcher struct {
|
|
client *http.Client
|
|
config *Config
|
|
hostSems map[string]chan struct{} // per-host semaphores
|
|
hostSemMu sync.Mutex // protects hostSems map
|
|
// allHostsSemaphore has one slot per connection allowed to all hosts
|
|
// together (config.MaxConnections).
|
|
allHostsSemaphore chan struct{}
|
|
// connectionWaitTimeout is ConnectionWaitTimeout; tests shorten it.
|
|
connectionWaitTimeout time.Duration
|
|
}
|
|
|
|
// New creates a new HTTPFetcher with SSRF protection.
|
|
func New(config *Config) *HTTPFetcher {
|
|
if config == nil {
|
|
config = DefaultConfig()
|
|
}
|
|
|
|
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
|
// re-checks at connect time (closing the DNS-rebinding window) against
|
|
// both the built-in ranges and the operator-supplied blocklist.
|
|
transport := &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
|
},
|
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
|
MaxIdleConns: DefaultMaxIdleConns,
|
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: config.Timeout,
|
|
// 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 errTooManyRedirects
|
|
}
|
|
|
|
// Validate the redirect target
|
|
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
|
if err != nil {
|
|
return fmt.Errorf("redirect blocked: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
return &HTTPFetcher{
|
|
client: client,
|
|
config: config,
|
|
hostSems: make(map[string]chan struct{}),
|
|
allHostsSemaphore: make(chan struct{}, config.MaxConnections),
|
|
connectionWaitTimeout: ConnectionWaitTimeout,
|
|
}
|
|
}
|
|
|
|
// Fetch retrieves content from the given URL with SSRF protection. When
|
|
// MaxConnections fetches are already in progress, it waits up to
|
|
// ConnectionWaitTimeout for one to finish, then fails with
|
|
// ErrTooManyConnections.
|
|
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
|
// Validate URL before making request
|
|
err := validateURL(ctx, url, f.config.AllowHTTP)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
release, err := f.acquireConnection(ctx, extractHost(url))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If we fail before returning a result, release the connection
|
|
success := false
|
|
|
|
defer func() {
|
|
if !success {
|
|
release()
|
|
}
|
|
}()
|
|
|
|
parsedURL, err := neturl.Parse(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
|
}
|
|
|
|
req := &http.Request{
|
|
Method: http.MethodGet,
|
|
URL: parsedURL,
|
|
Header: make(http.Header),
|
|
}
|
|
|
|
req.Header.Set("User-Agent", f.config.UserAgent)
|
|
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
|
|
|
// Use httptrace to capture connection details
|
|
var remoteAddr string
|
|
|
|
trace := &httptrace.ClientTrace{
|
|
GotConn: func(info httptrace.GotConnInfo) {
|
|
if info.Conn != nil {
|
|
remoteAddr = info.Conn.RemoteAddr().String()
|
|
}
|
|
},
|
|
}
|
|
req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
|
|
|
|
startTime := time.Now()
|
|
|
|
resp, err := f.client.Do(req)
|
|
|
|
fetchDuration := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return nil, ErrUpstreamTimeout
|
|
}
|
|
|
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
|
}
|
|
|
|
result, err := f.buildResult(resp, remoteAddr, fetchDuration, release)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Mark success so defer doesn't release the connection; closing the
|
|
// result's Content does
|
|
success = true
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// acquireConnection takes a slot for host, then one of the slots shared by
|
|
// all hosts, and returns the func that gives both back. The host's slot
|
|
// comes first, so fetches queued for one busy host hold no shared slot.
|
|
// Only the wait for a shared slot is bounded: after connectionWaitTimeout
|
|
// it fails with ErrTooManyConnections.
|
|
func (f *HTTPFetcher) acquireConnection(
|
|
ctx context.Context, host string,
|
|
) (func(), error) {
|
|
hostSem := f.getHostSemaphore(host)
|
|
|
|
select {
|
|
case hostSem <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
select {
|
|
case f.allHostsSemaphore <- struct{}{}:
|
|
case <-time.After(f.connectionWaitTimeout):
|
|
<-hostSem
|
|
|
|
return nil, ErrTooManyConnections
|
|
case <-ctx.Done():
|
|
<-hostSem
|
|
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
return func() {
|
|
<-hostSem
|
|
<-f.allHostsSemaphore
|
|
}, 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 calls release when closed.
|
|
func (f *HTTPFetcher) buildResult(
|
|
resp *http.Response,
|
|
remoteAddr string,
|
|
fetchDuration time.Duration,
|
|
release func(),
|
|
) (*FetchResult, error) {
|
|
// Extract HTTP version (strip "HTTP/" prefix)
|
|
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
|
|
|
// Extract TLS info if available
|
|
var tlsVersion, tlsCipherSuite string
|
|
|
|
if resp.TLS != nil {
|
|
tlsVersion = tls.VersionName(resp.TLS.Version)
|
|
tlsCipherSuite = tls.CipherSuiteName(resp.TLS.CipherSuite)
|
|
}
|
|
|
|
// Check status code
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
_ = resp.Body.Close()
|
|
|
|
return nil, fmt.Errorf("%w: status %d", ErrUpstreamError, resp.StatusCode)
|
|
}
|
|
|
|
// Validate content type
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if !f.isAllowedContentType(contentType) {
|
|
_ = resp.Body.Close()
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
|
|
}
|
|
|
|
// Wrap body with size limiter and semaphore releaser
|
|
limitedBody := &limitedReader{
|
|
reader: resp.Body,
|
|
remaining: f.config.MaxResponseSize,
|
|
}
|
|
|
|
return &FetchResult{
|
|
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, release},
|
|
ContentLength: resp.ContentLength,
|
|
ContentType: contentType,
|
|
Headers: resp.Header,
|
|
StatusCode: resp.StatusCode,
|
|
FetchDurationMs: fetchDuration.Milliseconds(),
|
|
RemoteAddr: remoteAddr,
|
|
HTTPVersion: httpVersion,
|
|
TLSVersion: tlsVersion,
|
|
TLSCipherSuite: tlsCipherSuite,
|
|
}, nil
|
|
}
|
|
|
|
// isAllowedContentType checks if the content type is in the allow list.
|
|
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|
// Extract the MIME type without parameters
|
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
|
|
|
for _, allowed := range f.config.AllowedContentTypes {
|
|
if strings.EqualFold(mediaType, allowed) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// validateURL checks if a URL is safe to fetch (not internal/private).
|
|
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
|
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
|
return ErrUnsupportedScheme
|
|
}
|
|
|
|
// Parse to extract host
|
|
host := extractHost(rawURL)
|
|
if host == "" {
|
|
return ErrInvalidHost
|
|
}
|
|
|
|
// Remove port if present
|
|
h, _, err := net.SplitHostPort(host)
|
|
if err == nil {
|
|
host = h
|
|
}
|
|
|
|
// Block obvious localhost patterns
|
|
if isLocalhost(host) {
|
|
return ErrSSRFBlocked
|
|
}
|
|
|
|
// Resolve the host to check IP addresses
|
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
|
}
|
|
|
|
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
|
return isPrivateIP(addr.IP)
|
|
})
|
|
if private {
|
|
return ErrSSRFBlocked
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractHost extracts the host from a URL string.
|
|
func extractHost(rawURL string) string {
|
|
// Simple extraction without full URL parsing
|
|
url := rawURL
|
|
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]
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
// isLocalhost checks if the host is localhost.
|
|
func isLocalhost(host string) bool {
|
|
host = strings.ToLower(host)
|
|
|
|
return host == "localhost" ||
|
|
host == localhostIPv4 ||
|
|
host == localhostIPv6 ||
|
|
host == "[::1]" ||
|
|
strings.HasSuffix(host, ".localhost") ||
|
|
strings.HasSuffix(host, ".local")
|
|
}
|
|
|
|
// isPrivateIP checks if an IP is private, loopback, or otherwise internal.
|
|
func isPrivateIP(ip net.IP) bool {
|
|
if ip == nil {
|
|
return true
|
|
}
|
|
|
|
// Check for loopback
|
|
if ip.IsLoopback() {
|
|
return true
|
|
}
|
|
|
|
// Check for private ranges
|
|
if ip.IsPrivate() {
|
|
return true
|
|
}
|
|
|
|
// Check for link-local
|
|
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return true
|
|
}
|
|
|
|
// Check for unspecified (0.0.0.0 or ::)
|
|
if ip.IsUnspecified() {
|
|
return true
|
|
}
|
|
|
|
// Check for multicast
|
|
if ip.IsMulticast() {
|
|
return true
|
|
}
|
|
|
|
// Additional checks for IPv4
|
|
if ip4 := ip.To4(); ip4 != nil {
|
|
// 169.254.0.0/16 - Link local
|
|
if ip4[0] == 169 && ip4[1] == 254 {
|
|
return true
|
|
}
|
|
// 0.0.0.0/8 - Current network
|
|
if ip4[0] == 0 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Special-use ranges the net.IP predicates above do not cover.
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
addr = addr.Unmap()
|
|
|
|
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
|
|
return prefix.Contains(addr)
|
|
})
|
|
}
|
|
|
|
// isBlockedIP reports whether ip is refused, either by the built-in
|
|
// internal-range check or by one of the operator-supplied prefixes.
|
|
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
|
if isPrivateIP(ip) {
|
|
return true
|
|
}
|
|
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
addr = addr.Unmap()
|
|
|
|
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
|
return prefix.Contains(addr)
|
|
})
|
|
}
|
|
|
|
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
|
|
// before connecting. New wraps dialSSRFSafe with the operator-supplied
|
|
// blocklist; this entry point enforces the built-in ranges alone.
|
|
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return dialSSRFSafe(ctx, network, addr, nil)
|
|
}
|
|
|
|
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
|
|
// internal range or operator-supplied blocked prefix, closing the
|
|
// DNS-rebinding window at connect time.
|
|
func dialSSRFSafe(
|
|
ctx context.Context,
|
|
network, addr string,
|
|
blocked []netip.Prefix,
|
|
) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Resolve the address
|
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
|
}
|
|
|
|
// Check all resolved IPs
|
|
for _, ip := range ips {
|
|
if isBlockedIP(ip, blocked) {
|
|
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("%w to %s", errConnectFailed, host)
|
|
}
|
|
|
|
// limitedReader wraps a reader and limits the number of bytes read.
|
|
type limitedReader struct {
|
|
reader io.Reader
|
|
remaining int64
|
|
}
|
|
|
|
func (r *limitedReader) Read(p []byte) (int, error) {
|
|
if r.remaining <= 0 {
|
|
return 0, ErrResponseTooLarge
|
|
}
|
|
|
|
if int64(len(p)) > r.remaining {
|
|
p = p[:r.remaining]
|
|
}
|
|
|
|
n, err := r.reader.Read(p)
|
|
r.remaining -= int64(n)
|
|
|
|
return n, err
|
|
}
|
|
|
|
// semaphoreReleasingReadCloser releases the fetch's connection slots when
|
|
// closed.
|
|
type semaphoreReleasingReadCloser struct {
|
|
*limitedReader
|
|
|
|
closer io.Closer
|
|
release func()
|
|
}
|
|
|
|
func (r *semaphoreReleasingReadCloser) Close() error {
|
|
err := r.closer.Close()
|
|
r.release()
|
|
|
|
return err
|
|
}
|