diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 1833adc..e7f116d 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -168,11 +168,11 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN served by the one public `GET /covers/{addr}` route from content-addressed bytes. There is no proxy, no per-Site rewrite, no second place that decides a Cover's renderable address: the wire `cover` is it. The only place a Site - name still appears in cover code is the acquisition module, where kagane's - image URLs are claimed by `latest.BrowserFetcher` (`browserCoverURL`) because - they answer a plain fetch with a challenge and - `cross-origin-resource-policy: same-origin`; every other Site's CDN answers - plain TLS. Templates render `.Cover` — the wire value — never anything else. + name still appears in cover code is the extraction module (`latest`), where + kagane's image URLs are claimed by `browserOnlyCoverURL` — they answer a + plain fetch with a challenge and `cross-origin-resource-policy: same-origin`; + every other Site's CDN answers plain TLS. Templates render `.Cover` — the + wire value — never anything else. - **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 index 535851b..9c3c93f 100644 --- a/backend/cover_test.go +++ b/backend/cover_test.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "net/http" "net/http/httptest" "strings" @@ -72,18 +73,34 @@ func TestPublicCoverRejectsUnknownAddress(t *testing.T) { // A content type outside the image set is never echoed back. The old kagane // proxy could fetch text/html from a challenged fetch and had to refuse it; -// the general route's only input is what the store accepted, and the store -// refuses to record anything that is not an image, so the address that would -// name one is a miss, not a served body. +// the general route's only input is the store, and the store refuses to +// record anything that is not an image — but the guarantee is pinned at the +// serving boundary, not the write gate, so a poisoned row (migrated data, a +// writer that skips the gate) is also never served. func TestPublicCoverNeverEchoesNonImage(t *testing.T) { const sourceURL = "https://cdn.example/cover" - srv, st := newWebTestServer(t, testConfig()) + st, dsn := newTestStoreURL(t) + // The write gate refuses non-image content types outright. if err := st.PutCover(sourceURL, []byte("`) +// kaganeImageURLRe matches the canonical compressed image route kagane's API +// publishes — the only cover URL form the extractor emits and the browser +// fetcher accepts. The URL is matched in full (scheme, host, id shape) rather +// than trusted: the value a fetcher is pointed at may have been client- +// supplied, and a headless browser is a strong SSRF primitive. +var kaganeImageURLRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`) + +// browserOnlyCoverURL reports whether the browser sidecar is the only fetcher +// for cover bytes at imageURL. kagane's image route answers a plain fetch with +// a challenge and `cross-origin-resource-policy: same-origin`, so a TLS fetch +// would only ever retrieve a challenge page and must not be attempted +// (ADR-0007). This is the byte-fetch router's per-Site knowledge; it lives in +// the extraction module, which owns kagane's URL shapes. +func browserOnlyCoverURL(imageURL string) bool { + return kaganeImageURLRe.MatchString(imageURL) +} + // kagane's browser-fetched series response publishes cover image IDs under // series_covers. The API's canonical compressed image route is the only URL // form accepted by the store and browser fetcher; no rendition is guessed. @@ -178,8 +195,12 @@ func kaganeCoverURL(body string) string { return "" } for _, cover := range response.SeriesCovers { - if kaganeImageIDRe.MatchString(cover.ImageID) { - return "https://kagane.to/api/v2/image/" + cover.ImageID + "/compressed" + // Validate the assembled URL against the same regex the browser + // fetcher enforces, so the extractor can never emit an address the + // fetch would refuse. + imageURL := "https://kagane.to/api/v2/image/" + cover.ImageID + "/compressed" + if kaganeImageURLRe.MatchString(imageURL) { + return imageURL } } return ""