Skip to content
quarry Artifact [2e22624539]
Login

Artifact [2e22624539]

Artifact 2e226245397fda6cd9754e43c56e1d1513349898bcae17629fac4de6194b05bf:


package web

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"time"

	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/fault"
)

// clientError is what the browser posts. Every field is untrusted text
// from a page anybody can load, so every field is bounded here as well
// as in the script — the script's caps are a courtesy, this one is the
// guarantee.
type clientError struct {
	Kind    string `json:"kind"`
	Message string `json:"message"`
	Source  string `json:"source"`
	Line    int    `json:"line"`
	Col     int    `json:"col"`
	Stack   string `json:"stack"`
	URL     string `json:"url"`
	UA      string `json:"ua"`
}

// clientErrorBudget rate-limits the ingest. The endpoint is anonymous by
// necessity — a script error on a public page happens to readers who
// have never signed in — which makes it the one write path a stranger
// can reach, so it gets a ceiling rather than trust. A fixed window is
// enough: the goal is to bound the damage, not to meter it precisely.
type clientErrorBudget struct {
	mu     sync.Mutex
	window time.Time
	n      int
}

const (
	// Per minute, across everybody. A genuinely broken page reports
	// once per signature per load, so this is a lot of readers before
	// it bites; a script pointed at the endpoint hits it immediately.
	clientErrorsPerMinute = 120
	maxClientErrorBody    = 8 << 10
)

func (b *clientErrorBudget) allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	if now := time.Now().Truncate(time.Minute); now.After(b.window) {
		b.window, b.n = now, 0
	}
	if b.n >= clientErrorsPerMinute {
		return false
	}
	b.n++
	return true
}

// clip1 bounds one untrusted field and strips the control characters
// that would let a report forge extra lines in the plain-text journal.
func clip1(s string, n int) string {
	s = strings.Map(func(r rune) rune {
		if r == '\n' || r == '\t' {
			return ' '
		}
		if r < 0x20 || r == 0x7f {
			return -1
		}
		return r
	}, s)
	if len(s) > n {
		return s[:n] + "…"
	}
	return s
}

// clientErrorHandler receives browser exceptions and journals them
// beside the server's own, so "what is broken" is one question with one
// answer rather than two surfaces nobody correlates.
//
// Anonymous by design, and therefore: same-origin only, size-capped,
// rate-limited, and every field bounded before it is stored.
// Exported because the API subtree is mounted whole ("/api/" ->
// apiRouter) by the binary, which shadows anything the web mux
// registers under it. The outer mux registers this directly, where a
// more specific pattern wins.
//
// It takes the web handler's options because it sits outside that
// handler: without the trusted-proxy rule, a report from a browser
// behind the proxy names an https origin the route cannot match, and
// every report is dropped.
func ClientErrorHandler(self string, opts ...Option) http.HandlerFunc {
	var o handlerOpts
	for _, f := range opts {
		f(&o)
	}
	return o.proxy.within(clientErrorHandler(self, o.place)).ServeHTTP
}

// place is where repositories appear, so a report from a page at
// /r/{owner}/{repo} names the repository rather than the owner.
func clientErrorHandler(self string, place func(string) (string, bool)) http.HandlerFunc {
	var budget clientErrorBudget
	return func(w http.ResponseWriter, req *http.Request) {
		// Same-origin. sendBeacon sends no custom headers, so this is
		// the check available: it turns the endpoint away from being a
		// general-purpose write anybody can drive from anywhere. It is
		// a speed bump, not authentication — which is why the budget
		// above exists too.
		if o := req.Header.Get("Origin"); o != "" && !sameOrigin(o, req, self) {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		if !budget.allow() {
			// 204 rather than 429: the page must not react, retry, or
			// report the failure of its own error reporter.
			w.WriteHeader(http.StatusNoContent)
			return
		}
		body, err := io.ReadAll(io.LimitReader(req.Body, maxClientErrorBody))
		if err != nil {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		var in clientError
		if json.Unmarshal(body, &in) != nil || strings.TrimSpace(in.Message) == "" {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		kind := clip1(in.Kind, 24)
		if kind == "" {
			kind = "error"
		}
		msg := clip1(in.Message, 300)
		// The page the reader was on is the "path" — it is what a
		// maintainer needs to reproduce. The script's own file and line
		// go in the trace, where a stack belongs.
		path := clip1(in.URL, 200)
		if path == "" {
			path = "/"
		}
		// Only the stack goes in the trace box now. The script's file,
		// the line, and the agent are context, and context is a field —
		// putting them here is what hid the failing URL behind a
		// disclosure while the title named nothing.
		var trace strings.Builder
		if in.Stack != "" {
			trace.WriteString(clip1(in.Stack, 4000))
		}

		// Surfaced as "browser" so the panel can tell at a glance which
		// side of the wire a failure came from.
		//
		// The resource URL used to be passed as the "source" — which put
		// it in the stack box, behind a disclosure, while the title read
		// "img failed to load" and named nothing. It is context, so it
		// goes in a field where a reader (or an agent) can act on it.
		f := fault.New(kind+": "+msg).
			Cost("a reader's page did not work; the server saw nothing").
			With("page", path)
		if in.Source != "" {
			f.With("resource", clip1(in.Source, 300))
		}
		if in.Line > 0 {
			f.With("at", fmt.Sprintf("%d:%d", in.Line, in.Col))
		}
		if in.UA != "" {
			f.With("agent", clip1(in.UA, 200))
		}
		// The page names the repository even when the report does not:
		// a fleet asking "which store is broken" would otherwise miss
		// every client-side failure on a repository page.
		repo := clip1(repoOfPath(path, place), 64)
		if repo != "" {
			f.With("repo", repo)
		}
		errlog.Default.ReportIn("browser", path, repo, trace.String(), f)
		w.WriteHeader(http.StatusNoContent)
	}
}

// sameOrigin says whether an Origin header names this server. self is
// the configured public base URL when there is one; without it the
// request's own Host is the best available answer.
func sameOrigin(origin string, req *http.Request, self string) bool {
	want := strings.TrimSuffix(self, "/")
	if want == "" {
		scheme := "http"
		if req.TLS != nil || proxyOf(req).forwardedProto(req) == "https" {
			scheme = "https"
		}
		want = scheme + "://" + req.Host
	}
	return strings.EqualFold(strings.TrimSuffix(origin, "/"), want)
}