diff --git a/README.md b/README.md index 250463e..5fb6a49 100644 --- a/README.md +++ b/README.md @@ -419,8 +419,11 @@ HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit" Where: - `host` — source origin hostname (e.g. `cdn.example.com`) -- `path` — source path (e.g. `/photos/cat.jpg`) -- `query` — source query string, empty string if none +- `path` — source path (e.g. `/photos/cat.jpg`), with each `%` written as `%25` + and each `:` as `%3A`, so `/photos/a:b.jpg` is signed as `/photos/a%3Ab.jpg` +- `query` — source query string, empty string if none, with `%` and `:` written + as in `path`; neither then holds a `:`, so path `/a:b` with no query and path + `/a` with query `b:` are signed differently - `width` — requested width in pixels, `0` for original - `height` — requested height in pixels, `0` for original - `format` — output format, one of those listed under Routes, with `original` diff --git a/TODO.md b/TODO.md index b85bf37..114b996 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,14 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 a `:` in the source path or query no longer moves where the path + ends and the query begins (closes #247): the signed data and the cache key + joined host, path and query with `:` and no escaping, so a URL signed for + `https://h/a:b` verified for `https://h/a?b:`; the two also shared a cache + key, and whichever was fetched first was served for both. In both, the path + and the query now have each `%` written as `%25` and each `:` as `%3A`; a path + and query holding neither are signed and keyed as before. Signature + Specification in `README.md` says so. - 2026-10-15 the example config allowlists no host (closes #249): it allowlisted `github.com` and `user-images.githubusercontent.com`, where anyone can put an image, so a deployment set up as Getting Started says served those images to diff --git a/internal/imgcache/storage.go b/internal/imgcache/storage.go index 395178d..35ff2f1 100644 --- a/internal/imgcache/storage.go +++ b/internal/imgcache/storage.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "time" ) @@ -371,11 +372,13 @@ func HashPath(path string) PathHash { // CacheKey generates a unique key for a request variant. // Format: sha256(host:path:query:width:height:format:quality:fit_mode) +// The path and query go through escapePercentAndColon, so that a ":" in +// either cannot be taken for the one between them. func CacheKey(req *ImageRequest) VariantKey { data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s", req.SourceHost, - req.SourcePath, - req.SourceQuery, + escapePercentAndColon(req.SourcePath), + escapePercentAndColon(req.SourceQuery), req.Size.Width, req.Size.Height, req.Format, @@ -387,6 +390,14 @@ func CacheKey(req *ImageRequest) VariantKey { return VariantKey(hex.EncodeToString(h[:])) } +// escapePercentAndColon returns s with each "%" written as "%25" and each +// ":" as "%3A", so that the result holds no ":". +func escapePercentAndColon(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + + return strings.ReplaceAll(s, ":", "%3A") +} + // VariantStorage handles key-based file storage for processed image variants. // Files are stored at: /// // Metadata is stored at: ///.meta diff --git a/internal/signature/signature.go b/internal/signature/signature.go index 0b43032..a683e39 100644 --- a/internal/signature/signature.go +++ b/internal/signature/signature.go @@ -10,6 +10,7 @@ import ( "fmt" "net/url" "strconv" + "strings" "time" ) @@ -154,15 +155,17 @@ func (s *Signer) GenerateSignedURL( // buildSignatureData creates the string to be signed. // Format: "host:path:query:width:height:format:expiration:quality:fit" -// All components are used verbatim (exact match). No normalization, -// suffix matching, or wildcard expansion is performed. Quality and fit -// are the effective transform values, so replaying a signed URL with a -// different quality or fit mode fails verification. +// The path and query go through escapePercentAndColon, so that a ":" in +// either cannot be taken for the one between them; every other component +// is used verbatim. No normalization, suffix matching, or wildcard +// expansion is performed. Quality and fit are the effective transform +// values, so replaying a signed URL with a different quality or fit mode +// fails verification. func (s *Signer) buildSignatureData(req *Request) string { return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s", req.SourceHost, - req.SourcePath, - req.SourceQuery, + escapePercentAndColon(req.SourcePath), + escapePercentAndColon(req.SourceQuery), req.Width, req.Height, req.Format, @@ -172,6 +175,14 @@ func (s *Signer) buildSignatureData(req *Request) string { ) } +// escapePercentAndColon returns s with each "%" written as "%25" and each +// ":" as "%3A", so that the result holds no ":". +func escapePercentAndColon(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + + return strings.ReplaceAll(s, ":", "%3A") +} + // ParseParams extracts signature and expiration from query parameters. func ParseParams(sig, expStr string) (string, time.Time, error) { if expStr == "" {