From c75a2f60b57e544fea8d0f2f305a435980af84cb Mon Sep 17 00:00:00 2001 From: SamAcctX <87765660+SamAcctX@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:09:43 -0500 Subject: [PATCH] server: sanitised session ids, chat length limit, websocket read limit, leaky frame queues (#712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * server: generated video pipelines keep at most one raw frame queued GetPipeline used plain `queue` elements, which buffer up to 1s / 200 buffers. When the encoder cannot keep up, frames pile up there and the stream (and every reaction to input) falls behind by up to that much. The queues now hold only the newest frame (leaky=downstream): a slow encoder drops frames instead of adding latency. Measured input-to-display latency (Alt+F2/Escape opens/closes a dialog, time to the first changed received frame; xfce image built with ./build, 1920x1080, default VP8 pipeline, 30 trials per run, runs interleaved): before p50/p90 ms after p50/p90 ms no CPU limit 268/303, 188/225 183/217, 157/226 --cpus=1 270/422, 362/673 155/237, 200/428 fps unchanged without a limit (25), slightly higher with one (22.5 -> 23.5-24). Pipelines given as raw gst strings are unchanged. Tests: TestVideoConfigGetPipelineQueues; client e2e on the built image. Co-Authored-By: Claude Opus 5.5 * server: session ids no longer carry the login name verbatim The multiuser and noauth providers built the session id as "-". The v3 API addresses sessions and members by id in the URL path (DELETE /api/sessions/{id}, POST /api/members/{id}, POST /api/room/control/give/{id}), so a login name like "x/../../logout?" produced an id that sent an admin's unencoded request to another endpoint, and even when the client encodes it, an id with "/" never matches the chi route: such a member could not be kicked, muted or banned. utils.SafeID maps a name to letters and digits of any script plus ".", "_" and "-": every run of other characters becomes one "-", leading and trailing "-" and "." are trimmed, the result is cut to 64 characters, and an empty result becomes "user". Nothing with a meaning in a URL path survives; letters outside ASCII are kept so names such as "Müller" stay recognisable (clients percent-encode ids in paths). Both providers now use SafeID(username) + "-" + token; the profile name keeps the name as typed, so nothing changes on screen. The file and object providers use the username itself as the member id. Members there are created explicitly by an admin, so POST /api/members now rejects a name SafeID would change with a 400 instead of silently altering it. The oauth provider derives ids from the IdP subject ("oauth:" + sub) and is unchanged. Tests: TestSafeID (pkg/utils). Co-Authored-By: Claude Fable 5.1 * server: chat plugin limits message length (chat.max_length) The chat plugin accepted text of any size, over the websocket (chat/message) and over REST (POST /api/chat), and broadcast it to every session. The web client caps its input box at 512 characters, but any client can send more, and one large message stalls every viewer's renderer. New option chat.max_length (default 512, counted in characters with utf8.RuneCountInString, not bytes; 0 disables the limit). Both paths go through one check: REST answers 400 with the length and the limit, the websocket handler logs at warn level and drops the message, as it already does for a sender without permission. The default never rejects what the web client can type: its maxlength counts UTF-16 units, which is never fewer than runes. Docs: plugins.md and release notes. help.txt/help.json list no plugin options (no chat.*, filetransfer.*, openinapp.* entries), so nothing was added there. Tests: TestCheckLength (internal/plugins/chat). Co-Authored-By: Claude Fable 5.1 * server: limit the size of websocket frames a client may send There was no read limit on the websocket, so a client could push frames of any size; they were read and decoded in full before a handler (such as the chat length check) could reject them. Frames above 1 MiB now end the connection, as gorilla's SetReadLimit does. The largest legitimate messages, an SDP answer and pasted clipboard text, stay far below that. Co-Authored-By: Claude Fable 5.1 * server: video RTP timestamps follow the time between captured frames ximagesrc gives a frame it captures late a buffer duration of only the time left until the next frame slot ("Frame duration is from now until the next expected capture time"), and frames dropped before the encoder leave gaps. pion advances the RTP clock by Sample.Duration, so whenever the encoder falls behind, the stream's clock runs slower than real time. Browsers grow their jitter buffer to make up for it and lip sync drifts. This happens with any queue in front of the encoder. Each video sample's duration is now the gap since the previous sample's PTS. The gap is known only when the next frame arrives, so it is applied one frame late; the clock itself stays right. The sink also counts frames in neko_capture_streamsink_frames: a rate below the configured fps means the encoder falls behind. Measured on the firefox image built with ./build, 1080p25 VP8, the container limited to 0.5 CPU: RTP clock 0.2-0.9x real time before, 1.00x after; browser video jitter buffer 0.24-1.9 s before, 60-220 ms after; audio up to 0.4 s (default queue) or 0.74 s (one-frame leaky queue) ahead of the video before, within -70..+150 ms after. Tests: TestSampleDuration; go test -race ./internal/capture. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- server/internal/api/members/controler.go | 5 +++ server/internal/capture/streamsink.go | 34 ++++++++++++++++++ server/internal/capture/streamsink_test.go | 23 +++++++++++++ server/internal/member/multiuser/provider.go | 5 ++- server/internal/member/noauth/provider.go | 5 ++- server/internal/plugins/chat/config.go | 8 +++++ server/internal/plugins/chat/manager.go | 18 ++++++++++ server/internal/plugins/chat/manager_test.go | 32 +++++++++++++++++ server/internal/websocket/manager.go | 6 ++++ server/pkg/types/capture.go | 10 ++++-- server/pkg/types/capture_test.go | 36 ++++++++++++++++++++ server/pkg/utils/id.go | 29 ++++++++++++++++ server/pkg/utils/id_test.go | 34 ++++++++++++++++++ webpage/docs/configuration/plugins.md | 4 ++- webpage/docs/release-notes.md | 9 ++++- 15 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 server/internal/plugins/chat/manager_test.go create mode 100644 server/pkg/types/capture_test.go create mode 100644 server/pkg/utils/id.go create mode 100644 server/pkg/utils/id_test.go diff --git a/server/internal/api/members/controler.go b/server/internal/api/members/controler.go index 0b0603cc..f567a75b 100644 --- a/server/internal/api/members/controler.go +++ b/server/internal/api/members/controler.go @@ -77,6 +77,11 @@ func (h *MembersHandler) membersCreate(w http.ResponseWriter, r *http.Request) e return utils.HttpBadRequest("username cannot be empty") } + // file and object providers use the username as the member id, which ends up in URL paths + if data.Username != utils.SafeID(data.Username) { + return utils.HttpBadRequest("username may only contain letters, digits, '.', '_' and '-', at most 64 characters, and may not start or end with '.' or '-'") + } + if data.Password == "" { return utils.HttpBadRequest("password cannot be empty") } diff --git a/server/internal/capture/streamsink.go b/server/internal/capture/streamsink.go index 404128b4..86e87954 100644 --- a/server/internal/capture/streamsink.go +++ b/server/internal/capture/streamsink.go @@ -58,6 +58,7 @@ type StreamSinkManagerCtx struct { // metrics currentListeners prometheus.Gauge totalBytes prometheus.Counter + frames prometheus.Counter pipelinesCounter prometheus.Counter pipelinesActive prometheus.Gauge } @@ -110,6 +111,17 @@ func streamSinkNew(codec codec.RTPCodec, pipelineFn func() (string, error), id s "codec_type": codec.Type.String(), }, }), + frames: promauto.NewCounter(prometheus.CounterOpts{ + Name: "streamsink_frames", + Namespace: "neko", + Subsystem: "capture", + Help: "Total number of video frames created by the pipeline; a rate below the configured fps means the encoder falls behind.", + ConstLabels: map[string]string{ + "video_id": id, + "codec_name": codec.Name, + "codec_type": codec.Type.String(), + }, + }), pipelinesCounter: promauto.NewCounter(prometheus.CounterOpts{ Name: "pipelines_total", Namespace: "neko", @@ -392,9 +404,11 @@ func (manager *StreamSinkManagerCtx) createPipeline() error { manager.pipeline.Play() pipeline := manager.pipeline + video := manager.codec.IsVideo() manager.wg.Go(func() { manager.logger.Debug().Msg("started emitting samples") + lastPTS := time.Duration(-1) for { sample, ok := <-pipeline.Sample() if !ok { @@ -402,6 +416,12 @@ func (manager *StreamSinkManagerCtx) createPipeline() error { return } + if video { + sample.Duration = sampleDuration(lastPTS, sample) + lastPTS = sample.PTS + manager.frames.Inc() + } + manager.onSample(sample) } }) @@ -436,6 +456,20 @@ func (manager *StreamSinkManagerCtx) saveSampleBitrate(timestamp time.Time, delt manager.brBuckets[curr] += delta } +// sampleDuration returns how far a consumer's clock (the RTP timestamp) must advance for this +// video sample: the time since the previous one. A buffer's own duration is not that: ximagesrc +// gives a frame it captures late only the time left until the next frame slot, and frames +// dropped before the encoder leave gaps. Advancing by it makes the stream's clock run slow +// under load, and browsers grow their jitter buffer to compensate (up to seconds). +// The gap is known only when the next sample arrives, so it is applied one frame late; the +// clock itself stays right. +func sampleDuration(lastPTS time.Duration, sample types.Sample) time.Duration { + if lastPTS < 0 || sample.PTS <= lastPTS { + return sample.Duration + } + return sample.PTS - lastPTS +} + func (manager *StreamSinkManagerCtx) onSample(sample types.Sample) { // save to metrics length := float64(sample.Length) diff --git a/server/internal/capture/streamsink_test.go b/server/internal/capture/streamsink_test.go index 98f33e72..dab0ceb2 100644 --- a/server/internal/capture/streamsink_test.go +++ b/server/internal/capture/streamsink_test.go @@ -230,3 +230,26 @@ func TestBitrateDuringPipelineRestart(t *testing.T) { s.destroyPipeline() } } + +func TestSampleDuration(t *testing.T) { + const frame = 40 * time.Millisecond + tests := []struct { + name string + lastPTS time.Duration + pts time.Duration + duration time.Duration + want time.Duration + }{ + {"first sample keeps its duration", -1, 0, frame, frame}, + {"next frame", 0, frame, frame, frame}, + {"late capture: ximagesrc's short duration is not the gap", 0, frame, 2 * time.Millisecond, frame}, + {"two frames dropped before the encoder", 0, 3 * frame, frame, 3 * frame}, + {"unknown pts", 0, -1, frame, frame}, + {"timestamps going backwards", 2 * frame, frame, frame, frame}, + } + for _, tt := range tests { + if got := sampleDuration(tt.lastPTS, types.Sample{PTS: tt.pts, Duration: tt.duration}); got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/server/internal/member/multiuser/provider.go b/server/internal/member/multiuser/provider.go index 3b708cad..0e5fb331 100644 --- a/server/internal/member/multiuser/provider.go +++ b/server/internal/member/multiuser/provider.go @@ -2,7 +2,6 @@ package multiuser import ( "errors" - "fmt" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/utils" @@ -33,8 +32,8 @@ func (provider *MemberProviderCtx) Authenticate(username string, password string return "", types.MemberProfile{}, err } - // id is username with token - id := fmt.Sprintf("%s-%s", username, token) + // id is username with token; the id ends up in URL paths, the name stays as typed + id := utils.SafeID(username) + "-" + token // if logged in as administrator if provider.config.AdminPassword == password { diff --git a/server/internal/member/noauth/provider.go b/server/internal/member/noauth/provider.go index 97cf1137..cedd33c1 100644 --- a/server/internal/member/noauth/provider.go +++ b/server/internal/member/noauth/provider.go @@ -2,7 +2,6 @@ package noauth import ( "errors" - "fmt" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/utils" @@ -43,8 +42,8 @@ func (provider *MemberProviderCtx) Authenticate(username string, password string return "", types.MemberProfile{}, err } - // id is username with token - id := fmt.Sprintf("%s-%s", username, token) + // id is username with token; the id ends up in URL paths, the name stays as typed + id := utils.SafeID(username) + "-" + token provider.profile.Name = username return id, provider.profile, nil diff --git a/server/internal/plugins/chat/config.go b/server/internal/plugins/chat/config.go index dd24835c..d6f6f530 100644 --- a/server/internal/plugins/chat/config.go +++ b/server/internal/plugins/chat/config.go @@ -7,6 +7,8 @@ import ( type Config struct { Enabled bool + // MaxLength is the longest accepted message in characters (not bytes); 0 disables the limit. + MaxLength int } func (Config) Init(cmd *cobra.Command) error { @@ -15,9 +17,15 @@ func (Config) Init(cmd *cobra.Command) error { return err } + cmd.PersistentFlags().Int("chat.max_length", 512, "longest accepted chat message in characters, 0 for no limit") + if err := viper.BindPFlag("chat.max_length", cmd.PersistentFlags().Lookup("chat.max_length")); err != nil { + return err + } + return nil } func (s *Config) Set() { s.Enabled = viper.GetBool("chat.enabled") + s.MaxLength = viper.GetInt("chat.max_length") } diff --git a/server/internal/plugins/chat/manager.go b/server/internal/plugins/chat/manager.go index 0a20556e..b1936960 100644 --- a/server/internal/plugins/chat/manager.go +++ b/server/internal/plugins/chat/manager.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "time" + "unicode/utf8" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -65,6 +66,14 @@ func (m *Manager) settingsForSession(session types.Session) (Settings, error) { }, nil } +// checkLength rejects text longer than chat.max_length characters. +func (m *Manager) checkLength(text string) error { + if n := utf8.RuneCountInString(text); m.config.MaxLength > 0 && n > m.config.MaxLength { + return fmt.Errorf("message has %d characters, limit is %d", n, m.config.MaxLength) + } + return nil +} + func (m *Manager) sendMessage(session types.Session, content Content) { now := time.Now() @@ -128,6 +137,11 @@ func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMes // we processed the message, return true return true } + if err := m.checkLength(content.Text); err != nil { + m.logger.Warn().Err(err).Msg("chat message dropped") + // we processed the message, return true + return true + } m.sendMessage(session, content) return true @@ -157,6 +171,10 @@ func (m *Manager) sendMessageHandler(w http.ResponseWriter, r *http.Request) err return err } + if err := m.checkLength(content.Text); err != nil { + return utils.HttpBadRequest(err.Error()) + } + m.sendMessage(session, content) return utils.HttpSuccess(w) } diff --git a/server/internal/plugins/chat/manager_test.go b/server/internal/plugins/chat/manager_test.go new file mode 100644 index 00000000..3afe009f --- /dev/null +++ b/server/internal/plugins/chat/manager_test.go @@ -0,0 +1,32 @@ +package chat + +import ( + "strings" + "testing" +) + +func TestCheckLength(t *testing.T) { + tests := []struct { + name string + max int + text string + ok bool + }{ + {"under", 5, "abcd", true}, + {"at", 5, "abcde", true}, + {"over", 5, "abcdef", false}, + {"empty", 5, "", true}, + {"multi-byte at", 5, "ééééé", true}, + {"multi-byte over", 5, "éééééé", false}, + {"emoji counts as one", 1, "😀", true}, + {"zero means unlimited", 0, strings.Repeat("a", 100000), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Manager{config: &Config{MaxLength: tt.max}} + if err := m.checkLength(tt.text); (err == nil) != tt.ok { + t.Errorf("checkLength(%d, %q) = %v, want ok=%v", tt.max, tt.text, err, tt.ok) + } + }) + } +} diff --git a/server/internal/websocket/manager.go b/server/internal/websocket/manager.go index e62bba33..f5a24fd4 100644 --- a/server/internal/websocket/manager.go +++ b/server/internal/websocket/manager.go @@ -28,6 +28,11 @@ const ( // maximum payload length for logging maxPayloadLogLength = 10_000 + + // largest frame a client may send; the biggest legitimate messages (an SDP answer, pasted + // clipboard text) are far below this, and without a limit a client can push arbitrarily + // large frames that are read and decoded before any handler can reject them + maxMessageSize = 1 << 20 ) // events that are not logged in debug mode @@ -215,6 +220,7 @@ func (manager *WebSocketManagerCtx) Upgrade(checkOrigin types.CheckOrigin) types if err != nil { return utils.HttpBadRequest().WithInternalErr(err) } + connection.SetReadLimit(maxMessageSize) // Cannot write HTTP response after connection upgrade manager.connect(connection, r) diff --git a/server/pkg/types/capture.go b/server/pkg/types/capture.go index 49e615df..f63f5e4d 100644 --- a/server/pkg/types/capture.go +++ b/server/pkg/types/capture.go @@ -182,8 +182,12 @@ func (config *VideoConfig) GetPipeline(screen ScreenSize) (string, error) { }), } + // queue holding at most the newest raw frame: when the encoder falls behind, older frames are + // dropped instead of piling up (a default queue buffers up to 1s, which becomes input lag) + const queue = "queue max-size-buffers=1 max-size-time=0 max-size-bytes=0 leaky=downstream" + // get fps pipeline - fpsPipeline := "! video/x-raw ! videoconvert ! queue" + fpsPipeline := "! video/x-raw ! videoconvert ! " + queue if config.Fps != "" { eval, err := gval.Full(language...).NewEvaluable(config.Fps) if err != nil { @@ -195,7 +199,7 @@ func (config *VideoConfig) GetPipeline(screen ScreenSize) (string, error) { return "", err } - fpsPipeline = fmt.Sprintf("! capsfilter caps=video/x-raw,framerate=%d/100 name=framerate ! videoconvert ! queue", int(val*100)) + fpsPipeline = fmt.Sprintf("! capsfilter caps=video/x-raw,framerate=%d/100 name=framerate ! videoconvert ! %s", int(val*100), queue) } // get scale pipeline @@ -222,7 +226,7 @@ func (config *VideoConfig) GetPipeline(screen ScreenSize) (string, error) { } // element videoscale parameter method to 0 meaning nearest neighbor - scalePipeline = fmt.Sprintf("! videoscale method=0 ! capsfilter caps=video/x-raw,width=%d,height=%d name=resolution ! queue", w, h) + scalePipeline = fmt.Sprintf("! videoscale method=0 ! capsfilter caps=video/x-raw,width=%d,height=%d name=resolution ! %s", w, h, queue) } // get encoder pipeline diff --git a/server/pkg/types/capture_test.go b/server/pkg/types/capture_test.go new file mode 100644 index 00000000..48ce1227 --- /dev/null +++ b/server/pkg/types/capture_test.go @@ -0,0 +1,36 @@ +package types + +import ( + "strings" + "testing" +) + +func TestVideoConfigGetPipelineQueues(t *testing.T) { + screen := ScreenSize{Width: 1920, Height: 1080, Rate: 30} + leaky := "queue max-size-buffers=1 max-size-time=0 max-size-bytes=0 leaky=downstream" + + tests := []struct { + name string + config VideoConfig + queues int + }{ + {"plain", VideoConfig{GstEncoder: "vp8enc"}, 1}, + {"fps", VideoConfig{Fps: "25", GstEncoder: "vp8enc"}, 1}, + {"fps+scale", VideoConfig{Fps: "25", Width: "width / 2", Height: "height / 2", GstEncoder: "vp8enc"}, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipeline, err := tt.config.GetPipeline(screen) + if err != nil { + t.Fatal(err) + } + // every queue in a generated pipeline keeps only the newest frame + if got := strings.Count(pipeline, "queue"); got != tt.queues { + t.Errorf("queues = %d, want %d: %s", got, tt.queues, pipeline) + } + if got := strings.Count(pipeline, leaky); got != tt.queues { + t.Errorf("leaky queues = %d, want %d: %s", got, tt.queues, pipeline) + } + }) + } +} diff --git a/server/pkg/utils/id.go b/server/pkg/utils/id.go new file mode 100644 index 00000000..69428057 --- /dev/null +++ b/server/pkg/utils/id.go @@ -0,0 +1,29 @@ +package utils + +import ( + "regexp" + "strings" +) + +var unsafeIDChars = regexp.MustCompile(`[^\p{L}\p{N}._-]+`) + +// SafeID maps a user-supplied name to a string that can be used as a +// session or member id in URL paths: letters and digits (any script), '.', +// '_' and '-' are kept, every run of other characters becomes a single '-', +// leading and trailing '-' and '.' are removed, the result is cut to 64 +// characters and an empty result becomes "user". +// +// Nothing that has a meaning in a URL path survives ("/", "?", "#", "%", +// spaces), and trimming '.' keeps "." and ".." from ever being an id. Letters +// outside ASCII are kept so that names such as "Müller" or "山田" stay +// recognisable; clients percent-encode ids when they put them in a path. +func SafeID(name string) string { + id := strings.Trim(unsafeIDChars.ReplaceAllString(name, "-"), "-.") + if r := []rune(id); len(r) > 64 { + id = strings.TrimRight(string(r[:64]), "-.") + } + if id == "" { + return "user" + } + return id +} diff --git a/server/pkg/utils/id_test.go b/server/pkg/utils/id_test.go new file mode 100644 index 00000000..e65959c2 --- /dev/null +++ b/server/pkg/utils/id_test.go @@ -0,0 +1,34 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestSafeID(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain", "john.doe_1-2", "john.doe_1-2"}, + {"path traversal", "x/../../logout?", "x-..-..-logout"}, + {"symbol suffix", "victim-abc12#", "victim-abc12"}, + {"spaces", "John Doe Jr", "John-Doe-Jr"}, + {"all symbols", "!@#$%^&*()", "user"}, + {"only dots", "..", "user"}, + {"empty", "", "user"}, + {"long", strings.Repeat("a", 70), strings.Repeat("a", 64)}, + {"long cut on dash", strings.Repeat("a", 63) + "/" + strings.Repeat("b", 10), strings.Repeat("a", 63)}, + {"unicode", "Zoë Müller", "Zoë-Müller"}, + {"unicode only", "山田太郎", "山田太郎"}, + {"long unicode cut by characters", strings.Repeat("ä", 70), strings.Repeat("ä", 64)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SafeID(tt.in); got != tt.want { + t.Errorf("SafeID(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/webpage/docs/configuration/plugins.md b/webpage/docs/configuration/plugins.md index 768b8daa..a73ba903 100644 --- a/webpage/docs/configuration/plugins.md +++ b/webpage/docs/configuration/plugins.md @@ -31,10 +31,12 @@ There exist a few pre-loaded internal plugins that are shipped with Neko: The chat plugin is a simple pre-loaded internal plugin that allows you to chat with other users in the same session. The chat messages are sent to the server and then broadcasted to all users in the same session. - enables the chat support. If set to `false`, the chat is disabled. +- is the longest accepted chat message, counted in characters (not bytes). Longer messages are rejected with `400` over the API and dropped over the websocket. Set to `0` to disable the limit. The chat plugin extends user profile and room settings by adding the following fields: diff --git a/webpage/docs/release-notes.md b/webpage/docs/release-notes.md index 1e7a1a22..9732c481 100644 --- a/webpage/docs/release-notes.md +++ b/webpage/docs/release-notes.md @@ -2,7 +2,14 @@ ## master {#master} -No unreleased changes yet. +### Fixes {#master-fixes} +- Fixed video timestamps running slow when the encoder falls behind: frames captured late or dropped before the encoder made the stream's clock lag real time, so browsers buffered up to seconds of video and lost lip sync. RTP timestamps now follow the time between captured frames ([#712](https://github.com/m1k1o/neko/pull/712)). + +### Changes {#master-changes} +- Generated video pipelines keep at most one raw frame queued, so a slow encoder drops frames instead of adding latency. +- New metric `neko_capture_streamsink_frames`: video frames produced per pipeline; a rate below the configured fps means the encoder falls behind. +- Session ids on the `multiuser` and `noauth` providers are built from a sanitised login name (the name itself is shown as typed); `POST /api/members` rejects usernames that would not be safe in a URL path. +- Chat messages are limited to `chat.max_length` characters (default 512, the web client's input limit); longer ones are rejected over the API and dropped over the websocket. ## [n.eko v3.1.6](https://github.com/m1k1o/neko/releases/tag/v3.1.6) {#v3.1.6}