carlos / platform Public

edge: the origin rule follows the listen port, and drops are recorded whenever the door is open

Five fixes from the whole-branch review, all in the browser door.

The origin rule's port was the constant 443. A browser puts the port in
Origin whenever it is not the scheme's default, so an edge started with
-https :8443 was told ":8443" by every page it served and refused every one
of them — the door silently shut on any deployment that does not own 443.
The port is now a field on Edge, set from the -https listen address, with
New defaulting it to 443 so a hand-built edge and a test behave like
production. The rule tightened at the same time: on an 8443 edge a portless
https origin is a different origin and is refused.

--dev serves the pages over plain http itself, so there http IS the origin
the browser reports. A dev edge accepts http origins on its own --http
port; a TLS edge refuses http outright, whatever port it names.

Drops are now recorded whenever the door is open, whatever the count switch
says. That overturns planning ruling 2. The Analytics tab renders the
Dropped headline and the by-reason line regardless of counting, the fleet
default is count off with the door open, and the old rule made every
refusal on every default app invisible — the one number that would explain
why a page's beacons never arrive. A drop-only day file carries no visitor
data, so this records refusals without counting anybody. A shut door still
records nothing at all: the owner shut it.

The reserved space is now matched on the cleaned first segment as well as
the literal prefix, so "/./__carlos/e" and "//__carlos/x" reach
serveReserved — which 404s what it does not recognise — instead of being
proxied to the app. The bare "/__carlos" lands there too, which is a change
from the old exact-prefix match and the right answer: the segment is the
platform's, and lookupUpstreamPath already refuses to let a prefix map
steer it.

And carlos.js sanitises a field's KEY, not only its value, matching the
sink's own fix — a counter keyed by email address was leaking the address
through the key.

One deliberate pin comes with them: a password-gated route still serves
carlos.js and still accepts beacons with no gate cookie (planning ruling
T2). That is the intended trade, and the test says why, so overturning it
later is a conversation rather than a surprise.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e54f09104b1a3ee44e494350e1e7593b33e39264 parent f71d8c9
7 files changed, +349 −34
  • internal/edge/browserdoor.go +87 −20
  • internal/edge/browserdoor_test.go +105 −10
  • internal/edge/carlosjs.go +1 −1
  • internal/edge/carlosjs_test.go +8 −0
  • internal/edge/edge.go +33 −2
  • internal/edge/gate_test.go +70 −0
  • internal/edge/reserved_test.go +45 −1
diff --git a/internal/edge/browserdoor.go b/internal/edge/browserdoor.go
index 14ff4a9fe..964460c45 100644
--- a/internal/edge/browserdoor.go
+++ b/internal/edge/browserdoor.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"hash/fnv"
"io"
+ "net"
"net/http"
"net/url"
"sort"
@@ -33,10 +34,10 @@ const (
// 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"
+ // defaultBeaconPort is what Edge.beaconPort holds unless the -https
+ // listen address says otherwise: the https default, which a browser
+ // omits from Origin entirely.
+ defaultBeaconPort = "443"
)
type errorBeacon struct {
@@ -124,15 +125,15 @@ func parseEventBeacon(b []byte) (eventBeacon, bool) {
// 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 {
+func (e *Edge) 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)
+ return len(vs) == 1 && e.beaconSourceOK(m, vs[0], want)
}
if ref := r.Header.Get("Referer"); ref != "" {
- return beaconSourceOK(m, ref, want)
+ return e.beaconSourceOK(m, ref, want)
}
return false
}
@@ -143,31 +144,86 @@ func beaconOriginOK(m map[string]upstream, r *http.Request, want string) bool {
// 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 one this edge listens on, 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 URL with a host, which covers "null",
+// opaque forms like "https:app.example.com", and scheme-relative values.
+//
+// The port half is not a constant 443 because the listen address is not.
+// A browser omits the port from Origin only when it is the scheme's
+// default, so an edge on :8443 is told ":8443" by every page it serves and
+// a portless https origin is a DIFFERENT origin — one this edge does not
+// serve — and is refused. Symmetrically, an edge on :443 is told no port
+// at all and an explicit ":443" would be a browser that never existed.
+//
+// http is accepted only in --dev, where the edge serves the pages over
+// plain http itself and http is therefore the origin the browser reports.
+// A TLS edge refuses http outright: on a production hostname an http
+// origin is either a downgrade or a forgery.
//
// 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 {
+func (e *Edge) beaconSourceOK(m map[string]upstream, raw, want string) bool {
u, err := url.Parse(raw)
- if err != nil || u.Scheme != "https" || u.User != nil {
+ if err != nil || u.User != nil {
+ return false
+ }
+ var wantPort string
+ switch u.Scheme {
+ case "https":
+ wantPort = e.beaconPort
+ if wantPort == "" {
+ wantPort = defaultBeaconPort
+ }
+ case "http":
+ if e.beaconHTTPPort == "" {
+ return false
+ }
+ wantPort = e.beaconHTTPPort
+ default:
return false
}
host := u.Hostname()
if host == "" {
return false
}
- if p := u.Port(); p != "" && p != beaconPort {
+ // The empty port is the scheme's default and nothing else: it matches
+ // only when this edge listens on that default.
+ p := u.Port()
+ if p == "" {
+ p = defaultPortFor(u.Scheme)
+ }
+ if p != wantPort {
return false
}
up, ok := lookupIn(m, strings.ToLower(host))
return ok && up.route.Host == want
}
+// defaultPortFor is the port a browser omits from an origin of that scheme.
+func defaultPortFor(scheme string) string {
+ if scheme == "http" {
+ return "80"
+ }
+ return defaultBeaconPort
+}
+
+// listenPort is the port half of a Go listen address — ":443" -> "443",
+// "127.0.0.1:8443" -> "8443" — falling back to def for anything without
+// one, including the empty string and a bare host. Only the browser door's
+// origin rule needs this; the listeners themselves take the address whole.
+func listenPort(addr, def string) string {
+ if addr == "" {
+ return def
+ }
+ if _, p, err := net.SplitHostPort(addr); err == nil && p != "" {
+ return p
+ }
+ return def
+}
+
// doorLimiter is the door's rate buckets (doorlimit.go), created lazily so a
// hand-built &Edge{} in a test has one. New sets the field for every real
// edge; the Once is what makes the two paths agree on a single limiter
@@ -188,9 +244,10 @@ func (e *Edge) doorLimiter() *doorLimiter {
// EVERY branch answers 202 with no body. The endpoint tells a caller
// nothing: not whether the app exists, not whether its door is open, not
// whether the beacon was kept. Where refusals show is the owner's own
-// dropped counters, by door and reason — and those are recorded only for an
-// app whose count switch is on (ruling 2), so an app that is not being
-// counted cannot be made to accumulate anything at all.
+// dropped counters, by door and reason, recorded whenever the door is open
+// whatever the count switch says — the tab shows those counters either way,
+// and a drop-only day file carries no visitor data. A shut door records
+// nothing at all: the owner shut it.
//
// Order matters and is the order below: is the door open at all, who may
// post (origin), how much (size), what shape (parse), whose it is
@@ -221,11 +278,21 @@ func (e *Edge) serveBeacon(w http.ResponseWriter, r *http.Request, host string,
return
}
now := time.Now()
- // drop records one refusal against rt's app, if that app is counted.
+ // drop records one refusal against rt's app. Reaching here means the
+ // door is open, and an open door's refusals are recorded whatever the
+ // count switch says: the Analytics tab renders the Dropped headline and
+ // the by-reason line regardless of counting, the fleet default is count
+ // off with the door on, and gating drops on count would make every
+ // refusal on every default app invisible — the one number that explains
+ // why a page's beacons are not arriving. The day file a drop-only delta
+ // writes carries no visitor data, so this records refusals without
+ // counting anybody. (Planning ruling 2, overturned by the whole-branch
+ // review.)
+ //
// Refusals before attribution are charged to the route the post arrived
// on (ruling 3): the beacon named no path we could trust yet.
drop := func(rt registry.Route, reason string) {
- if ref := e.analyticsMeter.Load(); ref != nil && e.switchesFor(rt).Count {
+ if ref := e.analyticsMeter.Load(); ref != nil {
ref.Drop(release.RouteAccount(rt), rt.App, door, reason, now)
}
}
@@ -233,7 +300,7 @@ func (e *Edge) serveBeacon(w http.ResponseWriter, r *http.Request, host string,
// this hostname's route serves. up is the hostname's own root route —
// the reserved segment is never prefix-steered — so its Host is what an
// Origin has to resolve to.
- if !beaconOriginOK(e.current(), r, up.route.Host) {
+ if !e.beaconOriginOK(e.current(), r, up.route.Host) {
drop(up.route, analytics.DropOrigin)
return
}
diff --git a/internal/edge/browserdoor_test.go b/internal/edge/browserdoor_test.go
index c91746b1b..74301c84f 100644
--- a/internal/edge/browserdoor_test.go
+++ b/internal/edge/browserdoor_test.go
@@ -89,6 +89,11 @@ func beaconOriginMap() map[string]upstream {
}
}
+// prodEdge is the origin rule's default shape: TLS on 443, no http door.
+// The rule is a method on Edge because the port it compares against is the
+// edge's own listen port, not a constant.
+func prodEdge() *Edge { return &Edge{beaconPort: defaultBeaconPort} }
+
func beaconReq(hdr map[string]string) *http.Request {
r := httptest.NewRequest("POST", "http://app.example.com/__carlos/e", nil)
for k, v := range hdr {
@@ -119,7 +124,7 @@ func TestBeaconOriginRule(t *testing.T) {
{"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 {
+ if got := prodEdge().beaconOriginOK(m, beaconReq(c.hdr), "app.example.com"); got != c.ok {
t.Errorf("%s: got %v want %v", c.name, got, c.ok)
}
}
@@ -180,7 +185,7 @@ func TestBeaconOriginHostileInputs(t *testing.T) {
{"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 {
+ if got := prodEdge().beaconOriginOK(m, beaconReq(c.hdr), "app.example.com"); got != c.ok {
t.Errorf("%s: got %v want %v", c.name, got, c.ok)
}
}
@@ -190,25 +195,106 @@ func TestBeaconOriginHostileInputs(t *testing.T) {
// 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") {
+ if prodEdge().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") {
+ if prodEdge().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") {
+ if prodEdge().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") {
+ if prodEdge().beaconOriginOK(nil, beaconReq(map[string]string{"Origin": "https://app.example.com"}), "app.example.com") {
t.Error("origin accepted against an empty map")
}
}
+// TestBeaconOriginPortFollowsListener: the port the rule compares against is
+// the edge's own https listen port, not a constant 443. A browser omits the
+// port from Origin only when it is the scheme's default, so an edge on :8443
+// is told ":8443" by every page it serves — and a portless https origin is a
+// different origin that this edge does not serve.
+func TestBeaconOriginPortFollowsListener(t *testing.T) {
+ m := beaconOriginMap()
+ alt := &Edge{beaconPort: "8443"}
+ cases := []struct {
+ name string
+ e *Edge
+ origin string
+ ok bool
+ }{
+ {"443 edge, portless origin", prodEdge(), "https://app.example.com", true},
+ {"443 edge, :443 origin", prodEdge(), "https://app.example.com:443", true},
+ {"443 edge, :8443 origin", prodEdge(), "https://app.example.com:8443", false},
+ {"8443 edge, :8443 origin", alt, "https://app.example.com:8443", true},
+ {"8443 edge, portless origin", alt, "https://app.example.com", false},
+ {"8443 edge, :443 origin", alt, "https://app.example.com:443", false},
+ // A zero-value Edge is a production edge: New sets 443, and a
+ // hand-built one must not silently refuse everything.
+ {"zero-value edge, portless origin", &Edge{}, "https://app.example.com", true},
+ }
+ for _, c := range cases {
+ if got := c.e.beaconOriginOK(m, beaconReq(map[string]string{"Origin": c.origin}), "app.example.com"); got != c.ok {
+ t.Errorf("%s: got %v want %v", c.name, got, c.ok)
+ }
+ }
+}
+
+// TestBeaconOriginDevHTTP: --dev serves the pages over plain http itself, so
+// http IS the origin the browser reports there — and only there. A TLS edge
+// refuses an http origin whatever port it names.
+func TestBeaconOriginDevHTTP(t *testing.T) {
+ m := beaconOriginMap()
+ dev := &Edge{beaconHTTPPort: "8080"}
+ cases := []struct {
+ name string
+ e *Edge
+ origin string
+ ok bool
+ }{
+ {"dev edge, http on its port", dev, "http://app.example.com:8080", true},
+ {"dev edge, http on another port", dev, "http://app.example.com:9090", false},
+ {"dev edge, portless http", dev, "http://app.example.com", false},
+ {"TLS edge, http on 8080", prodEdge(), "http://app.example.com:8080", false},
+ {"TLS edge, portless http", prodEdge(), "http://app.example.com", false},
+ {"TLS edge, http on 80", prodEdge(), "http://app.example.com:80", false},
+ }
+ for _, c := range cases {
+ if got := c.e.beaconOriginOK(m, beaconReq(map[string]string{"Origin": c.origin}), "app.example.com"); got != c.ok {
+ t.Errorf("%s: got %v want %v", c.name, got, c.ok)
+ }
+ }
+ // A dev edge on the default http port takes a portless http origin,
+ // because that is what a browser sends for port 80.
+ d80 := &Edge{beaconHTTPPort: "80"}
+ if !d80.beaconOriginOK(m, beaconReq(map[string]string{"Origin": "http://app.example.com"}), "app.example.com") {
+ t.Error("a dev edge on :80 must accept a portless http origin")
+ }
+}
+
+// TestListenPort pins the flag parsing the two fields above are set from.
+func TestListenPort(t *testing.T) {
+ for _, c := range []struct{ addr, def, want string }{
+ {":443", "443", "443"},
+ {":8443", "443", "8443"},
+ {"127.0.0.1:8443", "443", "8443"},
+ {"[::1]:8443", "443", "8443"},
+ {"", "443", "443"},
+ {"localhost", "443", "443"},
+ {":80", "80", "80"},
+ {":8080", "80", "8080"},
+ } {
+ if got := listenPort(c.addr, c.def); got != c.want {
+ t.Errorf("listenPort(%q, %q) = %q, want %q", c.addr, c.def, got, c.want)
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// The door handler (spec §3.4): POST /__carlos/e and /__carlos/a.
@@ -465,17 +551,26 @@ func TestDoorSwitches(t *testing.T) {
if len(sink.snap()) != 1 || len(meter.snapTracks()) != 0 || len(meter.snapDrops()) != dropsBefore {
t.Fatal("a shut door records nothing — no record, no track, no drop")
}
- // Count off: an origin drop is not recorded either (ruling 2).
+ // Count off, door open: an origin refusal IS recorded. The fleet default
+ // is exactly this pair, and the Analytics tab shows Dropped whether or
+ // not the app is counted, so a refusal here has to be visible — it is the
+ // only thing that explains why a page's beacons never arrive. (Planning
+ // ruling 2, overturned by the whole-branch review.)
e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}})
postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"})
- if len(meter.snapDrops()) != 0 {
- t.Fatal("drops are recorded only for a counted app")
+ drops := meter.snapDrops()
+ if len(drops) != 1 || drops[0].Door != analytics.DoorErrors || drops[0].Reason != analytics.DropOrigin {
+ t.Fatalf("count off, door open: drops = %+v, want one origin drop for door 'e'", drops)
}
+ // The count-off /a post above is still silently ignored, with no drop
+ // recorded: the owner chose not to count, and an event has nowhere to go.
+ // That refusal is the owner's own setting, not a refusal of the beacon.
// No switch map at all is a closed door, which is every app until the
// telemetry pass pushes one.
+ dropsBefore = len(meter.snapDrops())
e.SetSwitches(nil)
postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
- if len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 {
+ if len(sink.snap()) != 1 || len(meter.snapDrops()) != dropsBefore {
t.Fatal("an unswitched app's door is shut")
}
}
diff --git a/internal/edge/carlosjs.go b/internal/edge/carlosjs.go
index ed51c6630..62014407a 100644
--- a/internal/edge/carlosjs.go
+++ b/internal/edge/carlosjs.go
@@ -33,7 +33,7 @@ return s;}
function send(u,b){try{var s=JSON.stringify(b);
var ok=navigator.sendBeacon&&navigator.sendBeacon(u,new Blob([s],{type:'application/json'}));
if(!ok){fetch(u,{method:'POST',body:s,headers:{'Content-Type':'application/json'},keepalive:true,credentials:'omit'}).catch(function(){});}}catch(e){}}
-function fields(f){var o={},n=0,budget=4096;if(f&&typeof f==='object'){for(var k in f){if(!Object.prototype.hasOwnProperty.call(f,k))continue;if(n++>=32)break;var v,vs;try{v=f[k];if(v==null)continue;vs=typeof v==='object'?JSON.stringify(v):v;}catch(e){continue;}vs=san(vs).slice(0,256);var key=String(k).slice(0,64);if(key.length+vs.length>budget)break;budget-=key.length+vs.length;o[key]=vs;}}return o;}
+function fields(f){var o={},n=0,budget=4096;if(f&&typeof f==='object'){for(var k in f){if(!Object.prototype.hasOwnProperty.call(f,k))continue;if(n++>=32)break;var v,vs;try{v=f[k];if(v==null)continue;vs=typeof v==='object'?JSON.stringify(v):v;}catch(e){continue;}vs=san(vs).slice(0,256);var key=san(String(k)).slice(0,64);if(key.length+vs.length>budget)break;budget-=key.length+vs.length;o[key]=vs;}}return o;}
function err(kind,name,stack,f){try{send(E,{kind:String(kind||'Error').slice(0,64),name:san(name).slice(0,1024),stack:san(stack||'').slice(0,8192),path:san(location.pathname).slice(0,1024),fields:fields(f)});}catch(e){}}
window.addEventListener('error',function(ev){var e=ev.error;err(e&&e.name||'Error',ev.message||(e&&e.message)||'error',e&&e.stack||'',{file:ev.filename||'',line:String(ev.lineno||'')});});
window.addEventListener('unhandledrejection',function(ev){var r=ev.reason;var m=r&&r.message||(typeof r==='string'?r:'unhandled rejection');err(r&&r.name||'UnhandledRejection',m,r&&r.stack||'',{});});
diff --git a/internal/edge/carlosjs_test.go b/internal/edge/carlosjs_test.go
index 01423573f..8cfc7d33f 100644
--- a/internal/edge/carlosjs_test.go
+++ b/internal/edge/carlosjs_test.go
@@ -64,6 +64,14 @@ func TestCarlosJSContract(t *testing.T) {
if strings.Contains(carlosJS, `"pageview"`) || strings.Contains(carlosJS, `'pageview'`) {
t.Error("the snippet must not send pageviews in edge mode")
}
+ // A field KEY carries a secret as readily as its value — a counter keyed
+ // by email address is the ordinary case — so the key goes through san
+ // too, matching apperr's sink, which sanitises both ends on arrival.
+ // Asserted on the source because __san exposes san alone, so the node
+ // harness below cannot reach fields().
+ if !strings.Contains(carlosJS, "san(String(k))") {
+ t.Error("field keys must be sanitised, not just capped: want san(String(k))")
+ }
}
func TestCarlosJSNodeSyntax(t *testing.T) {
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 8afce80a1..1295d613a 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -109,6 +109,20 @@ type Edge struct {
// because HostPolicy refuses to mint certs for unknown hosts (anything else
// would let strangers burn the ACME rate limit). The :80 path always works.
fallbackURL string
+ // beaconPort is the port a page the edge served is on — the port half of
+ // the browser door's origin rule (browserdoor.go). It is the port of the
+ // `-https` listen address ("443" for the default ":443", "8443" for
+ // ":8443"), and New defaults it to "443" so a hand-built &Edge{} and a
+ // test behave like production. A browser sends the port in Origin
+ // whenever it is not the scheme's default, so an edge listening on 8443
+ // accepts https://host:8443 and refuses a portless https://host.
+ //
+ // beaconHTTPPort is the same for --dev's plain-HTTP listener, and is ""
+ // on every TLS edge. Set, it additionally accepts http origins on that
+ // port: dev serves the pages itself over http, so http IS the origin the
+ // browser reports. Production never accepts http at all.
+ beaconPort string
+ beaconHTTPPort string
// steeringHost is this deployment's DNS-steering hostname
// (edge.<apps-domain>), or "" when the deployment does not steer — which
// is every deployment today. Set once at construction from the
@@ -302,7 +316,7 @@ type upstream struct {
// New builds an Edge over reg and performs the startup reload. act may be nil
// (no hibernation).
func New(reg *registry.Registry, act Activator) (*Edge, error) {
- e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv(), doorLimit: newDoorLimiter()}
+ e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv(), doorLimit: newDoorLimiter(), beaconPort: defaultBeaconPort}
if err := e.Reload(); err != nil {
return nil, err
}
@@ -1301,7 +1315,14 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// or 404 exactly as before) and BEFORE the observing writer, so a beacon
// is neither metered nor compressed nor cached and never reaches the
// activator branches below (spec §3.4, §4).
- if strings.HasPrefix(r.URL.Path, ReservedPrefix) {
+ //
+ // The cleaned first segment is checked alongside the literal prefix for
+ // the reason lookupUpstreamPath checks both halves: "/./__carlos/e" and
+ // "//__carlos/x" are the reserved space spelled with dot and empty
+ // segments, and an upstream must never see them. serveReserved 404s
+ // anything it does not itself recognise, so widening what reaches it
+ // costs nothing and closes the proxied spelling.
+ if strings.HasPrefix(r.URL.Path, ReservedPrefix) || firstSegmentClean(r.URL.Path) == reservedSegment {
e.serveReserved(w, r, host, up)
return
}
@@ -2744,6 +2765,16 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
opt(e)
}
e.fallbackURL = *fallbackURL
+ // The browser door's origin rule needs the port this edge actually
+ // listens on, because a browser puts a non-default port in Origin
+ // (browserdoor.go beaconSourceOK). --dev serves the pages over plain
+ // http on --http, so that listener's port is what http origins are
+ // judged against; a TLS edge leaves beaconHTTPPort empty and refuses
+ // http entirely.
+ e.beaconPort = listenPort(*httpsAddr, defaultBeaconPort)
+ if *dev {
+ e.beaconHTTPPort = listenPort(*httpAddr, "80")
+ }
e.EnableCache(cache)
// Static serving lights up when the environment carries a deployment
diff --git a/internal/edge/gate_test.go b/internal/edge/gate_test.go
index dbeb39b26..60cb324b8 100644
--- a/internal/edge/gate_test.go
+++ b/internal/edge/gate_test.go
@@ -11,6 +11,8 @@ import (
"github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/telemetry"
)
func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) {
@@ -489,6 +491,74 @@ func TestGatedWebSocketHandshakeIsGated(t *testing.T) {
}
}
+// TestGatedRouteStillServesTheDoor pins planning ruling T2 deliberately: the
+// reserved space is answered before the gate, so a password-gated route still
+// serves /__carlos/carlos.js and still accepts beacons with no gate cookie.
+//
+// That is the intended trade. The snippet is a public static asset with
+// nothing of the app in it, and a beacon is an unauthenticated write to the
+// owner's own counters either way — the door's protection is the origin rule
+// and the rate buckets, never the gate. Making the gate cover the door would
+// mean a gated app's pages could never report an error at all, since the
+// beacon is a fetch the page makes without the visitor's cookie jar
+// necessarily following. If this is ever revisited, revisit it here.
+func TestGatedRouteStillServesTheDoor(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Errorf("upstream reached: %s", r.URL.Path)
+ }))
+ t.Cleanup(backend.Close)
+ h, err := gate.Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready", App: "web", Gate: h}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sink, meter := &memSink{}, &memAnalytics{}
+ e.EnableErrors(sink)
+ e.EnableAnalytics(meter)
+ e.SetSwitches(map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true, Door: true}})
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ // The gate is real: an ordinary page is 401 without a cookie.
+ if resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("gate not in force: / = %d, want 401", resp.StatusCode)
+ }
+ // The snippet, with no cookie.
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", ReservedPrefix+"carlos.js", "")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("carlos.js on a gated route = %d, want 200", resp.StatusCode)
+ }
+ if b, _ := io.ReadAll(resp.Body); len(b) == 0 {
+ t.Fatal("carlos.js served empty")
+ }
+ // A beacon, with no cookie, and it actually lands.
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+ReservedPrefix+"a", strings.NewReader(`{"name":"signup"}`))
+ req.Host = "gated.example.com"
+ req.Header.Set("Origin", "https://gated.example.com")
+ br, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ br.Body.Close()
+ if br.StatusCode != http.StatusAccepted {
+ t.Fatalf("beacon on a gated route = %d, want 202", br.StatusCode)
+ }
+ if tr := meter.snapTracks(); len(tr) != 1 || tr[0].Name != "signup" {
+ t.Fatalf("tracks = %+v, want the beacon kept without a gate cookie", tr)
+ }
+ if d := meter.snapDrops(); len(d) != 0 {
+ t.Fatalf("drops = %+v, want none", d)
+ }
+}
+
func TestUngatedRouteUnchanged(t *testing.T) {
backend, seen := cookieEchoBackend(t, "open page")
reg := openReg(t)
diff --git a/internal/edge/reserved_test.go b/internal/edge/reserved_test.go
index 8819683f7..cdb7a3829 100644
--- a/internal/edge/reserved_test.go
+++ b/internal/edge/reserved_test.go
@@ -42,7 +42,8 @@ func TestReservedPrefixNeverProxied(t *testing.T) {
if hits.Load() != 0 {
t.Fatalf("upstream saw %d reserved requests", hits.Load())
}
- // The prefix is exact: /__carlosx and /__carlos (no slash) are the app's.
+ // The match is segment-bounded: /__carlosx is a different first segment
+ // and is the app's.
req, _ := http.NewRequest("GET", srv.URL+"/__carlosx", nil)
req.Host = "app.example.com"
resp, err := http.DefaultClient.Do(req)
@@ -55,6 +56,49 @@ func TestReservedPrefixNeverProxied(t *testing.T) {
}
}
+// TestReservedPrefixMatchesCleanedPath: the reserved space spelled with dot
+// and empty segments is still the reserved space. "/./__carlos/e" and
+// "//__carlos/x" clean to a first segment of __carlos, and an upstream must
+// never see either — serveReserved 404s what it does not recognise, which is
+// the right answer for a path that resolves into the platform's own space.
+// The bare "/__carlos" is the same segment and lands there too.
+func TestReservedPrefixMatchesCleanedPath(t *testing.T) {
+ var hits atomic.Int32
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.Write([]byte("upstream"))
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ for _, p := range []string{"/./__carlos/x", "//__carlos/x", "/__carlos", "/a/../__carlos/e"} {
+ // The path is sent exactly as written: http.NewRequest would clean
+ // it before it ever left, which is the opposite of the point.
+ req, _ := http.NewRequest("GET", srv.URL, nil)
+ req.Host = "app.example.com"
+ req.URL.Opaque = p
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("%s: %v", p, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("%s: status %d, want 404", p, resp.StatusCode)
+ }
+ }
+ if hits.Load() != 0 {
+ t.Fatalf("upstream saw %d requests for the cleaned reserved space", hits.Load())
+ }
+}
+
// A hibernating route's activator is never invoked for a reserved path.
func TestReservedPrefixNeverWakes(t *testing.T) {
reg := openReg(t)