Skip to content

Commit f7637f7

Browse files
authored
fix: require configured trusted proxies before honoring X-Forwarded-For (L-2) (#74)
1 parent dd2f914 commit f7637f7

8 files changed

Lines changed: 196 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
181181
- **Memory add/replace pipe-to-shell filter** (`internal/memory/memory.go`) — `AddFact` and `ReplaceFact` now run `FactLooksUnsafe` in addition to the general guard scan, blocking agent-driven planting of download-and-execute facts.
182182
- **Skill learn-loop provenance propagation** (`internal/skills/learnloop.go`) — conversation-extracted suggestions and LLM-enhanced suggestions both retain the session's `SkillProvenance`, so tainted sessions cannot produce clean-looking auto-saved skills.
183183
- **Audit ingest recording for @-refs, `--ctx`, and Web-UI attachments** (`cmd/odek/refs.go`, `cmd/odek/serve.go`, `cmd/odek/audit.go`) — the per-session ingest recorder is attached before `@`-reference/`--ctx`/attachment resolution, `recordTurnAudit` scans `user` messages for untrusted wrappers in addition to `tool` messages, and the divergence heuristic compares agent actions against the original pre-enrichment prompt so attacker-injected resources are not treated as user-mentioned.
184-
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop.
184+
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop. `X-Forwarded-For` / `X-Real-Ip` headers are only honored when the direct remote address is in the configured `trusted_proxies` list, so spoofed headers cannot bypass the limiters.
185185
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
186186
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.
187187
- **Web UI attachment wrapping** (`cmd/odek/serve.go` + `cmd/odek/ui/app.js`) — files attached through the browser are sent separately from the user's text and wrapped with the nonce'd `<untrusted_content_*>` boundary (`source="attachment:<filename>"`) before injection into the prompt.

cmd/odek/next_security_vulnerabilities_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,107 @@ func TestServe_API_AcceptsServeTokenCookie(t *testing.T) {
304304
}
305305
}
306306

307+
// ── 4c. rate-limiting clientIP must not trust X-Forwarded-From by default ─
308+
309+
func TestClientIP_IgnoresForwardedHeadersByDefault(t *testing.T) {
310+
req := httptest.NewRequest(http.MethodGet, "/", nil)
311+
req.RemoteAddr = "127.0.0.1:12345"
312+
req.Header.Set("X-Forwarded-For", "1.2.3.4")
313+
if got := clientIP(req, nil); got != "127.0.0.1" {
314+
t.Fatalf("expected loopback address without trusted proxies, got %q", got)
315+
}
316+
}
317+
318+
func TestClientIP_HonorsForwardedHeadersFromTrustedProxy(t *testing.T) {
319+
req := httptest.NewRequest(http.MethodGet, "/", nil)
320+
req.RemoteAddr = "10.0.0.1:12345"
321+
req.Header.Set("X-Forwarded-For", "1.2.3.4")
322+
if got := clientIP(req, []string{"10.0.0.1"}); got != "1.2.3.4" {
323+
t.Fatalf("expected forwarded IP from trusted proxy, got %q", got)
324+
}
325+
}
326+
327+
func TestClientIP_HonorsForwardedHeadersFromTrustedCIDR(t *testing.T) {
328+
req := httptest.NewRequest(http.MethodGet, "/", nil)
329+
req.RemoteAddr = "10.0.0.42:12345"
330+
req.Header.Set("X-Forwarded-For", "1.2.3.4")
331+
if got := clientIP(req, []string{"10.0.0.0/8"}); got != "1.2.3.4" {
332+
t.Fatalf("expected forwarded IP from trusted CIDR, got %q", got)
333+
}
334+
}
335+
336+
func TestClientIP_RejectsForwardedHeadersFromUntrustedProxy(t *testing.T) {
337+
req := httptest.NewRequest(http.MethodGet, "/", nil)
338+
req.RemoteAddr = "192.168.1.1:12345"
339+
req.Header.Set("X-Forwarded-For", "1.2.3.4")
340+
if got := clientIP(req, []string{"10.0.0.0/8"}); got != "192.168.1.1" {
341+
t.Fatalf("expected direct remote address for untrusted proxy, got %q", got)
342+
}
343+
}
344+
345+
func TestIsTrustedProxy(t *testing.T) {
346+
if !isTrustedProxy("127.0.0.1", []string{"127.0.0.1"}) {
347+
t.Error("expected exact IP match")
348+
}
349+
if !isTrustedProxy("10.0.0.5", []string{"10.0.0.0/8"}) {
350+
t.Error("expected CIDR match")
351+
}
352+
if isTrustedProxy("192.168.1.1", []string{"10.0.0.0/8"}) {
353+
t.Error("expected no match for IP outside CIDR")
354+
}
355+
if isTrustedProxy("127.0.0.1", nil) {
356+
t.Error("expected no match for empty trusted list")
357+
}
358+
}
359+
360+
func TestLoadConfig_TrustedProxiesFromEnv(t *testing.T) {
361+
dir := t.TempDir()
362+
t.Setenv("HOME", dir)
363+
t.Setenv("ODEK_TRUSTED_PROXIES", "10.0.0.0/8, 127.0.0.1")
364+
365+
cfg := config.LoadConfig(config.CLIFlags{})
366+
want := []string{"10.0.0.0/8", "127.0.0.1"}
367+
if len(cfg.TrustedProxies) != len(want) {
368+
t.Fatalf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
369+
}
370+
for i := range want {
371+
if cfg.TrustedProxies[i] != want[i] {
372+
t.Errorf("TrustedProxies[%d] = %q, want %q", i, cfg.TrustedProxies[i], want[i])
373+
}
374+
}
375+
}
376+
377+
func TestLoadConfig_TrustedProxiesFromCLI(t *testing.T) {
378+
dir := t.TempDir()
379+
t.Setenv("HOME", dir)
380+
381+
cfg := config.LoadConfig(config.CLIFlags{TrustedProxies: []string{"10.0.0.0/8", "127.0.0.1"}})
382+
want := []string{"10.0.0.0/8", "127.0.0.1"}
383+
if len(cfg.TrustedProxies) != len(want) {
384+
t.Fatalf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
385+
}
386+
}
387+
388+
func TestLoadConfig_TrustedProxiesFromFile(t *testing.T) {
389+
dir := t.TempDir()
390+
t.Setenv("HOME", dir)
391+
392+
cfgDir := filepath.Join(dir, ".odek")
393+
os.MkdirAll(cfgDir, 0755)
394+
cfgPath := filepath.Join(cfgDir, "config.json")
395+
if err := os.WriteFile(cfgPath, []byte(`{
396+
"trusted_proxies": ["10.0.0.0/8", "127.0.0.1"]
397+
}`), 0644); err != nil {
398+
t.Fatal(err)
399+
}
400+
401+
cfg := config.LoadConfig(config.CLIFlags{})
402+
want := []string{"10.0.0.0/8", "127.0.0.1"}
403+
if len(cfg.TrustedProxies) != len(want) {
404+
t.Fatalf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
405+
}
406+
}
407+
307408
func TestServe_StaticSecurityHeaders(t *testing.T) {
308409
req := httptest.NewRequest(http.MethodGet, "/", nil)
309410
rr := httptest.NewRecorder()

cmd/odek/serve.go

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func serveCmd(args []string) error {
186186
var sandboxReadonly *bool
187187
var promptCaching *bool
188188
var sandboxImage, sandboxNetwork, sandboxMemory, sandboxCPUs, sandboxUser string
189-
var toolsEnabled, toolsDisabled []string
189+
var toolsEnabled, toolsDisabled, trustedProxies []string
190190

191191
for i := 0; i < len(args); i++ {
192192
switch args[i] {
@@ -246,6 +246,15 @@ func serveCmd(args []string) error {
246246
return fmt.Errorf("--no-tool requires a value")
247247
}
248248
toolsDisabled = append(toolsDisabled, args[i])
249+
case "--trusted-proxies":
250+
i++
251+
if i >= len(args) {
252+
return fmt.Errorf("--trusted-proxies requires a value")
253+
}
254+
trustedProxies = strings.Split(args[i], ",")
255+
for j := range trustedProxies {
256+
trustedProxies[j] = strings.TrimSpace(trustedProxies[j])
257+
}
249258
default:
250259
return fmt.Errorf("unknown flag %q for serve", args[i])
251260
}
@@ -262,6 +271,7 @@ func serveCmd(args []string) error {
262271
SandboxUser: sandboxUser,
263272
ToolsEnabled: toolsEnabled,
264273
ToolsDisabled: toolsDisabled,
274+
TrustedProxies: trustedProxies,
265275
})
266276
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
267277
return err
@@ -305,7 +315,7 @@ func serveCmd(args []string) error {
305315
mux.HandleFunc("/", handleStatic(wsToken))
306316
mux.Handle("/ws", &golangws.Server{
307317
Handshake: func(cfg *golangws.Config, req *http.Request) error {
308-
return wsHandshakeWithLimits(cfg, req, wsToken)
318+
return wsHandshakeWithLimits(cfg, req, wsToken, resolved.TrustedProxies)
309319
},
310320
Handler: func(conn *golangws.Conn) {
311321
handleWS(store, resourceReg, resolved, systemMessage, conn)
@@ -319,7 +329,7 @@ func serveCmd(args []string) error {
319329
}
320330
mux.Handle("/api/resources", apiAuth(handleResourceSearch(resourceReg)))
321331
mux.Handle("/api/sessions", apiAuth(handleSessionList(store)))
322-
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store)))
332+
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, resolved.TrustedProxies)))
323333
mux.Handle("/api/models", apiAuth(handleModelList(resolved.Model)))
324334
mux.Handle("/api/cancel", apiAuth(handleCancel(store)))
325335

@@ -370,6 +380,7 @@ Flags:
370380
--sandbox-user user Container user (e.g. 1000:1000)
371381
--tool name Enable a tool for the LLM (repeatable)
372382
--no-tool name Disable a tool for the LLM (repeatable)
383+
--trusted-proxies list Comma-separated IPs/CIDRs whose X-Forwarded-For headers are trusted
373384
--help, -h Show this help`)
374385
}
375386

@@ -1311,14 +1322,14 @@ func validateServeToken(cfg *golangws.Config, req *http.Request, token string) e
13111322
// applies a per-IP upgrade rate limit and acquires the global
13121323
// concurrent-connection semaphore. The semaphore is acquired before the
13131324
// WebSocket handshake completes and released when the handler exits.
1314-
func wsHandshakeWithLimits(cfg *golangws.Config, req *http.Request, token string) error {
1325+
func wsHandshakeWithLimits(cfg *golangws.Config, req *http.Request, token string, trustedProxies []string) error {
13151326
if err := validateServeToken(cfg, req, token); err != nil {
13161327
return err
13171328
}
13181329
if err := checkLocalOrigin(nil, req); err != nil {
13191330
return err
13201331
}
1321-
if !wsUpgradeLimiter.allow(clientIP(req)) {
1332+
if !wsUpgradeLimiter.allow(clientIP(req, trustedProxies)) {
13221333
return fmt.Errorf("WebSocket upgrade rate limit exceeded")
13231334
}
13241335
select {
@@ -1519,7 +1530,7 @@ func handleSessionList(store *session.Store) http.HandlerFunc {
15191530
}
15201531
}
15211532

1522-
func handleSessionByID(store *session.Store) http.HandlerFunc {
1533+
func handleSessionByID(store *session.Store, trustedProxies []string) http.HandlerFunc {
15231534
return func(w http.ResponseWriter, r *http.Request) {
15241535
id := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
15251536
if id == "" {
@@ -1531,7 +1542,7 @@ func handleSessionByID(store *session.Store) http.HandlerFunc {
15311542
case http.MethodGet:
15321543
// Rate-limit session detail lookups per IP to slow brute-force
15331544
// enumeration of the 128-bit ID space.
1534-
if !sessionLookupLimiter.allow(clientIP(r)) {
1545+
if !sessionLookupLimiter.allow(clientIP(r, trustedProxies)) {
15351546
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
15361547
return
15371548
}
@@ -1599,16 +1610,16 @@ func handleSessionByID(store *session.Store) http.HandlerFunc {
15991610
}
16001611
}
16011612

1602-
// clientIP returns a best-effort client identifier for rate limiting. It prefers
1603-
// X-Forwarded-For / X-Real-Ip only when the direct remote address is a loopback
1604-
// proxy, otherwise uses RemoteAddr. This avoids trusting spoofed headers from
1605-
// arbitrary clients while still working behind a local reverse proxy.
1606-
func clientIP(r *http.Request) string {
1613+
// clientIP returns a best-effort client identifier for rate limiting.
1614+
// X-Forwarded-For / X-Real-Ip headers are only honored when the direct remote
1615+
// address is in trustedProxies. An empty trustedProxies list means headers are
1616+
// ignored even from loopback, closing the X-Forwarded-For spoofing bypass.
1617+
func clientIP(r *http.Request, trustedProxies []string) string {
16071618
host, _, err := net.SplitHostPort(r.RemoteAddr)
16081619
if err != nil {
16091620
host = r.RemoteAddr
16101621
}
1611-
if host == "127.0.0.1" || host == "::1" || host == "localhost" {
1622+
if isTrustedProxy(host, trustedProxies) {
16121623
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
16131624
if i := strings.Index(fwd, ","); i > 0 {
16141625
return strings.TrimSpace(fwd[:i])
@@ -1622,6 +1633,28 @@ func clientIP(r *http.Request) string {
16221633
return host
16231634
}
16241635

1636+
// isTrustedProxy reports whether host is in the trusted proxy list. Entries may
1637+
// be exact IPs or CIDR ranges.
1638+
func isTrustedProxy(host string, trusted []string) bool {
1639+
if host == "" {
1640+
return false
1641+
}
1642+
ip := net.ParseIP(host)
1643+
for _, entry := range trusted {
1644+
entry = strings.TrimSpace(entry)
1645+
if entry == "" {
1646+
continue
1647+
}
1648+
if entry == host {
1649+
return true
1650+
}
1651+
if _, cidr, err := net.ParseCIDR(entry); err == nil && ip != nil && cidr.Contains(ip) {
1652+
return true
1653+
}
1654+
}
1655+
return false
1656+
}
1657+
16251658
func handleModelList(configuredModel string) http.HandlerFunc {
16261659
return func(w http.ResponseWriter, r *http.Request) {
16271660
if r.Method != http.MethodGet {

cmd/odek/serve_api_test.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func TestHandleSessionByID_GET_ReturnsSession(t *testing.T) {
7070
t.Fatalf("Create session: %v", err)
7171
}
7272

73-
handler := handleSessionByID(store)
73+
handler := handleSessionByID(store, nil)
7474
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
7575
req.Header.Set("X-Session-Token", sess.AuthToken)
7676
w := httptest.NewRecorder()
@@ -106,7 +106,7 @@ func TestHandleSessionByID_GET_ReturnsSession(t *testing.T) {
106106

107107
func TestHandleSessionByID_GET_NotFound(t *testing.T) {
108108
store := newTestSessionStore(t)
109-
handler := handleSessionByID(store)
109+
handler := handleSessionByID(store, nil)
110110

111111
// Valid ID format but doesn't exist on disk.
112112
req := httptest.NewRequest(http.MethodGet, "/api/sessions/20260101-aabbcc", nil)
@@ -120,7 +120,7 @@ func TestHandleSessionByID_GET_NotFound(t *testing.T) {
120120

121121
func TestHandleSessionByID_GET_MissingID(t *testing.T) {
122122
store := newTestSessionStore(t)
123-
handler := handleSessionByID(store)
123+
handler := handleSessionByID(store, nil)
124124

125125
req := httptest.NewRequest(http.MethodGet, "/api/sessions/", nil)
126126
w := httptest.NewRecorder()
@@ -145,7 +145,7 @@ func TestHandleSessionByID_GET_MessagesArePresent(t *testing.T) {
145145
t.Fatalf("Create: %v", err)
146146
}
147147

148-
handler := handleSessionByID(store)
148+
handler := handleSessionByID(store, nil)
149149
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
150150
req.Header.Set("X-Session-Token", sess.AuthToken)
151151
w := httptest.NewRecorder()
@@ -161,7 +161,7 @@ func TestHandleSessionByID_GET_MessagesArePresent(t *testing.T) {
161161

162162
func TestHandleSessionByID_MethodNotAllowed(t *testing.T) {
163163
store := newTestSessionStore(t)
164-
handler := handleSessionByID(store)
164+
handler := handleSessionByID(store, nil)
165165

166166
for _, method := range []string{http.MethodPatch, http.MethodPut, http.MethodHead} {
167167
req := httptest.NewRequest(method, "/api/sessions/20260101-aabbcc", nil)
@@ -182,7 +182,7 @@ func TestHandleSessionByID_DELETE_StillWorks(t *testing.T) {
182182
t.Fatalf("Create: %v", err)
183183
}
184184

185-
handler := handleSessionByID(store)
185+
handler := handleSessionByID(store, nil)
186186
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sess.ID, nil)
187187
req.Header.Set("X-Session-Token", sess.AuthToken)
188188
w := httptest.NewRecorder()
@@ -209,7 +209,7 @@ func TestHandleSessionByID_POST_RenameStillWorks(t *testing.T) {
209209
t.Fatalf("Create: %v", err)
210210
}
211211

212-
handler := handleSessionByID(store)
212+
handler := handleSessionByID(store, nil)
213213
body := strings.NewReader(`{"name":"renamed task"}`)
214214
req := httptest.NewRequest(http.MethodPost, "/api/sessions/"+sess.ID, body)
215215
req.Header.Set("Content-Type", "application/json")
@@ -232,7 +232,7 @@ func TestHandleSessionByID_GET_InvalidToken(t *testing.T) {
232232
store := newTestSessionStore(t)
233233
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
234234

235-
handler := handleSessionByID(store)
235+
handler := handleSessionByID(store, nil)
236236
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
237237
req.Header.Set("X-Session-Token", "wrong-token")
238238
w := httptest.NewRecorder()
@@ -247,7 +247,7 @@ func TestHandleSessionByID_GET_MissingToken(t *testing.T) {
247247
store := newTestSessionStore(t)
248248
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
249249

250-
handler := handleSessionByID(store)
250+
handler := handleSessionByID(store, nil)
251251
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
252252
w := httptest.NewRecorder()
253253
handler(w, req)
@@ -268,7 +268,7 @@ func TestHandleSessionByID_GET_LazyTokenBootstrap(t *testing.T) {
268268
t.Fatalf("Save: %v", err)
269269
}
270270

271-
handler := handleSessionByID(store)
271+
handler := handleSessionByID(store, nil)
272272
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
273273
w := httptest.NewRecorder()
274274
handler(w, req)
@@ -301,7 +301,7 @@ func TestHandleSessionByID_DELETE_RequiresToken(t *testing.T) {
301301
store := newTestSessionStore(t)
302302
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
303303

304-
handler := handleSessionByID(store)
304+
handler := handleSessionByID(store, nil)
305305
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/"+sess.ID, nil)
306306
req.Header.Set("X-Session-Token", "wrong-token")
307307
w := httptest.NewRecorder()
@@ -316,7 +316,7 @@ func TestHandleSessionByID_POST_RequiresToken(t *testing.T) {
316316
store := newTestSessionStore(t)
317317
sess, _ := store.Create([]llm.Message{{Role: "user", Content: "hi"}}, "m", "task")
318318

319-
handler := handleSessionByID(store)
319+
handler := handleSessionByID(store, nil)
320320
body := strings.NewReader(`{"name":"renamed"}`)
321321
req := httptest.NewRequest(http.MethodPost, "/api/sessions/"+sess.ID, body)
322322
req.Header.Set("Content-Type", "application/json")
@@ -336,7 +336,7 @@ func TestHandleSessionByID_GET_RateLimit(t *testing.T) {
336336
sessionLookupLimiter.reset()
337337
defer sessionLookupLimiter.reset()
338338

339-
handler := handleSessionByID(store)
339+
handler := handleSessionByID(store, nil)
340340
// Exhaust the 60/min allowance.
341341
for i := 0; i < 60; i++ {
342342
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+sess.ID, nil)
@@ -741,7 +741,7 @@ func buildServeMuxWithSessionByID(t *testing.T, store *session.Store) (net.Liste
741741
apiAuth := func(h http.Handler) http.Handler {
742742
return requireServeToken(token)(requireLocalHost(requireLocalOrigin(h)))
743743
}
744-
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store)))
744+
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, nil)))
745745
return ln, mux
746746
}
747747

cmd/odek/serve_approval_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func buildServeMuxPromptAll(t *testing.T, store *session.Store) (net.Listener, *
5656
mux.HandleFunc("/", handleStatic(wsToken))
5757
mux.Handle("/ws", &golangws.Server{
5858
Handshake: func(cfg *golangws.Config, req *http.Request) error {
59-
return wsHandshakeWithLimits(cfg, req, wsToken)
59+
return wsHandshakeWithLimits(cfg, req, wsToken, nil)
6060
},
6161
Handler: func(conn *golangws.Conn) {
6262
handleWS(store, resourceReg, resolved, systemMessage, conn)

0 commit comments

Comments
 (0)