carlos / platform Public

geoip: the strip treats underscores as hyphens, so X_Carlos_Geo_* aliases (one key to WSGI/CGI) never reach an app; tests allow only the five exact names through (Astra code review R12 P1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b0b97e977eedd30507797ec703a66e6279a3e333 parent d16d43c
3 files changed, +38 −8
  • internal/edge/geoipheaders_test.go +11 −4
  • internal/geoip/answer.go +13 −4
  • internal/geoip/answer_test.go +14 −0

CI passed — run details

diff --git a/internal/edge/geoipheaders_test.go b/internal/edge/geoipheaders_test.go
index 7833229ba..8ccd150ad 100644
--- a/internal/edge/geoipheaders_test.go
+++ b/internal/edge/geoipheaders_test.go
@@ -363,7 +363,7 @@ func TestForgeryMatrixAcrossLocalProxyTypes(t *testing.T) {
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := map[string]string{}
for k := range r.Header {
- if strings.HasPrefix(strings.ToLower(k), "x-carlos-geo-") || strings.EqualFold(k, geoip.HeaderHop) {
+ if n := strings.ToLower(strings.ReplaceAll(k, "_", "-")); strings.HasPrefix(n, "x-carlos-geo-") || n == strings.ToLower(geoip.HeaderHop) {
got[k] = r.Header.Get(k)
}
}
@@ -403,6 +403,8 @@ func TestForgeryMatrixAcrossLocalProxyTypes(t *testing.T) {
r.Header.Set("X-Carlos-Geo-City", "Atlantis")
r.Header.Set(geoip.HeaderHop, "hit;src=forged")
r.Header.Set("Connection", "X-Carlos-Geo-Country, X-Carlos-Geo-Source")
+ r.Header["X_Carlos_Geo_Country"] = []string{"KP"} // WSGI reads this as HTTP_X_CARLOS_GEO_COUNTRY
+ r.Header["X_Carlos_Hop_Geo"] = []string{"hit;src=forged"}
code, h := serveGeo(t, e, r)
if code != 200 {
t.Errorf("%s: status %d", name, code)
@@ -411,9 +413,14 @@ func TestForgeryMatrixAcrossLocalProxyTypes(t *testing.T) {
if h[geoip.HeaderCountry] != "GB" || h[geoip.HeaderSource] != "TestDB-GB" {
t.Errorf("%s: app saw %v, want the edge's GB answer", name, h)
}
- for _, bad := range []string{"X-Carlos-Geo-City", geoip.HeaderHop} {
- if _, ok := h[bad]; ok {
- t.Errorf("%s: %s reached the app", name, bad)
+ // Only the edge's own five, under their exact names, may arrive:
+ // any other spelling of the namespace (a city, the hop header, an
+ // underscore alias WSGI would read as the real one) is a forgery.
+ allowed := map[string]bool{geoip.HeaderCountry: true, geoip.HeaderSubdivision: true,
+ geoip.HeaderLat: true, geoip.HeaderLon: true, geoip.HeaderSource: true}
+ for k := range h {
+ if !allowed[k] {
+ t.Errorf("%s: %s reached the app", name, k)
}
}
}
diff --git a/internal/geoip/answer.go b/internal/geoip/answer.go
index 7350d2fc3..2f35bee16 100644
--- a/internal/geoip/answer.go
+++ b/internal/geoip/answer.go
@@ -70,17 +70,26 @@ func FromSnapshot(s geo.Snapshot) Answer {
// an X-Carlos-Geo-City (or any later name) that looks platform-set.
const geoPrefix = "X-Carlos-Geo-"
-// StripAll deletes every X-Carlos-Geo-* header and X-Carlos-Hop-Geo.
-// Header keys are canonicalised by net/http, but a map built by hand may
-// not be, so the match is case-insensitive.
+// StripAll deletes every X-Carlos-Geo-* header and X-Carlos-Hop-Geo, in
+// any case and with underscores in place of hyphens: CGI and WSGI turn
+// both into the same HTTP_X_CARLOS_GEO_* key, so X_Carlos_Geo_Country
+// would otherwise reach a Python app as the platform's own header (code
+// review R12-1).
func StripAll(h http.Header) {
for k := range h {
- if len(k) >= len(geoPrefix) && strings.EqualFold(k[:len(geoPrefix)], geoPrefix) || strings.EqualFold(k, HeaderHop) {
+ if reserved(k) {
delete(h, k)
}
}
}
+// reserved reports whether a header name falls in the platform's geo
+// namespace once underscores are read as hyphens.
+func reserved(k string) bool {
+ n := strings.ReplaceAll(k, "_", "-")
+ return len(n) >= len(geoPrefix) && strings.EqualFold(n[:len(geoPrefix)], geoPrefix) || strings.EqualFold(n, HeaderHop)
+}
+
// WriteApp deletes the app headers and writes this answer's. A miss and
// off write nothing at all; Source is written only beside another field.
func (a Answer) WriteApp(h http.Header) {
diff --git a/internal/geoip/answer_test.go b/internal/geoip/answer_test.go
index 9ff42c005..a709b670d 100644
--- a/internal/geoip/answer_test.go
+++ b/internal/geoip/answer_test.go
@@ -100,3 +100,17 @@ func TestStripAllRemovesTheWholeNamespace(t *testing.T) {
t.Fatalf("Capture must strip the namespace too: %v", h2)
}
}
+
+// Code review R12-1: underscore spellings are the same header to CGI/WSGI.
+func TestStripAllCatchesUnderscoreSpellings(t *testing.T) {
+ h := http.Header{}
+ for _, k := range []string{"X_Carlos_Geo_Country", "X_CARLOS_GEO_SOURCE", "X-Carlos_Geo-Lat", "X_Carlos_Hop_Geo", "x_carlos_hop_geo"} {
+ h[k] = []string{"forged"}
+ }
+ h.Set("X-Carlos-Version", "keep")
+ h["X_Forwarded_For"] = []string{"keep"}
+ StripAll(h)
+ if len(h) != 2 {
+ t.Fatalf("after strip = %v", h)
+ }
+}