Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a663669286 | ||
|
|
6d380fbf98 |
@@ -122,10 +122,6 @@ Configured via YAML file (`--config`). Key settings:
|
|||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
- `access_control_allow_origin` — CORS origin
|
||||||
- `allowlist_hosts` — list of allowed upstream hosts
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
|
||||||
added to the always-enforced built-in ranges (loopback, private,
|
|
||||||
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
|
|
||||||
aborts startup
|
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
- `upstream_fetch_timeout` — timeout for origin requests
|
||||||
- `upstream_max_response_size` — max origin response size
|
- `upstream_max_response_size` — max origin response size
|
||||||
- `downstream_timeout` — client response timeout
|
- `downstream_timeout` — client response timeout
|
||||||
|
|||||||
@@ -25,20 +25,21 @@ The disk cache is now size-bounded with LRU eviction
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
P1: rate limit global concurrent upstream fetches to prevent resource
|
P1: implement blocked networks configuration to extend SSRF protection
|
||||||
exhaustion
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-21 blocked networks configuration extending SSRF protection: a
|
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
|
||||||
`blocked_networks` config key taking a list of CIDRs (parsed with
|
route and the token generator (closes #62): added a shared
|
||||||
`net/netip`, an invalid entry aborts startup naming the key and value),
|
`ValidateImageRequest` in `internal/imgcache` enforcing the
|
||||||
added to the built-in blocklist rather than replacing it; the built-in
|
`MaxDimension` bound and `ValidateFitMode`, applied by both the
|
||||||
ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments
|
`/v1/image/` and `/v1/e/` routes, so an over-limit size or an unknown
|
||||||
`192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96`
|
fit mode is a 400 rather than an out-of-memory or a 500 from the
|
||||||
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
processor; the URL generator now checks every numeric form field and
|
||||||
re-resolution so the DNS-rebinding window remains closed; documented in
|
rejects a non-numeric or out-of-range `width`, `height`, `quality`,
|
||||||
`README.md` and `config.example.yml`.
|
or `ttl` with a 400 naming the field instead of coercing it to `0`,
|
||||||
|
and `width`/`height` are bounds-checked so an unusable token cannot be
|
||||||
|
minted
|
||||||
- 2026-09-21 http.Server hardening (closes #92): added
|
- 2026-09-21 http.Server hardening (closes #92): added
|
||||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||||
@@ -140,6 +141,8 @@ exhaustion
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- P1: rate limit global concurrent upstream fetches to prevent
|
||||||
|
resource exhaustion
|
||||||
- P1: strip EXIF and other metadata from processed images (privacy)
|
- P1: strip EXIF and other metadata from processed images (privacy)
|
||||||
- P2: security
|
- P2: security
|
||||||
- referer blacklist
|
- referer blacklist
|
||||||
|
|||||||
@@ -22,15 +22,6 @@ allowlist_hosts:
|
|||||||
- github.com
|
- github.com
|
||||||
- user-images.githubusercontent.com
|
- user-images.githubusercontent.com
|
||||||
|
|
||||||
# Additional CIDR ranges to refuse when fetching upstream, extending the
|
|
||||||
# SSRF protection. These are added to the always-enforced built-in ranges
|
|
||||||
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
|
|
||||||
# similar), never replacing them. Each entry must be a valid CIDR in IPv4
|
|
||||||
# or IPv6 form; an invalid entry aborts startup.
|
|
||||||
# blocked_networks:
|
|
||||||
# - 100.64.0.0/10
|
|
||||||
# - 2001:db8::/32
|
|
||||||
|
|
||||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||||
allow_http: false
|
allow_http: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
|
|
||||||
// each CIDR is parsed into the resolved prefixes in order.
|
|
||||||
func TestBlockedNetworksParsed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
yamlContent := signingKeyLine + `blocked_networks:
|
|
||||||
- 203.0.113.0/24
|
|
||||||
- 2001:db8::/32
|
|
||||||
`
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, yamlContent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("valid blocked_networks should load, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := []string{"203.0.113.0/24", "2001:db8::/32"}
|
|
||||||
if len(c.BlockedNetworks) != len(want) {
|
|
||||||
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, w := range want {
|
|
||||||
if got := c.BlockedNetworks[i].String(); got != w {
|
|
||||||
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
|
|
||||||
// operator list empty; the built-in defaults still apply in the fetcher.
|
|
||||||
func TestBlockedNetworksOmittedIsEmpty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.BlockedNetworks) != 0 {
|
|
||||||
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
|
|
||||||
// startup with an error naming the key and the offending value.
|
|
||||||
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
runAbortCases(t, []abortCase{
|
|
||||||
{
|
|
||||||
name: "not-a-cidr",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
- not-a-cidr
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bare-address-without-prefix",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
- 10.0.0.1
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty-entry",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
- ""
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-string-entry",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
- 42
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "null-value",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+3
-101
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -43,7 +42,6 @@ const (
|
|||||||
keyAllowHTTP = "allow_http"
|
keyAllowHTTP = "allow_http"
|
||||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
keyCacheMaxBytes = "cache_max_bytes"
|
keyCacheMaxBytes = "cache_max_bytes"
|
||||||
keyBlockedNetworks = "blocked_networks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
@@ -62,7 +60,6 @@ var (
|
|||||||
errNotAnInteger = errors.New("not an integer")
|
errNotAnInteger = errors.New("not an integer")
|
||||||
errNotABoolean = errors.New("not a boolean")
|
errNotABoolean = errors.New("not a boolean")
|
||||||
errNotAStringList = errors.New("not a list of strings")
|
errNotAStringList = errors.New("not a list of strings")
|
||||||
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
|
||||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||||
errEmptyEntry = errors.New("contains an empty entry")
|
errEmptyEntry = errors.New("contains an empty entry")
|
||||||
@@ -112,11 +109,6 @@ type Config struct {
|
|||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
|
|
||||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
|
||||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
|
||||||
// fetcher's dialer; the built-in ranges always apply.
|
|
||||||
BlockedNetworks []netip.Prefix
|
|
||||||
|
|
||||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||||
// disables the disk cache entirely. When cache_max_bytes is
|
// disables the disk cache entirely. When cache_max_bytes is
|
||||||
// omitted from the configuration, this holds the computed default
|
// omitted from the configuration, this holds the computed default
|
||||||
@@ -185,11 +177,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blockedNetworks, err := getBlockedNetworks(sc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
loader := &strictLoader{sc: sc}
|
loader := &strictLoader{sc: sc}
|
||||||
|
|
||||||
c := &Config{
|
c := &Config{
|
||||||
@@ -205,8 +192,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||||
UpstreamConnectionsPerHost: loader.intVal(
|
UpstreamConnectionsPerHost: loader.intVal(
|
||||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
BlockedNetworks: blockedNetworks,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The computed default for cache_max_bytes needs a validated
|
// The computed default for cache_max_bytes needs a validated
|
||||||
@@ -238,7 +224,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
return nil, loader.err
|
return nil, loader.err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.validate()
|
err := c.validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -322,7 +308,7 @@ func isKnownConfigKey(key string) bool {
|
|||||||
switch key {
|
switch key {
|
||||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks, "env":
|
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,87 +802,3 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBlockedNetworks parses the blocked_networks value into CIDR prefixes,
|
|
||||||
// or returns nil if the key is omitted. It accepts a YAML list of strings
|
|
||||||
// or a comma-separated string. An explicitly null value, a wrong type, an
|
|
||||||
// empty entry, a non-string entry, or an unparseable CIDR aborts startup
|
|
||||||
// naming the key and the offending value; a default (the built-in
|
|
||||||
// blocklist alone) applies only to an omitted key.
|
|
||||||
func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
|
||||||
if sc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, ok := sc.Get(keyBlockedNetworks)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if raw == nil {
|
|
||||||
return nil, errNullConfigValue(keyBlockedNetworks)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := blockedNetworkEntries(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
prefixes := make([]netip.Prefix, 0, len(entries))
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
prefix, err := netip.ParsePrefix(entry)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("config key %q: value %q is %w",
|
|
||||||
keyBlockedNetworks, entry, errNotAValidCIDR)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefixes = append(prefixes, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockedNetworkEntries extracts the raw blocked_networks entries as
|
|
||||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
|
||||||
// comma-separated string. Any other shape is a configuration error.
|
|
||||||
func blockedNetworkEntries(raw any) ([]string, error) {
|
|
||||||
switch val := raw.(type) {
|
|
||||||
case []any:
|
|
||||||
entries := make([]string, 0, len(val))
|
|
||||||
|
|
||||||
for _, item := range val {
|
|
||||||
str, ok := item.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
|
||||||
keyBlockedNetworks, item, item, errNotAString)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(str) == "" {
|
|
||||||
return nil, fmt.Errorf("config key %q: %w",
|
|
||||||
keyBlockedNetworks, errEmptyListEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, strings.TrimSpace(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
case string:
|
|
||||||
entries := make([]string, 0)
|
|
||||||
|
|
||||||
for part := range strings.SplitSeq(val, ",") {
|
|
||||||
trimmed := strings.TrimSpace(part)
|
|
||||||
if trimmed == "" {
|
|
||||||
return nil, fmt.Errorf("config key %q: value %q %w",
|
|
||||||
keyBlockedNetworks, val, errEmptyEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
|
|
||||||
keyBlockedNetworks, raw, raw, errNotAStringList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+98
-17
@@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -13,6 +15,11 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errInvalidFormField reports a generator form field whose value is
|
||||||
|
// non-numeric or out of range. The offending field name is wrapped in so the
|
||||||
|
// response can name it.
|
||||||
|
var errInvalidFormField = errors.New("invalid")
|
||||||
|
|
||||||
// HandleRoot serves the login page or generator page based on authentication state.
|
// HandleRoot serves the login page or generator page based on authentication state.
|
||||||
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -98,18 +105,26 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
|
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
||||||
|
if err != nil {
|
||||||
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,17 +152,38 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
|
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
||||||
|
// non-numeric or out-of-range field, or an unrecognized fit mode, is a client
|
||||||
|
// error naming the offending field, so an unusable token is never minted.
|
||||||
func buildGeneratePayload(
|
func buildGeneratePayload(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*encurl.Payload, time.Time, int) {
|
) (*encurl.Payload, time.Time, int, error) {
|
||||||
width, _ := strconv.Atoi(form.Get("width"))
|
width, err := parseFormDimension(form, "width")
|
||||||
height, _ := strconv.Atoi(form.Get("height"))
|
if err != nil {
|
||||||
quality, _ := strconv.Atoi(form.Get("quality"))
|
return nil, time.Time{}, 0, err
|
||||||
ttl, _ := strconv.Atoi(form.Get("ttl"))
|
}
|
||||||
|
|
||||||
if quality <= 0 {
|
height, err := parseFormDimension(form, "height")
|
||||||
quality = 85
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quality, err := parseFormCount(form, "quality", encurl.DefaultQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl, err := parseFormCount(form, "ttl", 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fitMode := imgcache.FitMode(form.Get("fit"))
|
||||||
|
|
||||||
|
err = imgcache.ValidateFitMode(fitMode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0,
|
||||||
|
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -168,11 +204,45 @@ func buildGeneratePayload(
|
|||||||
Height: height,
|
Height: height,
|
||||||
Format: imgcache.ImageFormat(form.Get("format")),
|
Format: imgcache.ImageFormat(form.Get("format")),
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: imgcache.FitMode(form.Get("fit")),
|
FitMode: fitMode,
|
||||||
ExpiresAt: expiresAtUnix,
|
ExpiresAt: expiresAtUnix,
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, expiresAt, ttl
|
return payload, expiresAt, ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
|
// value means "original size" (0). A non-numeric, negative, or over-limit
|
||||||
|
// value is rejected with an error naming the field.
|
||||||
|
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < 0 || value > imgcache.MaxDimension {
|
||||||
|
return 0, fmt.Errorf("%w %s", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormCount reads an optional non-negative integer form field, returning
|
||||||
|
// def when the field is empty and an error naming the field when the value is
|
||||||
|
// non-numeric or negative.
|
||||||
|
func parseFormCount(form url.Values, field string, def int) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < 0 {
|
||||||
|
return 0, fmt.Errorf("%w %s", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatorData holds template data for the generator page.
|
// generatorData holds template data for the generator page.
|
||||||
@@ -212,6 +282,15 @@ func (s *Handlers) renderLogin(
|
|||||||
|
|
||||||
func (s *Handlers) renderGenerator(
|
func (s *Handlers) renderGenerator(
|
||||||
w http.ResponseWriter, r *http.Request, data *generatorData,
|
w http.ResponseWriter, r *http.Request, data *generatorData,
|
||||||
|
) {
|
||||||
|
s.renderGeneratorStatus(w, r, data, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGeneratorStatus renders the generator page with an explicit HTTP
|
||||||
|
// status. The status is written before the body so both it and the
|
||||||
|
// Content-Type header take effect; a rejected form uses 400.
|
||||||
|
func (s *Handlers) renderGeneratorStatus(
|
||||||
|
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
|
||||||
) {
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
@@ -221,17 +300,19 @@ func (s *Handlers) renderGenerator(
|
|||||||
|
|
||||||
data.CSRFField = csrfField(r)
|
data.CSRFField = csrfField(r)
|
||||||
|
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
err := templates.Render(w, "generator.html", data)
|
err := templates.Render(w, "generator.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
|
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||||
|
form url.Values, status int,
|
||||||
) {
|
) {
|
||||||
s.renderGenerator(w, r, &generatorData{
|
s.renderGeneratorStatus(w, r, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
@@ -240,7 +321,7 @@ func (s *Handlers) renderGeneratorWithForm(
|
|||||||
FormQuality: form.Get("quality"),
|
FormQuality: form.Get("quality"),
|
||||||
FormFit: form.Get("fit"),
|
FormFit: form.Get("fit"),
|
||||||
FormTTL: form.Get("ttl"),
|
FormTTL: form.Get("ttl"),
|
||||||
})
|
}, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generatePost submits the /generate form with a valid session and CSRF token
|
||||||
|
// plus the caller's extra fields, returning the recorder.
|
||||||
|
func generatePost(
|
||||||
|
t *testing.T, extra url.Values,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
h, srv := newCSRFTestRouter(t)
|
||||||
|
|
||||||
|
sessionCookie := newSessionCookie(t, h)
|
||||||
|
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||||
|
cookies = append(cookies, sessionCookie)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
sourceURLField: {testSourceURL},
|
||||||
|
csrfTokenField: {token},
|
||||||
|
}
|
||||||
|
maps.Copy(form, extra)
|
||||||
|
|
||||||
|
return postForm(srv, "/generate", cookies, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
|
||||||
|
// rejected with 400 naming the field rather than being coerced to 0 and
|
||||||
|
// minting a 0-width token.
|
||||||
|
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{"width": {"abc"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a token was generated for non-numeric width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
|
||||||
|
// MaxDimension is rejected at generation time so an unusable token cannot be
|
||||||
|
// minted.
|
||||||
|
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{"width": {"100000"}})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||||
|
t.Error("a token was generated for an over-limit width")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,8 +111,6 @@ func (s *Handlers) initImageService() error {
|
|||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
}
|
}
|
||||||
|
|
||||||
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
|
||||||
|
|
||||||
// Create the service
|
// Create the service
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
|
|||||||
@@ -110,13 +110,6 @@ func (s *Handlers) parseImageRequest(
|
|||||||
|
|
||||||
if fit := query.Get("fit"); fit != "" {
|
if fit := query.Get("fit"); fit != "" {
|
||||||
req.FitMode = imgcache.FitMode(fit)
|
req.FitMode = imgcache.FitMode(fit)
|
||||||
|
|
||||||
fitErr := imgcache.ValidateFitMode(req.FitMode)
|
|
||||||
if fitErr != nil {
|
|
||||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default quality if not set
|
// Default quality if not set
|
||||||
@@ -129,6 +122,18 @@ func (s *Handlers) parseImageRequest(
|
|||||||
req.FitMode = imgcache.FitCover
|
req.FitMode = imgcache.FitCover
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||||
|
// route. Dimensions are already bounded by the path parser above; this
|
||||||
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||||
|
// reach the processor as a 500.
|
||||||
|
err = imgcache.ValidateImageRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
s.respondError(w, "invalid image request: "+err.Error(),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
return req, true
|
return req, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// Convert payload to ImageRequest
|
// Convert payload to ImageRequest
|
||||||
req := payload.ToImageRequest()
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
|
// Apply the same dimension and fit-mode bounds as the plain image
|
||||||
|
// route: a sealed payload is trusted for its origin, not for staying
|
||||||
|
// within limits, so an over-limit size or unknown fit mode is a 400
|
||||||
|
// here rather than an out-of-memory or a 500 from the processor.
|
||||||
|
err = imgcache.ValidateImageRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||||
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Log the request
|
// Log the request
|
||||||
s.log.Debug("encrypted image request",
|
s.log.Debug("encrypted image request",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||||
|
// generator seeded by the shared test signing key. The image service is left
|
||||||
|
// nil: these tests exercise validation that rejects a token before any image
|
||||||
|
// is fetched, so the handler must never reach the service.
|
||||||
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handlers{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
encGen: encGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||||
|
|
||||||
|
return encGen, r
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEncToken issues a GET for the given token and returns the recorder.
|
||||||
|
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
||||||
|
// token requesting a dimension beyond MaxDimension is rejected with 400
|
||||||
|
// instead of reaching the image processor and libvips.
|
||||||
|
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photo.jpg",
|
||||||
|
Width: 100000,
|
||||||
|
Height: 100000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := getEncToken(srv, token)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
||||||
|
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
||||||
|
// as a 500 from the image processor's default branch.
|
||||||
|
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encGen, srv := newEncTestServer(t)
|
||||||
|
|
||||||
|
token, err := encGen.Generate(&encurl.Payload{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photo.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
FitMode: imgcache.FitMode("bogus"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := getEncToken(srv, token)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package httpfetcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
|
||||||
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
|
|
||||||
// forms, alongside public controls that must stay reachable.
|
|
||||||
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ip string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"cgnat-low", "100.64.0.1", true},
|
|
||||||
{"cgnat-high", "100.127.255.254", true},
|
|
||||||
{"ietf-protocol", "192.0.0.1", true},
|
|
||||||
{"benchmark-low", "198.18.0.1", true},
|
|
||||||
{"benchmark-high", "198.19.255.254", true},
|
|
||||||
{"nat64", "64:ff9b::1", true},
|
|
||||||
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
|
|
||||||
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
|
|
||||||
{"cloud-metadata", "169.254.169.254", true},
|
|
||||||
{"public-v4", "8.8.8.8", false},
|
|
||||||
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
|
|
||||||
{"public-v6", "2001:4860:4860::8888", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ip := net.ParseIP(tc.ip)
|
|
||||||
if ip == nil {
|
|
||||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := isPrivateIP(ip)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// transportOf returns the *http.Transport backing a fetcher, so a test can
|
|
||||||
// exercise the SSRF-safe dialer New installed with the operator blocklist.
|
|
||||||
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
transport, ok := f.client.Transport.(*http.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
return transport
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
|
|
||||||
// blocked_networks entry is enforced by the dialer, in addition to the
|
|
||||||
// built-in ranges, while an address outside both stays dialable.
|
|
||||||
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
|
||||||
// blocking it can only come from the operator-supplied list.
|
|
||||||
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
|
||||||
|
|
||||||
transport := transportOf(t, New(cfg))
|
|
||||||
|
|
||||||
blocked := []string{
|
|
||||||
"198.51.100.5:80", // operator-supplied range
|
|
||||||
"10.0.0.5:80", // built-in RFC 1918, still enforced
|
|
||||||
"100.64.0.1:80", // built-in CGNAT range
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, addr := range blocked {
|
|
||||||
t.Run("blocked/"+addr, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := transport.DialContext(context.Background(), "tcp", addr)
|
|
||||||
if !errors.Is(err, ErrSSRFBlocked) {
|
|
||||||
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("public-not-blocked", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A cancelled context makes the dial fail without touching the
|
|
||||||
// network; the point is only that a public literal outside every
|
|
||||||
// blocked range is not SSRF-blocked.
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
|
|
||||||
if errors.Is(err, ErrSSRFBlocked) {
|
|
||||||
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
"net/netip"
|
|
||||||
neturl "net/url"
|
neturl "net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -47,20 +46,6 @@ const (
|
|||||||
localhostIPv6 = "::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.
|
// Fetcher errors.
|
||||||
var (
|
var (
|
||||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||||
@@ -122,9 +107,6 @@ type Config struct {
|
|||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
MaxConnectionsPerHost 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.
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
@@ -160,13 +142,9 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
config = DefaultConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create transport with SSRF-safe dialer. The dialer re-resolves and
|
// Create transport with SSRF-safe dialer
|
||||||
// re-checks at connect time (closing the DNS-rebinding window) against
|
|
||||||
// both the built-in ranges and the operator-supplied blocklist.
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
DialContext: ssrfSafeDialer,
|
||||||
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
|
|
||||||
},
|
|
||||||
TLSHandshakeTimeout: DefaultTLSTimeout,
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||||
MaxIdleConns: DefaultMaxIdleConns,
|
MaxIdleConns: DefaultMaxIdleConns,
|
||||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
||||||
@@ -473,53 +451,11 @@ func isPrivateIP(ip net.IP) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special-use ranges the net.IP predicates above do not cover.
|
return false
|
||||||
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
|
// ssrfSafeDialer is a custom dialer that validates IP addresses before connecting.
|
||||||
// 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) {
|
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)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -532,10 +468,8 @@ func dialSSRFSafe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check all resolved IPs
|
// Check all resolved IPs
|
||||||
for _, ip := range ips {
|
if slices.ContainsFunc(ips, isPrivateIP) {
|
||||||
if isBlockedIP(ip, blocked) {
|
return nil, ErrSSRFBlocked
|
||||||
return nil, ErrSSRFBlocked
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect using the first valid IP
|
// Connect using the first valid IP
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ func ValidateFitMode(fit FitMode) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateImageRequest checks that a request's dimensions are within
|
||||||
|
// MaxDimension and its fit mode is recognized. Both the plain /v1/image/
|
||||||
|
// route and the encrypted /v1/e/ route validate through this function so a
|
||||||
|
// request from either source enforces identical bounds, regardless of how it
|
||||||
|
// was constructed. A width or height of 0 means "original size" and is valid.
|
||||||
|
func ValidateImageRequest(req *ImageRequest) error {
|
||||||
|
if req.Size.Width < 0 || req.Size.Height < 0 {
|
||||||
|
return ErrInvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Size.Width > MaxDimension || req.Size.Height > MaxDimension {
|
||||||
|
return ErrDimensionTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateFitMode(req.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
// ImageRequest represents a request for a processed image
|
// ImageRequest represents a request for a processed image
|
||||||
type ImageRequest struct {
|
type ImageRequest struct {
|
||||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
// SourceHost is the origin host (e.g., "cdn.example.com")
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req ImageRequest
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "within bounds",
|
||||||
|
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size and empty fit",
|
||||||
|
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "width over limit",
|
||||||
|
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
||||||
|
wantErr: ErrDimensionTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "height over limit",
|
||||||
|
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
||||||
|
wantErr: ErrDimensionTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative width",
|
||||||
|
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
||||||
|
wantErr: ErrInvalidSize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid fit mode",
|
||||||
|
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
||||||
|
wantErr: ErrInvalidFitMode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := ValidateImageRequest(&tt.req)
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user