| index 09bd27195..16c1f1df9 100644 |
| --- a/internal/edge/analytics.go |
| +++ b/internal/edge/analytics.go |
| @@ -34,14 +34,21 @@ func (e *Edge) EnableAnalytics(m AnalyticsMeter) { |
| // SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The |
| // host's telemetry pass pushes it on every serial-triggered full pass and |
| // every five minutes (spec §3.3, "converged by boxes on the next pass"). |
| -// The map is stored, never copied and never mutated here: a caller must |
| -// hand over a map it will not write to again. |
| +// The map is COPIED before it is stored, so a caller may keep and reuse |
| +// the map it passed: the stored copy is read by every serving goroutine |
| +// without a lock, and a caller that went on writing to a shared map would |
| +// turn that into Go's fatal concurrent map read-and-write — an edge-wide |
| +// crash, not a race one recovers from. |
| func (e *Edge) SetAnalyticsOn(on map[string]bool) { |
| if on == nil { |
| e.analyticsOn.Store(nil) |
| return |
| } |
| - e.analyticsOn.Store(&on) |
| + cp := make(map[string]bool, len(on)) |
| + for k, v := range on { |
| + cp[k] = v |
| + } |
| + e.analyticsOn.Store(&cp) |
| } |
| |
| func (e *Edge) analyticsEnabled(rt registry.Route) bool { |
| @@ -62,9 +69,10 @@ func withHopVerified(ctx context.Context) context.Context { |
| |
| // clientIP is the address the visitor hash uses (spec §3.3): the last |
| // element of the verified inbound X-Forwarded-For chain on a proven hop, |
| -// otherwise RemoteAddr's host. A header the client set never reaches it — |
| -// on an unverified request acceptHop has already stripped the forwarded |
| -// headers, and this function does not look at them either way. |
| +// otherwise RemoteAddr's host. X-Forwarded-For is read ONLY under a proven |
| +// hop, whose mark nothing outside acceptHop can put on a request; on every |
| +// other request the header is ignored whether it is present or not, so a |
| +// header a client set can never reach the visitor hash. |
| func clientIP(r *http.Request) string { |
| if hopVerified(r.Context()) { |
| chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ") |
| @@ -82,6 +90,17 @@ func clientIP(r *http.Request) string { |
| return host |
| } |
| |
| +// isPrefetchPurpose reports whether a Sec-Purpose (or legacy Purpose) |
| +// header value says this request is a prefetch. The value is a token list: |
| +// Chrome sends a bare "prefetch" for link prefetches and |
| +// "prefetch;prerender" for a speculation-rules prerender, which also |
| +// carries Sec-Fetch-Dest: document and so would otherwise be counted as a |
| +// pageview nobody looked at. Only the first token decides. |
| +func isPrefetchPurpose(v string) bool { |
| + first, _, _ := strings.Cut(v, ";") |
| + return strings.EqualFold(strings.TrimSpace(first), "prefetch") |
| +} |
| + |
| // observeAnalytics files one Obs when the route's app is switched on. It |
| // runs inside the observer defer in ServeHTTP, after the response has |
| // completed, and reads only: the latched status and Content-Type off the |
| @@ -96,7 +115,7 @@ func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *counting |
| if u, err := url.Parse(r.Header.Get("Referer")); err == nil { |
| refHost = u.Hostname() |
| } |
| - prefetch := strings.EqualFold(r.Header.Get("Sec-Purpose"), "prefetch") || strings.EqualFold(r.Header.Get("Purpose"), "prefetch") |
| + prefetch := isPrefetchPurpose(r.Header.Get("Sec-Purpose")) || isPrefetchPurpose(r.Header.Get("Purpose")) |
| ref.Observe(analytics.Obs{ |
| Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host, |
| Method: r.Method, Status: ob.status, ContentType: ob.ctype, |