| 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") |
| + } |
| +} |