Check the origin's host and port; cap the size at 1 GiB (closes #61)

access_control_allow_origin now needs a host name (ASCII letters,
digits, hyphens, dots) or an IP address, and a port, when given, from 1
to 65535, read with net/url, net/netip and strconv. Two hosts, an empty
port, no host, a bad port or a non-ASCII host name abort startup, as a
* inside the value already did.

upstream_max_response_size above 1 GiB aborts startup: the image
processor reads one byte past the limit, which wrapped negative at the
largest 64-bit value, and a response is held whole in memory.
config.example.yml states the maximum.

Model: opus-5-5
This commit is contained in:
2026-09-29 06:11:57 +00:00
parent ff50a59bae
commit 841571ea08
3 changed files with 74 additions and 18 deletions
+5 -3
View File
@@ -63,9 +63,11 @@ exhaustion
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size` `upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
(default 50 MiB) and `downstream_timeout` (default `60s`, both the (default 50 MiB) and `downstream_timeout` (default `60s`, both the
server's write timeout and the per-request timeout); each has a server's write timeout and the per-request timeout); each has a
`PIXA_` variable; durations are positive Go duration strings, sizes a `PIXA_` variable; durations are positive Go duration strings, the size a
whole number of bytes; an invalid value aborts startup naming the key whole number of bytes up to 1 GiB, the origin `*` or one scheme, host
and the value; documented in `config.example.yml` and `README.md`. name or IP address and optional port; an invalid value aborts startup
naming the key and the value; documented in `config.example.yml` and
`README.md`.
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` - 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
counts the cached source images and processed variants (`source_content` counts the cached source images and processed variants (`source_content`
plus `variant_content`) and takes their size from `Cache.UsageBytes`, plus `variant_content`) and takes their size from `Cache.UsageBytes`,
+2 -1
View File
@@ -74,7 +74,8 @@ upstream_connections_per_host: 20
# Time allowed for one fetch from an upstream host (default: 30s) # Time allowed for one fetch from an upstream host (default: 30s)
upstream_fetch_timeout: 30s upstream_fetch_timeout: 30s
# Largest upstream response accepted, in bytes (default: 52428800, 50 MiB) # Largest upstream response accepted, in bytes, at most 1073741824
# (1 GiB) (default: 52428800, 50 MiB)
upstream_max_response_size: 52428800 upstream_max_response_size: 52428800
# Time allowed for answering one client request, the upstream fetch # Time allowed for answering one client request, the upstream fetch
+67 -14
View File
@@ -78,6 +78,7 @@ var (
errEmptyEntry = errors.New("contains an empty entry") errEmptyEntry = errors.New("contains an empty entry")
errNotAValidURL = errors.New("not a valid URL") errNotAValidURL = errors.New("not a valid URL")
errPortOutOfRange = errors.New("outside the valid port range") errPortOutOfRange = errors.New("outside the valid port range")
errSizeOutOfRange = errors.New("outside the accepted range")
errTooFewConnections = errors.New("must be at least 1") errTooFewConnections = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short") errValueTooShort = errors.New("value too short")
errPlaceholderKey = errors.New( errPlaceholderKey = errors.New(
@@ -578,10 +579,9 @@ func (c *Config) validate() error {
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative) settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
} }
if c.UpstreamMaxResponseSize <= 0 { err = c.validateUpstreamMaxResponseSize()
return fmt.Errorf("%s: value %d %w", if err != nil {
settingName(keyUpstreamMaxResponseSize), c.UpstreamMaxResponseSize, return err
errMustBePositive)
} }
for _, host := range c.AllowlistHosts { for _, host := range c.AllowlistHosts {
@@ -608,29 +608,82 @@ func (c *Config) validate() error {
return c.validateAccessControlAllowOrigin() return c.validateAccessControlAllowOrigin()
} }
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
// is from 1 byte to 1 GiB. An upstream response is read whole into
// memory, and the image processor reads one byte past this limit, which
// must not overflow.
func (c *Config) validateUpstreamMaxResponseSize() error {
const maxUpstreamMaxResponseSize = 1 << 30 // 1 GiB
if c.UpstreamMaxResponseSize < 1 ||
c.UpstreamMaxResponseSize > maxUpstreamMaxResponseSize {
return fmt.Errorf("%s: value %d is %w 1-%d",
settingName(keyUpstreamMaxResponseSize), c.UpstreamMaxResponseSize,
errSizeOutOfRange, maxUpstreamMaxResponseSize)
}
return nil
}
// validateAccessControlAllowOrigin checks that access_control_allow_origin // validateAccessControlAllowOrigin checks that access_control_allow_origin
// is "*" or one origin, a scheme and host with nothing after them, as // is "*" or one origin as browsers send it in the Origin header: a scheme,
// browsers send it in the Origin header. Anything else, such as a bare // a host name or IP address, and optionally a port from 1 to 65535, with
// hostname or a trailing slash, would match no request. An origin with a // nothing after them. Anything else, such as a bare hostname or a trailing
// "*" inside, such as https://*example.com, is refused because the CORS // slash, would match no request. A "*" is not allowed in a host name, so
// middleware reads that "*" as a pattern and would let other sites read // https://*example.com is refused; the CORS middleware would read that
// responses. // "*" as a pattern and let other sites read responses.
func (c *Config) validateAccessControlAllowOrigin() error { func (c *Config) validateAccessControlAllowOrigin() error {
origin := c.AccessControlAllowOrigin origin := c.AccessControlAllowOrigin
if origin == "*" { if origin == "*" {
return nil return nil
} }
parsed, err := url.Parse(origin) errOrigin := fmt.Errorf("%s: value %q is %w",
if err != nil || parsed.Host == "" || parsed.Scheme+"://"+parsed.Host != origin ||
strings.Contains(origin, "*") {
return fmt.Errorf("%s: value %q is %w",
settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin) settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin)
// url.Parse accepts an empty port, as in https://example.com:, and
// then reports no port, so a trailing colon is refused here.
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme+"://"+parsed.Host != origin ||
strings.HasSuffix(origin, ":") {
return errOrigin
}
host := parsed.Hostname()
_, err = netip.ParseAddr(host)
if err != nil && !isHostName(host) {
return errOrigin
}
// A port is a 16-bit number, and 0 is not a port a browser sends.
if parsed.Port() != "" {
port, err := strconv.ParseUint(parsed.Port(), 10, 16)
if err != nil || port == 0 {
return errOrigin
}
} }
return nil return nil
} }
// isHostName reports whether host is not empty and has only ASCII
// letters, digits, hyphens and dots.
func isHostName(host string) bool {
if host == "" {
return false
}
for _, r := range host {
isLetterOrDigit := 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' ||
'0' <= r && r <= '9'
if !isLetterOrDigit && r != '-' && r != '.' {
return false
}
}
return true
}
// validateAllowlistHost checks that an allowlist_hosts entry is a bare // validateAllowlistHost checks that an allowlist_hosts entry is a bare
// hostname, optionally with a leading dot for suffix matching. URLs, // hostname, optionally with a leading dot for suffix matching. URLs,
// paths, and whitespace indicate a misconfigured entry. An entry with // paths, and whitespace indicate a misconfigured entry. An entry with