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)}`;
+7 -4
View File
@@ -12,6 +12,7 @@ import {
isTokenRevoked,
pruneExpiredRevokedTokens,
createSession,
getSessionByTokenHash,
hashTokenForSession,
revokeSessionByTokenHash,
updateSessionActivity,
@@ -85,7 +86,6 @@ export async function generateToken(
const now = Math.floor(Date.now() / 1000);
const expiresAt = now + getSessionTtlSeconds();
try {
createSession({
id: crypto.randomUUID(),
userId: user.id,
@@ -95,9 +95,6 @@ export async function generateToken(
createdAt: now,
expiresAt,
});
} catch (e) {
console.error("[auth] Failed to create session record:", e);
}
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);
}
+667 -28
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,12 +1804,184 @@ export interface NotificationScreenshotRecord {
bytes: Uint8Array;
}
export function saveNotificationScreenshot(record: NotificationScreenshotRecord) {
db.run(
`INSERT OR REPLACE INTO notification_screenshots
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 (?, ?, ?, ?, ?, ?, ?, ?)`
,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
record.id,
record.notificationId,
record.clientId,
@@ -1727,6 +1991,26 @@ export function saveNotificationScreenshot(record: NotificationScreenshotRecord)
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,8 +2050,219 @@ export type NotificationRow = {
screenshotId?: string;
};
export function saveNotification(record: NotificationRow) {
db.run(
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -1786,6 +2281,16 @@ export function saveNotification(record: NotificationRow) {
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(
`INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)`,
userId, endpoint, p256dh, auth, Date.now(),
`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 INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)`,
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,10 +563,10 @@ async function deliverWebPushToAll(
url: "/notifications",
});
await Promise.allSettled(
subs
.filter((sub) => allowedUserIds.has(sub.userId))
.map(async (sub) => {
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,
@@ -359,7 +577,12 @@ async function deliverWebPushToAll(
} 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,18 +199,22 @@ 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)) {
await validateWebhookUrl(webhookUrl);
} catch (error: any) {
return Response.json(
{ error: "Webhook URL must be http(s)" },
{ error: error?.message || "Invalid webhook URL" },
{ status: 400 },
);
}
} catch {
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
}
}
const antiSpamMaxHits =
@@ -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";
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) {
// 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;
logger.info(`[purgatory] ID collision detected — reassigned to ${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,9 +919,13 @@ 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,
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),
@@ -602,6 +949,21 @@ export async function handleWebSocketMessage(
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