Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

### Added

- **WebSocket fragment timeout and size limits.** Fragment accumulation
now tracks per-connection timestamps via `ConnState.ws-frag-start`.
`sweep-idle` closes connections where fragments have been accumulating
longer than `App.ws-frag-ttl` seconds (default 30), preventing
memory exhaustion from incomplete messages. A separate
`App.ws-max-frag-size` constant (default 1 MiB) governs the maximum
accumulated fragment size, independent of `App.max-request-size`.

- **Slow-client timeout (slow loris protection).** New connections must
complete HTTP headers within `App.header-timeout` seconds (default 15).
Connections that trickle bytes without completing the request line and
Expand Down
13 changes: 12 additions & 1 deletion test/websocket.carp
Original file line number Diff line number Diff line change
Expand Up @@ -1012,4 +1012,15 @@
&(the (Array String) [])
&(let [app (-> (App.create) (App.WS @"/ws/echo" (fn [e p w] ())))]
@(WSRoute.protocols (Array.unsafe-first (App.ws-routes &app))))
"App.WS stores empty protocols on the route"))
"App.WS stores empty protocols on the route")

; ---------------------------------------------------------------------------
; Fragment TTL and size limit defaults
; ---------------------------------------------------------------------------

(assert-equal test 30 App.ws-frag-ttl "ws-frag-ttl defaults to 30 seconds")

(assert-equal test
1048576
App.ws-max-frag-size
"ws-max-frag-size defaults to 1 MiB"))
65 changes: 45 additions & 20 deletions web.carp
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,7 @@ responses. The `protocols` field lists the subprotocols that this route supports
ws-protocol (Map Int (Maybe String))
ws-frag-bufs (Map Int (Array Byte))
ws-frag-opcodes (Map Int Int)
ws-frag-start (Map Int Int)
ws-ping-count (Map Int Int)
ws-last-ping (Map Int Int)])

Expand Down Expand Up @@ -1428,6 +1429,8 @@ fallback.")
(def header-timeout 15)
(def ws-ping-interval 30)
(def ws-max-missed-pongs 3)
(def ws-frag-ttl 30)
(def ws-max-frag-size 1048576)
(def running true)

; -----------------------------------------------------------------------
Expand Down Expand Up @@ -1488,6 +1491,7 @@ fallback.")
(Map.remove! (ConnState.ws-protocol cs) &fd)
(Map.remove! (ConnState.ws-frag-bufs cs) &fd)
(Map.remove! (ConnState.ws-frag-opcodes cs) &fd)
(Map.remove! (ConnState.ws-frag-start cs) &fd)
(Map.remove! (ConnState.ws-ping-count cs) &fd)
(Map.remove! (ConnState.ws-last-ping cs) &fd)))

Expand Down Expand Up @@ -1701,12 +1705,19 @@ fallback.")
(do
(Map.remove! (ConnState.ws-frag-bufs cs) &fd)
(Map.remove! (ConnState.ws-frag-opcodes cs) &fd)
(Map.remove! (ConnState.ws-frag-start cs) &fd)
(set! should-close true)
(break))
(let-do [payload @(WSFrame.payload &frame)]
(Map.put! (ConnState.ws-frag-opcodes cs) &fd &op)
(Map.put! (ConnState.ws-frag-bufs cs) &fd &payload)
(set! offset (+ offset consumed))))
(if (> plen App.ws-max-frag-size)
; First fragment already exceeds limit
(do (set! should-close true) (break))
(let-do [payload @(WSFrame.payload &frame)]
(Map.put! (ConnState.ws-frag-opcodes cs) &fd &op)
(Map.put! (ConnState.ws-frag-bufs cs) &fd &payload)
(Map.put! (ConnState.ws-frag-start cs)
&fd
&(System.time))
(set! offset (+ offset consumed)))))
; Continuation frame (opcode 0)
(= op 0)
(if (not
Expand All @@ -1718,11 +1729,12 @@ fallback.")
&(fn [b]
(Array.length b))
0)]
(if (> (+ frag-size plen) App.max-request-size)
(if (> (+ frag-size plen) App.ws-max-frag-size)
; Accumulated message too large
(do
(Map.remove! (ConnState.ws-frag-bufs cs) &fd)
(Map.remove! (ConnState.ws-frag-opcodes cs) &fd)
(Map.remove! (ConnState.ws-frag-start cs) &fd)
(set! should-close true)
(break))
(let-do [frag-buf (Map.value-ref! (ConnState.ws-frag-bufs cs)
Expand All @@ -1741,6 +1753,7 @@ fallback.")
(Map.remove! (ConnState.ws-frag-bufs cs) &fd)
(Map.remove! (ConnState.ws-frag-opcodes cs)
&fd)
(Map.remove! (ConnState.ws-frag-start cs) &fd)
(if (= orig-op 1)
(~(WSRoute.handler (Array.unsafe-nth ws-routes
route-idx))
Expand All @@ -1765,6 +1778,7 @@ fallback.")
(do
(Map.remove! (ConnState.ws-frag-bufs cs) &fd)
(Map.remove! (ConnState.ws-frag-opcodes cs) &fd)
(Map.remove! (ConnState.ws-frag-start cs) &fd)
(set! should-close true)
(break))
(do
Expand Down Expand Up @@ -2054,20 +2068,21 @@ fallback.")
is-ws (Map.contains? (ConnState.ws-route-idx cs) &rfd)]
(if is-ws
; WebSocket: ping/pong dead client detection
(let [pcount (if (Map.contains? (ConnState.ws-ping-count cs) &rfd)
(Map.get (ConnState.ws-ping-count cs) &rfd)
0)
last-ping (if (Map.contains? (ConnState.ws-last-ping cs) &rfd)
(Map.get (ConnState.ws-last-ping cs) &rfd)
0)
since-ping (- now last-ping)
write-pending (Map.contains? (ConnState.write-bufs cs) &rfd)
action (ws-ping-action App.ws-ping-interval
App.ws-max-missed-pongs
idle
pcount
since-ping
write-pending)]
(let-do [pcount (if (Map.contains? (ConnState.ws-ping-count cs) &rfd)
(Map.get (ConnState.ws-ping-count cs) &rfd)
0)
last-ping (if (Map.contains? (ConnState.ws-last-ping cs)
&rfd)
(Map.get (ConnState.ws-last-ping cs) &rfd)
0)
since-ping (- now last-ping)
write-pending (Map.contains? (ConnState.write-bufs cs) &rfd)
action (ws-ping-action App.ws-ping-interval
App.ws-max-missed-pongs
idle
pcount
since-ping
write-pending)]
(cond
(= action 2) (queue-close cs rfd)
(= action 1)
Expand All @@ -2079,7 +2094,16 @@ fallback.")
(Map.put! (ConnState.ws-ping-count cs) &rfd &(+ pcount 1))
(Map.put! (ConnState.ws-last-ping cs) &rfd &now)
(ignore (Poll.modify poll rfd poll-write)))
()))
())
; Fragment TTL: close connections with stale partial messages
(when (and (> App.ws-frag-ttl 0)
(Map.contains? (ConnState.ws-frag-start cs) &rfd))
(let [frag-ts (Map.get (ConnState.ws-frag-start cs) &rfd)]
(when-do (> (- now frag-ts) App.ws-frag-ttl)
(Map.remove! (ConnState.ws-frag-start cs) &rfd)
(Map.remove! (ConnState.ws-frag-bufs cs) &rfd)
(Map.remove! (ConnState.ws-frag-opcodes cs) &rfd)
(queue-close cs rfd)))))
; HTTP: header accumulation timeout (slow loris) + idle timeout
(let [rs (Map.get (ConnState.read-start cs) &rfd)]
(cond
Expand Down Expand Up @@ -2175,6 +2199,7 @@ For multi-core scaling, run several copies behind a TCP load balancer.")
{}
{}
{}
{}
{})]
(IO.println &(fmt "Listening on %s:%d" host port))
(set! App.running true)
Expand Down