newFromSmartConfig went over the line limit, so referer_blocklist is now read through strictLoader, like the other typed settings, instead of its own parse step. The tests stop repeating string literals that goconst counts: the environment test uses other hosts, two case names change, and the handler test names each image route's URL instead of its path. Model: opus-5-5
186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/allowlist"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
|
|
// referer_blocklist.
|
|
const blockedReferer = "https://leech.example/page.html"
|
|
|
|
// countingFetcher passes each fetch on to the fetcher it holds and counts it.
|
|
type countingFetcher struct {
|
|
httpfetcher.Fetcher
|
|
|
|
fetches atomic.Int32
|
|
}
|
|
|
|
// Fetch counts the fetch and passes it on.
|
|
func (f *countingFetcher) Fetch(
|
|
ctx context.Context, url string,
|
|
) (*httpfetcher.FetchResult, error) {
|
|
f.fetches.Add(1)
|
|
|
|
return f.Fetcher.Fetch(ctx, url)
|
|
}
|
|
|
|
// newRefererRoutes returns both image routes of a Handlers whose
|
|
// referer_blocklist is "leech.example" and ".hotlinker.example", the
|
|
// Handlers, and the fetcher the routes fetch through. The JPEG at photoPath
|
|
// exists on allowlistedHost and on signedHost.
|
|
func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher) {
|
|
t.Helper()
|
|
|
|
fetcher := &countingFetcher{
|
|
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
|
|
}
|
|
|
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
|
StateDir: t.TempDir(),
|
|
CacheTTL: time.Hour,
|
|
NegativeTTL: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
|
}
|
|
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
Fetcher: fetcher,
|
|
SigningKey: testSigningKey,
|
|
Allowlist: []string{allowlistedHost},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("imgcache.NewService() error = %v", err)
|
|
}
|
|
|
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
if err != nil {
|
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
}
|
|
|
|
h := &Handlers{
|
|
log: slog.New(slog.DiscardHandler),
|
|
imgSvc: svc,
|
|
encGen: encGen,
|
|
refererBlocklist: allowlist.New(
|
|
[]string{"leech.example", ".hotlinker.example"}),
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", h.HandleImage())
|
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
|
|
|
return r, h, fetcher
|
|
}
|
|
|
|
// getWithReferer sends a GET for target to routes with referer as its
|
|
// Referer header, or with none when referer is empty, and returns the
|
|
// response.
|
|
func getWithReferer(
|
|
t *testing.T, routes http.Handler, target, referer string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
if referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
routes.ServeHTTP(rec, req)
|
|
t.Logf("GET %s with Referer %q: %d", target, referer, rec.Code)
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestRefererBlocklist verifies that both image routes refuse a request whose
|
|
// Referer names a host on referer_blocklist with 403 and the JSON error,
|
|
// without fetching from the upstream host, and serve a request with no
|
|
// Referer, one that does not parse, or one naming any other host. Hosts are
|
|
// matched as allowlist_hosts matches them.
|
|
func TestRefererBlocklist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
referer string
|
|
want int
|
|
}{
|
|
{"no referer", "", http.StatusOK},
|
|
{"unlisted host", "https://unlisted.example/page.html", http.StatusOK},
|
|
{"unparseable", "%zz", http.StatusOK},
|
|
{"listed host", blockedReferer, http.StatusForbidden},
|
|
{"subdomain of listed host", "https://www.leech.example/", http.StatusOK},
|
|
{"subdomain of dot pattern", "https://www.hotlinker.example/a.html",
|
|
http.StatusForbidden},
|
|
{"dot pattern without its dot", "https://hotlinker.example/",
|
|
http.StatusForbidden},
|
|
{"host continuing past dot pattern",
|
|
"https://hotlinker.example.evil.example/", http.StatusOK},
|
|
}
|
|
|
|
// The photo's URL on each image route.
|
|
photoURLs := map[string]func(t *testing.T, h *Handlers) string{
|
|
"plain URL": func(t *testing.T, _ *Handlers) string {
|
|
t.Helper()
|
|
|
|
return photoURL(allowlistedHost)
|
|
},
|
|
"encrypted URL": encPhotoURL,
|
|
}
|
|
|
|
for urlName, photoURLFor := range photoURLs {
|
|
for _, tc := range cases {
|
|
t.Run(urlName+", "+tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
routes, h, fetcher := newRefererRoutes(t)
|
|
|
|
rec := getWithReferer(t, routes, photoURLFor(t, h), tc.referer)
|
|
|
|
if tc.want == http.StatusOK {
|
|
requireServedPhoto(t, rec)
|
|
|
|
return
|
|
}
|
|
|
|
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
|
|
|
|
if n := fetcher.fetches.Load(); n != 0 {
|
|
t.Errorf("upstream fetched %d times, want 0", n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBlockedRefererRefusedWhenImageIsCached verifies that a request whose
|
|
// Referer is on referer_blocklist is refused even when the image it asks for
|
|
// is already cached, so the answer does not depend on the cache.
|
|
func TestBlockedRefererRefusedWhenImageIsCached(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
routes, h, _ := newRefererRoutes(t)
|
|
|
|
for _, target := range []string{photoURL(allowlistedHost), encPhotoURL(t, h)} {
|
|
requireServedPhoto(t, getWithReferer(t, routes, target, ""))
|
|
|
|
rec := getWithReferer(t, routes, target, blockedReferer)
|
|
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
|
|
}
|
|
}
|