carlos / platform Public

edge: beacon shapes with their caps, and the Origin rule

The pure half of the browser door (spec 2026-09-07 §3.4): the two beacon
shapes with their caps, and the rule that decides who may post one.
Nothing calls these yet — the handler is Task 7.

parseErrorBeacon and parseEventBeacon decode one POST body and cap every
string in it. Path is capped like name rather than left alone: apperr's
sink truncates Stack and nothing else, so an uncapped path would reach
storage at the full 16 KB body size. Fields are sorted before the count
cap so which 32 survive is deterministic.

beaconOriginOK applies §3.4. Origin wins when present, Referer is read
only when Origin is absent, Sec-Fetch-Site: cross-site is refused before
anything else, and both headers resolve through the same lookupIn the
request itself took — so an alias, and the one-label wildcard fold,
pass with no registry call.

Three refusals a browser never trips, kept because a forgery reaches
for them:

  - Userinfo. The authority is what follows the '@', and browsers strip
    userinfo from both headers, so "https://us@them/" is only ever an
    attempt to make one host read as another.
  - A port other than 443. §3.4 says "host and port resolve"; the edge
    terminates https on 443 alone, so any other port is a different web
    origin than the one we serve.
  - An Origin that is present but empty, or sent twice. Reading the
    header's value alone calls that absent and falls through to the
    softer Referer — the one place in this rule where a forgery would
    have a free choice of headers. Presence is read from the header
    map, so a bad Origin is refused outright as §3.4 requires.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2978aab9180c30a5817e3ff6ca4f9128f7201640 parent 93684bc
2 files changed, +366 −0
  • internal/edge/browserdoor.go +163 −0
  • internal/edge/browserdoor_test.go +203 −0
diff --git a/internal/edge/browserdoor.go b/internal/edge/browserdoor.go
new file mode 100644
index 000000000..a90fe0469
--- /dev/null
+++ b/internal/edge/browserdoor.go
@@ -0,0 +1,163 @@
+package edge
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/apperr"
+)
+
+// The browser door (spec 2026-09-07 §3.4): what a page may post, how much,
+// and who may post it. Parsing caps everything before the sink sees it;
+// the sink sanitises on arrival regardless of what the snippet did.
+//
+// Pure functions only — the handler that calls them, with its rate buckets
+// and its unconditional 202, is separate. Nothing here reads the clock, the
+// registry or the network, so every rule below is pinned by a table test.
+const (
+ maxBeaconBody = 16 << 10
+ maxBeaconKind = 64
+ maxBeaconName = 1024
+ maxBeaconFields = 32
+ maxBeaconField = 256
+ // maxBeaconFP bounds the browser-supplied fingerprint. It is a hash in
+ // every honest beacon; the cap is here so a hostile one cannot make it
+ // a payload, since fp is a grouping key the console displays.
+ maxBeaconFP = 64
+ // beaconPort is the only port a page the edge served can be on. The
+ // edge terminates https on 443 and nowhere else, so an origin naming
+ // any other port is a different web origin than the one we serve.
+ beaconPort = "443"
+)
+
+type errorBeacon struct {
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Stack string `json:"stack"`
+ Path string `json:"path"`
+ FP string `json:"fp"`
+ Fields map[string]string `json:"fields"`
+}
+
+type eventBeacon struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+func capStr(s string, n int) string {
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}
+
+// parseErrorBeacon decodes one POST /__carlos/e body and caps every string
+// in it. False means malformed — not a JSON object, or no name — and the
+// handler answers 202 either way; the caller learns nothing from a drop.
+//
+// Path is capped like Name rather than left alone: apperr's sink truncates
+// Stack and nothing else, so an uncapped path would reach storage at the
+// full body size. The caps are on bytes, not runes, so a cut can land
+// mid-rune; that is deliberate — these are opaque identifiers to us, and
+// the console renders them as text, not as a shell or a template.
+func parseErrorBeacon(b []byte) (errorBeacon, bool) {
+ var e errorBeacon
+ if err := json.Unmarshal(b, &e); err != nil || e.Name == "" {
+ return errorBeacon{}, false
+ }
+ e.Kind = capStr(e.Kind, maxBeaconKind)
+ e.Name = capStr(e.Name, maxBeaconName)
+ e.Stack = capStr(e.Stack, apperr.MaxStack)
+ e.Path = capStr(e.Path, maxBeaconName)
+ e.FP = capStr(e.FP, maxBeaconFP)
+ if len(e.Fields) > 0 {
+ keys := make([]string, 0, len(e.Fields))
+ for k := range e.Fields {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys) // deterministic under the cap
+ f := make(map[string]string, maxBeaconFields)
+ for i, k := range keys {
+ if i >= maxBeaconFields {
+ break
+ }
+ f[capStr(k, maxBeaconFP)] = capStr(e.Fields[k], maxBeaconField)
+ }
+ e.Fields = f
+ }
+ return e, true
+}
+
+// parseEventBeacon decodes one POST /__carlos/a body. Fields on the wire
+// are accepted and dropped: §5 reserves per-event properties, and until
+// that lands an event is a name and a path. Same caps as the error door so
+// the two cannot disagree about how big a name may be.
+func parseEventBeacon(b []byte) (eventBeacon, bool) {
+ var e eventBeacon
+ if err := json.Unmarshal(b, &e); err != nil || e.Name == "" {
+ return eventBeacon{}, false
+ }
+ e.Name = capStr(e.Name, maxBeaconName)
+ e.Path = capStr(e.Path, maxBeaconName)
+ return e, true
+}
+
+// beaconOriginOK is identification, not authentication (§3.4): the page
+// that posts must be one this hostname's route serves. Origin wins when
+// present — a bad Origin is refused even if Referer would pass — and
+// Referer is consulted only when Origin is absent. Both must be https and
+// must resolve through the SAME map lookup the request took to an entry
+// whose route.Host equals want; aliases share the primary's route verbatim
+// and so pass with no registry call.
+//
+// "Present" is header presence, not a non-empty value. An empty Origin, or
+// two of them, is a BAD Origin and is refused where reading the value
+// alone would have called it absent and fallen through to the softer
+// Referer — the one place in this rule where a forgery has a free choice
+// of headers.
+func beaconOriginOK(m map[string]upstream, r *http.Request, want string) bool {
+ if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
+ return false
+ }
+ if vs, ok := r.Header["Origin"]; ok {
+ return len(vs) == 1 && beaconSourceOK(m, vs[0], want)
+ }
+ if ref := r.Header.Get("Referer"); ref != "" {
+ return beaconSourceOK(m, ref, want)
+ }
+ return false
+}
+
+// beaconSourceOK decides one Origin or Referer value against the map.
+//
+// Three refusals a browser never trips, kept because a forgery reaches for
+// them: userinfo (the authority is what follows the '@', and browsers
+// strip userinfo from both headers, so its presence is only ever an
+// attempt to make the host read as something it is not); a port other than
+// 443, which §3.4 counts as part of what must resolve and which is a
+// distinct web origin the edge does not serve; and anything that is not an
+// absolute https URL with a host, which covers "null", opaque forms like
+// "https:app.example.com", and scheme-relative values.
+//
+// The host is lowercased into the shape registry rows — and therefore this
+// map — are written in. It is not otherwise normalised: a trailing-dot
+// host misses, which costs a real page nothing and keeps one hostname from
+// having two spellings that resolve.
+func beaconSourceOK(m map[string]upstream, raw, want string) bool {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "https" || u.User != nil {
+ return false
+ }
+ host := u.Hostname()
+ if host == "" {
+ return false
+ }
+ if p := u.Port(); p != "" && p != beaconPort {
+ return false
+ }
+ up, ok := lookupIn(m, strings.ToLower(host))
+ return ok && up.route.Host == want
+}
diff --git a/internal/edge/browserdoor_test.go b/internal/edge/browserdoor_test.go
new file mode 100644
index 000000000..b738cb629
--- /dev/null
+++ b/internal/edge/browserdoor_test.go
@@ -0,0 +1,203 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestParseErrorBeaconCaps(t *testing.T) {
+ long := strings.Repeat("x", 9000)
+ b, ok := parseErrorBeacon([]byte(`{"kind":"` + long + `","name":"boom","stack":"` + long + `","path":"/p?q=1","fp":"abc","fields":{"a":"` + long + `"}}`))
+ if !ok || len(b.Kind) != maxBeaconKind || len(b.Stack) != 8<<10 || len(b.Fields["a"]) != maxBeaconField || b.Path != "/p?q=1" {
+ t.Fatalf("caps: %+v ok=%v", b, ok)
+ }
+ if _, ok := parseErrorBeacon([]byte(`{"kind":"E"}`)); ok {
+ t.Fatal("a beacon without a name is malformed")
+ }
+ if _, ok := parseErrorBeacon([]byte(`[1,2]`)); ok {
+ t.Fatal("not an object")
+ }
+ many := "{"
+ for i := 0; i < 40; i++ {
+ many += `"k` + string(rune('a'+i%26)) + string(rune('0'+i/26)) + `":"v",`
+ }
+ b, _ = parseErrorBeacon([]byte(`{"name":"n","fields":` + many + `"z":"v"}}`))
+ if len(b.Fields) != maxBeaconFields {
+ t.Fatalf("fields capped to %d, got %d", maxBeaconFields, len(b.Fields))
+ }
+}
+
+// TestParseBeaconCapsEveryString pins the file's own promise — nothing
+// reaches the sink uncapped. Name and path are the two the sink does not
+// bound itself: apperr's sink truncates only Stack, so a 16 KB path would
+// otherwise be stored verbatim.
+func TestParseBeaconCapsEveryString(t *testing.T) {
+ long := strings.Repeat("x", 9000)
+ e, ok := parseErrorBeacon([]byte(`{"name":"` + long + `","path":"/` + long + `","fp":"` + long + `"}`))
+ if !ok {
+ t.Fatal("well-formed beacon rejected")
+ }
+ if len(e.Name) != maxBeaconName {
+ t.Errorf("error name capped to %d, got %d", maxBeaconName, len(e.Name))
+ }
+ if len(e.Path) != maxBeaconName {
+ t.Errorf("error path capped to %d, got %d", maxBeaconName, len(e.Path))
+ }
+ if len(e.FP) != 64 {
+ t.Errorf("fp capped to 64, got %d", len(e.FP))
+ }
+ a, ok := parseEventBeacon([]byte(`{"name":"` + long + `","path":"/` + long + `"}`))
+ if !ok {
+ t.Fatal("well-formed event rejected")
+ }
+ if len(a.Name) != maxBeaconName || len(a.Path) != maxBeaconName {
+ t.Errorf("event capped to %d, got name=%d path=%d", maxBeaconName, len(a.Name), len(a.Path))
+ }
+}
+
+func TestParseEventBeacon(t *testing.T) {
+ b, ok := parseEventBeacon([]byte(`{"name":"Signup","path":"/x","fields":{"plan":"pro"}}`))
+ if !ok || b.Name != "Signup" || b.Path != "/x" {
+ t.Fatalf("%+v %v", b, ok)
+ }
+ if _, ok := parseEventBeacon([]byte(`{"path":"/x"}`)); ok {
+ t.Fatal("no name")
+ }
+ if _, ok := parseEventBeacon([]byte(`[1,2]`)); ok {
+ t.Fatal("not an object")
+ }
+}
+
+func beaconOriginMap() map[string]upstream {
+ return map[string]upstream{
+ "app.example.com": {route: registry.Route{Host: "app.example.com"}},
+ "www.example.com": {route: registry.Route{Host: "app.example.com"}, alias: true},
+ "other.example.com": {route: registry.Route{Host: "other.example.com"}},
+ "*.wild.example.com": {route: registry.Route{Host: "app.example.com"}, alias: true},
+ "*.stray.example.com": {route: registry.Route{Host: "other.example.com"}, alias: true},
+ }
+}
+
+func beaconReq(hdr map[string]string) *http.Request {
+ r := httptest.NewRequest("POST", "http://app.example.com/__carlos/e", nil)
+ for k, v := range hdr {
+ r.Header.Set(k, v)
+ }
+ return r
+}
+
+func TestBeaconOriginRule(t *testing.T) {
+ m := beaconOriginMap()
+ cases := []struct {
+ name string
+ hdr map[string]string
+ ok bool
+ }{
+ {"origin matches", map[string]string{"Origin": "https://app.example.com"}, true},
+ {"origin with port", map[string]string{"Origin": "https://app.example.com:443"}, true},
+ {"alias origin shares the route", map[string]string{"Origin": "https://www.example.com"}, true},
+ {"http origin", map[string]string{"Origin": "http://app.example.com"}, false},
+ {"foreign origin", map[string]string{"Origin": "https://other.example.com"}, false},
+ {"unknown origin", map[string]string{"Origin": "https://evil.example.net"}, false},
+ {"null origin", map[string]string{"Origin": "null"}, false},
+ {"foreign origin, matching referer", map[string]string{"Origin": "https://other.example.com", "Referer": "https://app.example.com/page"}, false},
+ {"no origin, matching referer", map[string]string{"Referer": "https://app.example.com/page"}, true},
+ {"no origin, foreign referer", map[string]string{"Referer": "https://other.example.com/"}, false},
+ {"neither", map[string]string{}, false},
+ {"cross-site", map[string]string{"Origin": "https://app.example.com", "Sec-Fetch-Site": "cross-site"}, false},
+ {"same-origin fetch site", map[string]string{"Origin": "https://app.example.com", "Sec-Fetch-Site": "same-origin"}, true},
+ }
+ for _, c := range cases {
+ if got := beaconOriginOK(m, beaconReq(c.hdr), "app.example.com"); got != c.ok {
+ t.Errorf("%s: got %v want %v", c.name, got, c.ok)
+ }
+ }
+}
+
+// TestBeaconOriginHostileInputs pins the shapes a browser never sends and an
+// attacker therefore reaches for: a URL whose authority is not where it
+// appears to be, a non-default port (a different web origin on the same
+// name), and an empty or repeated Origin used to slip past the header into
+// the more forgiving Referer.
+func TestBeaconOriginHostileInputs(t *testing.T) {
+ m := beaconOriginMap()
+ cases := []struct {
+ name string
+ hdr map[string]string
+ ok bool
+ }{
+ // Case only ever differs in the header; the map is lowercase.
+ {"upper-case origin host", map[string]string{"Origin": "https://APP.Example.COM"}, true},
+ {"upper-case scheme", map[string]string{"Origin": "HTTPS://app.example.com"}, true},
+
+ // Userinfo: the real host is after the '@', never before it. A
+ // browser strips userinfo from both headers, so refusing it costs
+ // nothing and removes the whole "which half is the host" class.
+ {"origin with userinfo naming us", map[string]string{"Origin": "https://evil.example.net@app.example.com/"}, false},
+ {"origin with userinfo naming them", map[string]string{"Origin": "https://app.example.com@evil.example.net/"}, false},
+ {"referer with userinfo", map[string]string{"Referer": "https://evil.example.net@app.example.com/page"}, false},
+
+ // A different port is a different web origin, and the edge serves
+ // https on 443 alone (spec §3.4: "host and port resolve").
+ {"origin on a non-default port", map[string]string{"Origin": "https://app.example.com:8443"}, false},
+ {"origin on port 0", map[string]string{"Origin": "https://app.example.com:0"}, false},
+ {"referer on a non-default port", map[string]string{"Referer": "https://app.example.com:8443/page"}, false},
+
+ // Address literals are never a served route, and the trailing-dot
+ // form is not the shape registry rows are written in.
+ {"ipv4 literal", map[string]string{"Origin": "https://127.0.0.1"}, false},
+ {"ipv6 literal", map[string]string{"Origin": "https://[::1]"}, false},
+ {"trailing dot host", map[string]string{"Origin": "https://app.example.com."}, false},
+
+ // Nothing that fails to parse as an absolute https URL passes.
+ {"opaque url", map[string]string{"Origin": "https:app.example.com"}, false},
+ {"scheme-relative url", map[string]string{"Origin": "//app.example.com"}, false},
+ {"no host", map[string]string{"Origin": "https:///page"}, false},
+ {"unparseable", map[string]string{"Origin": "https://a b.example.com"}, false},
+
+ // The one-label wildcard fold is the same lookup the request took,
+ // so a page under a catch-all posts to the route that serves it —
+ // and the apex, which can never match its own wildcard, does not.
+ {"wildcard fold to our route", map[string]string{"Origin": "https://sub.wild.example.com"}, true},
+ {"wildcard fold to another route", map[string]string{"Origin": "https://sub.stray.example.com"}, false},
+ {"wildcard apex does not fold", map[string]string{"Origin": "https://wild.example.com"}, false},
+ {"two labels under the wildcard", map[string]string{"Origin": "https://a.b.wild.example.com"}, false},
+
+ // Sec-Fetch-Site is refused before anything else is read.
+ {"cross-site with no origin at all", map[string]string{"Sec-Fetch-Site": "cross-site"}, false},
+ {"cross-site with a good referer", map[string]string{"Referer": "https://app.example.com/p", "Sec-Fetch-Site": "Cross-Site"}, false},
+ {"same-site is not refused outright", map[string]string{"Origin": "https://app.example.com", "Sec-Fetch-Site": "same-site"}, true},
+ }
+ for _, c := range cases {
+ if got := beaconOriginOK(m, beaconReq(c.hdr), "app.example.com"); got != c.ok {
+ t.Errorf("%s: got %v want %v", c.name, got, c.ok)
+ }
+ }
+
+ // An Origin that is present but empty, or sent twice, is a bad Origin —
+ // not an absent one — so Referer is never reached (§3.4: "a bad Origin
+ // is rejected even if Referer passes").
+ r := beaconReq(map[string]string{"Referer": "https://app.example.com/page"})
+ r.Header["Origin"] = []string{""}
+ if beaconOriginOK(m, r, "app.example.com") {
+ t.Error("an empty Origin fell through to Referer")
+ }
+ r = beaconReq(map[string]string{"Referer": "https://app.example.com/page"})
+ r.Header["Origin"] = []string{"https://app.example.com", "https://evil.example.net"}
+ if beaconOriginOK(m, r, "app.example.com") {
+ t.Error("a repeated Origin was accepted")
+ }
+
+ // want is the route the request resolved to, not the hostname it was
+ // sent to: a good Origin for one app is not a good Origin for another.
+ if beaconOriginOK(m, beaconReq(map[string]string{"Origin": "https://app.example.com"}), "other.example.com") {
+ t.Error("origin accepted against another app's route")
+ }
+ if beaconOriginOK(nil, beaconReq(map[string]string{"Origin": "https://app.example.com"}), "app.example.com") {
+ t.Error("origin accepted against an empty map")
+ }
+}