fix(webapp): three readers on one network are three share opens (BEA-151) (#185)

The share actor key was token+"/"+IP, so every browser behind one NAT was
the same reader and the 10-minute visit debounce folded a whole office into
a single open — three personas each measured "1 open" for three readers, and
the panel's own copy promised the opposite.

The key gains a truncated hash of the User-Agent. ShareOpens already sums
across actor buckets and takes the max Last, so opens: 3 and an advancing
last_opened fall out with no aggregator change, no new field, and no change
to readDebounce. The UA is hashed because Record persists the actor through
ReadRepo into storage; token+"/"+IP stays the prefix so the existing leak
assertions keep covering the wider key.

The copy now states the rule the code implements, including its residual:
two people on one network in the same browser still count as one.

Deviation from the plan, deliberate: TestSec_Share_VisitorCannotInflateOrRedirectTheLedger
pinned "a visitor cannot split its own visits by varying the User-Agent".
That is now intended behavior, so the two UA rows move out of the
must-collapse set into an explicit assertion that they count separately.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-19 12:32:53 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent eef37fa89b
commit ae14d11ac3
8 changed files with 138 additions and 23 deletions
+1 -1
View File
@@ -371,7 +371,7 @@ classDiagram
class ShareOpen { class ShareOpen {
+Count +Last +Count +Last
} }
note for ShareOpen "The receipt on a public link: share-kind buckets only, which is what makes Last mean last OPENED — HeatEntry.LastRead is cross-kind, so a member viewing the file in the hub would otherwise move the date. Counts, never openers: the share actor is token+IP. Keyed by path, so two tokens on one file report the same number. Callers build the map ONCE per project and index it; a per-share call is a full byKey scan per row" note for ShareOpen "The receipt on a public link: share-kind buckets only, which is what makes Last mean last OPENED — HeatEntry.LastRead is cross-kind, so a member viewing the file in the hub would otherwise move the date. Counts, never openers: the share actor is token+IP+UA hash (token+IP alone folded a whole office into one reader, BEA-151 — the browser component is a heuristic that raises the floor, never identity, and is hashed because Record persists the actor). Keyed by path, so two tokens on one file report the same number. Callers build the map ONCE per project and index it; a per-share call is a full byKey scan per row"
class QuotaProvider { class QuotaProvider {
<<interface>> <<interface>>
@@ -51,13 +51,16 @@ export function shareDetail(s: ShareInfo, showProject: boolean): string {
return bits.join(" · "); return bits.join(" · ");
} }
// The two honesties about the number, worded once per section rather than // The honesties about the number, worded once per section rather than once
// once per row: opens are debounced VISITS (readDebounce, reads.go), and the // per row: opens are debounced VISITS (readDebounce, reads.go) keyed by
// count is per FILE not per link (heat is keyed by path), so two tokens on // browser+network rather than by person (shareActor, shares.go) — so the note
// one file report the same number. // states the residual instead of promising precision the signal lacks — and
// the count is per FILE not per link (heat is keyed by path), so two tokens
// on one file report the same number.
export const OPENS_NOTE = export const OPENS_NOTE =
"Opens count how many times a file has been read through a public link. " + "Opens count how many times a file has been read through a public link. " +
"Repeat opens by the same reader within 10 minutes count once."; "Repeat opens from the same browser and network within 10 minutes count once — " +
"two people on one network using the same browser still count as one.";
export function SharesTable({ export function SharesTable({
shares, shares,
+3 -3
View File
@@ -460,9 +460,9 @@ type ShareOpen struct {
// HeatEntry.LastRead is cross-kind, so a member viewing the file in the hub // HeatEntry.LastRead is cross-kind, so a member viewing the file in the hub
// would otherwise move the "opened through the link" date. // would otherwise move the "opened through the link" date.
// //
// Counts, never identities — the share actor is token+"/"+IP, a public // Counts, never identities — the share actor is token+"/"+IP+"/"+UA hash, a
// credential joined to an IP, and it must not leave the ledger. There is // public credential joined to a network and a browser, and it must not leave
// deliberately no distinct-openers field. // the ledger. There is deliberately no distinct-openers field.
// //
// One byKey scan per project, never one per share: callers build this map // One byKey scan per project, never one per share: callers build this map
// once and index it, because byKey is the full map and a project with 40 // once and index it, because byKey is the full map and a project with 40
+28 -6
View File
@@ -357,8 +357,12 @@ func TestSec_Share_PublicHitRecordsShareKindEndToEnd(t *testing.T) {
if b.Project != p.ID || b.Path != "hr/eng/payroll.md" { if b.Project != p.ID || b.Path != "hr/eng/payroll.md" {
t.Errorf("share visit recorded (%s, %s), want (%s, hr/eng/payroll.md)", b.Project, b.Path, p.ID) t.Errorf("share visit recorded (%s, %s), want (%s, hr/eng/payroll.md)", b.Project, b.Path, p.ID)
} }
if want := token + "/203.0.113.7"; b.Actor != want { // token/ip/uahash (BEA-151): the browser component is what stops a whole
t.Errorf("share actor = %q, want %q", b.Actor, want) // office behind one NAT from reading as one person. Asserted as a PREFIX
// so the identity half — link plus network, never a name — stays pinned
// without pinning the hash of an empty User-Agent.
if want := token + "/203.0.113.7/"; !strings.HasPrefix(b.Actor, want) {
t.Errorf("share actor = %q, want prefix %q", b.Actor, want)
} }
// The member view: a share hit is share traffic, never a human reader. // The member view: a share hit is share traffic, never a human reader.
@@ -450,6 +454,14 @@ func TestSec_Ledger_ReplicationAndHistoryViewsAreNeverReads(t *testing.T) {
// visitor controls — the query string, the method, the headers, the // visitor controls — the query string, the method, the headers, the
// casing of the token, X-Forwarded-For? // casing of the token, X-Forwarded-For?
// - can a visitor get an identity of its choosing recorded as an actor? // - can a visitor get an identity of its choosing recorded as an actor?
//
// User-Agent is the ONE exception, and it is deliberate (BEA-151): the actor
// key includes a hash of it, because without it three people in one office
// were one reader and the share panel reported "1 open" for all of them. So a
// visitor CAN split its own visits by rotating UAs — bounded by the per-IP
// limiter above the handler (ratelimit.go) and by retention folding, and it
// inflates only the count on a link the visitor already holds. Asserted below
// as intended behavior rather than left to look like a hole.
func TestSec_Share_VisitorCannotInflateOrRedirectTheLedger(t *testing.T) { func TestSec_Share_VisitorCannotInflateOrRedirectTheLedger(t *testing.T) {
h, srv, c, p := permHub(t) h, srv, c, p := permHub(t)
secledReads(t, srv) secledReads(t, srv)
@@ -472,8 +484,6 @@ func TestSec_Share_VisitorCannotInflateOrRedirectTheLedger(t *testing.T) {
{"/s/" + token + "?cachebust=2", nil}, {"/s/" + token + "?cachebust=2", nil},
{"/s/" + token + "?", nil}, {"/s/" + token + "?", nil},
{"/s/" + token + "?download=1&x=" + strings.Repeat("y", 200), nil}, {"/s/" + token + "?download=1&x=" + strings.Repeat("y", 200), nil},
{"/s/" + token, map[string]string{"User-Agent": "one"}},
{"/s/" + token, map[string]string{"User-Agent": "two"}},
{"/s/" + token, map[string]string{"X-Forwarded-For": "10.1.1.1"}}, {"/s/" + token, map[string]string{"X-Forwarded-For": "10.1.1.1"}},
{"/s/" + token, map[string]string{"X-Forwarded-For": "10.1.1.2, 10.1.1.3"}}, {"/s/" + token, map[string]string{"X-Forwarded-For": "10.1.1.2, 10.1.1.3"}},
{"/s/" + token, map[string]string{"X-Real-IP": "10.2.2.2"}}, {"/s/" + token, map[string]string{"X-Real-IP": "10.2.2.2"}},
@@ -504,6 +514,18 @@ func TestSec_Share_VisitorCannotInflateOrRedirectTheLedger(t *testing.T) {
t.Errorf("one visitor at one address produced %d ledger buckets — the 10-minute "+ t.Errorf("one visitor at one address produced %d ledger buckets — the 10-minute "+
"visit debounce is defeated by something the visitor chooses: %+v", len(got), got) "visit debounce is defeated by something the visitor chooses: %+v", len(got), got)
} }
// The documented exception: distinct browsers are distinct readers, so two
// UAs are two buckets. That is the fix, not a defeat of the debounce — and
// the actor still says only "this link, this network, some browser".
for _, ua := range []string{"one", "two"} {
secledGet(h, "/s/"+token, addr, map[string]string{"User-Agent": ua})
}
got = secledBuckets(srv.Reads)
if len(got) != 3 {
t.Errorf("two more browsers on one network produced %d buckets in total, want 3 — "+
"distinct browsers must count separately (BEA-151): %+v", len(got), got)
}
for _, b := range got { for _, b := range got {
if b.Project != p.ID { if b.Project != p.ID {
t.Errorf("a share visitor wrote a bucket for project %s, but the share is on %s: %+v", t.Errorf("a share visitor wrote a bucket for project %s, but the share is on %s: %+v",
@@ -512,8 +534,8 @@ func TestSec_Share_VisitorCannotInflateOrRedirectTheLedger(t *testing.T) {
if b.Kind != ReadKindShare { if b.Kind != ReadKindShare {
t.Errorf("an anonymous share visitor recorded a %s-kind bucket: %+v", b.Kind, b) t.Errorf("an anonymous share visitor recorded a %s-kind bucket: %+v", b.Kind, b)
} }
if !strings.HasPrefix(b.Actor, token+"/") { if !strings.HasPrefix(b.Actor, token+"/203.0.113.7/") {
t.Errorf("share actor %q is not token/ip — the visitor chose part of it: %+v", b.Actor, b) t.Errorf("share actor %q is not token/ip/browser — the visitor chose the identifying part of it: %+v", b.Actor, b)
} }
for _, planted := range []string{"alice@x.io", "dev-alice", "pwned", "10.1.1.1", "10.1.1.2", "10.2.2.2"} { for _, planted := range []string{"alice@x.io", "dev-alice", "pwned", "10.1.1.1", "10.1.1.2", "10.2.2.2"} {
if strings.Contains(b.Actor, planted) { if strings.Contains(b.Actor, planted) {
+20 -3
View File
@@ -1,6 +1,8 @@
package webapp package webapp
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
@@ -500,6 +502,21 @@ func (s *Server) shareCreatorStillBelongs(sh Share) bool {
// handleShared serves a share link: public, sandboxed, always the latest // handleShared serves a share link: public, sandboxed, always the latest
// synced content. // synced content.
// shareActor identifies one reader of a link well enough to debounce their
// own reloads without folding a whole office into a single visit: token+IP
// alone made every browser behind one NAT the same reader (BEA-151).
//
// The User-Agent is HASHED, never stored raw — Record persists the actor into
// the read buckets and out through ReadRepo, and a raw User-Agent there is a
// fingerprint sitting in storage indefinitely. Truncated because this only
// ever needs to GROUP, never to identify, and it must not leave the ledger
// either way. token+"/"+IP stays the prefix so the existing leak assertions
// keep covering the wider key.
func (s *Server) shareActor(r *http.Request, token string) string {
sum := sha256.Sum256([]byte(r.UserAgent()))
return token + "/" + s.clientIP(r) + "/" + hex.EncodeToString(sum[:8])
}
func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) { func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
// Sandbox everything under /s/ before anything can answer the request: // Sandbox everything under /s/ before anything can answer the request:
// shared content executes in an opaque origin (scripts allowed — charts in // shared content executes in an opaque origin (scripts allowed — charts in
@@ -543,9 +560,9 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
return return
} }
fi := snap.files[sp] fi := snap.files[sp]
// A share hit is external consumption. Actor is token+IP: one audience // A share hit is external consumption: one audience member reloading is
// member reloading is debounced to a visit, distinct visitors still count. // debounced to a visit, distinct visitors still count.
s.Reads.Record(sh.Project, sp, ReadKindShare, sh.Token+"/"+s.clientIP(r)) s.Reads.Record(sh.Project, sp, ReadKindShare, s.shareActor(r, sh.Token))
// Share links are the only unauthenticated door to stored bytes, so they // Share links are the only unauthenticated door to stored bytes, so they
// are the only egress a plan actually caps. The per-IP limiter above // are the only egress a plan actually caps. The per-IP limiter above
+76 -3
View File
@@ -583,12 +583,85 @@ func TestShareOpensOnTheWire(t *testing.T) {
t.Fatal("an opened link must carry last_opened") t.Fatal("an opened link must carry last_opened")
} }
// The actor is token+"/"+IP — a public credential joined to an IP. It // opens reads one link's count and last_opened back off the WIRE rather
// must not appear anywhere in the response, in any shape. // than out of the ledger, because the wire is what the panel shows.
opens := func(tok string) (float64, time.Time) {
t.Helper()
for _, sh := range listShares(t, srv, h, p.ID) {
if sh["token"] != tok {
continue
}
last, _ := sh["last_opened"].(string)
if last == "" {
return sh["opens"].(float64), time.Time{}
}
ts, err := time.Parse(time.RFC3339, last)
if err != nil {
t.Fatalf("last_opened %q: %v", last, err)
}
return sh["opens"].(float64), ts
}
t.Fatalf("link %s missing from the list", tok)
return 0, time.Time{}
}
// The debounce collapses reloads; it does not cap a link. Only the clock
// is under test, so it is moved rather than waited on.
ageDebounce := func() {
srv.Reads.mu.Lock()
defer srv.Reads.mu.Unlock()
for k, v := range srv.Reads.seen {
srv.Reads.seen[k] = v.Add(-readDebounce - time.Minute)
}
}
ageDebounce()
if rec := do(t, h, "GET", "/s/"+token, nil); rec.Code != 200 {
t.Fatalf("public fetch past the window: %d %s", rec.Code, rec.Body)
}
if got, _ := opens(token); got != 2 {
t.Fatalf("opens = %v after a reload past the debounce window, want 2", got)
}
// Three readers, ONE network, seconds apart: distinct browsers are
// distinct actors, so the count moves and last_opened follows the newest
// hit. token+"/"+IP alone made a whole office one reader (BEA-151) —
// httptest hands every request the same 192.0.2.1, which is exactly the
// NAT the personas were sitting behind.
uas := []string{
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) Safari/605.1",
"Mozilla/5.0 (X11; Linux x86_64) Firefox/128.0",
"Mozilla/5.0 (Windows NT 10.0; Win64) Chrome/126.0.0.0",
}
var afterFirst time.Time
for i, ua := range uas {
req := jsonReq(t, "GET", "/s/"+unopened, nil)
req.Header.Set("User-Agent", ua)
if rec := doHTTP(h, req); rec.Code != 200 {
t.Fatalf("reader %d: %d %s", i, rec.Code, rec.Body)
}
if i == 0 {
_, afterFirst = opens(unopened)
}
}
got, afterThird := opens(unopened)
if got != 3 {
t.Fatalf("three readers on one network reported %v opens, want 3 — distinct browsers are distinct readers", got)
}
if !afterThird.After(afterFirst) {
t.Fatalf("last_opened stuck at %s after two later readers; it must follow the newest open", afterFirst)
}
// The actor is token+"/"+IP+"/"+UA hash — a public credential joined to a
// network and a browser. No part of it may appear anywhere in the
// response, in any shape; a hash is not an exemption.
req := jsonReq(t, "GET", "/api/p/"+p.ID+"/shares", nil) req := jsonReq(t, "GET", "/api/p/"+p.ID+"/shares", nil)
authAs(t, srv, req) authAs(t, srv, req)
body := doHTTP(h, req).Body.String() body := doHTTP(h, req).Body.String()
for _, leak := range []string{token + "/", "192.0.2.1", "actor", "openers"} { leaks := []string{token + "/", unopened + "/", "192.0.2.1", "actor", "openers"}
for _, ua := range append(uas, "") {
sum := sha256.Sum256([]byte(ua))
leaks = append(leaks, hex.EncodeToString(sum[:8]))
}
for _, leak := range leaks {
if strings.Contains(body, leak) { if strings.Contains(body, leak) {
t.Fatalf("shares response leaks %q: %s", leak, body) t.Fatalf("shares response leaks %q: %s", leak, body)
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title> <title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-C17b7d2I.js"></script> <script type="module" crossorigin src="/assets/index-DvCVMCS-.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js"> <link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js"> <link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-DISTZ6FW.css"> <link rel="stylesheet" crossorigin href="/assets/index-DISTZ6FW.css">