monorepo/native/desktop/maplefile/internal/service/httpclient/httpclient.go

199 lines
6.8 KiB
Go

package httpclient
import (
"net"
"net/http"
"time"
)
// Service provides an HTTP client with proper timeouts.
// This addresses OWASP security concern B1: using http.DefaultClient which has
// no timeouts and can be vulnerable to slowloris attacks and resource exhaustion.
//
// Note: TLS/SSL is handled by Caddy reverse proxy in production (see OWASP report
// A04-4.1 "Certificate Pinning Not Required" - BY DESIGN). This service focuses
// on adding timeouts, not TLS configuration.
//
// For large file downloads, use DoDownloadNoTimeout() which relies on the request's
// context for cancellation instead of a fixed timeout. This allows multi-gigabyte
// files to download without timeout issues while still being cancellable.
type Service struct {
// client is the configured HTTP client for API requests
client *http.Client
// downloadClient is a separate client for file downloads with longer timeouts
downloadClient *http.Client
// noTimeoutClient is for large file downloads where context controls cancellation
noTimeoutClient *http.Client
}
// Config holds configuration options for the HTTP client service
type Config struct {
// RequestTimeout is the overall timeout for API requests (default: 30s)
RequestTimeout time.Duration
// DownloadTimeout is the overall timeout for file downloads (default: 10m)
DownloadTimeout time.Duration
// ConnectTimeout is the timeout for establishing connections (default: 10s)
ConnectTimeout time.Duration
// TLSHandshakeTimeout is the timeout for TLS handshake (default: 10s)
TLSHandshakeTimeout time.Duration
// IdleConnTimeout is how long idle connections stay in the pool (default: 90s)
IdleConnTimeout time.Duration
// MaxIdleConns is the max number of idle connections (default: 100)
MaxIdleConns int
// MaxIdleConnsPerHost is the max idle connections per host (default: 10)
MaxIdleConnsPerHost int
}
// DefaultConfig returns sensible default configuration values
func DefaultConfig() Config {
return Config{
RequestTimeout: 30 * time.Second,
DownloadTimeout: 10 * time.Minute,
ConnectTimeout: 10 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
}
// ProvideService creates a new HTTP client service with secure defaults
func ProvideService() *Service {
return NewService(DefaultConfig())
}
// NewService creates a new HTTP client service with the given configuration
func NewService(cfg Config) *Service {
// Create transport with timeouts and connection pooling
// Note: We don't set TLSClientConfig - Go's defaults are secure and
// production uses Caddy for TLS termination anyway
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
}
// Create the main client for API requests
client := &http.Client{
Transport: transport,
Timeout: cfg.RequestTimeout,
}
// Create a separate transport for downloads with longer timeouts
downloadTransport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
// Disable compression for downloads to avoid decompression overhead
DisableCompression: true,
}
// Create the download client with longer timeout
downloadClient := &http.Client{
Transport: downloadTransport,
Timeout: cfg.DownloadTimeout,
}
// Create a no-timeout transport for large file downloads
// This client has no overall timeout - cancellation is controlled via request context
// Connection and TLS handshake still have timeouts to prevent hanging on initial connect
noTimeoutTransport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
DisableCompression: true,
}
// No timeout - relies on context cancellation for large file downloads
noTimeoutClient := &http.Client{
Transport: noTimeoutTransport,
Timeout: 0, // No timeout
}
return &Service{
client: client,
downloadClient: downloadClient,
noTimeoutClient: noTimeoutClient,
}
}
// Client returns the HTTP client for API requests (30s timeout)
func (s *Service) Client() *http.Client {
return s.client
}
// DownloadClient returns the HTTP client for file downloads (10m timeout)
func (s *Service) DownloadClient() *http.Client {
return s.downloadClient
}
// Do executes an HTTP request using the API client
func (s *Service) Do(req *http.Request) (*http.Response, error) {
return s.client.Do(req)
}
// DoDownload executes an HTTP request using the download client (longer timeout)
func (s *Service) DoDownload(req *http.Request) (*http.Response, error) {
return s.downloadClient.Do(req)
}
// Get performs an HTTP GET request using the API client
func (s *Service) Get(url string) (*http.Response, error) {
return s.client.Get(url)
}
// GetDownload performs an HTTP GET request using the download client (longer timeout)
func (s *Service) GetDownload(url string) (*http.Response, error) {
return s.downloadClient.Get(url)
}
// DoLargeDownload executes an HTTP request for large file downloads.
// This client has NO overall timeout - cancellation must be handled via the request's context.
// Use this for multi-gigabyte files that may take hours to download.
// The connection establishment and TLS handshake still have timeouts.
//
// Example usage:
//
// ctx, cancel := context.WithCancel(context.Background())
// defer cancel() // Call cancel() to abort the download
// req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
// resp, err := httpClient.DoLargeDownload(req)
func (s *Service) DoLargeDownload(req *http.Request) (*http.Response, error) {
return s.noTimeoutClient.Do(req)
}
// GetLargeDownload performs an HTTP GET request for large file downloads.
// This client has NO overall timeout - the download can run indefinitely.
// Use this for multi-gigabyte files. To cancel, use DoLargeDownload with a context.
func (s *Service) GetLargeDownload(url string) (*http.Response, error) {
return s.noTimeoutClient.Get(url)
}