diff --git a/gateway/internal/connectsrv/cors.go b/gateway/internal/connectsrv/cors.go new file mode 100644 index 0000000..faf59e0 --- /dev/null +++ b/gateway/internal/connectsrv/cors.go @@ -0,0 +1,47 @@ +package connectsrv + +import "net/http" + +// nativeWebViewOrigins are the browser Origins of the packaged native apps. Capacitor serves the +// bundled SPA from a localhost scheme, so its Connect calls to the gateway are cross-origin; the web +// app is same-origin and needs no entry. Requests carry Authorization, so the allowlist reflects the +// exact origin rather than "*". +var nativeWebViewOrigins = map[string]bool{ + "https://localhost": true, // Capacitor Android (default https scheme) + "http://localhost": true, // Capacitor with the http scheme + "capacitor://localhost": true, // Capacitor iOS default scheme +} + +// withNativeCORS answers the CORS preflight and adds the CORS response headers for the packaged +// native apps' cross-origin calls to the Connect edge. Without it the WebView blocks every RPC on the +// preflight (the gateway otherwise returns 405 with no Access-Control-Allow-Origin), so a native +// build can never reach the gateway and stays stuck offline. Same-origin (web) and any non-native +// origin are untouched. +func withNativeCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if nativeWebViewOrigins[origin] { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + h.Set("Access-Control-Allow-Credentials", "true") + // The client interceptor reads the soft-tier update signal off the response. + h.Set("Access-Control-Expose-Headers", "X-Update-Recommended") + if r.Method == http.MethodOptions { + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + // Reflect the requested headers (the origin is already allowlisted): the Connect client + // sends Connect-Protocol-Version / Connect-Timeout-Ms plus X-Client-Version and + // Authorization, and the exact set varies by call. + if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + h.Set("Access-Control-Allow-Headers", reqHeaders) + } else { + h.Set("Access-Control-Allow-Headers", "Content-Type, Connect-Protocol-Version, X-Client-Version, Authorization") + } + h.Set("Access-Control-Max-Age", "86400") + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/gateway/internal/connectsrv/cors_test.go b/gateway/internal/connectsrv/cors_test.go new file mode 100644 index 0000000..f191a6a --- /dev/null +++ b/gateway/internal/connectsrv/cors_test.go @@ -0,0 +1,54 @@ +package connectsrv + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestWithNativeCORS(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := withNativeCORS(next) + + // Native-origin preflight → 204 with the CORS headers, reflecting the requested headers, without + // reaching the inner handler. + req := httptest.NewRequest(http.MethodOptions, "/scrabble.edge.v1.Gateway/Execute", nil) + req.Header.Set("Origin", "https://localhost") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "content-type,connect-protocol-version,x-client-version") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://localhost" { + t.Errorf("Allow-Origin = %q, want https://localhost", got) + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type,connect-protocol-version,x-client-version" { + t.Errorf("Allow-Headers = %q, want the reflected set", got) + } + + // Native-origin POST → the CORS headers are set and the inner handler runs. + req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil) + req.Header.Set("Origin", "capacitor://localhost") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("POST status = %d, want 200 (passed through)", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" { + t.Errorf("POST Allow-Origin = %q, want capacitor://localhost", got) + } + + // A non-native origin gets no CORS headers and still passes through (web is same-origin anyway). + req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil) + req.Header.Set("Origin", "https://evil.example") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("non-native POST status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("non-native Allow-Origin = %q, want empty", got) + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index beca627..c789790 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -302,7 +302,7 @@ func (s *Server) HTTPHandler() http.Handler { // honeypot hit is turned away before the body cap and the mux. Every request // body on the public listener is then capped (the admin proxy POSTs included); // the h2c server carries explicit stream/idle sizing. - return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{ + return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{ MaxConcurrentStreams: h2cMaxConcurrentStreams, IdleTimeout: h2cIdleTimeout, })