From ca96d8da3715655c9197f7177748732e9d393301 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 23:21:57 +0000 Subject: [PATCH] Refuse an image URL whose source path or query holds a # (closes #286) A # written as %23 reached the parser decoded and stayed in the source path or query. The source URL built from it ended at the # when it was fetched, so each text after it fetched the same upstream image, without a signature for an allowlisted host, and cached it under keys of its own. Such a URL is now refused with 400, and README.md says neither the source path nor the source query may hold a #. Model: opus-5-5 --- README.md | 5 +++-- TODO.md | 5 +++++ internal/imgcache/urlparser.go | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85d08c0..b84f4a1 100644 --- a/README.md +++ b/README.md @@ -323,8 +323,9 @@ first `/`, the size and format after the last `/`, and between them the source path up to the first `?` and the source query after it. So the `?` before the query, any `?` in the query, and every `%` in the source path or query must be percent-encoded, as `%3F` and `%25`. Any other character may be written as -itself or percent-encoded: `/`, `;`, `,`, `=` and `&` work either way. For -example, +itself or percent-encoded: `/`, `;`, `,`, `=` and `&` work either way. Neither +the source path nor the source query may hold a `#`, and an image URL that +writes one as `%23` is refused with 400. For example, `https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg` at 800x600 WebP is `/v1/image/cdn.example.com/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp`, diff --git a/TODO.md b/TODO.md index 6b6417e..4b27407 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,11 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 a `/v1/image/` URL whose source path or source query holds a `#`, + written as `%23`, is refused with 400 (closes #286): the source URL built from + it ended at the `#` when fetched, so each text after it fetched the same + upstream image, without a signature for an allowlisted host, and cached it + under keys of its own. `README.md` says neither may hold a `#`. - 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 diff --git a/internal/imgcache/urlparser.go b/internal/imgcache/urlparser.go index 467c8d9..59d95fb 100644 --- a/internal/imgcache/urlparser.go +++ b/internal/imgcache/urlparser.go @@ -171,6 +171,14 @@ func parseImageComponents(remainder string) (*ParsedURL, error) { // host host = strings.ToLower(host) + // Go's URL parser, which the fetcher uses, reads a "#" as the start of a + // fragment and drops the rest, so each text after it would be another + // spelling, cached under its own keys, of one upstream request + if strings.Contains(path, "#") { + return nil, fmt.Errorf( + `%w: the source path or query holds a "#"`, ErrInvalidPath) + } + // Extract query string if present in path if qIndex := strings.Index(path, "?"); qIndex != -1 { query = path[qIndex+1:]