dab6c11ea8
kage launched Chrome with --no-sandbox unconditionally, which turns off the browser's main security boundary for every run, including ordinary desktop use where the sandbox works fine. Since kage renders pages from the open web, a renderer exploit could then reach the host. Reported in #10. Keep the sandbox on by default and drop it only where it genuinely cannot initialize: inside a container, or when running as root (Chrome refuses to start a sandbox as root). Containers are detected from IN_DOCKER or the /.dockerenv marker, and there kage also sets --disable-dev-shm-usage because the default 64 MB /dev/shm is too small for the renderer on large pages. Whenever the sandbox is dropped kage says so on stderr, so it is never silent. Thanks to Dimitrios Prasakis for the report and to the commenter on Hacker News who suggested the IN_DOCKER opt-in.
164 lines
4.5 KiB
Go
164 lines
4.5 KiB
Go
package asset
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Downloader fetches asset bytes over plain HTTP. It is separate from the Chrome
|
|
// pool: assets are public bytes that rarely need a real browser, so a fast HTTP
|
|
// client keeps the crawl cheap. Failures are returned to the caller, which logs
|
|
// them and moves on — a missing asset degrades a page, it never aborts a clone.
|
|
type Downloader struct {
|
|
Client *http.Client
|
|
UserAgent string
|
|
MaxBytes int64 // per-asset cap; 0 = unlimited
|
|
Retries int // extra attempts for a transient failure (0 = try once)
|
|
}
|
|
|
|
// NewDownloader builds a Downloader with a sane client and the given timeout.
|
|
func NewDownloader(userAgent string, timeout time.Duration, maxBytes int64) *Downloader {
|
|
return &Downloader{
|
|
Client: &http.Client{Timeout: timeout},
|
|
UserAgent: userAgent,
|
|
MaxBytes: maxBytes,
|
|
// A few sites (and the bot-protection in front of them) reject the first
|
|
// request of a burst with a 403 or 429 but serve a retry fine, so give
|
|
// transient failures a couple of extra tries before giving up.
|
|
Retries: 3,
|
|
}
|
|
}
|
|
|
|
// Result is a downloaded asset.
|
|
type Result struct {
|
|
Body []byte
|
|
ContentType string
|
|
IsCSS bool
|
|
}
|
|
|
|
// StatusError reports a non-2xx HTTP response. It carries the code so callers
|
|
// can render a clear message ("HTTP 403 Forbidden") and decide whether a retry
|
|
// is worthwhile, without the URL baked in (the caller already has it).
|
|
type StatusError struct {
|
|
Code int
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
if t := http.StatusText(e.Code); t != "" {
|
|
return fmt.Sprintf("HTTP %d %s", e.Code, t)
|
|
}
|
|
return fmt.Sprintf("HTTP %d", e.Code)
|
|
}
|
|
|
|
// Get fetches u, sending referer as the Referer header. It reads at most
|
|
// MaxBytes and reports whether the body is CSS (so the caller can rewrite it).
|
|
// A transient failure (a 403/429/5xx or a network blip) is retried with a short
|
|
// backoff up to Retries times.
|
|
func (d *Downloader) Get(ctx context.Context, u *url.URL, referer string) (*Result, error) {
|
|
attempts := d.Retries + 1
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
var lastErr error
|
|
for i := 0; i < attempts; i++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff(i)):
|
|
}
|
|
}
|
|
res, err := d.try(ctx, u, referer)
|
|
if err == nil {
|
|
return res, nil
|
|
}
|
|
lastErr = err
|
|
if !transient(err) {
|
|
break
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
// try performs a single fetch attempt.
|
|
func (d *Downloader) try(ctx context.Context, u *url.URL, referer string) (*Result, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.UserAgent != "" {
|
|
req.Header.Set("User-Agent", d.UserAgent)
|
|
}
|
|
if referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
resp, err := d.Client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, &StatusError{Code: resp.StatusCode}
|
|
}
|
|
var r io.Reader = resp.Body
|
|
if d.MaxBytes > 0 {
|
|
r = io.LimitReader(resp.Body, d.MaxBytes)
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
return &Result{
|
|
Body: body,
|
|
ContentType: ct,
|
|
IsCSS: isCSS(ct, u),
|
|
}, nil
|
|
}
|
|
|
|
// backoff returns the pause before retry attempt i (1-based): 500ms, 1s, 2s, …
|
|
func backoff(i int) time.Duration {
|
|
d := 500 * time.Millisecond << (i - 1)
|
|
if max := 5 * time.Second; d > max {
|
|
d = max
|
|
}
|
|
return d
|
|
}
|
|
|
|
// transient reports whether an error is worth retrying. Bot-protection statuses
|
|
// (403/429), request-timeout and too-early (408/425), and 5xx server errors are
|
|
// transient; other 4xx (404, 401, 410, …) are permanent. A network error is
|
|
// retried, but a cancelled or expired context is not.
|
|
func transient(err error) bool {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
var se *StatusError
|
|
if errors.As(err, &se) {
|
|
switch se.Code {
|
|
case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests:
|
|
return true
|
|
}
|
|
return se.Code >= 500
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isCSS reports whether a response is a stylesheet, by content-type or by a
|
|
// .css path when the server sends no useful type.
|
|
func isCSS(contentType string, u *url.URL) bool {
|
|
if strings.Contains(strings.ToLower(contentType), "text/css") {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(strings.ToLower(u.Path), ".css")
|
|
}
|