Skip to content
quarry Artifact [07383947d5]
Login

Artifact [07383947d5]

Artifact 07383947d514d274e53e402e40d588325012f53441e52b9fe4114285f399df53:


// Package imgproxy serves the external images a rendered document names,
// from this forge instead of the reader's browser.
//
// A README can embed an image from anywhere: build badges, screenshots,
// diagrams. The forge's CSP admits images from itself only, so those
// rendered as broken, and loading them directly would tell every third
// party who reads the page. The renderer rewrites an external <img> to a
// signed /img URL; this package checks the signature, fetches the image
// once, caches it, and serves it.
//
// It must not become an open proxy or a way into the host's network:
//
//   - Only URLs the renderer signed are fetched. The key lives beside the
//     data it serves and never leaves the server.
//   - Only http and https, and never a loopback, private, link-local or
//     multicast address — checked at connect time on the address actually
//     dialed, so a name that resolves inward is refused too.
//   - Only image responses, capped in size and time.
//   - SVG is served with a sandboxing CSP and nosniff, so an image cannot
//     run script even if a browser is asked to open it directly.
package imgproxy

import (
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"mime"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"
)

const (
	keyFile  = "imageproxy.key"
	cacheDir = "imgcache"
	// MaxBytes caps one image. Badges are a few KiB; a screenshot in a
	// README rarely passes a few hundred.
	MaxBytes = 5 << 20
	// TTL is how long a cached image is served before it is fetched
	// again. Short, because badges are the common case and they change.
	TTL = time.Hour
)

// Proxy is the signer and the handler.
type Proxy struct {
	key    []byte
	dir    string
	client *http.Client
	now    func() time.Time
	mu     sync.Mutex // one fetch per URL at a time
	locks  map[string]*sync.Mutex
}

// New loads or creates the signing key under dataDir and prepares the
// cache beside it. allowPrivate admits private destinations; it exists
// for tests against a local server and is never set in production.
func New(dataDir string, allowPrivate bool) (*Proxy, error) {
	key, err := loadKey(filepath.Join(dataDir, keyFile))
	if err != nil {
		return nil, err
	}
	dir := filepath.Join(dataDir, cacheDir)
	if err := os.MkdirAll(dir, 0o750); err != nil {
		return nil, err
	}
	return &Proxy{key: key, dir: dir, client: guardedClient(allowPrivate), now: time.Now,
		locks: map[string]*sync.Mutex{}}, nil
}

func loadKey(path string) ([]byte, error) {
	if b, err := os.ReadFile(path); err == nil && len(b) >= 32 {
		return b, nil
	}
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return nil, err
	}
	if err := os.WriteFile(path, b, 0o600); err != nil {
		return nil, fmt.Errorf("imgproxy: key: %w", err)
	}
	return b, nil
}

func (p *Proxy) sign(u string) string {
	m := hmac.New(sha256.New, p.key)
	m.Write([]byte(u))
	return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}

// URL is the proxied address for an external image, or "" for anything
// that is not an absolute http or https URL (which the caller leaves
// alone).
func (p *Proxy) URL(src string) string {
	u, err := url.Parse(src)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
		return ""
	}
	return "/img?u=" + url.QueryEscape(src) + "&s=" + p.sign(src)
}

// ServeHTTP answers GET /img?u=…&s=….
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	src, sig := r.URL.Query().Get("u"), r.URL.Query().Get("s")
	if src == "" || !hmac.Equal([]byte(sig), []byte(p.sign(src))) {
		http.Error(w, "not a proxied image", http.StatusForbidden)
		return
	}
	if p.URL(src) == "" {
		http.Error(w, "not a proxied image", http.StatusForbidden)
		return
	}
	body, ctype, err := p.get(r.Context(), src)
	if err != nil {
		http.Error(w, "image unavailable", http.StatusBadGateway)
		return
	}
	h := w.Header()
	h.Set("Content-Type", ctype)
	h.Set("X-Content-Type-Options", "nosniff")
	h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
	h.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(TTL.Seconds())))
	w.Write(body)
}

// get serves from the cache while fresh, fetches otherwise, and falls
// back to a stale copy when the fetch fails — a badge host being down
// should not break a README that rendered a minute ago.
func (p *Proxy) get(ctx context.Context, src string) ([]byte, string, error) {
	name := sha256.Sum256([]byte(src))
	base := filepath.Join(p.dir, hex.EncodeToString(name[:]))
	lock := p.lockFor(base)
	lock.Lock()
	defer lock.Unlock()

	body, ctype, age, cached := p.read(base)
	if cached && age < TTL {
		return body, ctype, nil
	}
	fresh, ftype, err := p.fetch(ctx, src)
	if err != nil {
		if cached {
			return body, ctype, nil
		}
		return nil, "", err
	}
	p.write(base, fresh, ftype)
	return fresh, ftype, nil
}

func (p *Proxy) lockFor(base string) *sync.Mutex {
	p.mu.Lock()
	defer p.mu.Unlock()
	l, ok := p.locks[base]
	if !ok {
		l = &sync.Mutex{}
		p.locks[base] = l
	}
	return l
}

func (p *Proxy) read(base string) ([]byte, string, time.Duration, bool) {
	info, err := os.Stat(base + ".bin")
	if err != nil {
		return nil, "", 0, false
	}
	body, err := os.ReadFile(base + ".bin")
	if err != nil {
		return nil, "", 0, false
	}
	ctype, err := os.ReadFile(base + ".type")
	if err != nil {
		return nil, "", 0, false
	}
	return body, string(ctype), p.now().Sub(info.ModTime()), true
}

func (p *Proxy) write(base string, body []byte, ctype string) {
	// Temp and rename, so a reader never sees half an image. Failure only
	// costs the next reader a fetch.
	if err := os.WriteFile(base+".type.tmp", []byte(ctype), 0o640); err != nil {
		return
	}
	if err := os.WriteFile(base+".bin.tmp", body, 0o640); err != nil {
		return
	}
	os.Rename(base+".type.tmp", base+".type")
	os.Rename(base+".bin.tmp", base+".bin")
}

var errNotImage = errors.New("imgproxy: not an image")

func (p *Proxy) fetch(ctx context.Context, src string) ([]byte, string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
	if err != nil {
		return nil, "", err
	}
	req.Header.Set("User-Agent", "quarry-imgproxy")
	req.Header.Set("Accept", "image/*")
	resp, err := p.client.Do(req)
	if err != nil {
		return nil, "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, "", fmt.Errorf("imgproxy: upstream %d", resp.StatusCode)
	}
	mt, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
	if err != nil || !strings.HasPrefix(mt, "image/") {
		return nil, "", errNotImage
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBytes+1))
	if err != nil {
		return nil, "", err
	}
	if len(body) > MaxBytes {
		return nil, "", fmt.Errorf("imgproxy: larger than %d bytes", MaxBytes)
	}
	return body, mt, nil
}

// lookupIP is the resolver the guard uses, named so a test can substitute
// one.
var lookupIP = func(ctx context.Context, host string) ([]net.IPAddr, error) {
	return net.DefaultResolver.LookupIPAddr(ctx, host)
}

// reserved is an address a server-side fetcher has no business reaching
// on a document author's say-so.
func reserved(ip net.IP) bool {
	return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
		ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() ||
		ip.IsInterfaceLocalMulticast()
}

// guardedClient dials only the address it checked, so a name cannot
// resolve to one address for the check and another for the connection.
// Redirects are followed a few times; every hop dials through the guard.
func guardedClient(allowPrivate bool) *http.Client {
	tr := http.DefaultTransport.(*http.Transport).Clone()
	d := &net.Dialer{Timeout: 5 * time.Second}
	tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
		host, port, err := net.SplitHostPort(addr)
		if err != nil {
			return nil, err
		}
		ips, err := lookupIP(ctx, host)
		if err != nil {
			return nil, err
		}
		if len(ips) == 0 {
			return nil, fmt.Errorf("imgproxy: no address for %s", host)
		}
		if !allowPrivate {
			for _, ip := range ips {
				if reserved(ip.IP) {
					return nil, fmt.Errorf("imgproxy: refusing a private address (%s)", ip.IP)
				}
			}
		}
		return d.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
	}
	return &http.Client{
		Timeout:   10 * time.Second,
		Transport: tr,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= 3 {
				return errors.New("imgproxy: too many redirects")
			}
			if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
				return errors.New("imgproxy: redirect to a non-http scheme")
			}
			return nil
		},
	}
}