check / check (push) Canceled after 0s
An IPv4 address in brackets, such as [203.0.113.5], and brackets around no address at all, such as [abc], must be refused with 400. They now pass the host check, and Go's URL parser refuses them later, so the route answers 401. The new rows fail until the fix lands. Model: opus-5-5
343 lines
11 KiB
Go
343 lines
11 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)
|
|
}
|
|
})
|
|
}
|
|
}
|