tiny update

This commit is contained in:
KingKDot
2026-08-24 09:35:03 -05:00
parent fcc6cc53be
commit 0921e39333
131 changed files with 9948 additions and 1076 deletions
+78
View File
@@ -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
+1 -1
View File
@@ -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 {
@@ -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)
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
}
+16 -1
View File
@@ -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
+30
View File
@@ -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) {
@@ -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{}
+1 -1
View File
@@ -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()
+75
View File
@@ -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
+18 -15
View File
@@ -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=="],
+10 -5
View File
@@ -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"
}
}
+254 -54
View File
@@ -302,7 +302,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
state === "idle" ? "Stopped" :
"Connecting");
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
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, "&quot;").replace(/'/g, "&#39;");
}
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 = '<i class="fa-solid fa-check mr-1 text-emerald-400"></i>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 = '<i class="fa-solid fa-check mr-1"></i>' + (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 = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading…</div>';
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 = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Refreshing…</div>';
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 = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm">No windows or monitors found</div>';
windowMapList.innerHTML = '<div class="text-slate-500 text-xs text-center py-2">No visible windows on the hidden desktop</div>';
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 += `<div class="absolute border-2 border-dashed border-slate-600 rounded" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;" title="Monitor ${m.index}: ${m.name} (${m.width}x${m.height})">
<span class="absolute top-1 left-1.5 text-[10px] font-mono text-slate-500">${m.primary ? "★ " : ""}${m.name || "Monitor " + m.index}</span>
<span class="absolute bottom-1 right-1.5 text-[10px] font-mono text-slate-600">${m.width}×${m.height}</span>
</div>`;
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 += `<div class="absolute rounded border overflow-hidden cursor-default" style="left:${x}px;top:${y}px;width:${ww}px;height:${wh}px;border-color:${color};background:${color}18;" title="${escHtml(w.title)}\n${w.processName} (PID ${w.pid})\nPosition: ${w.x},${w.y} Size: ${w.width}×${w.height}\nMonitor: ${w.monitor >= 0 ? w.monitor : "none"}">
${wh > 20 && ww > 50 ? `<div class="px-1 py-0.5 text-[9px] font-mono text-slate-200 truncate" style="background:${color}50;">${escHtml(shortTitle)}</div>` : ""}
</div>`;
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 = `<div class="grid gap-1">
<div class="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">
<span></span><span>Title</span><span>Process</span><span>Position</span><span>Monitor</span>
</div>`;
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 += `<div class="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">
<span class="w-2.5 h-2.5 rounded-sm" style="background:${color};"></span>
<span class="truncate font-medium" title="${escHtml(w.title)}">${escHtml(w.title)}</span>
<span class="font-mono text-slate-400">${escHtml(w.processName)} <span class="text-slate-600">(${w.pid})</span></span>
<span class="font-mono text-slate-500">${w.x},${w.y} ${w.width}×${w.height}</span>
<span class="font-mono ${w.monitor >= 0 ? "text-slate-300" : "text-rose-400"}">${monLabel}</span>
</div>`;
}
listHtml += "</div>";
windowMapCanvas.replaceChildren(canvasFragment);
if (windows.length === 0) {
listHtml = '<div class="text-slate-500 text-xs text-center py-2">No visible windows on the hidden desktop</div>';
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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();
+15 -9
View File
@@ -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 = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>${data.error || "Failed to check eligible clients"}</p>`;
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>${escapeHtml(data.error || "Failed to check eligible clients")}</p>`;
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 += `<p class="text-white"><i class="fa-solid fa-users mr-2 text-amber-400"></i><strong>${data.eligible}</strong> client(s) will receive the update after build.</p>`;
html += `<p class="text-white"><i class="fa-solid fa-users mr-2 text-amber-400"></i><strong>${eligible}</strong> client(s) will receive the update after build.</p>`;
html += '<div class="mt-2 text-xs text-slate-400 space-y-1">';
html += `<p><i class="fa-solid fa-globe mr-1 text-blue-400"></i> ${data.totalOnline} total online client(s)</p>`;
if (data.skippedInMemory > 0) {
html += `<p><i class="fa-solid fa-memory mr-1 text-red-400"></i> ${data.skippedInMemory} client(s) will be skipped (running in-memory)</p>`;
html += `<p><i class="fa-solid fa-globe mr-1 text-blue-400"></i> ${totalOnline} total online client(s)</p>`;
if (skippedInMemory > 0) {
html += `<p><i class="fa-solid fa-memory mr-1 text-red-400"></i> ${skippedInMemory} client(s) will be skipped (running in-memory)</p>`;
}
if (data.skippedNoMatch > 0) {
html += `<p><i class="fa-solid fa-ban mr-1 text-slate-500"></i> ${data.skippedNoMatch} client(s) will be skipped (no matching platform)</p>`;
if (skippedNoMatch > 0) {
html += `<p><i class="fa-solid fa-ban mr-1 text-slate-500"></i> ${skippedNoMatch} client(s) will be skipped (no matching platform)</p>`;
}
html += "</div>";
if (data.eligible === 0 && data.totalOnline === 0) {
if (eligible === 0 && totalOnline === 0) {
html += '<p class="mt-3 text-xs text-slate-500">No clients are currently online. The build will still run but no updates will be sent.</p>';
}
@@ -2939,7 +2944,8 @@ if (buildUpdateAllBtn) {
updateAllModalBody.innerHTML = html;
} catch (err) {
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>Error: ${err.message}</p>`;
const message = err instanceof Error ? err.message : String(err || "Unknown error");
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>Error: ${escapeHtml(message)}</p>`;
}
});
}
+1 -1
View File
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
function formatTime(ts) {
@@ -71,7 +71,8 @@ function escapeHtml(value) {
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function setSummary(summary) {
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 `<span class="fi fi-${country.toLowerCase()}" style="margin-right:6px;border-radius:2px;"></span>`;
const code = String(country || "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return "";
return `<span class="fi fi-${code.toLowerCase()}" style="margin-right:6px;border-radius:2px;"></span>`;
}
function clientLabel(c) {
@@ -163,15 +180,17 @@ function buildResults(query, pages, clients) {
}
function makeIcon(faClass, colorClass) {
return `<i class="fa-solid ${faClass} ${colorClass || "text-slate-300"}" style="width:18px;text-align:center;"></i>`;
const icon = safeClassTokens(faClass, "fa-circle-question");
const color = safeClassTokens(colorClass, "text-slate-300");
return `<i class="fa-solid ${icon} ${color}" style="width:18px;text-align:center;"></i>`;
}
function renderRow(r, active) {
const activeCls = active ? "cmdp-row cmdp-row-active" : "cmdp-row";
if (r.kind === "page") {
return `<div class="${activeCls}" data-href="${r.href}">
return `<div class="${activeCls}" data-href="${escapeHtml(r.href)}">
${makeIcon(r.icon, r.color)}
<span class="cmdp-label">${r.label}</span>
<span class="cmdp-label">${escapeHtml(r.label)}</span>
<span class="cmdp-kind">page</span>
</div>`;
}
@@ -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 `<div class="${activeCls}" data-client="${c.id}">
<i class="fa ${os.icon} cv-tone-${os.tone}" style="width:18px;text-align:center;"></i>
const osIcon = safeClassTokens(os.icon, "fa-solid fa-circle-question");
const osTone = safeClassTokens(os.tone, "pill-unknown");
return `<div class="${activeCls}" data-client="${escapeHtml(c.id)}">
<i class="fa ${osIcon} cv-tone-${osTone}" style="width:18px;text-align:center;"></i>
<span class="cmdp-status-dot" style="${dot}"></span>
${flagFor(c.country)}
<span class="cmdp-label">${r.label}</span>
<span class="cmdp-label">${escapeHtml(r.label)}</span>
<span class="cmdp-kind">${c.online ? "online" : "offline"}</span>
</div>`;
}
if (r.kind === "action") {
return `<div class="${activeCls}" data-action="${r.action.key}">
return `<div class="${activeCls}" data-action="${escapeHtml(r.action.key)}">
${makeIcon(r.action.icon, r.action.color)}
<span class="cmdp-label">${r.action.label}</span>
<span class="cmdp-label">${escapeHtml(r.action.label)}</span>
<span class="cmdp-kind">needs client</span>
</div>`;
}
if (r.kind === "client-action") {
const c = r.client;
return `<div class="${activeCls}" data-href="${r.action.href(c.id)}">
const encodedId = encodeURIComponent(String(c.id || ""));
return `<div class="${activeCls}" data-href="${escapeHtml(r.action.href(encodedId))}">
${makeIcon(r.action.icon, r.action.color)}
<span class="cmdp-label">${r.action.label} · ${clientLabel(c)}</span>
<span class="cmdp-label">${escapeHtml(`${r.action.label} · ${clientLabel(c)}`)}</span>
<span class="cmdp-kind">action</span>
</div>`;
}
@@ -320,7 +342,7 @@ function renderList() {
}
let html = "";
if (state.mode === "client-actions" && state.pendingAction) {
html += `<div class="cmdp-section">Pick a client for: ${state.pendingAction.label}</div>`;
html += `<div class="cmdp-section">Pick a client for: ${escapeHtml(state.pendingAction.label)}</div>`;
}
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") {
+2 -1
View File
@@ -106,7 +106,8 @@ function tryFit() {
function setStatus(label, tone = "pill-offline") {
if (!statusPill) return;
statusPill.className = `pill ${tone}`;
statusPill.innerHTML = `<i class="fa-solid fa-circle"></i> ${label}`;
statusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
statusPill.appendChild(document.createTextNode(` ${String(label || "")}`));
}
function writeSystem(msg) {
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
+1 -1
View File
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
export function formatBytes(bytes, fractionDigits = 2) {
+4 -4
View File
@@ -342,14 +342,14 @@ function displayFileList(files) {
` : ""}
<button
class="view-log-btn px-3 py-2 rounded-lg border border-blue-700 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2"
data-filename="${file.name}"
data-filename="${escapeHtml(file.name)}"
>
<i class="fa-solid fa-eye"></i>
<span>View</span>
</button>
<button
class="download-log-btn px-3 py-2 rounded-lg border border-emerald-700 bg-emerald-900/40 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2"
data-filename="${file.name}"
data-filename="${escapeHtml(file.name)}"
>
<i class="fa-solid fa-download"></i>
<span>Download</span>
@@ -357,7 +357,7 @@ function displayFileList(files) {
${archiveMode ? "" : `
<button
class="delete-log-btn px-3 py-2 rounded-lg border border-red-700 bg-red-900/40 hover:bg-red-800/70 text-red-100 flex items-center gap-2"
data-filename="${file.name}"
data-filename="${escapeHtml(file.name)}"
>
<i class="fa-solid fa-trash"></i>
<span>Delete</span>
@@ -428,7 +428,7 @@ function formatDate(dateStr) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
return div.innerHTML;
return div.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function resetSearch() {
+1 -1
View File
@@ -47,7 +47,7 @@ function formatTimestamp(ts) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
return div.innerHTML;
return div.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function parseDetails(details) {
+1 -1
View File
@@ -1801,7 +1801,7 @@ async function openGroupPicker(clientId) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
return div.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function renderClientLogs(logs) {
+1 -1
View File
@@ -533,7 +533,7 @@ function formatBytes(bytes) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
return div.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function formatDuration(ms) {
@@ -10,7 +10,7 @@ export async function loadPluginNavItems(activeMap) {
if (!plugins || !Array.isArray(plugins)) return;
const navPlugins = plugins.filter(
(p) => p.navbar && p.enabled
(p) => p && typeof p === "object" && p.navbar && p.enabled
);
if (navPlugins.length === 0) return;
@@ -28,11 +28,17 @@ export async function loadPluginNavItems(activeMap) {
if (!childContainer) return;
const itemsHtml = navPlugins.map((p) => {
const linkId = `plugin-nav-${p.id}`;
activeMap[`/plugins/${p.id}`] = linkId;
const pluginId = String(p.id || "").trim();
if (
!/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(pluginId) ||
pluginId.includes("..") ||
pluginId.endsWith(".")
) return "";
const linkId = `plugin-nav-${pluginId}`;
activeMap[`/plugins/${pluginId}`] = linkId;
const childObj = {
href: `/plugins/${p.id}`,
href: `/plugins/${pluginId}`,
label: p.navbar.label || p.name,
icon: p.navbar.icon || "fa-cube",
iconColor: "text-slate-300",
+39 -8
View File
@@ -81,16 +81,45 @@ export const NAV_GROUPS = [
TOPBAR — dropdown menus
────────────────────────────────────────────── */
function escapeNavHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function safeNavHref(value) {
const href = String(value ?? "").trim();
if (!href.startsWith("/") || href.startsWith("//") || /[\u0000-\u001f\u007f]/.test(href)) {
return "#";
}
return escapeNavHtml(href);
}
function safeNavId(value) {
return String(value ?? "").replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 192);
}
function safeNavClassList(value) {
return String(value ?? "")
.split(/\s+/)
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token))
.join(" ");
}
export function dropdownItem(child) {
const hiddenCls = child.hidden ? " hidden" : "";
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
const badgeHtml = child.hasBadge
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
: "";
return `<a href="${child.href}" id="${child.linkId}"${prefetchAttr}
class="nav-dd-item${hiddenCls}" data-link-id="${child.linkId}">
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
<span>${child.label}</span>
const linkId = safeNavId(child.linkId);
return `<a href="${safeNavHref(child.href)}" id="${linkId}"${prefetchAttr}
class="nav-dd-item${hiddenCls}" data-link-id="${linkId}">
<i class="fa-solid ${safeNavClassList(child.icon)} ${safeNavClassList(child.iconColor)} nav-dd-item-icon"></i>
<span>${escapeNavHtml(child.label)}</span>
${badgeHtml}
</a>`;
}
@@ -241,10 +270,12 @@ export function sidebarChild(child) {
const badgeHtml = child.hasBadge
? `<span id="enrollment-badge" class="sb-badge hidden"></span>`
: "";
return `<a href="${child.href}" id="${child.linkId}"
class="sb-link sb-link-child${hiddenCls}" data-link-id="${child.linkId}" title="${child.label}">
<i class="fa-solid ${child.icon} ${child.iconColor} sb-icon"></i>
<span class="sb-text">${child.label}</span>
const linkId = safeNavId(child.linkId);
const label = escapeNavHtml(child.label);
return `<a href="${safeNavHref(child.href)}" id="${linkId}"
class="sb-link sb-link-child${hiddenCls}" data-link-id="${linkId}" title="${label}">
<i class="fa-solid ${safeNavClassList(child.icon)} ${safeNavClassList(child.iconColor)} sb-icon"></i>
<span class="sb-text">${label}</span>
${badgeHtml}
</a>`;
}
+42 -74
View File
@@ -79,6 +79,8 @@ const previewModalImg = document.getElementById("notification-preview-image");
const previewModalClose = document.getElementById("notification-preview-close");
const MAX_ROWS = 200;
const MAX_CACHED_PREVIEW_OBJECT_URLS = 8;
const previewObjectUrls = new Map();
// Defaults loaded from /api/notifications/my-settings
let defaultWebhookTemplate = "";
@@ -225,7 +227,7 @@ function formatTime(ts) {
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
return div.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function wireToggle(inputEl, btnEl) {
@@ -459,26 +461,50 @@ function previewFormatter(cell) {
return wrapper;
}
mountPreviewLoader(wrapper, item, row.isLive);
return wrapper;
}
function rememberPreviewObjectUrl(notificationId, objectUrl) {
const existing = previewObjectUrls.get(notificationId);
if (existing && existing !== objectUrl) URL.revokeObjectURL(existing);
previewObjectUrls.delete(notificationId);
previewObjectUrls.set(notificationId, objectUrl);
while (previewObjectUrls.size > MAX_CACHED_PREVIEW_OBJECT_URLS) {
const oldestId = previewObjectUrls.keys().next().value;
const oldestUrl = previewObjectUrls.get(oldestId);
previewObjectUrls.delete(oldestId);
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
}
}
function mountPreviewLoader(wrapper, item, isLive) {
const notificationId = item?.id || "";
const skipFetch = !notificationId || (!row.isLive && !item?.screenshotId);
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
if (skipFetch) {
wrapper.textContent = "-";
wrapper.className = "text-slate-500";
return wrapper;
return;
}
if (item._previewObjectUrl) {
const img = createPreviewImage(item._previewObjectUrl);
wrapper.appendChild(img);
return wrapper;
const cachedUrl = previewObjectUrls.get(notificationId);
if (cachedUrl) {
wrapper.appendChild(createPreviewImage(cachedUrl));
return;
}
wrapper.textContent = "Loading...";
wrapper.className = "text-slate-500";
const loadButton = document.createElement("button");
loadButton.type = "button";
loadButton.className = "text-xs px-2 py-1 rounded border border-slate-700 bg-slate-900/80 hover:bg-slate-800 text-slate-300";
loadButton.textContent = "View image";
wrapper.appendChild(loadButton);
let attempts = 0;
const maxAttempts = 5;
const fetchPreview = async () => {
if (!wrapper.isConnected) return;
attempts += 1;
loadButton.disabled = true;
loadButton.textContent = attempts > 1 ? `Retrying (${attempts}/${maxAttempts})...` : "Loading...";
try {
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
const res = await fetch(url, { cache: "no-store" });
@@ -493,7 +519,7 @@ function previewFormatter(cell) {
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
item._previewObjectUrl = objectUrl;
rememberPreviewObjectUrl(notificationId, objectUrl);
wrapper.innerHTML = "";
wrapper.className = "";
wrapper.appendChild(createPreviewImage(objectUrl));
@@ -506,8 +532,7 @@ function previewFormatter(cell) {
wrapper.className = "text-slate-500";
}
};
fetchPreview();
return wrapper;
loadButton.addEventListener("click", fetchPreview, { once: true });
}
function createPreviewImage(objectUrl) {
@@ -524,7 +549,6 @@ function createPreviewImage(objectUrl) {
previewModal.classList.remove("hidden");
previewModal.classList.add("flex");
});
if (img.decode) img.decode().catch(() => {});
return img;
}
@@ -549,67 +573,7 @@ function createNotificationRowElement(item, isLive) {
const preview = row.querySelector(".preview-slot");
if (preview) {
const notificationId = item?.id || "";
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
if (skipFetch) {
preview.textContent = "-";
preview.className = "text-slate-500";
} else {
preview.textContent = "Loading...";
preview.className = "text-slate-500";
const img = document.createElement("img");
img.className = "max-h-32 w-auto rounded border border-slate-800/80 cursor-zoom-in";
img.loading = "lazy";
img.alt = "Notification screenshot";
let attempts = 0;
const maxAttempts = 5;
const fetchPreview = async () => {
attempts += 1;
try {
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) {
if ((res.status === 202 || res.status === 404) && attempts < maxAttempts) {
setTimeout(fetchPreview, 1000 * attempts);
return;
}
preview.textContent = "-";
preview.className = "text-slate-500";
return;
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
img.classList.add("opacity-0");
preview.innerHTML = "";
preview.className = "";
preview.appendChild(img);
img.addEventListener("load", () => img.classList.remove("opacity-0"));
img.addEventListener("error", () => {
preview.textContent = "-";
preview.className = "text-slate-500";
URL.revokeObjectURL(objectUrl);
});
img.src = objectUrl;
img.dataset.previewUrl = objectUrl;
if (img.decode) img.decode().catch(() => {});
img.addEventListener("click", () => {
if (!previewModal || !previewModalImg) return;
previewModalImg.src = img.dataset.previewUrl || objectUrl;
previewModal.classList.remove("hidden");
previewModal.classList.add("flex");
});
} catch {
if (attempts < maxAttempts) {
setTimeout(fetchPreview, 1000 * attempts);
return;
}
preview.textContent = "-";
preview.className = "text-slate-500";
}
};
fetchPreview();
}
mountPreviewLoader(preview, item, isLive);
}
return row;
@@ -967,6 +931,10 @@ const clearIfActive = () => {
};
document.addEventListener("visibilitychange", clearIfActive);
window.addEventListener("focus", clearIfActive);
window.addEventListener("beforeunload", () => {
for (const objectUrl of previewObjectUrls.values()) URL.revokeObjectURL(objectUrl);
previewObjectUrls.clear();
});
clearIfActive();
function updateDesktopPermissionUi() {
+4 -3
View File
@@ -362,7 +362,8 @@ function escapeHtml(value) {
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function setPluginEnabled(plugin, enabled, confirmed = false) {
@@ -441,8 +442,8 @@ function showPluginEnableConfirmModal(plugin, sigInfo) {
<h3 class="text-lg font-semibold text-slate-100">Enable Unverified Plugin</h3>
</div>
<p class="text-sm text-slate-300 mb-2">${statusText}</p>
<p class="text-sm text-slate-400 mb-1">Plugin: <strong class="text-slate-200">${plugin.name || plugin.id}</strong></p>
${sig.fingerprint ? `<p class="text-xs text-slate-500 font-mono mb-3">Signer: ${sig.fingerprint}</p>` : '<p class="text-xs text-slate-500 mb-3">No signature present.</p>'}
<p class="text-sm text-slate-400 mb-1">Plugin: <strong class="text-slate-200">${escapeHtml(plugin.name || plugin.id)}</strong></p>
${sig.fingerprint ? `<p class="text-xs text-slate-500 font-mono mb-3">Signer: ${escapeHtml(sig.fingerprint)}</p>` : '<p class="text-xs text-slate-500 mb-3">No signature present.</p>'}
<p class="text-sm text-slate-400" style="margin-bottom: 16px;">Type <strong class="text-emerald-300">confirm</strong> below to enable this plugin:</p>
<input type="text" id="plugin-enable-confirm-input" class="w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 text-sm focus:outline-none focus:border-emerald-500" style="margin-bottom: 16px;" placeholder="Type confirm" autocomplete="off" spellcheck="false" />
<div class="plugin-modal-actions flex justify-end gap-2">
+3 -2
View File
@@ -75,7 +75,8 @@ function updateStatus(state, text) {
disconnected: '<i class="fa-solid fa-circle text-slate-500"></i>',
};
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, "&quot;").replace(/'/g, "&#39;");
}
// Context menu
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function dedupeGpu(raw) {
@@ -852,7 +852,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
};
const label = streamStatusLabel;
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
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 = `
<div class="flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation text-amber-400"></i>
<span><strong>macOS permissions missing:</strong> ${list}</span>
<span><strong>macOS permissions missing:</strong> <span class="rd-elevate-missing"></span></span>
</div>
<div class="text-xs text-amber-300/80">
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
</div>
<div id="rdElevateStatus" class="text-xs text-slate-400 hidden"></div>
`;
const missingEl = banner.querySelector(".rd-elevate-missing");
if (missingEl) missingEl.textContent = list;
// Insert banner above the canvas area
if (canvasContainer && canvasContainer.parentNode) {
+7 -5
View File
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
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
? `<img src="${escapeHtml(imageUrl)}" alt="">`
: `<i class="${escapeHtml(icon || "fa-solid fa-puzzle-piece")}"></i>`;
+1 -1
View File
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
let cachedSavedScripts = [];
+32 -2
View File
@@ -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 || !/^<svg[\s>]/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 {
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
// ── Actions ───────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
testAllRpcBtn.addEventListener("click", async () => {
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
function getRule(clientId) {
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
function findBlockDef(blockId) {
+8 -2
View File
@@ -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 = `<i class="fa-solid fa-circle"></i><span>${text}</span>`;
statusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
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 = `<i class="fa-solid fa-circle"></i><span>${text}</span>`;
daStatusPill.innerHTML = '<i class="fa-solid fa-circle"></i>';
const label = document.createElement("span");
label.textContent = String(text || "");
daStatusPill.appendChild(label);
}
function daClearPlayback() {
+4 -1
View File
@@ -523,7 +523,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
state === "error" ? "Error" :
"Stopped");
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
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";
+1 -1
View File
@@ -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, "&quot;").replace(/'/g, "&#39;");
}
clientSearch.addEventListener("input", filterAndRenderClients);
+1 -2
View File
@@ -60,8 +60,7 @@
<p class="mb-1.5 text-slate-300 font-medium">Downloading password-protected files:</p>
<p class="mb-1">Use the <code class="px-1 py-0.5 bg-slate-900 rounded text-rose-300">X-Download-Password</code> header:</p>
<pre class="mt-1 mb-2 p-2 bg-slate-900 rounded text-emerald-300 overflow-x-auto select-all">curl -H "X-Download-Password: yourpass" https://host/api/file-share/&lt;id&gt;/download -o file.ext</pre>
<p class="mb-1">Or pass it as a query parameter:</p>
<pre class="mt-1 p-2 bg-slate-900 rounded text-emerald-300 overflow-x-auto select-all">curl "https://host/api/file-share/&lt;id&gt;/download?password=yourpass" -o file.ext</pre>
<p>Do not put the password in the URL; URLs can be retained in browser history and access logs.</p>
</div>
</div>
<div>
@@ -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,
@@ -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();
}
});
});
+91 -5
View File
@@ -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();
});
+22 -1
View File
@@ -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)}`;
+16 -13
View File
@@ -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<JWTPayload | null> {
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;
@@ -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);
});
@@ -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, unknown>) => string;
sidebarChild: (child: Record<string, unknown>) => 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} <span>${label}</span>`');
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} <span>${label}</span>`');
expect(source).not.toContain("<strong>macOS permissions missing:</strong> ${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} <span>${label}</span>`');
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("<span>${text}</span>");
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"}</p>');
expect(source).not.toContain("Error: ${err.message}</p>");
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 <img",
label: '<img src=x onerror="alert(1)">',
};
test("escapes labels and rejects executable topbar attributes", () => {
const html = dropdownItem(hostile);
expect(html).toContain('href="#"');
expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
expect(html).not.toContain("<img src=x");
expect(html).not.toContain("javascript:alert");
expect(html).not.toContain('onmouseover="');
});
test("escapes labels and rejects executable sidebar attributes", () => {
const html = sidebarChild(hostile);
expect(html).toContain('href="#"');
expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
expect(html).not.toContain("<img src=x");
expect(html).not.toContain("javascript:alert");
expect(html).not.toContain('onfocus="');
});
});
@@ -0,0 +1,388 @@
import { describe, expect, test } from "bun:test";
import Database from "bun:sqlite";
const chatServerUrl = new URL("../../plugins/chat/server.js", import.meta.url).href;
const chatModule = await import(chatServerUrl) as {
default: {
setup: (ctx: any) => 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<string, number> = {}) {
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("<svg><script>alert(1)</script></svg>"))).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("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>");
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("<html>")]), "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("<html><script>alert(1)</script></html>"), "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("<!doctype html><script>alert(1)</script>"), "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("<svg><script>alert(1)</script></svg>");
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");
});
});
+18 -9
View File
@@ -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<void> {
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);
}
+694 -55
View File
@@ -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<ScreenshotQuotaRow>(
`SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(bytes)), 0) AS bytes
FROM notification_screenshots`,
).get()
: database.query<ScreenshotQuotaRow>(
`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<ScreenshotPruneRow>(
`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<ScreenshotPruneRow>(
`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<any>(`SELECT * FROM push_subscriptions WHERE user_id=?`)
.all(userId)
.query<any>(
`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<any>(`SELECT * FROM push_subscriptions`)
.all()
.query<any>(
`SELECT * FROM push_subscriptions
ORDER BY created_at DESC, id DESC
LIMIT ?`,
)
.all(limit)
.map((r: any) => ({
id: r.id,
userId: r.user_id,
@@ -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
+3
View File
@@ -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 (
@@ -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<string>();
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<string, unknown> = {}) {
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);
});
});
+1
View File
@@ -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");
});
});
+1 -5
View File
@@ -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);
+4 -1
View File
@@ -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,
@@ -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");
});
});
@@ -0,0 +1,213 @@
export type MessagePackPreflightLimits = {
maxDepth: number;
maxContainerItems: number;
};
export const DEFAULT_UNTRUSTED_MESSAGEPACK_LIMITS: Readonly<MessagePackPreflightLimits> =
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<MessagePackPreflightLimits> = {},
): 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");
}
+10 -1
View File
@@ -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", () => {
+18 -2
View File
@@ -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);
}
}
@@ -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();
}
});
});
@@ -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();
}
});
});
+2
View File
@@ -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;
}
+15
View File
@@ -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();
}
+11 -4
View File
@@ -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);
+13 -5
View File
@@ -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;
}
+31 -5
View File
@@ -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<string, string | undefined> = {};
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;
+35 -43
View File
@@ -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;
@@ -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<Response>) => 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,
);
});
});
+5 -1
View File
@@ -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<Response | null>;
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";
@@ -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", () => {
+4 -2
View File
@@ -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=()",
@@ -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();
}
});
});
+394 -50
View File
@@ -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<string, Map<string, KeylogFileMeta>>();
type PendingArchiveFiles = {
files: Map<string, KeylogFileMeta>;
expiresAt: number;
};
const pendingFileMeta = new Map<string, PendingArchiveFiles>();
const archiveMessageRates = new Map<string, { windowStart: number; count: number }>();
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<string | number> = [];
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<string, KeylogFileMeta>): 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<SocketData>): 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<string, KeylogFileMeta>();
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,
@@ -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<T>(
items: readonly T[],
task: (item: T) => Promise<void>,
options: {
concurrency?: number;
batchSize?: number;
onError?: (error: unknown, item: T) => void;
} = {},
): Promise<void> {
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);
},
},
);
}
@@ -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<string, Set<string>>();
const pluginLoadingByClient = new Map<string, Set<string>>();
const forwarded: Array<{ clientId: string; pluginId: string; event: string; payload: unknown }> = [];
const pluginState = {
enabled: Object.fromEntries(enabledPluginIds.map((id) => [id, true])),
lastError: {} as Record<string, string>,
};
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<string, PendingNotificationScreenshot>();
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<string, PendingNotificationScreenshot>([[
"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);
});
});
*/
@@ -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<number>();
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]);
});
});
@@ -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);
});
});
@@ -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<Uint8Array<ArrayBuffer>> {
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<any> {
const bytes = await readBodyLimited(req, maxBytes);
return JSON.parse(new TextDecoder().decode(bytes));
}
@@ -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);
});
});
@@ -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 });
}
@@ -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(),
@@ -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) {
@@ -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);
});
});
@@ -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<string, FileSharePasswordAttempts>();
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<string, string> = {
@@ -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",
},
});
}
@@ -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();
@@ -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<string, string> = {
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);
});
});
@@ -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<URL> {
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 });
@@ -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<Response> {
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);
});
});
@@ -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);
}
});
});
@@ -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<string, RegRateLimitEntry>();
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 });
}
@@ -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({
@@ -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<string, PublishToken>();
@@ -149,7 +151,14 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
let body: ArrayBuffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "DELETE") {
body = await req.arrayBuffer();
try {
body = (await readBodyLimited(req, MAX_WEBRTC_SIGNAL_BODY_BYTES)).buffer;
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return new Response("Payload Too Large", { status: 413 });
}
throw error;
}
}
let upstreamResp: Response;
@@ -9,7 +9,7 @@ async function getGeoip() {
}
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuild, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, upsertPendingClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getClientBuildOwnership, getBuild, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
import { getConfig } from "../../config";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
@@ -241,7 +241,7 @@ type WsLifecycleDeps = {
handleKeyloggerMessage: (clientId: string, payload: any) => void;
handleKeylogArchiveMessage?: (clientId: string, payload: any, ws?: ServerWebSocket<SocketData>) => 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<string, ReturnType<typeof setTimeout>>();
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 "<unknown>";
const clean = value.trim().slice(0, 128);
return clean || "<unknown>";
}
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<object, { ip: string; verifying: boolean }>();
const activeByIp = new Map<string, number>();
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<SocketData>) {
const t = ws.data.enrollmentTimeout;
if (t) {
clearTimeout(t);
enrollmentTimeouts.delete(clientId);
ws.data.enrollmentTimeout = undefined;
}
}
function releaseEnrollmentAdmission(ws: ServerWebSocket<SocketData>): void {
enrollmentAdmission.release(ws);
}
function rejectEnrollment(ws: ServerWebSocket<SocketData>): void {
ws.data.enrollmentState = "rejected";
ws.data.enrollmentNonce = undefined;
clearEnrollmentTimeout(ws);
releaseEnrollmentAdmission(ws);
}
const enrollmentAdmissionWarningAt = new Map<EnrollmentAdmissionDenial, number>();
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<ArrayBuffer> | 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<boolean> {
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<string, unknown>;
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<SocketData>, deps: WsLifecycleDeps): void {
@@ -348,6 +599,14 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, 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<SocketData>, 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;
@@ -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);
});
});

Some files were not shown because too many files have changed in this diff Show More