// 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
},
}
}