diff --git a/README.md b/README.md index 4fc97e9..85d08c0 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,11 @@ pixa lower-cases the host of a `/v1/image/` URL before anything else uses it, so `CDN.Example.com` is matched, fetched, cached and counted against `upstream_connections_per_host` as `cdn.example.com`. Such a URL whose host has a port, as in `cdn.example.com:8443`, or a `:` with no port, needs a signature -even when the host is allowlisted. A host holding `@` is refused with 400. +even when the host is allowlisted. The host must be a host name or an IPv4 +address (letters, digits, hyphens, underscores and dots) or an IPv6 address in +brackets, either one optionally followed by `:` and a port. Any other host, such +as one holding `@`, or a `?`, `#`, `%`, `\` or space written percent-encoded, is +refused with 400. ### Configuration diff --git a/TODO.md b/TODO.md index 0ef9f6b..6b6417e 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,17 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 the host of a `/v1/image/` URL must be a host name or an IPv4 + address (letters, digits, hyphens, underscores and dots) or an IPv6 address in + brackets, either one optionally followed by `:` and a port, and any other host + is refused with 400 (closes #275): only `@` was refused, so a `?` written as + `%3F` gave the host `cdn.example.com?x=1`, which Go's URL parser reads as + `cdn.example.com` with a query. Such a URL passed the allowlist, and each text + after the `?` fetched the host's root path and cached the answer under keys of + its own. A `#`, `%`, `\` or space written percent-encoded is refused the same + way, and so is an IPv6 address without brackets, which needed a signature + before. Underscores are kept because `allowlist_hosts` accepts host names + holding them. - 2026-10-15 the README says how a source URL with a query is written into an image URL, and the image route accepts it (closes #255): the query follows the source path after `%3F`. pixa now reads the image URL's path decoded once, so diff --git a/internal/imgcache/urlparser.go b/internal/imgcache/urlparser.go index 1794d1f..d109025 100644 --- a/internal/imgcache/urlparser.go +++ b/internal/imgcache/urlparser.go @@ -50,6 +50,12 @@ var sizeFormatRegex = regexp.MustCompile( // percentEscapeRegex matches one percent-escape, such as "%2e" var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`) +// hostRegex matches the host of an image URL: a host name or an IPv4 address +// (letters, digits, "-", "_" and "."), or an IPv6 address in brackets, either +// one optionally followed by ":" and a port, which may be empty +var hostRegex = regexp.MustCompile( + `^([A-Za-z0-9_.-]+|\[[0-9A-Fa-f:.]+\])(:[0-9]*)?$`) + // ParsedURL contains the parsed components of an image proxy URL. type ParsedURL struct { // Host is the source origin host (e.g., "cdn.example.com") @@ -141,10 +147,12 @@ func parseImageComponents(remainder string) (*ParsedURL, error) { return nil, ErrMissingHost } - // A URL reads what comes before "@" as a user name and password, and the - // rest as the host - if strings.Contains(host, "@") { - return nil, fmt.Errorf("%w: %q holds @", ErrInvalidHost, host) + // Go's URL parser, which the allowlist check and the fetcher use, reads a + // host that hostRegex does not match as a different host, or refuses it: + // what comes before an "@" is a user name and password, and what comes + // after a "?" or "#" is a query or a fragment + if !hostRegex.MatchString(host) { + return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host) } // Host names do not depend on letter case, so the allowlist, the upstream