From 32a1411fd1ea7cb65fcc7fdff1555e0e1e708fde Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 9 Jul 2026 15:26:59 +0800 Subject: [PATCH] Accept act_as query param on /fmsg/ws and allow PATCH in CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers cannot set custom headers on a WebSocket upgrade, so the X-FMSG-Act-As header alone cannot reach GET /fmsg/ws from a web client. Accept an act_as query parameter as an alternative (header wins when both are present); validation is identical to the header path. Also add PATCH to the default CORS allowed methods — browser preflights for PATCH /fmsg/sub-accounts/:agent (update CIDRs) were rejected. Co-Authored-By: Claude Fable 5 --- README.md | 5 +++++ internal/handlers/ws.go | 18 ++++++++++++++--- internal/handlers/ws_test.go | 39 ++++++++++++++++++++++++++++++++++++ internal/middleware/cors.go | 8 ++++---- 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 internal/handlers/ws_test.go diff --git a/README.md b/README.md index 18e496d..ffa1907 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,11 @@ an `access_token` query parameter (browsers, which cannot set headers on a WebSocket). The handshake fails with `401`/`400`/`403`/`503` — the same statuses as the REST middleware — before the connection is upgraded. +An EdDSA-authenticated owner may act as one of their granted identities by +supplying either the `X-FMSG-Act-As` header or an `act_as` query parameter +(again for browsers, which cannot set headers on a WebSocket); the header wins +when both are present, and validation is identical to the REST routes. + **Events:** every frame is a JSON envelope with a `type` discriminator so new event types can be added without breaking clients: diff --git a/internal/handlers/ws.go b/internal/handlers/ws.go index f492d9a..2ef4a66 100644 --- a/internal/handlers/ws.go +++ b/internal/handlers/ws.go @@ -69,9 +69,21 @@ func NewWSHandler(verifier *middleware.Verifier, hub *Hub, allowedOrigins []stri } } +// actAsParam returns the requested act-as address: the X-FMSG-Act-As header +// when present, else the act_as query parameter. The query parameter exists +// because browsers cannot set custom headers on a WebSocket upgrade; it is +// validated identically to the header. +func actAsParam(c *gin.Context) string { + if v := c.GetHeader("X-FMSG-Act-As"); v != "" { + return v + } + return c.Query("act_as") +} + // Connect handles GET /fmsg/ws. It authenticates the request (via the -// access_token query parameter or an Authorization header), upgrades the -// connection, and runs it until either side closes. +// access_token query parameter or an Authorization header, optionally acting +// as a sub-account via the X-FMSG-Act-As header or act_as query parameter), +// upgrades the connection, and runs it until either side closes. func (h *WSHandler) Connect(c *gin.Context) { token := c.Query("access_token") if token == "" { @@ -82,7 +94,7 @@ func (h *WSHandler) Connect(c *gin.Context) { return } - res, status, msg := h.verifier.AuthenticateRequest(c.Request.Context(), token, c.ClientIP(), c.GetHeader("X-FMSG-Act-As")) + res, status, msg := h.verifier.AuthenticateRequest(c.Request.Context(), token, c.ClientIP(), actAsParam(c)) if status != http.StatusOK { c.JSON(status, gin.H{"error": msg}) return diff --git a/internal/handlers/ws_test.go b/internal/handlers/ws_test.go new file mode 100644 index 0000000..288fe79 --- /dev/null +++ b/internal/handlers/ws_test.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestActAsParam(t *testing.T) { + tests := []struct { + name string + header string + query string + want string + }{ + {name: "neither set", want: ""}, + {name: "header only", header: "@bot@example.com", want: "@bot@example.com"}, + {name: "query only", query: "@bot@example.com", want: "@bot@example.com"}, + {name: "header wins over query", header: "@a@example.com", query: "@b@example.com", want: "@a@example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + target := "/fmsg/ws" + if tt.query != "" { + target += "?act_as=" + url.QueryEscape(tt.query) + } + c.Request = httptest.NewRequest("GET", target, nil) + if tt.header != "" { + c.Request.Header.Set("X-FMSG-Act-As", tt.header) + } + if got := actAsParam(c); got != tt.want { + t.Errorf("actAsParam() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index ac763eb..f717190 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -25,12 +25,12 @@ type CORSConfig struct { } // DefaultCORSConfig returns a CORSConfig populated with values appropriate for -// this API: GET/POST/PUT/DELETE/OPTIONS plus Authorization and Content-Type -// request headers, with a 10 minute preflight cache. Callers must still set -// AllowedOrigins. +// this API: GET/POST/PUT/PATCH/DELETE/OPTIONS plus Authorization and +// Content-Type request headers, with a 10 minute preflight cache. Callers must +// still set AllowedOrigins. func DefaultCORSConfig() CORSConfig { return CORSConfig{ - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Authorization", "Content-Type", "X-FMSG-Act-As"}, MaxAge: 10 * time.Minute, }