Encode ' in the source query; the generator compares the path as written
check / check (push) Canceled after 0s

A "'" in the source query is percent-encoded with the other characters
a browser encodes in a query. The generator page compares the URL pixa
would fetch with the path as written, which url.URL keeps in RawPath,
so a path holding %2F is refused whatever else it holds. It also
refuses a source URL holding a user name and password, which pixa does
not send, and a source path holding a ".." segment, as /v1/image/ does.
README.md and TODO.md say so.

Model: opus-5-5
This commit is contained in:
2026-10-16 01:02:08 +00:00
parent 5bfec71b61
commit 729c0ff222
4 changed files with 69 additions and 32 deletions
+39 -7
View File
@@ -9,7 +9,9 @@ import (
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"sneak.berlin/go/pixa/internal/clientip"
@@ -133,6 +135,16 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return
}
// /v1/image/ refuses a ".." segment in the source path, and so does
// the generator. The path is decoded, so %2e%2e counts too
if slices.Contains(strings.Split(parsed.Path, "/"), "..") {
s.renderGeneratorWithForm(w, r,
"Source URL path cannot hold a .. segment", r.Form,
http.StatusBadRequest)
return
}
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
if err != nil {
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
@@ -141,17 +153,14 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return
}
// The image URL must fetch the source URL as it was written. The
// token keeps the path decoded and pixa fetches it percent-encoded
// again, so a path written another way, such as with %2F, would fetch
// another URL. The scheme is not kept: only path and query are compared
// The image URL must fetch the source URL as it was written
sourceRequest := payload.ToImageRequest()
sourceRequest.AllowHTTP = s.config.AllowHTTP
fetchURL := sourceRequest.SourceURL()
fetchURL, err := url.Parse(sourceRequest.SourceURL())
if err != nil || fetchURL.RequestURI() != parsed.RequestURI() {
if !fetchedAsWritten(parsed, fetchURL) {
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
"written: pixa would fetch "+sourceRequest.SourceURL(),
"written: pixa would fetch "+fetchURL,
r.Form, http.StatusBadRequest)
return
@@ -189,6 +198,29 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
}
}
// fetchedAsWritten reports whether fetchURL, the URL pixa fetches for an
// image URL made from the source URL source, is source as it was written,
// apart from the scheme, which the token does not keep. It is not when source
// holds a user name and password, which the token does not keep either, or a
// "?" with no query after it. The token keeps the source path decoded and
// pixa fetches it percent-encoded again, so a path written another way, such
// as with %2F, is fetched as another path. url.URL keeps the path as written
// in RawPath when it is not the decoded path percent-encoded again.
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
// such as a space, so RawPath is compared itself.
func fetchedAsWritten(source *url.URL, fetchURL string) bool {
writtenPath := source.RawPath
if writtenPath == "" {
writtenPath = source.EscapedPath()
}
fetched, err := url.Parse(fetchURL)
return err == nil && source.User == nil && !source.ForceQuery &&
fetched.EscapedPath() == writtenPath &&
fetched.RawQuery == source.RawQuery
}
// buildGeneratePayload parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
// non-numeric or out-of-range width, height, quality or ttl, or an