Compare commits

...

2 Commits

Author SHA1 Message Date
developer d89438040b Merge pull request 'fix(gateway): allow the native WebView origin via CORS (native stuck-offline)' (#270) from feature/native-cors into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-14 19:38:27 +00:00
Ilia Denisov f0b7ad47d4 fix(gateway): allow the native WebView origin via CORS
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The packaged native app (Capacitor) serves the bundled SPA from a localhost scheme, so its Connect calls to erudit-game.ru are cross-origin. The gateway had no CORS handling, so the preflight OPTIONS returned 405 with no Access-Control-Allow-Origin and the WebView blocked every RPC — a native build could never reach the gateway and stayed stuck offline (the native online path was never exercised on-device; on-device D was airplane-mode only). Add a CORS middleware that answers the preflight and sets the response headers for the native localhost origins (https/http/capacitor://localhost); web is same-origin and untouched. Fixes the native emulator 'starts offline, can't go online' report.
2026-07-14 21:21:53 +02:00
3 changed files with 102 additions and 1 deletions
+47
View File
@@ -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)
})
}
+54
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
})