All checks were successful
check / check (push) Successful in 5s
Closes [issue #50](#50) ## Summary Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage. ## Changes ### Dockerfile - **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage - **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes - **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256) - **golangci-lint bumped** from v1.64.8 to v2.11.3 - All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest - Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t) ### Linter Config (.golangci.yml) - Migrated from v1 to v2 format (`version: "2"` added) - Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2) - Same set of linters enabled — no rules weakened ### Code Fixes (all lint issues from v2 upgrade) - Added package comments to all packages - Added doc comments to all exported types, functions, and methods - Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint) - Fixed unused parameters flagged by `revive` (renamed to `_`) - Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls - Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf` - Fixed `staticcheck` QF1003: converted if/else chain to tagged switch - Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`) - Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt` - Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores) ### README.md - Updated version requirements: Go 1.26+, golangci-lint v2.11+ - Updated Dockerfile description in project structure ## Verification `docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed. Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #55 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
223 lines
4.1 KiB
Go
223 lines
4.1 KiB
Go
package delivery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// dnsResolutionTimeout is the maximum time to wait for
|
|
// DNS resolution during SSRF validation.
|
|
dnsResolutionTimeout = 5 * time.Second
|
|
)
|
|
|
|
// Sentinel errors for SSRF validation.
|
|
var (
|
|
errNoHostname = errors.New("URL has no hostname")
|
|
errNoIPs = errors.New(
|
|
"hostname resolved to no IP addresses",
|
|
)
|
|
errBlockedIP = errors.New(
|
|
"blocked private/reserved IP range",
|
|
)
|
|
errInvalidScheme = errors.New(
|
|
"only http and https are allowed",
|
|
)
|
|
)
|
|
|
|
// blockedNetworks contains all private/reserved IP ranges
|
|
// that should be blocked to prevent SSRF attacks.
|
|
//
|
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
var blockedNetworks []*net.IPNet
|
|
|
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
|
func init() {
|
|
cidrs := []string{
|
|
"127.0.0.0/8",
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"169.254.0.0/16",
|
|
"0.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"192.0.0.0/24",
|
|
"192.0.2.0/24",
|
|
"198.18.0.0/15",
|
|
"198.51.100.0/24",
|
|
"203.0.113.0/24",
|
|
"224.0.0.0/4",
|
|
"240.0.0.0/4",
|
|
"::1/128",
|
|
"fc00::/7",
|
|
"fe80::/10",
|
|
}
|
|
|
|
for _, cidr := range cidrs {
|
|
_, network, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
panic(fmt.Sprintf(
|
|
"ssrf: failed to parse CIDR %q: %v",
|
|
cidr, err,
|
|
))
|
|
}
|
|
|
|
blockedNetworks = append(
|
|
blockedNetworks, network,
|
|
)
|
|
}
|
|
}
|
|
|
|
// isBlockedIP checks whether an IP address falls within
|
|
// any blocked private/reserved network range.
|
|
func isBlockedIP(ip net.IP) bool {
|
|
for _, network := range blockedNetworks {
|
|
if network.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ValidateTargetURL checks that an HTTP delivery target
|
|
// URL is safe from SSRF attacks.
|
|
func ValidateTargetURL(
|
|
ctx context.Context, targetURL string,
|
|
) error {
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
err = validateScheme(parsed.Scheme)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return errNoHostname
|
|
}
|
|
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return checkBlockedIP(ip)
|
|
}
|
|
|
|
return validateHostname(ctx, host)
|
|
}
|
|
|
|
func validateScheme(scheme string) error {
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf(
|
|
"unsupported URL scheme %q: %w",
|
|
scheme, errInvalidScheme,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkBlockedIP(ip net.IP) error {
|
|
if isBlockedIP(ip) {
|
|
return fmt.Errorf(
|
|
"target IP %s is in a blocked "+
|
|
"private/reserved range: %w",
|
|
ip, errBlockedIP,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateHostname(
|
|
ctx context.Context, host string,
|
|
) error {
|
|
dnsCtx, cancel := context.WithTimeout(
|
|
ctx, dnsResolutionTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
ips, err := net.DefaultResolver.LookupIPAddr(
|
|
dnsCtx, host,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to resolve hostname %q: %w",
|
|
host, err,
|
|
)
|
|
}
|
|
|
|
if len(ips) == 0 {
|
|
return fmt.Errorf(
|
|
"hostname %q: %w", host, errNoIPs,
|
|
)
|
|
}
|
|
|
|
for _, ipAddr := range ips {
|
|
if isBlockedIP(ipAddr.IP) {
|
|
return fmt.Errorf(
|
|
"hostname %q resolves to blocked "+
|
|
"IP %s: %w",
|
|
host, ipAddr.IP, errBlockedIP,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewSSRFSafeTransport creates an http.Transport with a
|
|
// custom DialContext that blocks connections to
|
|
// private/reserved IP addresses.
|
|
func NewSSRFSafeTransport() *http.Transport {
|
|
return &http.Transport{
|
|
DialContext: ssrfDialContext,
|
|
}
|
|
}
|
|
|
|
func ssrfDialContext(
|
|
ctx context.Context,
|
|
network, addr string,
|
|
) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: invalid address %q: %w",
|
|
addr, err,
|
|
)
|
|
}
|
|
|
|
ips, err := net.DefaultResolver.LookupIPAddr(
|
|
ctx, host,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: DNS resolution failed for %q: %w",
|
|
host, err,
|
|
)
|
|
}
|
|
|
|
for _, ipAddr := range ips {
|
|
if isBlockedIP(ipAddr.IP) {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: connection to %s (%s) "+
|
|
"blocked: %w",
|
|
host, ipAddr.IP, errBlockedIP,
|
|
)
|
|
}
|
|
}
|
|
|
|
var dialer net.Dialer
|
|
|
|
return dialer.DialContext(
|
|
ctx, network,
|
|
net.JoinHostPort(ips[0].IP.String(), port),
|
|
)
|
|
}
|