9d6d3bde72
## Summary Adds a plain-TLS cover byte fetcher with a destination-class SSRF gate and wires public cover sources through the content-addressed filesystem store. ## Changes - Resolve hostnames before connecting; refuse non-HTTPS, loopback, private, link-local, unique-local, CGNAT, credentials, and mixed public/private DNS answers. - Re-check every redirect and resolve/classify again at dial time to close DNS rebinding. - Reuse `maxBodyBytes`; reject oversized responses and non-image content types before persistence. - Add generic `Store.GetCover`/`PutCover` source-URL storage while preserving the browser-backed kagane path. - Keep cover prefetch failures isolated from chapter polling. - Add observable tests for TLS, no-connection refusals, all refused address classes, redirect blocking, streaming body caps, non-image rejection, content-addressed persistence, DNS rebinding, and poller routing. ## Verification - `go test -count=1 ./...` - `go vet ./...` Both pass. No test touches the live network. Closes #57 Reviewed-on: #66 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
88 lines
3.0 KiB
Go
88 lines
3.0 KiB
Go
package web
|
|
|
|
import (
|
|
"bookmarkmanager/backend/internal/store"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// CoverFetcher retrieves one kagane cover by image id. Satisfied by
|
|
// latest.BrowserFetcher. It is nil when BROWSER_WS_URL is unset; uncached
|
|
// covers are then unavailable, while covers already stored by the backend
|
|
// remain available without a browser.
|
|
type CoverFetcher interface {
|
|
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
|
}
|
|
|
|
// coverIDRe matches the request path segment that becomes part of an outbound
|
|
// URL. The proxy is session-gated, but the id still reaches a headless browser,
|
|
// so it is validated at the boundary rather than passed through.
|
|
var coverIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`)
|
|
|
|
// coverTimeout bounds one proxied cover. Shorter than the fetcher's own
|
|
// challenge budget on purpose: a browser page is waiting on this, and a cover
|
|
// that has not arrived by now is better left as a broken slot than as a request
|
|
// holding a connection open.
|
|
const coverTimeout = 20 * time.Second
|
|
|
|
// kaganeCover serves a kagane cover from the backend's own origin.
|
|
//
|
|
// kagane answers image requests with a Cloudflare challenge and
|
|
// `cross-origin-resource-policy: same-origin`, so the web UI cannot render one
|
|
// directly under any combination of referrer policy or crossorigin attribute
|
|
// (verified 2026-08-08). Fetching it through the headless browser that already
|
|
// clears the challenge, and re-serving it here, is what puts the bytes on an
|
|
// origin the page may load from.
|
|
func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !coverIDRe.MatchString(id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body, contentType, ok, err := h.store.GetKaganeCover(id)
|
|
if err != nil {
|
|
log.Printf("read kagane cover %s: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if ok {
|
|
writeCover(w, body, contentType)
|
|
return
|
|
}
|
|
if h.covers == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), coverTimeout)
|
|
defer cancel()
|
|
body, contentType, err = h.covers.Image(ctx, id)
|
|
if err != nil {
|
|
log.Printf("kagane cover %s: %v", id, err)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !store.IsCoverContentType(contentType) {
|
|
log.Printf("kagane cover %s: unexpected content type %q", id, contentType)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := h.store.PutKaganeCover(id, body, contentType); err != nil {
|
|
log.Printf("persist kagane cover %s: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeCover(w, body, contentType)
|
|
}
|
|
|
|
// writeCover sends the bytes with a long cache life: an image id names one
|
|
// immutable rendering, so a client that has it never needs to ask again.
|
|
func writeCover(w http.ResponseWriter, body []byte, contentType string) {
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "private, max-age=604800, immutable")
|
|
w.Write(body)
|
|
}
|