Compare commits
2
Commits
5506858834
...
729c0ff222
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
729c0ff222 | ||
|
|
5bfec71b61 |
No files matched your search
@@ -339,11 +339,11 @@ percent-escape. So `my%20cat.jpg` in the image URL is the source path
|
|||||||
`/my%20cat.jpg`, fetched as `/my%2520cat.jpg`; and `100%25.png` is fetched as
|
`/my%20cat.jpg`, fetched as `/my%2520cat.jpg`; and `100%25.png` is fetched as
|
||||||
`/100%25.png`. The source query is the source URL's query as that URL writes it,
|
`/100%25.png`. The source query is the source URL's query as that URL writes it,
|
||||||
its percent-escapes included, and is sent as it is, except that the characters a
|
its percent-escapes included, and is sent as it is, except that the characters a
|
||||||
browser percent-encodes in a query (a space, `"`, `<`, `>`, control characters
|
browser percent-encodes in a query (a space, `"`, `'`, `<`, `>`, control
|
||||||
and characters outside ASCII) and a `%` that does not begin a percent-escape are
|
characters and characters outside ASCII) and a `%` that does not begin a
|
||||||
percent-encoded. pixa signs and caches the query in that form, so
|
percent-escape are percent-encoded. pixa signs and caches the query in that
|
||||||
`img%3Fname=my%20cat` and `img%3Fname=my%2520cat` in the image URL are the same
|
form, so `img%3Fname=my%20cat` and `img%3Fname=my%2520cat` in the image URL are
|
||||||
source query, `name=my%20cat`.
|
the same source query, `name=my%20cat`.
|
||||||
|
|
||||||
A size with one side `0` has that side scaled to keep the source's proportions,
|
A size with one side `0` has that side scaled to keep the source's proportions,
|
||||||
and is refused with 400 when that side would come out above 8192, the most
|
and is refused with 400 when that side would come out above 8192, the most
|
||||||
@@ -459,11 +459,13 @@ nor change what it asks for.
|
|||||||
sent with an empty format, or none, also makes a JPEG XL URL. Width and
|
sent with an empty format, or none, also makes a JPEG XL URL. Width and
|
||||||
height both empty or `0` keep the original size; if only one of them is empty
|
height both empty or `0` keep the original size; if only one of them is empty
|
||||||
or `0`, that side is scaled to keep the image's proportions. A URL whose
|
or `0`, that side is scaled to keep the image's proportions. A URL whose
|
||||||
scaled side would come out above 8192 answers 400. So does a source URL that
|
scaled side would come out above 8192 answers 400, and so does a source URL
|
||||||
the image URL would fetch written another way, such as one whose path holds
|
whose path holds a `..` segment. So does a source URL that the image URL
|
||||||
`%2F`, fetched as `/`, or `(`, fetched as `%28`, as pixa keeps the source
|
would fetch written another way, and the page then shows the URL pixa would
|
||||||
path decoded and fetches it percent-encoded again (see Routes); the page then
|
fetch: one with a user name and password, which pixa does not send, or one
|
||||||
shows the URL pixa would fetch.
|
whose path holds `%2F`, fetched as `/`, or `(`, fetched as `%28`, as pixa
|
||||||
|
keeps the source path decoded and fetches it percent-encoded again (see
|
||||||
|
Routes).
|
||||||
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
3. The page shows the URL, `https://<host>/v1/e/<token>/img.<format>`, and when
|
||||||
it expires. `<host>` is the host the page was opened on, and the URL starts
|
it expires. `<host>` is the host the page was opened on, and the URL starts
|
||||||
with `http` instead while `debug` is on. The name after the token is ignored
|
with `http` instead while `debug` is on. The name after the token is ignored
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-16 the source URL a request fetches is built with `net/url` from the
|
||||||
|
source host, path and query, not joined as text (closes #288). The source
|
||||||
|
path, decoded, is fetched percent-encoded again, so `100%25.png` fetches
|
||||||
|
`/100%25.png` where it answered 401, and `my%20cat.png` and `my%2520cat.png`
|
||||||
|
fetch different paths where both fetched `/my%20cat.png` under two cache keys.
|
||||||
|
The source query stays as the source URL writes it; the characters a browser
|
||||||
|
percent-encodes in a query, such as `'` or a space, which was sent raw and
|
||||||
|
answered 502, are percent-encoded when the image URL is read, so the query is
|
||||||
|
signed and cached as it is sent. The generator page answers 400, showing the
|
||||||
|
URL pixa would fetch, for a source URL its image URL would fetch written
|
||||||
|
another way, such as with `%2F` in the path, whatever else the path holds, or
|
||||||
|
with a user name and password. It also answers 400 for a source path holding a
|
||||||
|
`..` segment, as `/v1/image/` refuses one.
|
||||||
- 2026-10-15 a thin source requested with `fit=cover` no longer makes libvips
|
- 2026-10-15 a thin source requested with `fit=cover` no longer makes libvips
|
||||||
build a huge image (closes #282): libvips' thumbnail scaled the whole source
|
build a huge image (closes #282): libvips' thumbnail scaled the whole source
|
||||||
to cover the requested size and copied that into memory before it cut out the
|
to cover the requested size and copied that into memory before it cut out the
|
||||||
@@ -42,17 +55,6 @@ P2: security: per-origin rate limiting
|
|||||||
resident memory peaked at about 242 MiB for the 8192x64 source and 431 MiB for
|
resident memory peaked at about 242 MiB for the 8192x64 source and 431 MiB for
|
||||||
the 8192x1 one. The output can sit a fraction of an output pixel from where it
|
the 8192x1 one. The output can sit a fraction of an output pixel from where it
|
||||||
was.
|
was.
|
||||||
- 2026-10-16 the source URL a request fetches is built with `net/url` from the
|
|
||||||
source host, path and query, not joined as text (closes #288). The source
|
|
||||||
path, decoded, is fetched percent-encoded again, so `100%25.png` fetches
|
|
||||||
`/100%25.png` where it answered 401, and `my%20cat.png` and `my%2520cat.png`
|
|
||||||
fetch different paths where both fetched `/my%20cat.png` under two cache keys.
|
|
||||||
The source query stays as the source URL writes it; the characters a browser
|
|
||||||
percent-encodes in a query, such as a space, which was sent raw and answered
|
|
||||||
502, are percent-encoded when the image URL is read, so the query is signed
|
|
||||||
and cached as it is sent. The generator page answers 400, showing the URL pixa
|
|
||||||
would fetch, for a source URL its image URL would fetch written another way,
|
|
||||||
such as with `%2F` in the path.
|
|
||||||
- 2026-10-15 a `/v1/image/` URL whose source path or source query holds a `#`,
|
- 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
|
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
|
it ended at the `#` when fetched, so each text after it fetched the same
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/clientip"
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
@@ -133,6 +135,16 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
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)
|
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
@@ -141,17 +153,14 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The image URL must fetch the source URL as it was written. The
|
// The image URL must fetch the source URL as it was written
|
||||||
// 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
|
|
||||||
sourceRequest := payload.ToImageRequest()
|
sourceRequest := payload.ToImageRequest()
|
||||||
sourceRequest.AllowHTTP = s.config.AllowHTTP
|
sourceRequest.AllowHTTP = s.config.AllowHTTP
|
||||||
|
fetchURL := sourceRequest.SourceURL()
|
||||||
|
|
||||||
fetchURL, err := url.Parse(sourceRequest.SourceURL())
|
if !fetchedAsWritten(parsed, fetchURL) {
|
||||||
if err != nil || fetchURL.RequestURI() != parsed.RequestURI() {
|
|
||||||
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
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)
|
r.Form, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
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
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
// 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
|
// non-numeric or out-of-range width, height, quality or ttl, or an
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ var sizeFormatRegex = regexp.MustCompile(
|
|||||||
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
|
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
|
||||||
|
|
||||||
// queryEscapeRegex matches a percent-escape, or one character that a browser
|
// queryEscapeRegex matches a percent-escape, or one character that a browser
|
||||||
// percent-encodes in a URL's query: one outside the printable ASCII characters
|
// percent-encodes in the query of an http or https URL: one outside the
|
||||||
// "!" to "~", such as a space, a control character or a character outside
|
// printable ASCII characters "!" to "~", such as a space, a control character
|
||||||
// ASCII, or one of `"#<>`. A "%" that does not begin a percent-escape matches too.
|
// or a character outside ASCII, or one of `"#'<>`. A "%" that does not begin a
|
||||||
var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#<>%]`)
|
// percent-escape matches too.
|
||||||
|
var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
|
||||||
|
|
||||||
// hostRegex matches the forms the host of an image URL may take: a host name
|
// hostRegex matches the forms the host of an image URL may take: a host name
|
||||||
// or an IPv4 address (letters, digits, "-", "_" and "."), or an IPv6 address
|
// or an IPv4 address (letters, digits, "-", "_" and "."), or an IPv6 address
|
||||||
|
|||||||
@@ -157,10 +157,11 @@ func postGenerate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestImageURLFetchesItsSourcePathAndQuery requests image URLs whose source
|
// TestImageURLFetchesItsSourcePathAndQuery requests image URLs whose source
|
||||||
// query holds a space and whose source path holds a "%", each written
|
// query holds a space, written percent-encoded, or a "'", and whose source
|
||||||
// percent-encoded, and checks the one request the upstream receives: the
|
// path holds a "%", written percent-encoded, and checks the one request the
|
||||||
// source path percent-encoded again, and the source query with the space
|
// upstream receives: the source path percent-encoded again, and the source
|
||||||
// written %20.
|
// query with the space written %20 and the "'" written %27, as a browser
|
||||||
|
// writes them in a query.
|
||||||
func TestImageURLFetchesItsSourcePathAndQuery(t *testing.T) {
|
func TestImageURLFetchesItsSourcePathAndQuery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -174,6 +175,8 @@ func TestImageURLFetchesItsSourcePathAndQuery(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"space in the query", "/photo.png%3Fname=my%20cat",
|
{"space in the query", "/photo.png%3Fname=my%20cat",
|
||||||
"/photo.png?name=my%20cat"},
|
"/photo.png?name=my%20cat"},
|
||||||
|
{"apostrophe in the query", "/photo.png%3Fname=it's",
|
||||||
|
"/photo.png?name=it%27s"},
|
||||||
{"percent sign in the path", percentSignPath, percentSignPath},
|
{"percent sign in the path", percentSignPath, percentSignPath},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,30 +308,64 @@ func TestGeneratedURLFetchesTheSourceURLAsEntered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGeneratorRefusesSourceURLItWouldFetchAnotherWay submits a source URL
|
// TestGeneratorRefusesSourceURLItCannotFetchAsWritten submits source URLs the
|
||||||
// whose path holds %2F. pixa keeps the path decoded and would fetch it with a
|
// generator page must make no encrypted URL for, and checks that it answers
|
||||||
// "/" there, which is another URL, so the generator page answers 400, shows
|
// 400 and shows why. pixa keeps the source path decoded and fetches it
|
||||||
// the URL pixa would fetch, and makes no encrypted URL.
|
// percent-encoded again, so a path holding %2F would be fetched with a "/"
|
||||||
func TestGeneratorRefusesSourceURLItWouldFetchAnotherWay(t *testing.T) {
|
// there, whatever else the path holds. pixa sends no user name or password,
|
||||||
|
// and no "?" with no query after it. For these the page shows the URL pixa
|
||||||
|
// would fetch. A source path holding a ".." segment is refused, as
|
||||||
|
// /v1/image/ refuses it.
|
||||||
|
func TestGeneratorRefusesSourceURLItCannotFetchAsWritten(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startImageProxy(t, startRecordingUpstream(t).Server)
|
|
||||||
|
|
||||||
rec := postGenerate(t, s, "https://"+upstreamHost+"/a%2Fb.png")
|
|
||||||
body := rec.Body.String()
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(body, "/v1/e/") {
|
|
||||||
t.Error("the page shows an encrypted URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The test config sets allow_http, so pixa would fetch over plain HTTP
|
// The test config sets allow_http, so pixa would fetch over plain HTTP
|
||||||
fetchURL := "http://" + upstreamHost + "/a/b.png"
|
const fetchedFrom = "http://" + upstreamHost
|
||||||
if !strings.Contains(body, fetchURL) {
|
|
||||||
t.Errorf("the page does not show the URL pixa would fetch, %s; body %s",
|
tests := []struct {
|
||||||
fetchURL, body)
|
name string
|
||||||
|
// sourceURL is the source URL after "https://"
|
||||||
|
sourceURL string
|
||||||
|
// wantShown is what the page must show: the URL pixa would fetch, or
|
||||||
|
// why it makes no encrypted URL
|
||||||
|
wantShown string
|
||||||
|
}{
|
||||||
|
{"encoded slash", upstreamHost + "/a%2Fb.png",
|
||||||
|
fetchedFrom + "/a/b.png"},
|
||||||
|
{"encoded slash and a space", upstreamHost + "/a%2Fb c.png",
|
||||||
|
fetchedFrom + "/a/b%20c.png"},
|
||||||
|
{"encoded slash and a bar", upstreamHost + "/a%2Fb|c.png",
|
||||||
|
fetchedFrom + "/a/b%7Cc.png"},
|
||||||
|
{"encoded slash and a letter outside ASCII", upstreamHost + "/café%2Fx.png",
|
||||||
|
fetchedFrom + "/caf%C3%A9/x.png"},
|
||||||
|
{"user name and password", "user:pass@" + upstreamHost + "/a.png",
|
||||||
|
fetchedFrom + "/a.png"},
|
||||||
|
{"question mark with no query", upstreamHost + "/a.png?",
|
||||||
|
fetchedFrom + "/a.png"},
|
||||||
|
{"two-dot segment", upstreamHost + "/a/../b.png",
|
||||||
|
"cannot hold a .. segment"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startImageProxy(t, startRecordingUpstream(t).Server)
|
||||||
|
|
||||||
|
rec := postGenerate(t, s, "https://"+tt.sourceURL)
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(body, "/v1/e/") {
|
||||||
|
t.Error("the page shows an encrypted URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, tt.wantShown) {
|
||||||
|
t.Errorf("the page does not show %s; body %s", tt.wantShown, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user