diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 736710a..ed46a1c 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -126,9 +126,20 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`/userscript/novel-bookmark.user.js`, both supplied by bindmount; the
`__API_TOKEN__` placeholder inside them is substituted with the requesting
Reader's credential at serve time).
- `BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull;
- unset disables browser polling and leaves those sites to the userscript
- alone).
+ `BROWSER_WS_URL` (CDP endpoint of the `chrome/` sidecar, used by the poller
+ for kagane and novelfull *and* by the web UI's kagane cover proxy; unset
+ disables browser polling and serves 404 from the proxy, leaving those sites
+ to the userscript alone).
+- **kagane covers are proxied, not hot-linked:** kagane serves cover images
+ behind the same challenge as its pages and with
+ `cross-origin-resource-policy: same-origin`, so no `
` on the web UI's
+ origin can load one — not even from a browser holding the clearance cookie
+ (verified 2026-08-08). `Bookmark.CoverURL` rewrites a stored kagane
+ `og:image` to `/img/kagane/{id}`, served by `internal/web/cover.go` through
+ `latest.BrowserFetcher.Image` and memoised in-process. The templates render
+ `.CoverURL`, never `.Cover`. The id is matched against a UUID regex before it
+ reaches the browser: the stored value is client-supplied, so an unchecked one
+ is an SSRF primitive pointed at the deployment's own network.
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
(renders the bindmounted script with the acting Reader's derived credential
substituted in — the credential never appears in page markup, the address
diff --git a/backend/cover_test.go b/backend/cover_test.go
new file mode 100644
index 0000000..bf9b44f
--- /dev/null
+++ b/backend/cover_test.go
@@ -0,0 +1,155 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+)
+
+// fakeCovers stands in for the headless browser. It counts calls so the test
+// can prove the cache spares the browser a second navigation.
+type fakeCovers struct {
+ body []byte
+ contentType string
+ err error
+ calls atomic.Int32
+ lastID atomic.Value
+}
+
+func (f *fakeCovers) Image(_ context.Context, imageID string) ([]byte, string, error) {
+ f.calls.Add(1)
+ f.lastID.Store(imageID)
+ if f.err != nil {
+ return nil, "", f.err
+ }
+ return f.body, f.contentType, nil
+}
+
+const testCoverID = "019fe11a-84c3-7fc3-a84b-88787374b617"
+
+func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ if cookie != nil {
+ req.AddCookie(cookie)
+ }
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ return rr
+}
+
+// kagane serves its covers behind a Cloudflare challenge and with
+// cross-origin-resource-policy: same-origin, so the UI can only show one by
+// re-serving the bytes from its own origin.
+func TestKaganeCoverProxiesAndCaches(t *testing.T) {
+ cf := &fakeCovers{body: []byte("\x00webp-bytes"), contentType: "image/webp"}
+ cfg := testConfig()
+ cfg.Covers = cf
+ srv, st := newWebTestServer(t, cfg)
+ cookie := sessionCookie(t, st)
+
+ for i := range 2 {
+ rr := getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("request %d: status = %d, want 200", i, rr.Code)
+ }
+ if got := rr.Body.String(); got != string(cf.body) {
+ t.Fatalf("request %d: body = %q, want %q", i, got, cf.body)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "image/webp" {
+ t.Fatalf("request %d: Content-Type = %q, want image/webp", i, got)
+ }
+ }
+ if got := cf.calls.Load(); got != 1 {
+ t.Fatalf("fetcher called %d times, want 1 — the second read must come from the cache", got)
+ }
+ if got := cf.lastID.Load(); got != testCoverID {
+ t.Fatalf("fetched image id = %v, want %s", got, testCoverID)
+ }
+}
+
+// The proxy reaches a headless browser, so it is not open to the internet.
+func TestKaganeCoverRequiresSession(t *testing.T) {
+ cf := &fakeCovers{body: []byte("x"), contentType: "image/webp"}
+ cfg := testConfig()
+ cfg.Covers = cf
+ srv, _ := newWebTestServer(t, cfg)
+
+ rr := getCover(t, srv, "/img/kagane/"+testCoverID, nil)
+ if rr.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", rr.Code)
+ }
+ if got := cf.calls.Load(); got != 0 {
+ t.Fatalf("fetcher called %d times for an unauthenticated request, want 0", got)
+ }
+}
+
+func TestKaganeCoverRejectsBadInput(t *testing.T) {
+ cases := []struct {
+ name string
+ id string
+ fetch *fakeCovers
+ }{
+ {
+ "an id that is not a uuid never reaches the browser",
+ "solo-leveling",
+ &fakeCovers{body: []byte("x"), contentType: "image/webp"},
+ },
+ {
+ "a uuid-shaped id with a trailing segment is rejected whole",
+ testCoverID + "x",
+ &fakeCovers{body: []byte("x"), contentType: "image/webp"},
+ },
+ {
+ "a challenged fetch is a missing cover",
+ testCoverID,
+ &fakeCovers{err: errors.New("challenge held")},
+ },
+ {
+ "a content type outside the image set is not echoed back",
+ testCoverID,
+ &fakeCovers{body: []byte("