From ec74559ca5590017e6fbb931f1836c530c6db84e Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 8 Aug 2026 23:03:58 +0700 Subject: [PATCH] feat(web): proxy kagane cover images so the UI can render them kagane serves cover images from behind the same Cloudflare challenge as its pages and with cross-origin-resource-policy: same-origin. The second header is the decisive one: no on the web UI's origin can load a kagane cover even from a browser that already holds the clearance cookie, verified 2026-08-08 by loading one from a foreign origin with and without a referrer. Hot-linking cannot be made to work, so every kagane series rendered the monogram placeholder. Bookmark.CoverURL rewrites a stored kagane og:image to /img/kagane/{id} and returns every other cover untouched; the templates render .CoverURL in place of .Cover. The endpoint is session-gated like every other UI route, and hands the id to the shared headless browser, whose fetch is same-origin with kagane and therefore satisfies both the challenge and the CORP header. Results are memoised in-process, so a cover costs one navigation per deployment lifetime. The id is matched against a UUID regex before it reaches the browser. That gate is load-bearing rather than tidiness: the cover is a stored client-supplied string, so an unvalidated one turns the endpoint into an SSRF primitive aimed at the deployment's own network. ServeMux path-cleans a traversal into a redirect before the handler runs, but the handler does not rely on that, and a test pins it. With BROWSER_WS_URL unset there is no browser and the endpoint answers 404 rather than reaching for a nil fetcher - the same degrade-to- userscript behaviour the poller already has for these sites. --- backend/AGENTS.md | 17 ++- backend/cover_test.go | 155 +++++++++++++++++++++ backend/internal/store/store.go | 16 +++ backend/internal/store/store_test.go | 32 +++++ backend/internal/web/cover.go | 129 +++++++++++++++++ backend/internal/web/templates/card.html | 2 +- backend/internal/web/templates/chrome.html | 2 +- backend/internal/web/web.go | 11 +- backend/main.go | 59 ++++---- 9 files changed, 393 insertions(+), 30 deletions(-) create mode 100644 backend/cover_test.go create mode 100644 backend/internal/web/cover.go 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("