86160c164a
comix.to began answering plain-TLS fetches with a Cloudflare JavaScript challenge on 2026-08-12, so every poll got a 403 interstitial and its cover host static.comix.to is gated the same way. comix joins kagane and novelfull as a browser Site: one registry entry, no plain-TLS fallback, and cover bytes routed through the browser's image path behind a fully pinned URL pattern. The read is an in-tab fetch of the Series URL, not a DOM render: comix is an SPA, so rendering costs ~65 requests for the same server-rendered HTML one fetch returns (24.5 KB, ~480 ms). Parsers and stored Series identity are untouched. Verified live against the real browser unit: page 24793 bytes in one fetch, chapter 53, cover accepted by the pin and 26862 image bytes retrieved by direct navigation (comix's Series page sets cross-origin-embedder-policy: require-corp, so an in-page fetch of the cover host cannot work).
206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// CoverBytesFetcher retrieves one cover from its source URL. The caller owns
|
|
// persistence; this seam keeps network policy independent from the store.
|
|
type CoverBytesFetcher interface {
|
|
Fetch(ctx context.Context, sourceURL string) (body []byte, contentType string, err error)
|
|
}
|
|
|
|
// fetchCoverBytes routes a cover's byte retrieval by URL shape, not by Site
|
|
// name: the browser fetcher's module claims the addresses only it can fetch
|
|
// (kagane's image route answers a plain fetch with a challenge and
|
|
// `cross-origin-resource-policy: same-origin`, static.comix.to answers one with
|
|
// the same challenge its pages serve), and everything else goes over
|
|
// plain TLS. Missing fetchers degrade to an error the caller logs, never a
|
|
// fallback onto a path that cannot succeed. One routing rule for the poll and
|
|
// the acquirer, so the two cannot drift apart.
|
|
func fetchCoverBytes(ctx context.Context, cover string, browser BrowserCoverFetcher, tls CoverBytesFetcher) ([]byte, string, error) {
|
|
if browserOnlyCoverURL(cover) {
|
|
if browser == nil {
|
|
return nil, "", errors.New("no cover fetcher")
|
|
}
|
|
return browser.Image(ctx, cover)
|
|
}
|
|
if tls == nil {
|
|
return nil, "", errors.New("no cover fetcher")
|
|
}
|
|
return tls.Fetch(ctx, cover)
|
|
}
|
|
|
|
// CoverResolver resolves a host before any connection is attempted. Tests
|
|
// inject it to exercise hostile DNS results without touching the live network.
|
|
type CoverResolver func(context.Context, string) ([]netip.Addr, error)
|
|
|
|
// TLSCoverFetcher retrieves image bytes with the standard HTTPS client. Unlike
|
|
// TLSFetcher, it does not need a browser fingerprint: cover hosts are public
|
|
// CDNs and the response is accepted only after the destination gate passes.
|
|
type TLSCoverFetcher struct {
|
|
client *http.Client
|
|
resolve CoverResolver
|
|
}
|
|
|
|
var _ CoverBytesFetcher = (*TLSCoverFetcher)(nil)
|
|
|
|
const coverRequestTimeout = 30 * time.Second
|
|
|
|
var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10")
|
|
|
|
// NewCoverFetcher builds the production cover client with the real resolver.
|
|
func NewCoverFetcher() *TLSCoverFetcher {
|
|
return NewCoverFetcherWithResolver(nil)
|
|
}
|
|
|
|
// NewCoverFetcherWithResolver builds a cover client using resolve, or the real
|
|
// system resolver when resolve is nil.
|
|
func NewCoverFetcherWithResolver(resolve CoverResolver) *TLSCoverFetcher {
|
|
if resolve == nil {
|
|
resolve = defaultCoverResolver
|
|
}
|
|
return newCoverFetcher(newCoverHTTPClient(resolve), resolve)
|
|
}
|
|
|
|
func newCoverFetcher(client *http.Client, resolve CoverResolver) *TLSCoverFetcher {
|
|
f := &TLSCoverFetcher{client: client, resolve: resolve}
|
|
client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
|
|
if err := f.validateURL(req.Context(), req.URL); err != nil {
|
|
return fmt.Errorf("redirect destination: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
return f
|
|
}
|
|
|
|
func defaultCoverResolver(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
}
|
|
|
|
func newCoverHTTPClient(resolve CoverResolver) *http.Client {
|
|
base, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
base = &http.Transport{}
|
|
}
|
|
transport := base.Clone()
|
|
// A proxy would make the dial target the proxy rather than the cover host,
|
|
// defeating destination classification. Cover fetching is direct by design.
|
|
transport.Proxy = nil
|
|
dialer := &net.Dialer{}
|
|
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("split cover address %q: %w", address, err)
|
|
}
|
|
addrs, err := resolveCoverHost(ctx, host, resolve)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, addr := range addrs {
|
|
if !publicCoverAddress(addr) {
|
|
return nil, fmt.Errorf("cover host resolves to refused address %s", addr)
|
|
}
|
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("cover host %q has no reachable address", host)
|
|
}
|
|
return &http.Client{Transport: transport, Timeout: coverRequestTimeout}
|
|
}
|
|
|
|
func (f *TLSCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte, string, error) {
|
|
u, err := url.Parse(sourceURL)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("parse cover URL: %w", err)
|
|
}
|
|
if err := f.validateURL(ctx, u); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("build cover request: %w", err)
|
|
}
|
|
resp, err := f.client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("fetch cover: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, "", fmt.Errorf("fetch cover: status %d", resp.StatusCode)
|
|
}
|
|
raw, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("fetch cover: unsupported content type %q", resp.Header.Get("Content-Type"))
|
|
}
|
|
contentType, ok := store.CoverContentType(raw)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("fetch cover: unsupported content type %q", raw)
|
|
}
|
|
if resp.ContentLength > maxBodyBytes {
|
|
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read cover: %w", err)
|
|
}
|
|
if len(body) > maxBodyBytes {
|
|
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
|
}
|
|
return body, contentType, nil
|
|
}
|
|
|
|
// This gate deliberately differs from fetchableSeriesURL: cover hosts are
|
|
// site-independent CDNs, so a Site host allowlist would reject valid covers.
|
|
func (f *TLSCoverFetcher) validateURL(ctx context.Context, u *url.URL) error {
|
|
if u == nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
|
return errors.New("cover URL must use HTTPS without credentials")
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return errors.New("cover URL has no host")
|
|
}
|
|
addrs, err := resolveCoverHost(ctx, host, f.resolve)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve cover host %q: %w", host, err)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return fmt.Errorf("resolve cover host %q: no addresses", host)
|
|
}
|
|
for _, addr := range addrs {
|
|
if !publicCoverAddress(addr) {
|
|
return fmt.Errorf("cover host %q resolves to refused address %s", host, addr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveCoverHost(ctx context.Context, host string, resolve CoverResolver) ([]netip.Addr, error) {
|
|
if literal, err := netip.ParseAddr(host); err == nil {
|
|
return []netip.Addr{literal.Unmap()}, nil
|
|
}
|
|
return resolve(ctx, strings.TrimSuffix(host, "."))
|
|
}
|
|
|
|
func publicCoverAddress(addr netip.Addr) bool {
|
|
addr = addr.Unmap()
|
|
return addr.IsValid() && addr.IsGlobalUnicast() &&
|
|
!addr.IsLoopback() && !addr.IsPrivate() && !addr.IsLinkLocalUnicast() &&
|
|
!carrierGradeNAT.Contains(addr)
|
|
}
|