check / check (push) Canceled after 0s
The image route serves these today: the source URL ends at the #, so each text after it fetches the same upstream image and caches it under keys of its own. Model: opus-5-5
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"image/color"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// allowlistedHost is the only host on the allowlist of the image route
|
|
// newImageRoute builds.
|
|
const allowlistedHost = "allowed.example.com"
|
|
|
|
// newImageRoute returns the image route of a Handlers whose service fetches
|
|
// with fetcher and checks signatures with testSigningKey.
|
|
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|
t.Helper()
|
|
|
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
|
StateDir: t.TempDir(),
|
|
NegativeTTL: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create cache: %v", err)
|
|
}
|
|
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
Fetcher: fetcher,
|
|
SigningKey: testSigningKey,
|
|
Allowlist: []string{allowlistedHost},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", h.HandleImage())
|
|
|
|
return r
|
|
}
|
|
|
|
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
|
// each of hosts, and answers any other URL with an upstream error.
|
|
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
|
t.Helper()
|
|
|
|
photo := &fstest.MapFile{
|
|
Data: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}),
|
|
}
|
|
|
|
files := fstest.MapFS{}
|
|
for _, host := range hosts {
|
|
files[host+photoPath] = photo
|
|
}
|
|
|
|
return httpfetcher.NewMock(files)
|
|
}
|
|
|
|
// photoURL returns the image route URL of photoPath on host, as a 50x50 JPEG.
|
|
func photoURL(host string) string {
|
|
return "/v1/image/" + host + photoPath + "/50x50.jpeg"
|
|
}
|
|
|
|
// photoURLWithSig returns photoURL(host) with sig and expires as its sig and
|
|
// exp.
|
|
func photoURLWithSig(host, sig string, expires time.Time) string {
|
|
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
|
}
|
|
|
|
// photoSignature returns the signature of photoURL(host) at the default
|
|
// quality and fit, made with key and expiring at expires.
|
|
func photoSignature(key, host string, expires time.Time) string {
|
|
return signature.New(key).Sign(&signature.Request{
|
|
SourceHost: host,
|
|
SourcePath: photoPath,
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: expires,
|
|
})
|
|
}
|
|
|
|
// sendGet sends a GET for target to route and returns the response.
|
|
func sendGet(
|
|
t *testing.T, route http.Handler, target string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
route.ServeHTTP(rec, req)
|
|
t.Logf("GET %s: %d", target, rec.Code)
|
|
|
|
return rec
|
|
}
|
|
|
|
// checkErrorBody checks that rec has status wantStatus and the JSON error body
|
|
// the image route sends: wantError, wantStatus and the time in RFC 3339.
|
|
func checkErrorBody(
|
|
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantError string,
|
|
) {
|
|
t.Helper()
|
|
|
|
if rec.Code != wantStatus {
|
|
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
|
|
}
|
|
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
Status int `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("decoding response body: %v", err)
|
|
}
|
|
|
|
if body.Error != wantError || body.Status != wantStatus {
|
|
t.Errorf("body error and status = %q %d, want %q %d",
|
|
body.Error, body.Status, wantError, wantStatus)
|
|
}
|
|
|
|
_, err = time.Parse(time.RFC3339, body.Timestamp)
|
|
if err != nil {
|
|
t.Errorf("body timestamp: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_ErrorAnswers checks the status and the JSON error body the
|
|
// image route answers each request below with. The JPEG at photoPath exists on
|
|
// signedHost and on each host below that differs from it, so a request refused
|
|
// with 401 would otherwise be served.
|
|
func TestHandleImage_ErrorAnswers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A signature for signedHost must not verify for any of these.
|
|
parentHost := "example.com"
|
|
siblingHost := "other.example.com"
|
|
subdomainHost := "img." + signedHost
|
|
appendedHost := signedHost + ".example.net"
|
|
|
|
photos := newPhotoFetcher(t,
|
|
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
|
// The real fetcher refuses localhost before any lookup or connection.
|
|
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
|
|
|
exp := time.Now().Add(time.Hour)
|
|
expired := time.Now().Add(-time.Hour)
|
|
sig := photoSignature(testSigningKey, signedHost, exp)
|
|
otherKeySig := photoSignature("another-signing-key", signedHost, exp)
|
|
expiredSig := photoSignature(testSigningKey, signedHost, expired)
|
|
localhostSig := photoSignature(testSigningKey, "localhost", exp)
|
|
|
|
// The error every request refused for its signature gets.
|
|
const unauthorized = "unauthorized"
|
|
|
|
tests := []struct {
|
|
name string
|
|
fetcher httpfetcher.Fetcher
|
|
target string
|
|
wantStatus int
|
|
wantError string
|
|
}{
|
|
{"no sig or exp", photos, photoURL(signedHost),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"exp but no sig", photos,
|
|
fmt.Sprintf("%s?exp=%d", photoURL(signedHost), exp.Unix()),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig made with another key", photos,
|
|
photoURLWithSig(signedHost, otherKeySig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig without its = padding", photos,
|
|
photoURLWithSig(signedHost, strings.TrimRight(sig, "="), exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig in upper case", photos,
|
|
photoURLWithSig(signedHost, strings.ToUpper(sig), exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"expired sig", photos, photoURLWithSig(signedHost, expiredSig, expired),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent for the parent domain", photos,
|
|
photoURLWithSig(parentHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent for a sibling host", photos,
|
|
photoURLWithSig(siblingHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent for a subdomain", photos,
|
|
photoURLWithSig(subdomainHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent with another domain appended", photos,
|
|
photoURLWithSig(appendedHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"unparseable path", photos,
|
|
"/v1/image/" + allowlistedHost + photoPath + "/big.jpeg",
|
|
http.StatusBadRequest, "invalid image URL: invalid size format"},
|
|
{"blocked upstream address", realFetcher,
|
|
photoURLWithSig("localhost", localhostSig, exp),
|
|
http.StatusForbidden, "forbidden"},
|
|
{"upstream error", photos,
|
|
"/v1/image/" + allowlistedHost + "/images/missing.jpg/50x50.jpeg",
|
|
http.StatusBadGateway, "upstream error"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, tt.fetcher), tt.target)
|
|
checkErrorBody(t, rec, tt.wantStatus, tt.wantError)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_AllowlistOrSignature checks that the image route serves an
|
|
// image without a signature for a host on the allowlist only, and for another
|
|
// host only with a valid signature.
|
|
func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
|
exp := time.Now().Add(time.Hour)
|
|
sig := photoSignature(testSigningKey, signedHost, exp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
wantStatus int
|
|
}{
|
|
{"allowlisted host, no sig", photoURL(allowlistedHost), http.StatusOK},
|
|
{"other host, no sig", photoURL(signedHost), http.StatusUnauthorized},
|
|
{"other host, valid sig", photoURLWithSig(signedHost, sig, exp),
|
|
http.StatusOK},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_InvalidHost_Returns400 sends image URLs whose host no URL
|
|
// host can be, and checks that each is refused with 400 naming the host as
|
|
// decoded: allowlistedHost with a character after it that a URL host cannot
|
|
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
|
// around something that is not an IPv6 address. A "?" or "#" after
|
|
// allowlistedHost would otherwise end the host when the source URL is read,
|
|
// so the request would pass the allowlist and be fetched and cached under a
|
|
// host that is not the one fetched.
|
|
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
photos := newPhotoFetcher(t, allowlistedHost)
|
|
|
|
tests := []struct {
|
|
name string
|
|
writtenHost string
|
|
decodedHost string
|
|
}{
|
|
{"? written as %3F", allowlistedHost + "%3Fx=1", allowlistedHost + "?x=1"},
|
|
{"# written as %23", allowlistedHost + "%23x", allowlistedHost + "#x"},
|
|
{"% written as %25", allowlistedHost + "%253F", allowlistedHost + "%3F"},
|
|
{`\ written as %5C`, allowlistedHost + "%5Cx", allowlistedHost + `\x`},
|
|
{"space written as %20", allowlistedHost + "%20", allowlistedHost + " "},
|
|
{"IPv6 address without brackets", "2001:db8::1", "2001:db8::1"},
|
|
{"IPv4 address in brackets", "[203.0.113.5]", "[203.0.113.5]"},
|
|
{"no address in brackets", "[abc]", "[abc]"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos), photoURL(tt.writtenHost))
|
|
checkErrorBody(t, rec, http.StatusBadRequest,
|
|
fmt.Sprintf("invalid image URL: invalid source host: %q",
|
|
tt.decodedHost))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_HostForms_Served checks that the image route serves, with
|
|
// a valid signature, the image at photoPath on a host name, an IPv4 address,
|
|
// an IPv6 address in brackets, and a host followed by ":" with or without a
|
|
// port.
|
|
func TestHandleImage_HostForms_Served(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
hosts := []string{
|
|
allowlistedHost,
|
|
"img_1.example.com",
|
|
"203.0.113.5",
|
|
"[2001:db8::1]",
|
|
allowlistedHost + ":8443",
|
|
"[2001:db8::1]:8443",
|
|
allowlistedHost + ":",
|
|
}
|
|
|
|
photos := newPhotoFetcher(t, hosts...)
|
|
exp := time.Now().Add(time.Hour)
|
|
|
|
for _, host := range hosts {
|
|
t.Run(host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sig := photoSignature(testSigningKey, host, exp)
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos),
|
|
photoURLWithSig(host, sig, exp))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d: %s",
|
|
rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
|
// source path or source query holds a "#", written as %23, and checks that
|
|
// each is refused with 400, the one signed for that source path included. The
|
|
// source URL would otherwise end at the "#" when it is fetched, so each text
|
|
// after it would fetch the image at photoPath and cache it under keys of its
|
|
// own.
|
|
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
|
exp := time.Now().Add(time.Hour)
|
|
|
|
sig := signature.New(testSigningKey).Sign(&signature.Request{
|
|
SourceHost: signedHost,
|
|
SourcePath: photoPath + "#x",
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: exp,
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
}{
|
|
{"# in the source path",
|
|
"/v1/image/" + allowlistedHost + photoPath + "%23x/50x50.jpeg"},
|
|
{"# in the source query",
|
|
"/v1/image/" + allowlistedHost + photoPath + "%3Fv=1%23x/50x50.jpeg"},
|
|
{"# in the source path, signed",
|
|
fmt.Sprintf("/v1/image/%s%s%%23x/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, photoPath, sig, exp.Unix())},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
|
checkErrorBody(t, rec, http.StatusBadRequest,
|
|
`invalid image URL: invalid image path: `+
|
|
`the source path or query holds a "#"`)
|
|
})
|
|
}
|
|
}
|