check / check (push) Canceled after 0s
A source URL holding %25, a literal "%", written by the README rule or by GenerateSignedURL, is refused with 400: the path traversal check refuses the "%" that one more decoding leaves. A double-encoded ".." next to such a "%" must stay refused. Model: opus-5-5
335 lines
9.8 KiB
Go
335 lines
9.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
|
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
|
// request that passes the signature check gets 502 from the failed fetch, and
|
|
// one that fails the check gets 401.
|
|
const signedHost = "signed.example.com"
|
|
|
|
// getImage sends a GET for target to the image route of fix and returns the
|
|
// response status.
|
|
func getImage(t *testing.T, fix *testFixtures, target string) int {
|
|
t.Helper()
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
t.Logf("GET %s: %d", target, rec.Code)
|
|
|
|
return rec.Code
|
|
}
|
|
|
|
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
|
|
// and fit cover, the values the route uses when a URL has no q or fit, and
|
|
// sends that signature with each q and fit below.
|
|
func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expires := time.Now().Add(time.Hour)
|
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
sig := signer.Sign(&signature.Request{
|
|
SourceHost: signedHost,
|
|
SourcePath: "/images/photo.jpg",
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: expires,
|
|
})
|
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, sig, expires.Unix())
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
wantStatus int
|
|
}{
|
|
{"no q or fit", "", http.StatusBadGateway},
|
|
{"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway},
|
|
{"replayed with q=40", "&q=40", http.StatusUnauthorized},
|
|
{"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
status := getImage(t, setupTestHandler(t), signedURL+tt.query)
|
|
if status != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", status, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
|
// service's signed-URL generator to the route.
|
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
quality int
|
|
fitMode imgcache.FitMode
|
|
}{
|
|
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
|
{"quality and fit unset", 0, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
SourceHost: signedHost,
|
|
SourcePath: "/images/photo.jpg",
|
|
Size: imgcache.Size{Width: 50, Height: 50},
|
|
Format: imgcache.FormatJPEG,
|
|
Quality: tt.quality,
|
|
FitMode: tt.fitMode,
|
|
}, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
}
|
|
|
|
status := getImage(t, fix, signedURL)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is
|
|
// not a whole number, and one whose exp is empty. Each is refused with 400
|
|
// naming exp and the value, not with the 401 a URL without exp still gets.
|
|
func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
query string
|
|
wantStatus int
|
|
wantError string
|
|
}{
|
|
{"sig=x&exp=banana", http.StatusBadRequest,
|
|
`invalid exp: not a number, got "banana"`},
|
|
{"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`},
|
|
{"sig=x", http.StatusUnauthorized, "unauthorized"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.query, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
"/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("decoding response body: %v", err)
|
|
}
|
|
|
|
if rec.Code != tt.wantStatus || body.Error != tt.wantError {
|
|
t.Errorf("got %d %q, want %d %q",
|
|
rec.Code, body.Error, tt.wantStatus, tt.wantError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
|
// by the service's signed-URL generator for source URLs with a query to the
|
|
// route, through a chi router: chi routes on the path as the client wrote it
|
|
// whenever that is not Go's own encoding of it. A 502 means the signature
|
|
// passed, so the route read the source path and query that were signed.
|
|
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
}{
|
|
{"slash and colon", "url=https://origin.example.com/cat.jpg"},
|
|
{"semicolon", "a=1;b=2"},
|
|
{"comma", "crop=0,0,50,50"},
|
|
{"equals and ampersand", "a=1&b=2"},
|
|
{"percent-escape", "name=my%20cat"},
|
|
{"question mark", "next=/a?b=1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
SourceHost: signedHost,
|
|
SourcePath: photoPath,
|
|
SourceQuery: tt.query,
|
|
Size: imgcache.Size{Width: 50, Height: 50},
|
|
Format: imgcache.FormatJPEG,
|
|
}, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
}
|
|
|
|
status := getImage(t, fix, signedURL)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_SourceQueryEncodedAnyWay signs one source URL with a query
|
|
// and writes it into the image URL in ways the README allows: the "?" before
|
|
// the query and each "%" in it percent-encoded, and every other character as
|
|
// itself or percent-encoded. A 502 means the signature passed.
|
|
func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expires := time.Now().Add(time.Hour)
|
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
sig := signer.Sign(&signature.Request{
|
|
SourceHost: signedHost,
|
|
SourcePath: photoPath,
|
|
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: expires,
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
writtenQuery string
|
|
}{
|
|
{"only ? and % encoded",
|
|
"%3Furl=https://origin.example.com/my%2520cat.jpg"},
|
|
{"every reserved character encoded",
|
|
"%3Furl%3Dhttps%3A%2F%2Forigin.example.com%2Fmy%2520cat.jpg"},
|
|
{"lower-case hex digits",
|
|
"%3furl=https://origin.example.com/my%2520cat.jpg"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := fmt.Sprintf("/v1/image/%s%s%s/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, photoPath, tt.writtenQuery, sig, expires.Unix())
|
|
|
|
status := getImage(t, setupTestHandler(t), target)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
|
// source query that each hold %25, the percent-escape of a literal "%", and
|
|
// sends each to the route written as the README says, with that "%" written as
|
|
// %25, and as GenerateSignedURL writes it. A 502 means the signature passed.
|
|
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
sourcePath string
|
|
sourceQuery string
|
|
// writtenPathAndQuery is the source path and query as the README says
|
|
// to write them into the image URL
|
|
writtenPathAndQuery string
|
|
}{
|
|
{"path", "/sale/50%25off.jpg", "", "/sale/50%2525off.jpg"},
|
|
{"query", photoPath, "discount=50%25", photoPath + "%3Fdiscount=50%2525"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name+" written by the README rule", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expires := time.Now().Add(time.Hour)
|
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
sig := signer.Sign(&signature.Request{
|
|
SourceHost: signedHost,
|
|
SourcePath: tt.sourcePath,
|
|
SourceQuery: tt.sourceQuery,
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: expires,
|
|
})
|
|
|
|
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
|
|
|
status := getImage(t, setupTestHandler(t), target)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
|
|
t.Run(tt.name+" written by GenerateSignedURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
SourceHost: signedHost,
|
|
SourcePath: tt.sourcePath,
|
|
SourceQuery: tt.sourceQuery,
|
|
Size: imgcache.Size{Width: 50, Height: 50},
|
|
Format: imgcache.FormatJPEG,
|
|
}, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
}
|
|
|
|
status := getImage(t, fix, signedURL)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|