tiny update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function sendCmd(type, payload) {
|
||||
@@ -2038,7 +2041,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
||||
const lookupBtn = document.getElementById("backstageLookupBtn");
|
||||
if (!resultsEl) return;
|
||||
|
||||
if (msg.done) {
|
||||
if (msg?.done) {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const mappedWindow = windows[i];
|
||||
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
|
||||
const monitorLabel = mappedWindow.monitor >= 0
|
||||
? (monitors[mappedWindow.monitor]?.name || `#${mappedWindow.monitor}`)
|
||||
: "—";
|
||||
const row = document.createElement("div");
|
||||
row.className = "grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 items-center px-2 py-1.5 rounded hover:bg-slate-800/50 text-xs text-slate-300";
|
||||
|
||||
const colorMarker = createWindowMapSpan("w-2.5 h-2.5 rounded-sm", "");
|
||||
colorMarker.style.background = color;
|
||||
const title = createWindowMapSpan("truncate font-medium", mappedWindow.title);
|
||||
title.title = mappedWindow.title;
|
||||
const process = createWindowMapSpan("font-mono text-slate-400", mappedWindow.processName + " ");
|
||||
process.appendChild(createWindowMapSpan("text-slate-600", `(${mappedWindow.pid})`));
|
||||
row.append(
|
||||
colorMarker,
|
||||
title,
|
||||
process,
|
||||
createWindowMapSpan(
|
||||
"font-mono text-slate-500",
|
||||
`${mappedWindow.x},${mappedWindow.y} ${mappedWindow.width}×${mappedWindow.height}`,
|
||||
),
|
||||
createWindowMapSpan(
|
||||
`font-mono ${mappedWindow.monitor >= 0 ? "text-slate-300" : "text-rose-400"}`,
|
||||
monitorLabel,
|
||||
),
|
||||
);
|
||||
list.appendChild(row);
|
||||
}
|
||||
windowMapList.replaceChildren(list);
|
||||
}
|
||||
|
||||
connectWs();
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const ROLE_COLORS = {
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
|
||||
@@ -71,7 +71,8 @@ function escapeHtml(value) {
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function safeClassTokens(value, fallback = "") {
|
||||
const tokens = String(value || "")
|
||||
.split(/\s+/)
|
||||
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
|
||||
return tokens.length ? tokens.join(" ") : fallback;
|
||||
}
|
||||
|
||||
function flagFor(country) {
|
||||
if (!country || country.length !== 2) return "";
|
||||
return `<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") {
|
||||
|
||||
@@ -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, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -131,5 +131,5 @@ export function formatBytes(bytes) {
|
||||
export function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function escapeHtml(value) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value == null ? "" : String(value);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function formatBytes(bytes, fractionDigits = 2) {
|
||||
|
||||
@@ -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, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
|
||||
@@ -47,7 +47,7 @@ function formatTimestamp(ts) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text || "";
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function parseDetails(details) {
|
||||
|
||||
@@ -1801,7 +1801,7 @@ async function openGroupPicker(clientId) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderClientLogs(logs) {
|
||||
|
||||
@@ -533,7 +533,7 @@ function formatBytes(bytes) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : String(text);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -81,16 +81,45 @@ export const NAV_GROUPS = [
|
||||
TOPBAR — dropdown menus
|
||||
────────────────────────────────────────────── */
|
||||
|
||||
function escapeNavHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function safeNavHref(value) {
|
||||
const href = String(value ?? "").trim();
|
||||
if (!href.startsWith("/") || href.startsWith("//") || /[\u0000-\u001f\u007f]/.test(href)) {
|
||||
return "#";
|
||||
}
|
||||
return escapeNavHtml(href);
|
||||
}
|
||||
|
||||
function safeNavId(value) {
|
||||
return String(value ?? "").replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 192);
|
||||
}
|
||||
|
||||
function safeNavClassList(value) {
|
||||
return String(value ?? "")
|
||||
.split(/\s+/)
|
||||
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function dropdownItem(child) {
|
||||
const hiddenCls = child.hidden ? " hidden" : "";
|
||||
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
|
||||
const badgeHtml = child.hasBadge
|
||||
? `<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>`;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ const previewModalImg = document.getElementById("notification-preview-image");
|
||||
const previewModalClose = document.getElementById("notification-preview-close");
|
||||
|
||||
const MAX_ROWS = 200;
|
||||
const MAX_CACHED_PREVIEW_OBJECT_URLS = 8;
|
||||
const previewObjectUrls = new Map();
|
||||
|
||||
// Defaults loaded from /api/notifications/my-settings
|
||||
let defaultWebhookTemplate = "";
|
||||
@@ -225,7 +227,7 @@ function formatTime(ts) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : String(text);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wireToggle(inputEl, btnEl) {
|
||||
@@ -459,26 +461,50 @@ function previewFormatter(cell) {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
mountPreviewLoader(wrapper, item, row.isLive);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function rememberPreviewObjectUrl(notificationId, objectUrl) {
|
||||
const existing = previewObjectUrls.get(notificationId);
|
||||
if (existing && existing !== objectUrl) URL.revokeObjectURL(existing);
|
||||
previewObjectUrls.delete(notificationId);
|
||||
previewObjectUrls.set(notificationId, objectUrl);
|
||||
while (previewObjectUrls.size > MAX_CACHED_PREVIEW_OBJECT_URLS) {
|
||||
const oldestId = previewObjectUrls.keys().next().value;
|
||||
const oldestUrl = previewObjectUrls.get(oldestId);
|
||||
previewObjectUrls.delete(oldestId);
|
||||
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function mountPreviewLoader(wrapper, item, isLive) {
|
||||
const notificationId = item?.id || "";
|
||||
const skipFetch = !notificationId || (!row.isLive && !item?.screenshotId);
|
||||
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
|
||||
if (skipFetch) {
|
||||
wrapper.textContent = "-";
|
||||
wrapper.className = "text-slate-500";
|
||||
return wrapper;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item._previewObjectUrl) {
|
||||
const img = createPreviewImage(item._previewObjectUrl);
|
||||
wrapper.appendChild(img);
|
||||
return wrapper;
|
||||
const cachedUrl = previewObjectUrls.get(notificationId);
|
||||
if (cachedUrl) {
|
||||
wrapper.appendChild(createPreviewImage(cachedUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
wrapper.textContent = "Loading...";
|
||||
wrapper.className = "text-slate-500";
|
||||
const loadButton = document.createElement("button");
|
||||
loadButton.type = "button";
|
||||
loadButton.className = "text-xs px-2 py-1 rounded border border-slate-700 bg-slate-900/80 hover:bg-slate-800 text-slate-300";
|
||||
loadButton.textContent = "View image";
|
||||
wrapper.appendChild(loadButton);
|
||||
let attempts = 0;
|
||||
const maxAttempts = 5;
|
||||
const fetchPreview = async () => {
|
||||
if (!wrapper.isConnected) return;
|
||||
attempts += 1;
|
||||
loadButton.disabled = true;
|
||||
loadButton.textContent = attempts > 1 ? `Retrying (${attempts}/${maxAttempts})...` : "Loading...";
|
||||
try {
|
||||
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
@@ -493,7 +519,7 @@ function previewFormatter(cell) {
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
item._previewObjectUrl = objectUrl;
|
||||
rememberPreviewObjectUrl(notificationId, objectUrl);
|
||||
wrapper.innerHTML = "";
|
||||
wrapper.className = "";
|
||||
wrapper.appendChild(createPreviewImage(objectUrl));
|
||||
@@ -506,8 +532,7 @@ function previewFormatter(cell) {
|
||||
wrapper.className = "text-slate-500";
|
||||
}
|
||||
};
|
||||
fetchPreview();
|
||||
return wrapper;
|
||||
loadButton.addEventListener("click", fetchPreview, { once: true });
|
||||
}
|
||||
|
||||
function createPreviewImage(objectUrl) {
|
||||
@@ -524,7 +549,6 @@ function createPreviewImage(objectUrl) {
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
});
|
||||
if (img.decode) img.decode().catch(() => {});
|
||||
return img;
|
||||
}
|
||||
|
||||
@@ -549,67 +573,7 @@ function createNotificationRowElement(item, isLive) {
|
||||
|
||||
const preview = row.querySelector(".preview-slot");
|
||||
if (preview) {
|
||||
const notificationId = item?.id || "";
|
||||
const skipFetch = !notificationId || (!isLive && !item?.screenshotId);
|
||||
if (skipFetch) {
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
} else {
|
||||
preview.textContent = "Loading...";
|
||||
preview.className = "text-slate-500";
|
||||
const img = document.createElement("img");
|
||||
img.className = "max-h-32 w-auto rounded border border-slate-800/80 cursor-zoom-in";
|
||||
img.loading = "lazy";
|
||||
img.alt = "Notification screenshot";
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 5;
|
||||
const fetchPreview = async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const url = `/api/notifications/${encodeURIComponent(notificationId)}/screenshot?ts=${Date.now()}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
if ((res.status === 202 || res.status === 404) && attempts < maxAttempts) {
|
||||
setTimeout(fetchPreview, 1000 * attempts);
|
||||
return;
|
||||
}
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
img.classList.add("opacity-0");
|
||||
preview.innerHTML = "";
|
||||
preview.className = "";
|
||||
preview.appendChild(img);
|
||||
img.addEventListener("load", () => img.classList.remove("opacity-0"));
|
||||
img.addEventListener("error", () => {
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
img.src = objectUrl;
|
||||
img.dataset.previewUrl = objectUrl;
|
||||
if (img.decode) img.decode().catch(() => {});
|
||||
img.addEventListener("click", () => {
|
||||
if (!previewModal || !previewModalImg) return;
|
||||
previewModalImg.src = img.dataset.previewUrl || objectUrl;
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
});
|
||||
} catch {
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(fetchPreview, 1000 * attempts);
|
||||
return;
|
||||
}
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
}
|
||||
};
|
||||
fetchPreview();
|
||||
}
|
||||
mountPreviewLoader(preview, item, isLive);
|
||||
}
|
||||
|
||||
return row;
|
||||
@@ -967,6 +931,10 @@ const clearIfActive = () => {
|
||||
};
|
||||
document.addEventListener("visibilitychange", clearIfActive);
|
||||
window.addEventListener("focus", clearIfActive);
|
||||
window.addEventListener("beforeunload", () => {
|
||||
for (const objectUrl of previewObjectUrls.values()) URL.revokeObjectURL(objectUrl);
|
||||
previewObjectUrls.clear();
|
||||
});
|
||||
clearIfActive();
|
||||
|
||||
function updateDesktopPermissionUi() {
|
||||
|
||||
@@ -362,7 +362,8 @@ function escapeHtml(value) {
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function setPluginEnabled(plugin, enabled, confirmed = false) {
|
||||
@@ -441,8 +442,8 @@ function showPluginEnableConfirmModal(plugin, sigInfo) {
|
||||
<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">
|
||||
|
||||
@@ -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, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -44,7 +44,7 @@ if (typeof document !== "undefined" && typeof MutationObserver !== "undefined")
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const ROW_SELECTOR = "[data-client-row]";
|
||||
@@ -699,12 +699,14 @@ export function createRenderer({
|
||||
return dashboardBadges(client)
|
||||
.sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0))
|
||||
.map((badge) => {
|
||||
const label = String(badge.label || badge.title || badge.pluginId || "Plugin");
|
||||
const title = String(badge.title || label);
|
||||
const label = String(badge.label || badge.title || badge.pluginId || "Plugin").slice(0, 256);
|
||||
const title = String(badge.title || label).slice(0, 512);
|
||||
const tone = String(badge.tone || "info").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "info";
|
||||
const icon = String(badge.icon || "").replace(/[^a-z0-9_\- ]/gi, "").trim();
|
||||
const imageUrl = String(badge.imageUrl || "").trim();
|
||||
const href = String(badge.href || "").trim();
|
||||
const rawImageUrl = String(badge.imageUrl || "").trim().slice(0, 2_048);
|
||||
const rawHref = String(badge.href || "").trim().slice(0, 2_048);
|
||||
const imageUrl = /^(?:https?:\/\/|\/(?!\/))/i.test(rawImageUrl) ? rawImageUrl : "";
|
||||
const href = /^(?:https?:\/\/|\/(?!\/)|#)/i.test(rawHref) ? rawHref : "";
|
||||
const visual = imageUrl
|
||||
? `<img src="${escapeHtml(imageUrl)}" alt="">`
|
||||
: `<i class="${escapeHtml(icon || "fa-solid fa-puzzle-piece")}"></i>`;
|
||||
|
||||
@@ -238,7 +238,7 @@ function updateSelectedCount() {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
let cachedSavedScripts = [];
|
||||
|
||||
@@ -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, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,7 +117,7 @@ function renderRpcEndpointManager(records) {
|
||||
function escapeHtml(value) {
|
||||
const element = document.createElement("span");
|
||||
element.textContent = String(value);
|
||||
return element.innerHTML;
|
||||
return element.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
testAllRpcBtn.addEventListener("click", async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ function showMessage(text, type = "ok") {
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value == null ? "" : String(value);
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function getRule(clientId) {
|
||||
|
||||
@@ -115,7 +115,7 @@ let dragGhost = null;
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function findBlockDef(blockId) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -211,7 +211,7 @@ function formatBytes(bytes) {
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
clientSearch.addEventListener("input", filterAndRenderClients);
|
||||
|
||||
@@ -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/<id>/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/<id>/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { authenticateRequest, extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
|
||||
import { getConfig, updateAppearanceConfig, type Config } from "./config";
|
||||
import { generateTotpCode } from "./mfa";
|
||||
import { getBrandingImage } from "./db";
|
||||
import { getBrandingImage, hashTokenForSession, revokeSessionByTokenHash } from "./db";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
@@ -46,6 +46,27 @@ describe("auth token extraction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session-backed authentication", () => {
|
||||
test("a revoked session invalidates HTTP authentication immediately", async () => {
|
||||
const username = `session_revoke_${Date.now().toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, "viewer", "test");
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
try {
|
||||
const token = await generateToken(getUserById(created.userId!)!);
|
||||
const request = new Request("https://localhost/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect((await authenticateRequest(request))?.userId).toBe(created.userId!);
|
||||
|
||||
expect(revokeSessionByTokenHash(hashTokenForSession(token))).toBe(true);
|
||||
expect(await authenticateRequest(request)).toBeNull();
|
||||
} finally {
|
||||
deleteUser(created.userId!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("forced password change authentication", () => {
|
||||
test("a pending account can only use identity, logout, and its own password endpoint", async () => {
|
||||
const username = `forced_password_${Date.now().toString(36)}`;
|
||||
|
||||
+16
-13
@@ -12,6 +12,7 @@ import {
|
||||
isTokenRevoked,
|
||||
pruneExpiredRevokedTokens,
|
||||
createSession,
|
||||
getSessionByTokenHash,
|
||||
hashTokenForSession,
|
||||
revokeSessionByTokenHash,
|
||||
updateSessionActivity,
|
||||
@@ -85,19 +86,15 @@ export async function generateToken(
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = now + getSessionTtlSeconds();
|
||||
|
||||
try {
|
||||
createSession({
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
ip: sessionMeta?.ip || null,
|
||||
userAgent: sessionMeta?.userAgent || null,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[auth] Failed to create session record:", e);
|
||||
}
|
||||
createSession({
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
ip: sessionMeta?.ip || null,
|
||||
userAgent: sessionMeta?.userAgent || null,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -107,6 +104,12 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = getSessionByTokenHash(hashTokenForSession(token));
|
||||
if (!session || session.expiresAt <= Math.floor(Date.now() / 1000)) {
|
||||
tokenCache.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = tokenCache.get(token);
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.timestamp;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { getConfig } from "./config";
|
||||
|
||||
test("defaults the bootstrap login to admin/admin when no override is supplied", () => {
|
||||
const config = getConfig();
|
||||
expect(config.auth.username).toBe("admin");
|
||||
expect(config.auth.password).toBe("admin");
|
||||
expect(config.auth.passwordIsUserSupplied).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const navTemplateUrl = new URL("../public/assets/nav/template.js", import.meta.url).href;
|
||||
const { dropdownItem, sidebarChild } = await import(navTemplateUrl) as {
|
||||
dropdownItem: (child: Record<string, unknown>) => string;
|
||||
sidebarChild: (child: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
describe("backstage fake-client rendering", () => {
|
||||
test("does not concatenate lookup paths or window-map values into HTML", async () => {
|
||||
const source = await Bun.file(new URL("../public/assets/backstage.js", import.meta.url)).text();
|
||||
expect(source).not.toContain("+ msg.path");
|
||||
expect(source).not.toContain("windowMapCanvas.innerHTML = html");
|
||||
expect(source).not.toContain("windowMapList.innerHTML = listHtml");
|
||||
expect(source).toContain("normalizeWindowMapPayload");
|
||||
expect(source).toContain("document.createTextNode((killExe ?");
|
||||
expect(source).toContain("windowMapCanvas.replaceChildren(canvasFragment)");
|
||||
expect(source).toContain("windowMapList.replaceChildren(list)");
|
||||
});
|
||||
|
||||
test("renders fake-client stream status labels as text", async () => {
|
||||
const source = await Bun.file(new URL("../public/assets/backstage.js", import.meta.url)).text();
|
||||
expect(source).not.toContain('statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`');
|
||||
expect(source).toContain('labelEl.textContent = String(label || "")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote media fake-client rendering", () => {
|
||||
test("renders remote desktop status and permission names as text", async () => {
|
||||
const source = await Bun.file(new URL("../public/assets/remotedesktop.js", import.meta.url)).text();
|
||||
expect(source).not.toContain('statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`');
|
||||
expect(source).not.toContain("<strong>macOS permissions missing:</strong> ${list}");
|
||||
expect(source).toContain('labelEl.textContent = String(label || "")');
|
||||
expect(source).toContain("missingEl.textContent = list");
|
||||
});
|
||||
|
||||
test("renders webcam status reasons as text", async () => {
|
||||
const source = await Bun.file(new URL("../public/assets/webcam.js", import.meta.url)).text();
|
||||
expect(source).not.toContain('statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`');
|
||||
expect(source).toContain('labelEl.textContent = String(label || "")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared connection-status rendering", () => {
|
||||
test("keeps dynamic status labels out of HTML templates", async () => {
|
||||
const [consoleSource, processSource, voiceSource] = await Promise.all([
|
||||
Bun.file(new URL("../public/assets/console.js", import.meta.url)).text(),
|
||||
Bun.file(new URL("../public/assets/processes.js", import.meta.url)).text(),
|
||||
Bun.file(new URL("../public/assets/voice.js", import.meta.url)).text(),
|
||||
]);
|
||||
|
||||
expect(consoleSource).not.toContain("${label}");
|
||||
expect(consoleSource).toContain("document.createTextNode");
|
||||
expect(processSource).not.toContain("${text}");
|
||||
expect(processSource).toContain("document.createTextNode");
|
||||
expect(voiceSource).not.toContain("<span>${text}</span>");
|
||||
expect(voiceSource).toContain('label.textContent = String(text || "")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings and build API rendering", () => {
|
||||
test("keeps MFA SVG responses out of the document DOM and rejects executable links", async () => {
|
||||
const source = await Bun.file(new URL("../public/assets/settings.js", import.meta.url)).text();
|
||||
expect(source).not.toContain('mfaQrCode.innerHTML = data.qrSvg');
|
||||
expect(source).not.toContain('mfaOtpauthLink.href = data.otpauthUrl');
|
||||
expect(source).toContain("data:image/svg+xml;charset=utf-8");
|
||||
expect(source).toContain('parsed.protocol === "otpauth:" && parsed.hostname === "totp"');
|
||||
});
|
||||
|
||||
test("escapes build eligibility errors and coerces response counts", async () => {
|
||||
const source = await Bun.file(new URL("../public/assets/build.js", import.meta.url)).text();
|
||||
expect(source).not.toContain('${data.error || "Failed to check eligible clients"}</p>');
|
||||
expect(source).not.toContain("Error: ${err.message}</p>");
|
||||
expect(source).toContain('escapeHtml(data.error || "Failed to check eligible clients")');
|
||||
expect(source).toContain("Math.trunc(Number(data.eligible) || 0)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dynamic plugin navigation rendering", () => {
|
||||
const hostile = {
|
||||
href: "javascript:alert(1)",
|
||||
linkId: 'plugin\" autofocus onfocus=\"alert(1)',
|
||||
icon: 'fa-cube\" onmouseover=\"alert(1)',
|
||||
iconColor: "text-red-400 <img",
|
||||
label: '<img src=x onerror="alert(1)">',
|
||||
};
|
||||
|
||||
test("escapes labels and rejects executable topbar attributes", () => {
|
||||
const html = dropdownItem(hostile);
|
||||
expect(html).toContain('href="#"');
|
||||
expect(html).toContain("<img src=x onerror="alert(1)">");
|
||||
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("<img src=x onerror="alert(1)">");
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types";
|
||||
import Fuse from "fuse.js";
|
||||
import { getThumbnailSummaries } from "../thumbnails";
|
||||
import { db, dbPath } from "./connection";
|
||||
import { db, dbPath, type TypedDatabase } from "./connection";
|
||||
import "./schema";
|
||||
|
||||
export * from "./repositories/chat";
|
||||
@@ -41,8 +41,8 @@ const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os
|
||||
last_seen=excluded.last_seen,
|
||||
online=COALESCE(excluded.online, clients.online),
|
||||
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
|
||||
build_tag=COALESCE(excluded.build_tag, clients.build_tag),
|
||||
built_by_user_id=COALESCE(excluded.built_by_user_id, clients.built_by_user_id),
|
||||
build_tag=COALESCE(clients.build_tag, excluded.build_tag),
|
||||
built_by_user_id=COALESCE(clients.built_by_user_id, excluded.built_by_user_id),
|
||||
enrollment_status=CASE WHEN excluded.enrollment_status <> 'pending' THEN excluded.enrollment_status ELSE COALESCE(clients.enrollment_status, 'pending') END,
|
||||
public_key=COALESCE(excluded.public_key, clients.public_key),
|
||||
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint),
|
||||
@@ -56,13 +56,26 @@ const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os
|
||||
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
|
||||
elevation=COALESCE(excluded.elevation, clients.elevation),
|
||||
permissions=COALESCE(excluded.permissions, clients.permissions)
|
||||
WHERE
|
||||
excluded.public_key IS NULL
|
||||
OR clients.public_key = excluded.public_key
|
||||
OR (
|
||||
clients.public_key IS NULL
|
||||
AND COALESCE(clients.enrollment_status, 'pending') <> 'approved'
|
||||
)
|
||||
`;
|
||||
|
||||
const upsertClientRowStmt = db.prepare(UPSERT_CLIENT_ROW_SQL);
|
||||
|
||||
function upsertClientRowInternal(partial: ClientDbRow): void {
|
||||
function positiveEnrollmentLimit(name: string, fallback: number, max: number): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(max, Math.max(1, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
function upsertClientRowInternal(partial: ClientDbRow): boolean {
|
||||
const now = partial.lastSeen ?? Date.now();
|
||||
upsertClientRowStmt.run(
|
||||
const result = upsertClientRowStmt.run(
|
||||
partial.id,
|
||||
partial.hwid ?? partial.id,
|
||||
partial.role ?? null,
|
||||
@@ -97,17 +110,83 @@ function upsertClientRowInternal(partial: ClientDbRow): void {
|
||||
partial.permissions ? JSON.stringify(partial.permissions) : null,
|
||||
);
|
||||
|
||||
if (partial.hwid) {
|
||||
db.run(
|
||||
`DELETE FROM clients WHERE hwid=? AND id<>?`,
|
||||
partial.hwid,
|
||||
partial.id,
|
||||
);
|
||||
}
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function publicKeyOwnedByAnotherClient(partial: ClientDbRow): boolean {
|
||||
if (!partial.publicKey) return false;
|
||||
const owner = db
|
||||
.query<{ id: string }>("SELECT id FROM clients WHERE public_key=? LIMIT 1")
|
||||
.get(partial.publicKey);
|
||||
return !!owner && owner.id !== partial.id;
|
||||
}
|
||||
|
||||
const upsertClientRowTx = db.transaction((partial: ClientDbRow) => {
|
||||
if (publicKeyOwnedByAnotherClient(partial)) return false;
|
||||
return upsertClientRowInternal(partial);
|
||||
});
|
||||
|
||||
const upsertPendingClientRowTx = db.transaction((partial: ClientDbRow & { publicKey: string }) => {
|
||||
const now = partial.lastSeen ?? Date.now();
|
||||
const ttlHours = positiveEnrollmentLimit("OVERLORD_PENDING_ENROLLMENT_TTL_HOURS", 24 * 7, 24 * 365);
|
||||
const stale = db.run(
|
||||
`DELETE FROM clients WHERE id IN (
|
||||
SELECT id FROM clients
|
||||
WHERE COALESCE(enrollment_status, 'pending') = 'pending'
|
||||
AND last_seen < ?
|
||||
LIMIT 500
|
||||
)`,
|
||||
now - ttlHours * 60 * 60 * 1000,
|
||||
);
|
||||
const existing = db
|
||||
.query<{ public_key: string | null }>(
|
||||
`SELECT public_key FROM clients WHERE id=? LIMIT 1`,
|
||||
)
|
||||
.get(partial.id);
|
||||
|
||||
const keyOwner = db
|
||||
.query<{ id: string }>("SELECT id FROM clients WHERE public_key=? LIMIT 1")
|
||||
.get(partial.publicKey);
|
||||
|
||||
// An enrollment request may refresh a row only when it proves the same
|
||||
// identity. In particular, a legacy approved row without a key must not be
|
||||
// claimed by supplying a new key for its ID.
|
||||
if (existing && existing.public_key !== partial.publicKey) {
|
||||
return { changed: false, pruned: stale.changes > 0 };
|
||||
}
|
||||
if (keyOwner && keyOwner.id !== partial.id) {
|
||||
return { changed: false, pruned: stale.changes > 0 };
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
const maxGlobal = positiveEnrollmentLimit("OVERLORD_MAX_PENDING_ENROLLMENTS", 100_000, 100_000);
|
||||
const maxPerIp = positiveEnrollmentLimit("OVERLORD_MAX_PENDING_ENROLLMENTS_PER_IP", 10_000, 10_000);
|
||||
const globalCount = db.query<{ count: number }>(
|
||||
"SELECT COUNT(*) AS count FROM clients WHERE COALESCE(enrollment_status, 'pending')='pending'",
|
||||
).get()?.count ?? 0;
|
||||
if (globalCount >= maxGlobal) {
|
||||
return { changed: false, pruned: stale.changes > 0 };
|
||||
}
|
||||
if (partial.ip) {
|
||||
const ipCount = db.query<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM clients
|
||||
WHERE COALESCE(enrollment_status, 'pending')='pending' AND ip=?`,
|
||||
).get(partial.ip)?.count ?? 0;
|
||||
if (ipCount >= maxPerIp) {
|
||||
return { changed: false, pruned: stale.changes > 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changed: upsertClientRowInternal({ ...partial, enrollmentStatus: "pending" }),
|
||||
pruned: stale.changes > 0,
|
||||
};
|
||||
});
|
||||
|
||||
const upsertClientRowsTx = db.transaction((rows: ClientDbRow[]) => {
|
||||
for (const row of rows) {
|
||||
if (publicKeyOwnedByAnotherClient(row)) continue;
|
||||
upsertClientRowInternal(row);
|
||||
}
|
||||
});
|
||||
@@ -115,8 +194,21 @@ const upsertClientRowsTx = db.transaction((rows: ClientDbRow[]) => {
|
||||
export function upsertClientRow(
|
||||
partial: ClientDbRow,
|
||||
) {
|
||||
upsertClientRowInternal(partial);
|
||||
invalidateClientMetricsSummaryCache();
|
||||
const changed = upsertClientRowTx(partial);
|
||||
if (changed) invalidateClientMetricsSummaryCache();
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an unapproved enrollment without allowing the claimed ID or HWID to
|
||||
* mutate a different client identity.
|
||||
*/
|
||||
export function upsertPendingClientRow(
|
||||
partial: ClientDbRow & { publicKey: string },
|
||||
): boolean {
|
||||
const result = upsertPendingClientRowTx(partial);
|
||||
if (result.changed || result.pruned) invalidateClientMetricsSummaryCache();
|
||||
return result.changed;
|
||||
}
|
||||
|
||||
export function upsertClientRows(rows: ClientDbRow[]): void {
|
||||
@@ -1712,21 +1804,213 @@ export interface NotificationScreenshotRecord {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export function saveNotificationScreenshot(record: NotificationScreenshotRecord) {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO notification_screenshots
|
||||
(id, notification_id, client_id, ts, format, width, height, bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
,
|
||||
record.id,
|
||||
record.notificationId,
|
||||
record.clientId,
|
||||
record.ts,
|
||||
record.format,
|
||||
record.width ?? null,
|
||||
record.height ?? null,
|
||||
record.bytes,
|
||||
export const MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export type NotificationScreenshotQuotaLimits = {
|
||||
perClientCount: number;
|
||||
perClientBytes: number;
|
||||
globalCount: number;
|
||||
globalBytes: number;
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS: NotificationScreenshotQuotaLimits = {
|
||||
perClientCount: 10_000,
|
||||
perClientBytes: 1024 * 1024 * 1024,
|
||||
globalCount: 100_000,
|
||||
globalBytes: 16 * 1024 * 1024 * 1024,
|
||||
};
|
||||
const MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE = 250;
|
||||
|
||||
function notificationScreenshotLimit(
|
||||
name: string,
|
||||
fallback: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = Number(process.env[name]);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) return fallback;
|
||||
return Math.min(value, maximum);
|
||||
}
|
||||
|
||||
function getNotificationScreenshotQuotaLimits(): NotificationScreenshotQuotaLimits {
|
||||
return {
|
||||
perClientCount: notificationScreenshotLimit(
|
||||
"OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_COUNT",
|
||||
DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.perClientCount,
|
||||
10_000,
|
||||
),
|
||||
perClientBytes: notificationScreenshotLimit(
|
||||
"OVERLORD_NOTIFICATION_SCREENSHOT_CLIENT_MAX_BYTES",
|
||||
DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.perClientBytes,
|
||||
1024 * 1024 * 1024,
|
||||
),
|
||||
globalCount: notificationScreenshotLimit(
|
||||
"OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_COUNT",
|
||||
DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.globalCount,
|
||||
100_000,
|
||||
),
|
||||
globalBytes: notificationScreenshotLimit(
|
||||
"OVERLORD_NOTIFICATION_SCREENSHOT_GLOBAL_MAX_BYTES",
|
||||
DEFAULT_NOTIFICATION_SCREENSHOT_QUOTAS.globalBytes,
|
||||
16 * 1024 * 1024 * 1024,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type ScreenshotQuotaRow = { count: number; bytes: number | null };
|
||||
type ScreenshotPruneRow = { id: string; bytes: number };
|
||||
|
||||
function deleteNotificationScreenshotRows(
|
||||
database: TypedDatabase,
|
||||
ids: string[],
|
||||
): void {
|
||||
if (ids.length === 0) return;
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
database.run(
|
||||
`UPDATE notifications SET screenshot_id=NULL WHERE screenshot_id IN (${placeholders})`,
|
||||
...ids,
|
||||
);
|
||||
database.run(
|
||||
`DELETE FROM notification_screenshots WHERE id IN (${placeholders})`,
|
||||
...ids,
|
||||
);
|
||||
}
|
||||
|
||||
function pruneNotificationScreenshotScope(
|
||||
database: TypedDatabase,
|
||||
clientId: string | null,
|
||||
protectedId: string,
|
||||
incomingBytes: number,
|
||||
maxCount: number,
|
||||
maxBytes: number,
|
||||
): boolean {
|
||||
const stats = clientId === null
|
||||
? database.query<ScreenshotQuotaRow>(
|
||||
`SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(bytes)), 0) AS bytes
|
||||
FROM notification_screenshots`,
|
||||
).get()
|
||||
: database.query<ScreenshotQuotaRow>(
|
||||
`SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(bytes)), 0) AS bytes
|
||||
FROM notification_screenshots WHERE client_id=?`,
|
||||
).get(clientId);
|
||||
let count = Number(stats?.count ?? 0);
|
||||
let bytes = Number(stats?.bytes ?? 0);
|
||||
if (count + 1 <= maxCount && bytes + incomingBytes <= maxBytes) return true;
|
||||
|
||||
const candidates = clientId === null
|
||||
? database.query<ScreenshotPruneRow>(
|
||||
`SELECT id, LENGTH(bytes) AS bytes FROM notification_screenshots
|
||||
WHERE id<>?
|
||||
ORDER BY ts ASC, id ASC LIMIT ?`,
|
||||
).all(protectedId, MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE)
|
||||
: database.query<ScreenshotPruneRow>(
|
||||
`SELECT id, LENGTH(bytes) AS bytes FROM notification_screenshots
|
||||
WHERE client_id=? AND id<>? ORDER BY ts ASC, id ASC LIMIT ?`,
|
||||
).all(clientId, protectedId, MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE);
|
||||
|
||||
const pruneIds: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
pruneIds.push(candidate.id);
|
||||
count -= 1;
|
||||
bytes -= Number(candidate.bytes || 0);
|
||||
if (count + 1 <= maxCount && bytes + incomingBytes <= maxBytes) break;
|
||||
}
|
||||
deleteNotificationScreenshotRows(database, pruneIds);
|
||||
return count + 1 <= maxCount && bytes + incomingBytes <= maxBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the atomic screenshot writer separately so storage/quota behavior can
|
||||
* be regression-tested against an isolated SQLite database.
|
||||
*/
|
||||
export function createNotificationScreenshotSaver(
|
||||
database: TypedDatabase,
|
||||
quotaProvider: () => NotificationScreenshotQuotaLimits = getNotificationScreenshotQuotaLimits,
|
||||
): (record: NotificationScreenshotRecord) => boolean {
|
||||
const transaction = database.transaction((record: NotificationScreenshotRecord): boolean => {
|
||||
const byteLength = record.bytes?.byteLength ?? 0;
|
||||
if (byteLength <= 0 || byteLength > MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES) return false;
|
||||
|
||||
const notification = database.query<{ screenshot_id: string | null }>(
|
||||
`SELECT screenshot_id FROM notifications WHERE id=? AND client_id=? LIMIT 1`,
|
||||
).get(record.notificationId, record.clientId);
|
||||
if (!notification || notification.screenshot_id) return false;
|
||||
|
||||
const limits = quotaProvider();
|
||||
if (
|
||||
limits.perClientCount <= 0
|
||||
|| limits.perClientBytes <= 0
|
||||
|| limits.globalCount <= 0
|
||||
|| limits.globalBytes <= 0
|
||||
|| byteLength > limits.perClientBytes
|
||||
|| byteLength > limits.globalBytes
|
||||
) return false;
|
||||
|
||||
// Clean up a bounded number of rows left behind by older non-transactional
|
||||
// writes before accounting for the new image.
|
||||
const orphanRows = database.query<{ id: string }>(
|
||||
`SELECT s.id FROM notification_screenshots s
|
||||
LEFT JOIN notifications n
|
||||
ON n.id=s.notification_id
|
||||
AND n.client_id=s.client_id
|
||||
AND n.screenshot_id=s.id
|
||||
WHERE n.id IS NULL
|
||||
AND s.id<>?
|
||||
ORDER BY s.ts ASC, s.id ASC
|
||||
LIMIT ?`,
|
||||
).all(record.id, MAX_SCREENSHOT_ROWS_PRUNED_PER_SAVE);
|
||||
deleteNotificationScreenshotRows(database, orphanRows.map((row) => row.id));
|
||||
|
||||
const perClientSatisfied = pruneNotificationScreenshotScope(
|
||||
database,
|
||||
record.clientId,
|
||||
record.id,
|
||||
byteLength,
|
||||
limits.perClientCount,
|
||||
limits.perClientBytes,
|
||||
);
|
||||
const globalSatisfied = pruneNotificationScreenshotScope(
|
||||
database,
|
||||
null,
|
||||
record.id,
|
||||
byteLength,
|
||||
limits.globalCount,
|
||||
limits.globalBytes,
|
||||
);
|
||||
if (!perClientSatisfied || !globalSatisfied) return false;
|
||||
|
||||
database.run(
|
||||
`INSERT INTO notification_screenshots
|
||||
(id, notification_id, client_id, ts, format, width, height, bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.id,
|
||||
record.notificationId,
|
||||
record.clientId,
|
||||
record.ts,
|
||||
record.format,
|
||||
record.width ?? null,
|
||||
record.height ?? null,
|
||||
record.bytes,
|
||||
);
|
||||
const linked = database.run(
|
||||
`UPDATE notifications SET screenshot_id=?
|
||||
WHERE id=? AND client_id=? AND screenshot_id IS NULL`,
|
||||
record.id,
|
||||
record.notificationId,
|
||||
record.clientId,
|
||||
);
|
||||
if (linked.changes !== 1) {
|
||||
throw new Error("notification screenshot link race");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
const saveNotificationScreenshotTx = createNotificationScreenshotSaver(db);
|
||||
|
||||
export function saveNotificationScreenshot(record: NotificationScreenshotRecord): boolean {
|
||||
return saveNotificationScreenshotTx(record);
|
||||
}
|
||||
|
||||
export function getNotificationScreenshot(notificationId: string): NotificationScreenshotRecord | null {
|
||||
@@ -1766,26 +2050,247 @@ export type NotificationRow = {
|
||||
screenshotId?: string;
|
||||
};
|
||||
|
||||
export function saveNotification(record: NotificationRow) {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO notifications
|
||||
(id, client_id, host, user, os, title, process, process_path, detail, pid, keyword, category, ts, screenshot_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.id,
|
||||
record.clientId,
|
||||
record.host ?? null,
|
||||
record.user ?? null,
|
||||
record.os ?? null,
|
||||
record.title,
|
||||
record.process ?? null,
|
||||
record.processPath ?? null,
|
||||
record.detail ?? null,
|
||||
record.pid ?? null,
|
||||
record.keyword ?? null,
|
||||
record.category,
|
||||
record.ts,
|
||||
record.screenshotId ?? null,
|
||||
export type NotificationHistoryQuotaLimits = {
|
||||
perClientCount: number;
|
||||
perClientBytes: number;
|
||||
globalCount: number;
|
||||
globalBytes: number;
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_HISTORY_QUOTAS: NotificationHistoryQuotaLimits = {
|
||||
perClientCount: 100_000,
|
||||
perClientBytes: 1024 * 1024 * 1024,
|
||||
globalCount: 1_000_000,
|
||||
globalBytes: 16 * 1024 * 1024 * 1024,
|
||||
};
|
||||
const MAX_NOTIFICATION_ROWS_PRUNED_PER_SAVE = 2_000;
|
||||
const NOTIFICATION_ROW_OVERHEAD_BYTES = 64;
|
||||
const NOTIFICATION_PAYLOAD_BYTES_SQL = `(
|
||||
${NOTIFICATION_ROW_OVERHEAD_BYTES}
|
||||
+ COALESCE(LENGTH(CAST(id AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(client_id AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(host AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(user AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(os AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(title AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(process AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(process_path AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(detail AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(keyword AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(category AS BLOB)), 0)
|
||||
+ COALESCE(LENGTH(CAST(screenshot_id AS BLOB)), 0)
|
||||
)`;
|
||||
|
||||
function notificationHistoryLimit(
|
||||
name: string,
|
||||
fallback: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = Number(process.env[name]);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) return fallback;
|
||||
return Math.min(value, maximum);
|
||||
}
|
||||
|
||||
function getNotificationHistoryQuotaLimits(): NotificationHistoryQuotaLimits {
|
||||
return {
|
||||
perClientCount: notificationHistoryLimit(
|
||||
"OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_COUNT",
|
||||
DEFAULT_NOTIFICATION_HISTORY_QUOTAS.perClientCount,
|
||||
100_000,
|
||||
),
|
||||
perClientBytes: notificationHistoryLimit(
|
||||
"OVERLORD_NOTIFICATION_HISTORY_CLIENT_MAX_BYTES",
|
||||
DEFAULT_NOTIFICATION_HISTORY_QUOTAS.perClientBytes,
|
||||
1024 * 1024 * 1024,
|
||||
),
|
||||
globalCount: notificationHistoryLimit(
|
||||
"OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_COUNT",
|
||||
DEFAULT_NOTIFICATION_HISTORY_QUOTAS.globalCount,
|
||||
1_000_000,
|
||||
),
|
||||
globalBytes: notificationHistoryLimit(
|
||||
"OVERLORD_NOTIFICATION_HISTORY_GLOBAL_MAX_BYTES",
|
||||
DEFAULT_NOTIFICATION_HISTORY_QUOTAS.globalBytes,
|
||||
16 * 1024 * 1024 * 1024,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalNotificationStringBytes(value: string | undefined): number {
|
||||
return value === undefined ? 0 : Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
export function notificationPayloadBytes(record: NotificationRow): number {
|
||||
return NOTIFICATION_ROW_OVERHEAD_BYTES
|
||||
+ Buffer.byteLength(record.id, "utf8")
|
||||
+ Buffer.byteLength(record.clientId, "utf8")
|
||||
+ optionalNotificationStringBytes(record.host)
|
||||
+ optionalNotificationStringBytes(record.user)
|
||||
+ optionalNotificationStringBytes(record.os)
|
||||
+ Buffer.byteLength(record.title, "utf8")
|
||||
+ optionalNotificationStringBytes(record.process)
|
||||
+ optionalNotificationStringBytes(record.processPath)
|
||||
+ optionalNotificationStringBytes(record.detail)
|
||||
+ optionalNotificationStringBytes(record.keyword)
|
||||
+ Buffer.byteLength(record.category, "utf8")
|
||||
+ optionalNotificationStringBytes(record.screenshotId);
|
||||
}
|
||||
|
||||
function deleteNotificationHistoryRows(
|
||||
database: TypedDatabase,
|
||||
ids: string[],
|
||||
): void {
|
||||
if (ids.length === 0) return;
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
database.run(
|
||||
`DELETE FROM notification_screenshots
|
||||
WHERE notification_id IN (${placeholders})
|
||||
OR id IN (
|
||||
SELECT screenshot_id FROM notifications
|
||||
WHERE id IN (${placeholders}) AND screenshot_id IS NOT NULL
|
||||
)`,
|
||||
...ids,
|
||||
...ids,
|
||||
);
|
||||
database.run(
|
||||
`DELETE FROM notifications WHERE id IN (${placeholders})`,
|
||||
...ids,
|
||||
);
|
||||
}
|
||||
|
||||
function pruneNotificationHistoryScope(
|
||||
database: TypedDatabase,
|
||||
clientId: string | null,
|
||||
protectedId: string,
|
||||
projectedCountDelta: number,
|
||||
projectedBytesDelta: number,
|
||||
maxCount: number,
|
||||
maxBytes: number,
|
||||
): boolean {
|
||||
const stats = clientId === null
|
||||
? database.query<{ count: number; bytes: number }>(
|
||||
`SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(${NOTIFICATION_PAYLOAD_BYTES_SQL}), 0) AS bytes
|
||||
FROM notifications`,
|
||||
).get()
|
||||
: database.query<{ count: number; bytes: number }>(
|
||||
`SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(${NOTIFICATION_PAYLOAD_BYTES_SQL}), 0) AS bytes
|
||||
FROM notifications WHERE client_id=?`,
|
||||
).get(clientId);
|
||||
let count = Number(stats?.count ?? 0) + projectedCountDelta;
|
||||
let bytes = Number(stats?.bytes ?? 0) + projectedBytesDelta;
|
||||
if (count <= maxCount && bytes <= maxBytes) return true;
|
||||
|
||||
const candidates = (clientId === null
|
||||
? database.query<{ id: string; bytes: number }>(
|
||||
`SELECT id, ${NOTIFICATION_PAYLOAD_BYTES_SQL} AS bytes
|
||||
FROM notifications
|
||||
WHERE id<>?
|
||||
ORDER BY ts ASC, id ASC
|
||||
LIMIT ?`,
|
||||
).all(protectedId, MAX_NOTIFICATION_ROWS_PRUNED_PER_SAVE)
|
||||
: database.query<{ id: string; bytes: number }>(
|
||||
`SELECT id, ${NOTIFICATION_PAYLOAD_BYTES_SQL} AS bytes
|
||||
FROM notifications
|
||||
WHERE client_id=? AND id<>?
|
||||
ORDER BY ts ASC, id ASC
|
||||
LIMIT ?`,
|
||||
).all(clientId, protectedId, MAX_NOTIFICATION_ROWS_PRUNED_PER_SAVE));
|
||||
|
||||
const pruneIds: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
pruneIds.push(candidate.id);
|
||||
count -= 1;
|
||||
bytes -= Number(candidate.bytes || 0);
|
||||
if (count <= maxCount && bytes <= maxBytes) break;
|
||||
}
|
||||
deleteNotificationHistoryRows(database, pruneIds);
|
||||
return count <= maxCount && bytes <= maxBytes;
|
||||
}
|
||||
|
||||
/** Build an atomic notification writer for isolated quota regression tests. */
|
||||
export function createNotificationSaver(
|
||||
database: TypedDatabase,
|
||||
quotaProvider: () => NotificationHistoryQuotaLimits = getNotificationHistoryQuotaLimits,
|
||||
): (record: NotificationRow) => boolean {
|
||||
const transaction = database.transaction((record: NotificationRow): boolean => {
|
||||
if (
|
||||
typeof record.id !== "string"
|
||||
|| record.id.length === 0
|
||||
|| typeof record.clientId !== "string"
|
||||
|| record.clientId.length === 0
|
||||
|| typeof record.title !== "string"
|
||||
|| typeof record.category !== "string"
|
||||
|| !Number.isFinite(record.ts)
|
||||
) return false;
|
||||
|
||||
const limits = quotaProvider();
|
||||
const bytes = notificationPayloadBytes(record);
|
||||
if (
|
||||
limits.perClientCount <= 0
|
||||
|| limits.perClientBytes <= 0
|
||||
|| limits.globalCount <= 0
|
||||
|| limits.globalBytes <= 0
|
||||
|| bytes > limits.perClientBytes
|
||||
|| bytes > limits.globalBytes
|
||||
) return false;
|
||||
|
||||
const existing = database.query<{ client_id: string; bytes: number }>(
|
||||
`SELECT client_id, ${NOTIFICATION_PAYLOAD_BYTES_SQL} AS bytes
|
||||
FROM notifications WHERE id=? LIMIT 1`,
|
||||
).get(record.id);
|
||||
const existingInClientScope = existing?.client_id === record.clientId;
|
||||
|
||||
const perClientSatisfied = pruneNotificationHistoryScope(
|
||||
database,
|
||||
record.clientId,
|
||||
record.id,
|
||||
existingInClientScope ? 0 : 1,
|
||||
bytes - (existingInClientScope ? Number(existing.bytes || 0) : 0),
|
||||
limits.perClientCount,
|
||||
limits.perClientBytes,
|
||||
);
|
||||
const globalSatisfied = pruneNotificationHistoryScope(
|
||||
database,
|
||||
null,
|
||||
record.id,
|
||||
existing ? 0 : 1,
|
||||
bytes - Number(existing?.bytes || 0),
|
||||
limits.globalCount,
|
||||
limits.globalBytes,
|
||||
);
|
||||
if (!perClientSatisfied || !globalSatisfied) return false;
|
||||
|
||||
database.run(
|
||||
`INSERT OR REPLACE INTO notifications
|
||||
(id, client_id, host, user, os, title, process, process_path, detail, pid, keyword, category, ts, screenshot_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.id,
|
||||
record.clientId,
|
||||
record.host ?? null,
|
||||
record.user ?? null,
|
||||
record.os ?? null,
|
||||
record.title,
|
||||
record.process ?? null,
|
||||
record.processPath ?? null,
|
||||
record.detail ?? null,
|
||||
record.pid ?? null,
|
||||
record.keyword ?? null,
|
||||
record.category,
|
||||
record.ts,
|
||||
record.screenshotId ?? null,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
const saveNotificationTx = createNotificationSaver(db);
|
||||
|
||||
export function saveNotification(record: NotificationRow): boolean {
|
||||
return saveNotificationTx(record);
|
||||
}
|
||||
|
||||
export function updateNotificationScreenshotId(notificationId: string, screenshotId: string) {
|
||||
@@ -1950,12 +2455,131 @@ export interface PushSubscriptionRecord {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function savePushSubscription(userId: number, endpoint: string, p256dh: string, auth: string): void {
|
||||
export interface PushSubscriptionLimits {
|
||||
perUser: number;
|
||||
global: number;
|
||||
}
|
||||
|
||||
export type SavePushSubscriptionResult =
|
||||
| { ok: true; action: "created" | "updated"; evictedEndpoint?: string }
|
||||
| { ok: false; reason: "endpoint_conflict" }
|
||||
| { ok: false; reason: "global_limit"; limit: number };
|
||||
|
||||
function positivePushSubscriptionLimit(name: string, fallback: number, maximum: number): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(maximum, Math.max(1, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function getPushSubscriptionLimits(): PushSubscriptionLimits {
|
||||
return {
|
||||
perUser: positivePushSubscriptionLimit(
|
||||
"OVERLORD_MAX_PUSH_SUBSCRIPTIONS_PER_USER",
|
||||
10,
|
||||
1_000,
|
||||
),
|
||||
global: positivePushSubscriptionLimit(
|
||||
"OVERLORD_MAX_PUSH_SUBSCRIPTIONS",
|
||||
10_000,
|
||||
1_000_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const savePushSubscriptionTx = db.transaction((
|
||||
userId: number,
|
||||
endpoint: string,
|
||||
p256dh: string,
|
||||
auth: string,
|
||||
limits: PushSubscriptionLimits,
|
||||
): SavePushSubscriptionResult => {
|
||||
const existing = db
|
||||
.query<{ user_id: number }>(
|
||||
`SELECT user_id FROM push_subscriptions WHERE endpoint=? LIMIT 1`,
|
||||
)
|
||||
.get(endpoint);
|
||||
|
||||
// Do not let one signed-in user take over another user's endpoint. Updating
|
||||
// the keys for the endpoint's existing owner does not consume another slot.
|
||||
if (existing) {
|
||||
if (existing.user_id !== userId) {
|
||||
return { ok: false, reason: "endpoint_conflict" };
|
||||
}
|
||||
db.run(
|
||||
`UPDATE push_subscriptions
|
||||
SET p256dh=?, auth=?, created_at=?
|
||||
WHERE endpoint=? AND user_id=?`,
|
||||
p256dh,
|
||||
auth,
|
||||
Date.now(),
|
||||
endpoint,
|
||||
userId,
|
||||
);
|
||||
return { ok: true, action: "updated" };
|
||||
}
|
||||
|
||||
const userCount = Number(db
|
||||
.query<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM push_subscriptions WHERE user_id=?`,
|
||||
)
|
||||
.get(userId)?.count ?? 0);
|
||||
|
||||
let evictedEndpoint: string | undefined;
|
||||
if (userCount >= limits.perUser) {
|
||||
const oldest = db
|
||||
.query<{ endpoint: string }>(
|
||||
`SELECT endpoint FROM push_subscriptions
|
||||
WHERE user_id=?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId);
|
||||
if (oldest) {
|
||||
db.run(
|
||||
`DELETE FROM push_subscriptions WHERE endpoint=? AND user_id=?`,
|
||||
oldest.endpoint,
|
||||
userId,
|
||||
);
|
||||
evictedEndpoint = oldest.endpoint;
|
||||
}
|
||||
} else {
|
||||
const globalCount = Number(db
|
||||
.query<{ count: number }>(`SELECT COUNT(*) AS count FROM push_subscriptions`)
|
||||
.get()?.count ?? 0);
|
||||
if (globalCount >= limits.global) {
|
||||
return { ok: false, reason: "global_limit", limit: limits.global };
|
||||
}
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
userId, endpoint, p256dh, auth, Date.now(),
|
||||
userId,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
Date.now(),
|
||||
);
|
||||
return { ok: true, action: "created", ...(evictedEndpoint ? { evictedEndpoint } : {}) };
|
||||
});
|
||||
|
||||
export function savePushSubscription(
|
||||
userId: number,
|
||||
endpoint: string,
|
||||
p256dh: string,
|
||||
auth: string,
|
||||
limits: PushSubscriptionLimits = getPushSubscriptionLimits(),
|
||||
): SavePushSubscriptionResult {
|
||||
const defaults = getPushSubscriptionLimits();
|
||||
const normalizedLimits = {
|
||||
perUser: Number.isFinite(limits.perUser)
|
||||
? Math.max(1, Math.min(1_000, Math.floor(limits.perUser)))
|
||||
: defaults.perUser,
|
||||
global: Number.isFinite(limits.global)
|
||||
? Math.max(1, Math.min(1_000_000, Math.floor(limits.global)))
|
||||
: defaults.global,
|
||||
};
|
||||
return savePushSubscriptionTx(userId, endpoint, p256dh, auth, normalizedLimits);
|
||||
}
|
||||
|
||||
export function deletePushSubscription(endpoint: string): void {
|
||||
@@ -1976,9 +2600,15 @@ export function deletePushSubscriptionsByUser(userId: number): void {
|
||||
}
|
||||
|
||||
export function getPushSubscriptionsByUser(userId: number): PushSubscriptionRecord[] {
|
||||
const limit = getPushSubscriptionLimits().perUser;
|
||||
return db
|
||||
.query<any>(`SELECT * FROM push_subscriptions WHERE user_id=?`)
|
||||
.all(userId)
|
||||
.query<any>(
|
||||
`SELECT * FROM push_subscriptions
|
||||
WHERE user_id=?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(userId, limit)
|
||||
.map((r: any) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
@@ -1989,10 +2619,19 @@ export function getPushSubscriptionsByUser(userId: number): PushSubscriptionReco
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
|
||||
export function getAllPushSubscriptions(maxResults = getPushSubscriptionLimits().global): PushSubscriptionRecord[] {
|
||||
const limit = Math.max(
|
||||
0,
|
||||
Math.min(getPushSubscriptionLimits().global, Math.floor(Number(maxResults) || 0)),
|
||||
);
|
||||
if (limit === 0) return [];
|
||||
return db
|
||||
.query<any>(`SELECT * FROM push_subscriptions`)
|
||||
.all()
|
||||
.query<any>(
|
||||
`SELECT * FROM push_subscriptions
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(limit)
|
||||
.map((r: any) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
|
||||
@@ -130,6 +130,24 @@ export function incrementSharedFileDownloadCount(id: string): boolean {
|
||||
return (result as any)?.changes ? (result as any).changes > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reserves one download while enforcing expiry and max-downloads.
|
||||
* The conditional UPDATE prevents concurrent requests from all observing the
|
||||
* same stale download_count and overshooting the configured limit.
|
||||
*/
|
||||
export function claimSharedFileDownload(id: string, now = Date.now()): boolean {
|
||||
const result = db.run(
|
||||
`UPDATE shared_files
|
||||
SET download_count = download_count + 1
|
||||
WHERE id=?
|
||||
AND (expires_at IS NULL OR expires_at >= ?)
|
||||
AND (max_downloads IS NULL OR download_count < max_downloads)`,
|
||||
id,
|
||||
now,
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function deleteExpiredSharedFiles(): string[] {
|
||||
const now = Date.now();
|
||||
const expired = db
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { db } from "../db/connection";
|
||||
import { db, type TypedDatabase } from "../db/connection";
|
||||
import { getConfig } from "../config";
|
||||
import { logger } from "../logger";
|
||||
import { encodeMessage } from "../protocol";
|
||||
@@ -13,8 +13,310 @@ type KeylogFileMeta = {
|
||||
modifiedAt: number | null;
|
||||
};
|
||||
|
||||
const pendingFileMeta = new Map<string, Map<string, KeylogFileMeta>>();
|
||||
type PendingArchiveFiles = {
|
||||
files: Map<string, KeylogFileMeta>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const pendingFileMeta = new Map<string, PendingArchiveFiles>();
|
||||
const archiveMessageRates = new Map<string, { windowStart: number; count: number }>();
|
||||
const MAX_RETRIEVES_PER_LIST = 25;
|
||||
const MAX_FILES_PER_LIST = 100_000;
|
||||
const MAX_KEYLOG_FILENAME_LENGTH = 255;
|
||||
const MAX_ARCHIVE_FILES_PER_CLIENT = 100_000;
|
||||
const MAX_ARCHIVE_BYTES_PER_CLIENT = 10 * 1024 * 1024 * 1024;
|
||||
const DEFAULT_MAX_ARCHIVE_FILES_GLOBAL = 1_000_000;
|
||||
const DEFAULT_MAX_ARCHIVE_BYTES_GLOBAL = 64 * 1024 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_ROWS_PRUNED_PER_SAVE = 5_000;
|
||||
const KEYLOG_CONTENT_BYTES_SQL = "COALESCE(LENGTH(CAST(content AS BLOB)), 0)";
|
||||
const PENDING_ARCHIVE_TTL_MS = 5 * 60_000;
|
||||
const MAX_PENDING_ARCHIVE_CLIENTS = 5_000;
|
||||
|
||||
export type KeylogArchiveQuotaLimits = {
|
||||
perClientFiles: number;
|
||||
perClientBytes: number;
|
||||
globalFiles: number;
|
||||
globalBytes: number;
|
||||
};
|
||||
|
||||
export type KeylogArchiveRecord = {
|
||||
clientId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
modifiedAt: number | null;
|
||||
retrievedAt: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function positiveArchiveLimit(name: string, fallback: number, maximum: number): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(parsed, maximum);
|
||||
}
|
||||
|
||||
function getKeylogArchiveQuotaLimits(): KeylogArchiveQuotaLimits {
|
||||
return {
|
||||
perClientFiles: MAX_ARCHIVE_FILES_PER_CLIENT,
|
||||
perClientBytes: MAX_ARCHIVE_BYTES_PER_CLIENT,
|
||||
globalFiles: positiveArchiveLimit(
|
||||
"OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_FILES",
|
||||
DEFAULT_MAX_ARCHIVE_FILES_GLOBAL,
|
||||
1_000_000,
|
||||
),
|
||||
globalBytes: positiveArchiveLimit(
|
||||
"OVERLORD_INPUT_ARCHIVE_GLOBAL_MAX_BYTES",
|
||||
DEFAULT_MAX_ARCHIVE_BYTES_GLOBAL,
|
||||
64 * 1024 * 1024 * 1024,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function deleteArchivedKeylogRows(
|
||||
database: TypedDatabase,
|
||||
ids: number[],
|
||||
): void {
|
||||
if (ids.length === 0) return;
|
||||
const deleteFile = database.prepare("DELETE FROM keylog_archive_files WHERE id = ?");
|
||||
let deleteFts: { run(...params: any[]): unknown } | null = null;
|
||||
try {
|
||||
deleteFts = database.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?");
|
||||
} catch {
|
||||
// FTS5 is optional on SQLite builds that do not provide it.
|
||||
}
|
||||
for (const id of ids) {
|
||||
deleteFts?.run(id);
|
||||
deleteFile.run(id);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceArchivedKeylogFts(
|
||||
database: TypedDatabase,
|
||||
row: { id: number; client_id: string; filename: string; content: string },
|
||||
): void {
|
||||
try {
|
||||
database.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?").run(row.id);
|
||||
database.prepare(
|
||||
`INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(row.id, row.id, row.client_id, row.filename, row.content);
|
||||
} catch {
|
||||
// The archive remains usable without full-text search. Search already has
|
||||
// a bounded LIKE fallback for SQLite builds without FTS5.
|
||||
}
|
||||
}
|
||||
|
||||
type ArchivedKeylogQuotaRow = { id: number; bytes: number };
|
||||
type ArchivedKeylogQuotaTotals = { files: number; bytes: number };
|
||||
|
||||
function getArchivedKeylogQuotaTotals(
|
||||
database: TypedDatabase,
|
||||
clientId: string | null,
|
||||
): ArchivedKeylogQuotaTotals {
|
||||
const row = clientId === null
|
||||
? database.prepare(
|
||||
`SELECT COUNT(*) AS files, COALESCE(SUM(${KEYLOG_CONTENT_BYTES_SQL}), 0) AS bytes
|
||||
FROM keylog_archive_files`,
|
||||
).get()
|
||||
: database.prepare(
|
||||
`SELECT COUNT(*) AS files, COALESCE(SUM(${KEYLOG_CONTENT_BYTES_SQL}), 0) AS bytes
|
||||
FROM keylog_archive_files WHERE client_id = ?`,
|
||||
).get(clientId);
|
||||
return row as ArchivedKeylogQuotaTotals;
|
||||
}
|
||||
|
||||
function archivedKeylogScopeFits(
|
||||
database: TypedDatabase,
|
||||
clientId: string | null,
|
||||
projectedFilesDelta: number,
|
||||
projectedBytesDelta: number,
|
||||
maxFiles: number,
|
||||
maxBytes: number,
|
||||
): boolean {
|
||||
const totals = getArchivedKeylogQuotaTotals(database, clientId);
|
||||
return Number(totals.files || 0) + projectedFilesDelta <= maxFiles
|
||||
&& Number(totals.bytes || 0) + projectedBytesDelta <= maxBytes;
|
||||
}
|
||||
|
||||
function pruneArchivedKeylogScope(
|
||||
database: TypedDatabase,
|
||||
clientId: string | null,
|
||||
protectedId: number | null,
|
||||
projectedFilesDelta: number,
|
||||
projectedBytesDelta: number,
|
||||
maxFiles: number,
|
||||
maxBytes: number,
|
||||
): boolean {
|
||||
const totals = getArchivedKeylogQuotaTotals(database, clientId);
|
||||
let files = Number(totals.files || 0) + projectedFilesDelta;
|
||||
let bytes = Number(totals.bytes || 0) + projectedBytesDelta;
|
||||
if (files <= maxFiles && bytes <= maxBytes) return true;
|
||||
|
||||
const clauses: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
if (clientId !== null) {
|
||||
clauses.push("client_id = ?");
|
||||
params.push(clientId);
|
||||
}
|
||||
if (protectedId !== null) {
|
||||
clauses.push("id <> ?");
|
||||
params.push(protectedId);
|
||||
}
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const candidates = database.prepare(
|
||||
`SELECT id, ${KEYLOG_CONTENT_BYTES_SQL} AS bytes
|
||||
FROM keylog_archive_files
|
||||
${where}
|
||||
ORDER BY retrieved_at ASC, id ASC
|
||||
LIMIT ?`,
|
||||
).all(...params, MAX_ARCHIVE_ROWS_PRUNED_PER_SAVE) as ArchivedKeylogQuotaRow[];
|
||||
|
||||
const pruneIds: number[] = [];
|
||||
for (const candidate of candidates) {
|
||||
pruneIds.push(candidate.id);
|
||||
files -= 1;
|
||||
bytes -= Number(candidate.bytes || 0);
|
||||
if (files <= maxFiles && bytes <= maxBytes) break;
|
||||
}
|
||||
|
||||
// Persist a bounded reconciliation batch even if admitting the incoming row
|
||||
// will require another pass. The incoming/protected row is not mutated.
|
||||
deleteArchivedKeylogRows(database, pruneIds);
|
||||
return files <= maxFiles && bytes <= maxBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the archive writer separately so quota, pruning, and FTS consistency
|
||||
* can be regression-tested against an isolated SQLite database.
|
||||
*/
|
||||
export function createKeylogArchiveWriter(
|
||||
database: TypedDatabase,
|
||||
quotaProvider: () => KeylogArchiveQuotaLimits = getKeylogArchiveQuotaLimits,
|
||||
): (record: KeylogArchiveRecord) => boolean {
|
||||
const transaction = database.transaction((record: KeylogArchiveRecord): boolean => {
|
||||
const limits = quotaProvider();
|
||||
const contentBytes = typeof record.content === "string"
|
||||
? Buffer.byteLength(record.content, "utf8")
|
||||
: -1;
|
||||
if (
|
||||
typeof record.clientId !== "string"
|
||||
|| record.clientId.length === 0
|
||||
|| record.clientId.length > 128
|
||||
|| typeof record.filename !== "string"
|
||||
|| record.filename.length === 0
|
||||
|| record.filename.length > MAX_KEYLOG_FILENAME_LENGTH
|
||||
|| !Number.isSafeInteger(record.size)
|
||||
|| record.size < 0
|
||||
|| record.size !== contentBytes
|
||||
|| limits.perClientFiles <= 0
|
||||
|| limits.perClientBytes <= 0
|
||||
|| limits.globalFiles <= 0
|
||||
|| limits.globalBytes <= 0
|
||||
|| record.size > limits.perClientBytes
|
||||
|| record.size > limits.globalBytes
|
||||
) return false;
|
||||
|
||||
const old = database.prepare(
|
||||
`SELECT id, ${KEYLOG_CONTENT_BYTES_SQL} AS bytes
|
||||
FROM keylog_archive_files WHERE client_id = ? AND filename = ?`,
|
||||
).get(record.clientId, record.filename) as { id: number; bytes: number } | undefined;
|
||||
const projectedFilesDelta = old ? 0 : 1;
|
||||
const projectedBytesDelta = record.size - Number(old?.bytes || 0);
|
||||
let perClientSatisfied = pruneArchivedKeylogScope(
|
||||
database,
|
||||
record.clientId,
|
||||
old?.id ?? null,
|
||||
projectedFilesDelta,
|
||||
projectedBytesDelta,
|
||||
limits.perClientFiles,
|
||||
limits.perClientBytes,
|
||||
);
|
||||
const globalSatisfied = pruneArchivedKeylogScope(
|
||||
database,
|
||||
null,
|
||||
old?.id ?? null,
|
||||
projectedFilesDelta,
|
||||
projectedBytesDelta,
|
||||
limits.globalFiles,
|
||||
limits.globalBytes,
|
||||
);
|
||||
if (!perClientSatisfied && globalSatisfied) {
|
||||
// Fleet pruning may also have removed rows from this client, so use the
|
||||
// post-global state rather than conservatively requiring another write.
|
||||
perClientSatisfied = archivedKeylogScopeFits(
|
||||
database,
|
||||
record.clientId,
|
||||
projectedFilesDelta,
|
||||
projectedBytesDelta,
|
||||
limits.perClientFiles,
|
||||
limits.perClientBytes,
|
||||
);
|
||||
}
|
||||
if (!perClientSatisfied || !globalSatisfied) return false;
|
||||
|
||||
database.prepare(
|
||||
`INSERT INTO keylog_archive_files(client_id, filename, size, modified_at, retrieved_at, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(client_id, filename) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
modified_at = excluded.modified_at,
|
||||
retrieved_at = excluded.retrieved_at,
|
||||
content = excluded.content`,
|
||||
).run(
|
||||
record.clientId,
|
||||
record.filename,
|
||||
record.size,
|
||||
record.modifiedAt,
|
||||
record.retrievedAt,
|
||||
record.content,
|
||||
);
|
||||
const stored = database.prepare(
|
||||
"SELECT id, client_id, filename, content FROM keylog_archive_files WHERE client_id = ? AND filename = ?",
|
||||
).get(record.clientId, record.filename) as {
|
||||
id: number;
|
||||
client_id: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
} | undefined;
|
||||
if (!stored) throw new Error("keylog archive write did not persist a row");
|
||||
replaceArchivedKeylogFts(database, stored);
|
||||
return true;
|
||||
});
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
const storeArchivedKeylog = createKeylogArchiveWriter(db);
|
||||
|
||||
function consumeArchiveMessageRate(clientId: string, kind: "list" | "content"): boolean {
|
||||
const now = Date.now();
|
||||
const windowMs = 60_000;
|
||||
const max = kind === "list" ? 6 : 60;
|
||||
const key = `${clientId}:${kind}`;
|
||||
let state = archiveMessageRates.get(key);
|
||||
if (!state || now - state.windowStart >= windowMs) {
|
||||
state = { windowStart: now, count: 0 };
|
||||
}
|
||||
state.count += 1;
|
||||
archiveMessageRates.set(key, state);
|
||||
if (archiveMessageRates.size > 10_000) {
|
||||
for (const [rateKey, entry] of archiveMessageRates) {
|
||||
if (now - entry.windowStart >= windowMs * 2) archiveMessageRates.delete(rateKey);
|
||||
}
|
||||
}
|
||||
return state.count <= max;
|
||||
}
|
||||
|
||||
function setPendingFiles(clientId: string, files: Map<string, KeylogFileMeta>): void {
|
||||
const now = Date.now();
|
||||
for (const [pendingClientId, entry] of pendingFileMeta) {
|
||||
if (entry.expiresAt <= now) pendingFileMeta.delete(pendingClientId);
|
||||
}
|
||||
if (!pendingFileMeta.has(clientId) && pendingFileMeta.size >= MAX_PENDING_ARCHIVE_CLIENTS) {
|
||||
const oldest = pendingFileMeta.keys().next().value as string | undefined;
|
||||
if (oldest) pendingFileMeta.delete(oldest);
|
||||
}
|
||||
pendingFileMeta.set(clientId, { files, expiresAt: now + PENDING_ARCHIVE_TTL_MS });
|
||||
}
|
||||
|
||||
function rot13(str: string): string {
|
||||
return String(str || "").replace(/[a-zA-Z]/g, (char) => {
|
||||
@@ -25,9 +327,18 @@ function rot13(str: string): string {
|
||||
|
||||
function normalizeMeta(file: any): KeylogFileMeta | null {
|
||||
const name = typeof file?.name === "string" ? file.name.trim() : "";
|
||||
if (!name || name.includes("/") || name.includes("\\")) return null;
|
||||
const size = Math.max(0, Math.floor(Number(file?.size) || 0));
|
||||
const date = typeof file?.date === "string" ? file.date : "";
|
||||
if (
|
||||
!name ||
|
||||
name.length > MAX_KEYLOG_FILENAME_LENGTH ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
name.includes("/") ||
|
||||
name.includes("\\")
|
||||
) return null;
|
||||
const rawSize = Number(file?.size);
|
||||
if (!Number.isSafeInteger(rawSize) || rawSize < 0) return null;
|
||||
const size = rawSize;
|
||||
const date = typeof file?.date === "string" ? file.date.slice(0, 64) : "";
|
||||
const parsed = date ? Date.parse(date) : NaN;
|
||||
return {
|
||||
name,
|
||||
@@ -37,8 +348,16 @@ function normalizeMeta(file: any): KeylogFileMeta | null {
|
||||
};
|
||||
}
|
||||
|
||||
function getPendingMeta(clientId: string, filename: string): KeylogFileMeta | null {
|
||||
return pendingFileMeta.get(clientId)?.get(filename) ?? null;
|
||||
function takePendingMeta(clientId: string, filename: string): KeylogFileMeta | null {
|
||||
const pending = pendingFileMeta.get(clientId);
|
||||
if (!pending || pending.expiresAt <= Date.now()) {
|
||||
pendingFileMeta.delete(clientId);
|
||||
return null;
|
||||
}
|
||||
const meta = pending.files.get(filename) ?? null;
|
||||
pending.files.delete(filename);
|
||||
if (pending.files.size === 0) pendingFileMeta.delete(clientId);
|
||||
return meta;
|
||||
}
|
||||
|
||||
function shouldArchiveForClient(clientId: string): boolean {
|
||||
@@ -58,41 +377,37 @@ function buildFtsQuery(query: string): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function rebuildArchiveFts(row: { id: number; client_id: string; filename: string; content: string }): void {
|
||||
try {
|
||||
db.prepare("DELETE FROM keylog_archive_fts WHERE rowid = ?").run(row.id);
|
||||
db.prepare(
|
||||
`INSERT INTO keylog_archive_fts(rowid, file_id, client_id, filename, content)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(row.id, row.id, row.client_id, row.filename, row.content);
|
||||
} catch (err) {
|
||||
logger.warn(`[keylog-archive] failed to update FTS for ${row.client_id}/${row.filename}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function archiveKeylogContent(clientId: string, filename: string, encodedContent: string, meta?: KeylogFileMeta | null): void {
|
||||
function archiveKeylogContent(clientId: string, filename: string, encodedContent: string): void {
|
||||
if (!shouldArchiveForClient(clientId)) return;
|
||||
const safeMeta = meta ?? getPendingMeta(clientId, filename);
|
||||
const normalized = normalizeMeta({ name: filename, size: 0, date: "" });
|
||||
if (!normalized || normalized.name !== filename) return;
|
||||
const safeMeta = takePendingMeta(clientId, filename);
|
||||
if (!safeMeta) {
|
||||
logger.warn(`[keylog-archive] ignored unsolicited content for ${clientId}/${filename}`);
|
||||
return;
|
||||
}
|
||||
const maxFileBytes = Math.max(1, getConfig().inputArchive.maxFileBytes);
|
||||
const encodedBytes = Buffer.byteLength(encodedContent, "utf8");
|
||||
if (safeMeta.size > maxFileBytes || encodedBytes > maxFileBytes) {
|
||||
logger.warn(`[keylog-archive] rejected oversized content for ${clientId}/${filename}`);
|
||||
return;
|
||||
}
|
||||
const decoded = rot13(String(encodedContent || ""));
|
||||
const now = Date.now();
|
||||
const size = safeMeta?.size ?? encodedContent.length;
|
||||
const modifiedAt = safeMeta?.modifiedAt ?? null;
|
||||
const size = encodedBytes;
|
||||
const modifiedAt = safeMeta.modifiedAt;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO keylog_archive_files(client_id, filename, size, modified_at, retrieved_at, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(client_id, filename) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
modified_at = excluded.modified_at,
|
||||
retrieved_at = excluded.retrieved_at,
|
||||
content = excluded.content`,
|
||||
).run(clientId, filename, size, modifiedAt, now, decoded);
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT id, client_id, filename, content FROM keylog_archive_files WHERE client_id = ? AND filename = ?")
|
||||
.get(clientId, filename) as { id: number; client_id: string; filename: string; content: string } | undefined;
|
||||
if (row) rebuildArchiveFts(row);
|
||||
pendingFileMeta.get(clientId)?.delete(filename);
|
||||
if (!storeArchivedKeylog({
|
||||
clientId,
|
||||
filename,
|
||||
size,
|
||||
modifiedAt,
|
||||
retrievedAt: now,
|
||||
content: decoded,
|
||||
})) {
|
||||
logger.warn(`[keylog-archive] rejected ${clientId}/${filename}: archive quota exceeded`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?: ServerWebSocket<SocketData>): void {
|
||||
@@ -100,26 +415,44 @@ export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?:
|
||||
if (!shouldArchiveForClient(clientId)) return;
|
||||
|
||||
if (payload.type === "keylog_file_list") {
|
||||
const files = Array.isArray(payload.files) ? payload.files.map(normalizeMeta).filter(Boolean) as KeylogFileMeta[] : [];
|
||||
if (!consumeArchiveMessageRate(clientId, "list")) {
|
||||
logger.warn(`[keylog-archive] ignored excessive file lists from ${clientId}`);
|
||||
return;
|
||||
}
|
||||
const files = Array.isArray(payload.files)
|
||||
? payload.files.slice(0, MAX_FILES_PER_LIST).map(normalizeMeta).filter(Boolean) as KeylogFileMeta[]
|
||||
: [];
|
||||
if (files.length === 0 || !ws) return;
|
||||
|
||||
const maxFileBytes = getConfig().inputArchive.maxFileBytes;
|
||||
const existingRows = db
|
||||
.prepare("SELECT filename, size, modified_at as modifiedAt FROM keylog_archive_files WHERE client_id = ?")
|
||||
.all(clientId) as Array<{ filename: string; size: number; modifiedAt: number | null }>;
|
||||
.prepare(
|
||||
`SELECT filename, ${KEYLOG_CONTENT_BYTES_SQL} as size, modified_at as modifiedAt
|
||||
FROM keylog_archive_files WHERE client_id = ?
|
||||
ORDER BY retrieved_at DESC LIMIT ?`,
|
||||
)
|
||||
.all(clientId, MAX_ARCHIVE_FILES_PER_CLIENT) as Array<{ filename: string; size: number; modifiedAt: number | null }>;
|
||||
const totals = db.prepare(
|
||||
`SELECT COUNT(*) AS files, COALESCE(SUM(${KEYLOG_CONTENT_BYTES_SQL}), 0) AS bytes
|
||||
FROM keylog_archive_files WHERE client_id = ?`,
|
||||
).get(clientId) as { files: number; bytes: number };
|
||||
const existing = new Map(existingRows.map((row) => [row.filename, row]));
|
||||
let pending = pendingFileMeta.get(clientId);
|
||||
if (!pending) {
|
||||
pending = new Map();
|
||||
pendingFileMeta.set(clientId, pending);
|
||||
}
|
||||
const pending = new Map<string, KeylogFileMeta>();
|
||||
|
||||
let requested = 0;
|
||||
let projectedFiles = Number(totals.files || 0);
|
||||
let projectedBytes = Number(totals.bytes || 0);
|
||||
for (const file of files) {
|
||||
if (file.size > maxFileBytes) continue;
|
||||
const old = existing.get(file.name);
|
||||
const changed = !old || old.size !== file.size || (file.modifiedAt !== null && old.modifiedAt !== file.modifiedAt);
|
||||
if (!changed || requested >= MAX_RETRIEVES_PER_LIST) continue;
|
||||
const nextFiles = projectedFiles + (old ? 0 : 1);
|
||||
const nextBytes = projectedBytes - Number(old?.size || 0) + file.size;
|
||||
if (
|
||||
nextFiles > MAX_ARCHIVE_FILES_PER_CLIENT
|
||||
|| nextBytes > MAX_ARCHIVE_BYTES_PER_CLIENT
|
||||
) continue;
|
||||
pending.set(file.name, file);
|
||||
try {
|
||||
ws.send(encodeMessage({
|
||||
@@ -129,15 +462,24 @@ export function handleKeylogArchiveMessage(clientId: string, payload: any, ws?:
|
||||
payload: { filename: file.name },
|
||||
} as any));
|
||||
requested++;
|
||||
projectedFiles = nextFiles;
|
||||
projectedBytes = nextBytes;
|
||||
} catch (err) {
|
||||
logger.warn(`[keylog-archive] failed to request ${clientId}/${file.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (pending.size > 0) setPendingFiles(clientId, pending);
|
||||
else pendingFileMeta.delete(clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "keylog_file_content" && typeof payload.filename === "string") {
|
||||
archiveKeylogContent(clientId, payload.filename, String(payload.content || ""));
|
||||
if (typeof payload.content !== "string") return;
|
||||
if (!consumeArchiveMessageRate(clientId, "content")) {
|
||||
logger.warn(`[keylog-archive] ignored excessive file content from ${clientId}`);
|
||||
return;
|
||||
}
|
||||
archiveKeylogContent(clientId, payload.filename, payload.content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +501,14 @@ export function dispatchKeylogArchiveSync(clientId: string, ws: ServerWebSocket<
|
||||
export function listArchivedKeylogs(clientId: string): Array<{ name: string; size: number; date: string; archived: true; retrievedAt: number }> {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT filename, size, modified_at as modifiedAt, retrieved_at as retrievedAt
|
||||
`SELECT filename, ${KEYLOG_CONTENT_BYTES_SQL} as size,
|
||||
modified_at as modifiedAt, retrieved_at as retrievedAt
|
||||
FROM keylog_archive_files
|
||||
WHERE client_id = ?
|
||||
ORDER BY COALESCE(modified_at, retrieved_at) DESC, filename ASC`,
|
||||
ORDER BY COALESCE(modified_at, retrieved_at) DESC, filename ASC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(clientId) as Array<{ filename: string; size: number; modifiedAt: number | null; retrievedAt: number }>;
|
||||
.all(clientId, MAX_ARCHIVE_FILES_PER_CLIENT) as Array<{ filename: string; size: number; modifiedAt: number | null; retrievedAt: number }>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
name: row.filename,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
getNotificationScreenshot,
|
||||
saveNotificationScreenshot,
|
||||
updateNotificationScreenshotId,
|
||||
MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES,
|
||||
getAllPushSubscriptions,
|
||||
deletePushSubscription,
|
||||
type NotificationScreenshotRecord,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../db";
|
||||
import { logger } from "../logger";
|
||||
import { sendWebPush } from "./web-push";
|
||||
import { fetchPublicHttpResponse } from "./url-security";
|
||||
|
||||
export type NotificationRecord = {
|
||||
id: string;
|
||||
@@ -58,6 +59,225 @@ export const DEFAULT_TELEGRAM_TEMPLATE =
|
||||
|
||||
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
|
||||
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
|
||||
export const MAX_NOTIFICATION_SCREENSHOT_BYTES = MAX_NOTIFICATION_SCREENSHOT_RECORD_BYTES;
|
||||
export const MAX_NOTIFICATION_SCREENSHOT_DIMENSION = 32_768;
|
||||
// Keep worst-case browser RGBA decode near 32 MiB per image. Compressed byte
|
||||
// limits alone do not protect against solid-color PNG/WebP decompression bombs.
|
||||
export const MAX_NOTIFICATION_SCREENSHOT_PIXELS = 512 * 1024 * 1024;
|
||||
|
||||
function boundedPositiveIntegerEnv(name: string, fallback: number, maximum: number): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(maximum, Math.max(1, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export const WEB_PUSH_DELIVERY_CONCURRENCY = boundedPositiveIntegerEnv(
|
||||
"OVERLORD_WEB_PUSH_DELIVERY_CONCURRENCY",
|
||||
8,
|
||||
64,
|
||||
);
|
||||
export const WEB_PUSH_DELIVERY_BATCH_SIZE = boundedPositiveIntegerEnv(
|
||||
"OVERLORD_WEB_PUSH_DELIVERY_BATCH_SIZE",
|
||||
100,
|
||||
1_000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Settle every task while keeping both the active promise count and each
|
||||
* materialized batch bounded. This preserves allSettled-style fan-out without
|
||||
* creating one live promise per stored subscription.
|
||||
*/
|
||||
export async function settleInBoundedBatches<T>(
|
||||
items: readonly T[],
|
||||
task: (item: T) => Promise<void>,
|
||||
options: {
|
||||
concurrency?: number;
|
||||
batchSize?: number;
|
||||
onError?: (error: unknown, item: T) => void;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const requestedConcurrency = Number(options.concurrency ?? WEB_PUSH_DELIVERY_CONCURRENCY);
|
||||
const requestedBatchSize = Number(options.batchSize ?? WEB_PUSH_DELIVERY_BATCH_SIZE);
|
||||
const concurrency = Number.isFinite(requestedConcurrency)
|
||||
? Math.max(1, Math.min(64, Math.floor(requestedConcurrency)))
|
||||
: WEB_PUSH_DELIVERY_CONCURRENCY;
|
||||
const batchSize = Number.isFinite(requestedBatchSize)
|
||||
? Math.max(1, Math.min(1_000, Math.floor(requestedBatchSize)))
|
||||
: WEB_PUSH_DELIVERY_BATCH_SIZE;
|
||||
|
||||
for (let batchStart = 0; batchStart < items.length; batchStart += batchSize) {
|
||||
const batch = items.slice(batchStart, batchStart + batchSize);
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, batch.length) },
|
||||
async () => {
|
||||
while (nextIndex < batch.length) {
|
||||
const item = batch[nextIndex++];
|
||||
try {
|
||||
await task(item);
|
||||
} catch (error) {
|
||||
try {
|
||||
options.onError?.(error, item);
|
||||
} catch {
|
||||
// Delivery remains best-effort even if diagnostic logging fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
}
|
||||
|
||||
export type ValidatedNotificationScreenshot = {
|
||||
bytes: Uint8Array;
|
||||
format: "jpeg" | "png" | "webp";
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
function normalizeScreenshotDimension(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > MAX_NOTIFICATION_SCREENSHOT_DIMENSION) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function screenshotDimensionsAllowed(width: number, height: number): boolean {
|
||||
return Number.isInteger(width)
|
||||
&& Number.isInteger(height)
|
||||
&& width > 0
|
||||
&& height > 0
|
||||
&& width <= MAX_NOTIFICATION_SCREENSHOT_DIMENSION
|
||||
&& height <= MAX_NOTIFICATION_SCREENSHOT_DIMENSION
|
||||
&& width * height <= MAX_NOTIFICATION_SCREENSHOT_PIXELS;
|
||||
}
|
||||
|
||||
function parsePngScreenshot(buf: Buffer): { width: number; height: number } | null {
|
||||
if (buf.length < 45 || !buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return null;
|
||||
let offset = 8;
|
||||
let dimensions: { width: number; height: number } | null = null;
|
||||
let sawData = false;
|
||||
while (offset + 12 <= buf.length) {
|
||||
const length = buf.readUInt32BE(offset);
|
||||
const end = offset + 12 + length;
|
||||
if (end > buf.length) return null;
|
||||
const type = buf.subarray(offset + 4, offset + 8).toString("ascii");
|
||||
if (type === "acTL" || type === "fcTL" || type === "fdAT") return null;
|
||||
if (offset === 8) {
|
||||
if (type !== "IHDR" || length !== 13) return null;
|
||||
dimensions = { width: buf.readUInt32BE(offset + 8), height: buf.readUInt32BE(offset + 12) };
|
||||
} else if (type === "IDAT") {
|
||||
sawData = true;
|
||||
} else if (type === "IEND") {
|
||||
return length === 0 && end === buf.length && sawData ? dimensions : null;
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJpegScreenshot(buf: Buffer): { width: number; height: number } | null {
|
||||
if (buf.length < 16 || buf[0] !== 0xff || buf[1] !== 0xd8 || buf[buf.length - 2] !== 0xff || buf[buf.length - 1] !== 0xd9) return null;
|
||||
let offset = 2;
|
||||
while (offset + 4 <= buf.length - 2) {
|
||||
if (buf[offset] !== 0xff) return null;
|
||||
while (offset < buf.length && buf[offset] === 0xff) offset += 1;
|
||||
const marker = buf[offset++];
|
||||
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
||||
if (offset + 2 > buf.length) return null;
|
||||
const length = buf.readUInt16BE(offset);
|
||||
if (length < 2 || offset + length > buf.length) return null;
|
||||
const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
||||
if (isFrame) {
|
||||
if (length < 8) return null;
|
||||
return { width: buf.readUInt16BE(offset + 5), height: buf.readUInt16BE(offset + 3) };
|
||||
}
|
||||
if (marker === 0xda) return null;
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseWebpScreenshot(buf: Buffer): { width: number; height: number } | null {
|
||||
if (buf.length < 20 || buf.subarray(0, 4).toString("ascii") !== "RIFF" || buf.readUInt32LE(4) + 8 !== buf.length || buf.subarray(8, 12).toString("ascii") !== "WEBP") return null;
|
||||
let offset = 12;
|
||||
let dimensions: { width: number; height: number } | null = null;
|
||||
while (offset + 8 <= buf.length) {
|
||||
const type = buf.subarray(offset, offset + 4).toString("ascii");
|
||||
const length = buf.readUInt32LE(offset + 4);
|
||||
const data = offset + 8;
|
||||
const end = data + length;
|
||||
if (end > buf.length || type === "ANIM" || type === "ANMF") return null;
|
||||
if (type === "VP8 " && length >= 10 && buf[data + 3] === 0x9d && buf[data + 4] === 0x01 && buf[data + 5] === 0x2a) {
|
||||
dimensions ??= { width: buf.readUInt16LE(data + 6) & 0x3fff, height: buf.readUInt16LE(data + 8) & 0x3fff };
|
||||
} else if (type === "VP8L" && length >= 5 && buf[data] === 0x2f) {
|
||||
const packed = buf.readUInt32LE(data + 1);
|
||||
dimensions ??= { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
|
||||
} else if (type === "VP8X" && length >= 10) {
|
||||
if ((buf[data] & 0x02) !== 0) return null;
|
||||
dimensions ??= {
|
||||
width: 1 + buf[data + 4] + (buf[data + 5] << 8) + (buf[data + 6] << 16),
|
||||
height: 1 + buf[data + 7] + (buf[data + 8] << 8) + (buf[data + 9] << 16),
|
||||
};
|
||||
}
|
||||
offset = end + (length % 2);
|
||||
}
|
||||
return offset === buf.length ? dimensions : null;
|
||||
}
|
||||
|
||||
export function validateNotificationScreenshotPayload(
|
||||
payload: any,
|
||||
): ValidatedNotificationScreenshot | null {
|
||||
let bytes: Uint8Array | null = null;
|
||||
if (payload?.data instanceof Uint8Array) {
|
||||
bytes = new Uint8Array(
|
||||
payload.data.buffer,
|
||||
payload.data.byteOffset,
|
||||
payload.data.byteLength,
|
||||
);
|
||||
} else if (payload?.data instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(payload.data);
|
||||
} else if (ArrayBuffer.isView(payload?.data)) {
|
||||
bytes = new Uint8Array(
|
||||
payload.data.buffer,
|
||||
payload.data.byteOffset,
|
||||
payload.data.byteLength,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bytes || bytes.byteLength === 0 || bytes.byteLength > MAX_NOTIFICATION_SCREENSHOT_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawFormat = typeof payload?.format === "string" ? payload.format.toLowerCase() : "jpeg";
|
||||
const format = rawFormat === "jpg" ? "jpeg" : rawFormat;
|
||||
if (format !== "jpeg" && format !== "png" && format !== "webp") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const dimensions = format === "png"
|
||||
? parsePngScreenshot(buf)
|
||||
: format === "webp"
|
||||
? parseWebpScreenshot(buf)
|
||||
: parseJpegScreenshot(buf);
|
||||
if (!dimensions || !screenshotDimensionsAllowed(dimensions.width, dimensions.height)) return null;
|
||||
const reportedWidth = normalizeScreenshotDimension(payload?.width);
|
||||
const reportedHeight = normalizeScreenshotDimension(payload?.height);
|
||||
if (
|
||||
(payload?.width !== undefined && reportedWidth !== dimensions.width)
|
||||
|| (payload?.height !== undefined && reportedHeight !== dimensions.height)
|
||||
) return null;
|
||||
|
||||
return {
|
||||
bytes,
|
||||
format,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPrivateOrInternalHostname(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase();
|
||||
@@ -156,21 +376,25 @@ export function storeNotificationScreenshot(
|
||||
width?: number,
|
||||
height?: number,
|
||||
): void {
|
||||
if (!bytes || bytes.length === 0) return;
|
||||
const validated = validateNotificationScreenshotPayload({ data: bytes, format, width, height });
|
||||
if (!validated) return;
|
||||
const screenshotId = uuidv4();
|
||||
|
||||
saveNotificationScreenshot({
|
||||
const saved = saveNotificationScreenshot({
|
||||
id: screenshotId,
|
||||
notificationId: pending.notificationId,
|
||||
clientId: pending.clientId,
|
||||
ts: pending.ts,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
format: validated.format,
|
||||
width: validated.width,
|
||||
height: validated.height,
|
||||
bytes: validated.bytes,
|
||||
});
|
||||
|
||||
updateNotificationScreenshotId(pending.notificationId, screenshotId);
|
||||
if (!saved) {
|
||||
logger.warn(
|
||||
`[notify] screenshot was not stored due to a missing notification, duplicate result, or storage quota notification=${pending.notificationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverToUserWebhook(
|
||||
@@ -190,12 +414,6 @@ async function deliverToUserWebhook(
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (isPrivateOrInternalHostname(hostname)) {
|
||||
logger.warn(`[notify] blocked webhook to private/internal address: ${hostname}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isDiscord = /discord(app)?\.com$/i.test(parsed.hostname);
|
||||
if (isDiscord) {
|
||||
@@ -227,11 +445,11 @@ async function deliverToUserWebhook(
|
||||
new Blob([screenshot.bytes as any], { type: meta.contentType }),
|
||||
filename,
|
||||
);
|
||||
await fetch(url, { method: "POST", body: form });
|
||||
await fetchPublicHttpResponse(url, { method: "POST", body: form });
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(url, {
|
||||
await fetchPublicHttpResponse(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -240,7 +458,7 @@ async function deliverToUserWebhook(
|
||||
}
|
||||
|
||||
const body = buildWebhookBody(target, record);
|
||||
await fetch(url, {
|
||||
await fetchPublicHttpResponse(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
@@ -345,21 +563,26 @@ async function deliverWebPushToAll(
|
||||
url: "/notifications",
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subs
|
||||
.filter((sub) => allowedUserIds.has(sub.userId))
|
||||
.map(async (sub) => {
|
||||
const result = await sendWebPush(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
);
|
||||
if (result.gone) {
|
||||
deletePushSubscription(sub.endpoint);
|
||||
logger.info(`[web-push] removed gone subscription for user ${sub.userId}`);
|
||||
} else if (!result.success) {
|
||||
logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`);
|
||||
}
|
||||
}),
|
||||
const eligibleSubscriptions = subs.filter((sub) => allowedUserIds.has(sub.userId));
|
||||
await settleInBoundedBatches(
|
||||
eligibleSubscriptions,
|
||||
async (sub) => {
|
||||
const result = await sendWebPush(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
);
|
||||
if (result.gone) {
|
||||
deletePushSubscription(sub.endpoint);
|
||||
logger.info(`[web-push] removed gone subscription for user ${sub.userId}`);
|
||||
} else if (!result.success) {
|
||||
logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
onError: (error, sub) => {
|
||||
logger.warn(`[web-push] delivery threw for user ${sub.userId}`, error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,8 +621,9 @@ export async function deliverWebPushClientEvent(
|
||||
url: dest,
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
subs.map(async (sub) => {
|
||||
await settleInBoundedBatches(
|
||||
subs,
|
||||
async (sub) => {
|
||||
const role = getUserRole(sub.userId);
|
||||
if (!role) return;
|
||||
if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return;
|
||||
@@ -423,7 +647,12 @@ export async function deliverWebPushClientEvent(
|
||||
if (result.gone) {
|
||||
deletePushSubscription(sub.endpoint);
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
onError: (error, sub) => {
|
||||
logger.warn(`[web-push] client-event delivery threw for user ${sub.userId}`, error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -434,7 +663,15 @@ export async function deliverNotificationWithScreenshot(
|
||||
const screenshot = await waitForNotificationScreenshot(record.id);
|
||||
const targets = getUserDeliveryTargets(record.clientId);
|
||||
await Promise.allSettled([
|
||||
...targets.map((t) => deliverToUser(t, record, screenshot)),
|
||||
settleInBoundedBatches(
|
||||
targets,
|
||||
async (target) => deliverToUser(target, record, screenshot),
|
||||
{
|
||||
onError: (error, target) => {
|
||||
logger.warn(`[notify] delivery threw for user ${target.username}`, error);
|
||||
},
|
||||
},
|
||||
),
|
||||
deliverWebPushToAll(record, getUserDeliveryTargets),
|
||||
]);
|
||||
}
|
||||
@@ -470,8 +707,9 @@ export async function deliverClientEventToExternalChannels(
|
||||
const label = CLIENT_EVENT_LABELS[event] || "Client Event";
|
||||
const ts = Date.now();
|
||||
|
||||
await Promise.allSettled(
|
||||
targets.map(async (target) => {
|
||||
await settleInBoundedBatches(
|
||||
targets,
|
||||
async (target) => {
|
||||
if (target.webhookEnabled && target.clientEventWebhook && target.webhookUrl) {
|
||||
const url = target.webhookUrl.trim();
|
||||
if (!url) return;
|
||||
@@ -501,13 +739,13 @@ export async function deliverClientEventToExternalChannels(
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
};
|
||||
const payload = { content: label, embeds: [embed] };
|
||||
await fetch(url, {
|
||||
await fetchPublicHttpResponse(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
await fetch(url, {
|
||||
await fetchPublicHttpResponse(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -553,7 +791,12 @@ export async function deliverClientEventToExternalChannels(
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
onError: (error, target) => {
|
||||
logger.warn(`[notify] client event delivery threw for user ${target.username}`, error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
MAX_NOTIFICATION_SCREENSHOT_BYTES,
|
||||
validateNotificationScreenshotPayload,
|
||||
} from "./notification-delivery";
|
||||
import {
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_GLOBAL,
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_CLIENT,
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY,
|
||||
MAX_PLUGIN_EVENT_PAYLOAD_BYTES,
|
||||
MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE,
|
||||
MAX_PENDING_NOTIFICATION_SCREENSHOTS,
|
||||
createNotificationPluginHandlers,
|
||||
hasPendingNotificationScreenshotCapacity,
|
||||
hasPluginUIEventBufferCapacity,
|
||||
isPlausibleChatAttachmentEventPayload,
|
||||
normalizeClientNotificationPayload,
|
||||
type PendingNotificationScreenshot,
|
||||
} from "./ws-notifications-plugin";
|
||||
|
||||
function createPluginEventHarness(enabledPluginIds = ["sample", "chat"]) {
|
||||
const pluginLoadedByClient = new Map<string, Set<string>>();
|
||||
const pluginLoadingByClient = new Map<string, Set<string>>();
|
||||
const forwarded: Array<{ clientId: string; pluginId: string; event: string; payload: unknown }> = [];
|
||||
const pluginState = {
|
||||
enabled: Object.fromEntries(enabledPluginIds.map((id) => [id, true])),
|
||||
lastError: {} as Record<string, string>,
|
||||
};
|
||||
let saveCount = 0;
|
||||
const handlers = createNotificationPluginHandlers({
|
||||
notificationRate: new Map(),
|
||||
pendingNotificationScreenshots: new Map(),
|
||||
pluginLoadedByClient,
|
||||
pluginLoadingByClient,
|
||||
pendingPluginEvents: new Map(),
|
||||
pluginState,
|
||||
getNotificationConfig: () => ({}),
|
||||
canUserAccessClient: () => false,
|
||||
isClientOwnedByUser: () => false,
|
||||
getUserRole: () => undefined,
|
||||
isClientNotificationsMuted: () => false,
|
||||
storeNotificationScreenshot: () => {},
|
||||
deliverNotificationWithScreenshot: async () => {},
|
||||
getDeliveryTargetsForClientEvent: () => [],
|
||||
savePluginState: async () => { saveCount += 1; },
|
||||
forwardPluginEventToRuntime: (clientId, pluginId, event, payload) => {
|
||||
forwarded.push({ clientId, pluginId, event, payload });
|
||||
},
|
||||
});
|
||||
return {
|
||||
handlers,
|
||||
forwarded,
|
||||
pluginLoadedByClient,
|
||||
pluginLoadingByClient,
|
||||
pluginState,
|
||||
getSaveCount: () => saveCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe("client notification input hardening", () => {
|
||||
test("bounds and normalizes untrusted notification fields", () => {
|
||||
const normalized = normalizeClientNotificationPayload({
|
||||
title: ` hello\n${"x".repeat(600)} `,
|
||||
keyword: "k".repeat(200),
|
||||
process: "p".repeat(400),
|
||||
processPath: "z".repeat(3_000),
|
||||
pid: -1,
|
||||
category: "unexpected",
|
||||
ts: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
|
||||
expect(normalized).not.toBeNull();
|
||||
expect(normalized!.title.length).toBe(512);
|
||||
expect(normalized!.title.includes("\n")).toBe(false);
|
||||
expect(normalized!.keyword?.length).toBe(128);
|
||||
expect(normalized!.process?.length).toBe(256);
|
||||
expect(normalized!.processPath?.length).toBe(2_048);
|
||||
expect(normalized!.pid).toBeUndefined();
|
||||
expect(normalized!.category).toBe("active_window");
|
||||
expect(normalized as any).not.toHaveProperty("ts");
|
||||
});
|
||||
|
||||
test("rejects empty titles", () => {
|
||||
expect(normalizeClientNotificationPayload({ title: "\u0000\n" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification screenshot input hardening", () => {
|
||||
test("accepts supported bounded images and preserves typed-array bounds", () => {
|
||||
const jpeg = new Uint8Array([
|
||||
0xff, 0xd8,
|
||||
0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
|
||||
0xff, 0xd9,
|
||||
]);
|
||||
const backing = new Uint8Array(jpeg.length + 2);
|
||||
backing.set(jpeg, 1);
|
||||
const validated = validateNotificationScreenshotPayload({
|
||||
data: backing.subarray(1, 1 + jpeg.length),
|
||||
format: "JPG",
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
expect(validated?.format).toBe("jpeg");
|
||||
expect(Array.from(validated?.bytes ?? [])).toEqual(Array.from(jpeg));
|
||||
expect(validated?.width).toBe(1);
|
||||
expect(validated?.height).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects oversized or unsupported screenshot payloads", () => {
|
||||
expect(
|
||||
validateNotificationScreenshotPayload({
|
||||
data: new Uint8Array(MAX_NOTIFICATION_SCREENSHOT_BYTES + 1),
|
||||
format: "jpeg",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateNotificationScreenshotPayload({ data: new Uint8Array([1]), format: "svg" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateNotificationScreenshotPayload({ data: new Uint8Array([1, 2, 3]), format: "jpeg" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects tiny compressed images with unsafe decoded dimensions", () => {
|
||||
const png = Buffer.alloc(57);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png, 0);
|
||||
png.writeUInt32BE(13, 8);
|
||||
png.write("IHDR", 12, "ascii");
|
||||
png.writeUInt32BE(32_768, 16);
|
||||
png.writeUInt32BE(32_768, 20);
|
||||
png.writeUInt32BE(0, 33);
|
||||
png.write("IDAT", 37, "ascii");
|
||||
png.writeUInt32BE(0, 45);
|
||||
png.write("IEND", 49, "ascii");
|
||||
|
||||
expect(validateNotificationScreenshotPayload({ data: png, format: "png" })).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects animated PNG envelopes", () => {
|
||||
const png = Buffer.alloc(57);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png, 0);
|
||||
png.writeUInt32BE(13, 8);
|
||||
png.write("IHDR", 12, "ascii");
|
||||
png.writeUInt32BE(1, 16);
|
||||
png.writeUInt32BE(1, 20);
|
||||
png.writeUInt32BE(0, 33);
|
||||
png.write("IDAT", 37, "ascii");
|
||||
png.writeUInt32BE(0, 45);
|
||||
png.write("IEND", 49, "ascii");
|
||||
const animationControl = Buffer.alloc(20);
|
||||
animationControl.writeUInt32BE(8, 0);
|
||||
animationControl.write("acTL", 4, "ascii");
|
||||
animationControl.writeUInt32BE(2, 8);
|
||||
const apng = Buffer.concat([png.subarray(0, 33), animationControl, png.subarray(33)]);
|
||||
|
||||
expect(validateNotificationScreenshotPayload({ data: apng, format: "png" })).toBeNull();
|
||||
});
|
||||
|
||||
test("bounds pending screenshot requests globally and per client", () => {
|
||||
const now = Date.now();
|
||||
const sharedTimeout = setTimeout(() => {}, 60_000);
|
||||
const pending = new Map<string, PendingNotificationScreenshot>();
|
||||
try {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
pending.set(`same-client-${i}`, {
|
||||
notificationId: `notification-${i}`,
|
||||
clientId: "client-a",
|
||||
ts: now,
|
||||
timeout: sharedTimeout,
|
||||
});
|
||||
}
|
||||
expect(hasPendingNotificationScreenshotCapacity(pending, "client-a", now)).toBe(false);
|
||||
expect(hasPendingNotificationScreenshotCapacity(pending, "client-b", now)).toBe(true);
|
||||
|
||||
pending.clear();
|
||||
for (let i = 0; i < MAX_PENDING_NOTIFICATION_SCREENSHOTS; i += 1) {
|
||||
pending.set(`global-${i}`, {
|
||||
notificationId: `notification-${i}`,
|
||||
clientId: `client-${i}`,
|
||||
ts: now,
|
||||
timeout: sharedTimeout,
|
||||
});
|
||||
}
|
||||
expect(hasPendingNotificationScreenshotCapacity(pending, "new-client", now)).toBe(false);
|
||||
} finally {
|
||||
clearTimeout(sharedTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
test("prunes expired pending screenshot requests before applying caps", () => {
|
||||
const now = Date.now();
|
||||
const timeout = setTimeout(() => {}, 60_000);
|
||||
const pending = new Map<string, PendingNotificationScreenshot>([[
|
||||
"expired",
|
||||
{
|
||||
notificationId: "old-notification",
|
||||
clientId: "client-a",
|
||||
ts: now - 31_000,
|
||||
timeout,
|
||||
},
|
||||
]]);
|
||||
|
||||
expect(hasPendingNotificationScreenshotCapacity(pending, "client-a", now)).toBe(true);
|
||||
expect(pending.size).toBe(0);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy client plugin event compatibility", () => {
|
||||
test("accepts plugin lifecycle and ordinary events without server-side trust state", () => {
|
||||
const harness = createPluginEventHarness([]);
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample-c",
|
||||
event: "ready",
|
||||
payload: { ready: true },
|
||||
});
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample-c",
|
||||
event: "loaded",
|
||||
});
|
||||
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready", "loaded"]);
|
||||
expect(harness.pluginLoadedByClient.get("client-a")?.has("sample-c")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Retained as executable documentation of the stricter policy that was
|
||||
// deliberately reversed because it broke established third-party plugins.
|
||||
/*
|
||||
describe("replaced client plugin event hardening", () => {
|
||||
test("only accepts loaded acknowledgements for server-initiated plugin loads", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "loaded",
|
||||
});
|
||||
expect(harness.pluginLoadedByClient.get("client-a")).toBeUndefined();
|
||||
expect(harness.forwarded).toHaveLength(0);
|
||||
|
||||
harness.handlers.markPluginLoading("client-a", "sample");
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "ready",
|
||||
payload: { ready: true },
|
||||
});
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready"]);
|
||||
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "loaded",
|
||||
});
|
||||
expect(harness.pluginLoadedByClient.get("client-a")?.has("sample")).toBe(true);
|
||||
expect(harness.pluginLoadingByClient.get("client-a")?.has("sample")).toBe(false);
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready", "loaded"]);
|
||||
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "loaded",
|
||||
});
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["ready", "loaded"]);
|
||||
});
|
||||
|
||||
test("rejects ordinary events until loaded but permits a load failure", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "pong",
|
||||
payload: { ok: true },
|
||||
});
|
||||
expect(harness.forwarded).toHaveLength(0);
|
||||
|
||||
harness.handlers.markPluginLoading("client-a", "sample");
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "error",
|
||||
error: "load failed",
|
||||
});
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["error"]);
|
||||
expect(harness.pluginLoadingByClient.get("client-a")?.has("sample")).toBe(false);
|
||||
expect(harness.pluginState.lastError.sample).toBe("load failed");
|
||||
expect(harness.getSaveCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("ordinary plugin messages cannot mutate or persist error state", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
|
||||
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "pong",
|
||||
message: "pretend failure",
|
||||
payload: { ok: true },
|
||||
});
|
||||
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["pong"]);
|
||||
expect(harness.pluginState.lastError.sample).toBeUndefined();
|
||||
expect(harness.getSaveCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("coalesces repeated changed error state writes from a loaded client", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
|
||||
|
||||
for (const message of ["failure one", "failure two", "failure three"]) {
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "error",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
expect(harness.pluginState.lastError.sample).toBe("failure three");
|
||||
expect(harness.getSaveCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("uses a small default payload limit with a narrow chat attachment exception", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
harness.pluginLoadedByClient.set("client-a", new Set(["sample", "chat"]));
|
||||
const aboveDefault = {
|
||||
name: "image.png",
|
||||
mime: "image/png",
|
||||
dataB64: "A".repeat(MAX_PLUGIN_EVENT_PAYLOAD_BYTES + 4),
|
||||
};
|
||||
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "echo",
|
||||
payload: aboveDefault,
|
||||
});
|
||||
expect(harness.forwarded).toHaveLength(0);
|
||||
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "chat",
|
||||
event: "chat_attachment",
|
||||
payload: aboveDefault,
|
||||
});
|
||||
expect(harness.forwarded.map((entry) => entry.event)).toEqual(["chat_attachment"]);
|
||||
expect(harness.handlers.drainPluginUIEvents("client-a", "chat")).toEqual([]);
|
||||
|
||||
expect(isPlausibleChatAttachmentEventPayload({
|
||||
mime: "text/html",
|
||||
dataB64: "AAAA",
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test("rate limits repeated events per client/plugin key", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
|
||||
for (let i = 0; i < MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE + 5; i += 1) {
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "pong",
|
||||
payload: { i },
|
||||
});
|
||||
}
|
||||
expect(harness.forwarded).toHaveLength(MAX_PLUGIN_EVENTS_PER_KEY_PER_MINUTE);
|
||||
});
|
||||
|
||||
test("evicts old UI events before the per-key byte buffer can grow unbounded", () => {
|
||||
const harness = createPluginEventHarness();
|
||||
harness.pluginLoadedByClient.set("client-a", new Set(["sample"]));
|
||||
const payload = { text: "x".repeat(240 * 1024) };
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
harness.handlers.handlePluginEvent("client-a", {
|
||||
pluginId: "sample",
|
||||
event: "echo",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
expect(harness.forwarded).toHaveLength(40);
|
||||
const buffered = harness.handlers.drainPluginUIEvents("client-a", "sample");
|
||||
expect(buffered.length).toBeGreaterThan(0);
|
||||
expect(buffered.length).toBeLessThan(40);
|
||||
});
|
||||
|
||||
test("enforces per-key, per-client, and global buffered-byte ceilings", () => {
|
||||
expect(hasPluginUIEventBufferCapacity(
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY - 1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)).toBe(true);
|
||||
expect(hasPluginUIEventBufferCapacity(
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_KEY,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)).toBe(false);
|
||||
expect(hasPluginUIEventBufferCapacity(
|
||||
0,
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_PER_CLIENT,
|
||||
0,
|
||||
1,
|
||||
)).toBe(false);
|
||||
expect(hasPluginUIEventBufferCapacity(
|
||||
0,
|
||||
0,
|
||||
MAX_BUFFERED_PLUGIN_EVENT_BYTES_GLOBAL,
|
||||
1,
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
deletePushSubscriptionsByUser,
|
||||
getAllPushSubscriptions,
|
||||
getPushSubscriptionsByUser,
|
||||
savePushSubscription,
|
||||
} from "../db";
|
||||
import { db } from "../db/connection";
|
||||
import {
|
||||
clearRequestRateLimitsForTests,
|
||||
consumePushSubscriptionMutationRateLimit,
|
||||
} from "../rateLimit";
|
||||
import { settleInBoundedBatches } from "./notification-delivery";
|
||||
|
||||
const testUserIds = new Set<number>();
|
||||
let nextTestUserId = 1_500_000_000 + Math.floor(Math.random() * 100_000_000);
|
||||
|
||||
afterEach(() => {
|
||||
for (const userId of testUserIds) deletePushSubscriptionsByUser(userId);
|
||||
testUserIds.clear();
|
||||
clearRequestRateLimitsForTests();
|
||||
});
|
||||
|
||||
function uniqueUserId(): number {
|
||||
const id = nextTestUserId++;
|
||||
testUserIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("Web Push subscription abuse controls", () => {
|
||||
test("transactionally caps storage, evicts only the owner's oldest entry, and prevents endpoint takeover", () => {
|
||||
const userA = uniqueUserId();
|
||||
const userB = uniqueUserId();
|
||||
const prefix = `push-security-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const endpoint = (name: string) => `https://push.example.test/${prefix}/${name}`;
|
||||
const baseline = Number(
|
||||
db.query<{ count: number }>("SELECT COUNT(*) AS count FROM push_subscriptions").get()?.count ?? 0,
|
||||
);
|
||||
const limits = { perUser: 2, global: baseline + 3 };
|
||||
|
||||
expect(savePushSubscription(userA, endpoint("a1"), "key-a1", "auth-a1", limits)).toEqual({
|
||||
ok: true,
|
||||
action: "created",
|
||||
});
|
||||
expect(savePushSubscription(userA, endpoint("a2"), "key-a2", "auth-a2", limits).ok).toBe(true);
|
||||
|
||||
const replacement = savePushSubscription(
|
||||
userA,
|
||||
endpoint("a3"),
|
||||
"key-a3",
|
||||
"auth-a3",
|
||||
limits,
|
||||
);
|
||||
expect(replacement).toEqual({
|
||||
ok: true,
|
||||
action: "created",
|
||||
evictedEndpoint: endpoint("a1"),
|
||||
});
|
||||
expect(getPushSubscriptionsByUser(userA).map((sub) => sub.endpoint).sort()).toEqual(
|
||||
[endpoint("a2"), endpoint("a3")].sort(),
|
||||
);
|
||||
|
||||
expect(savePushSubscription(userB, endpoint("a2"), "stolen", "stolen", limits)).toEqual({
|
||||
ok: false,
|
||||
reason: "endpoint_conflict",
|
||||
});
|
||||
expect(savePushSubscription(userB, endpoint("b1"), "key-b1", "auth-b1", limits).ok).toBe(true);
|
||||
expect(savePushSubscription(userB, endpoint("b2"), "key-b2", "auth-b2", limits)).toEqual({
|
||||
ok: false,
|
||||
reason: "global_limit",
|
||||
limit: baseline + 3,
|
||||
});
|
||||
|
||||
// Refreshing an endpoint already owned by this user remains possible at
|
||||
// the global cap and does not create another row.
|
||||
expect(savePushSubscription(userA, endpoint("a2"), "new-key", "new-auth", limits)).toEqual({
|
||||
ok: true,
|
||||
action: "updated",
|
||||
});
|
||||
expect(getPushSubscriptionsByUser(userA).find((sub) => sub.endpoint === endpoint("a2"))?.p256dh)
|
||||
.toBe("new-key");
|
||||
expect(getAllPushSubscriptions(2)).toHaveLength(2);
|
||||
expect(getAllPushSubscriptions(0)).toEqual([]);
|
||||
});
|
||||
|
||||
test("rate-limits subscribe and unsubscribe mutations per authenticated user", () => {
|
||||
const previous = {
|
||||
max: process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX,
|
||||
window: process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS,
|
||||
lockout: process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS,
|
||||
};
|
||||
process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX = "2";
|
||||
process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS = "10";
|
||||
process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS = "10";
|
||||
clearRequestRateLimitsForTests();
|
||||
|
||||
try {
|
||||
expect(consumePushSubscriptionMutationRateLimit(42).limited).toBe(false);
|
||||
expect(consumePushSubscriptionMutationRateLimit(42).limited).toBe(false);
|
||||
const blocked = consumePushSubscriptionMutationRateLimit(42);
|
||||
expect(blocked.limited).toBe(true);
|
||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
||||
expect(consumePushSubscriptionMutationRateLimit(43).limited).toBe(false);
|
||||
} finally {
|
||||
if (previous.max === undefined) delete process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX;
|
||||
else process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_MAX = previous.max;
|
||||
if (previous.window === undefined) delete process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS;
|
||||
else process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_WINDOW_SECONDS = previous.window;
|
||||
if (previous.lockout === undefined) delete process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS;
|
||||
else process.env.OVERLORD_PUSH_SUBSCRIPTION_RATE_LIMIT_LOCKOUT_SECONDS = previous.lockout;
|
||||
}
|
||||
});
|
||||
|
||||
test("settles delivery in bounded batches without aborting after a failure", async () => {
|
||||
const items = Array.from({ length: 11 }, (_, index) => index);
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let firstBatchCompleted = 0;
|
||||
let completedWhenSecondBatchStarted = -1;
|
||||
const attempted: number[] = [];
|
||||
const errors: number[] = [];
|
||||
|
||||
await settleInBoundedBatches(
|
||||
items,
|
||||
async (item) => {
|
||||
if (item === 4 && completedWhenSecondBatchStarted < 0) {
|
||||
completedWhenSecondBatchStarted = firstBatchCompleted;
|
||||
}
|
||||
attempted.push(item);
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
} finally {
|
||||
active -= 1;
|
||||
if (item < 4) firstBatchCompleted += 1;
|
||||
}
|
||||
if (item === 6) throw new Error("expected delivery failure");
|
||||
},
|
||||
{
|
||||
concurrency: 3,
|
||||
batchSize: 4,
|
||||
onError: (_error, item) => errors.push(item),
|
||||
},
|
||||
);
|
||||
|
||||
expect(attempted.sort((a, b) => a - b)).toEqual(items);
|
||||
expect(maxActive).toBeLessThanOrEqual(3);
|
||||
expect(completedWhenSecondBatchStarted).toBe(4);
|
||||
expect(errors).toEqual([6]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readJsonBodyLimited, RequestBodyTooLargeError } from "./request-body";
|
||||
|
||||
describe("readJsonBodyLimited", () => {
|
||||
test("parses a bounded JSON request", async () => {
|
||||
const request = new Request("https://localhost/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user: "alice" }),
|
||||
});
|
||||
expect(await readJsonBodyLimited(request, 1_024)).toEqual({ user: "alice" });
|
||||
});
|
||||
|
||||
test("rejects declared and streamed bodies over the limit", async () => {
|
||||
const declared = new Request("https://localhost/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "100" },
|
||||
body: "{}",
|
||||
});
|
||||
await expect(readJsonBodyLimited(declared, 10)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
|
||||
|
||||
const streamed = new Request("https://localhost/api/login", {
|
||||
method: "POST",
|
||||
body: "x".repeat(100),
|
||||
});
|
||||
await expect(readJsonBodyLimited(streamed, 10)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export class RequestBodyTooLargeError extends Error {
|
||||
constructor(readonly maxBytes: number) {
|
||||
super(`Request body exceeds ${maxBytes} byte limit`);
|
||||
this.name = "RequestBodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBodyLimited(req: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const declaredLength = Number(req.headers.get("content-length") || 0);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw new RequestBodyTooLargeError(maxBytes);
|
||||
}
|
||||
|
||||
const reader = req.body?.getReader();
|
||||
if (!reader) return new Uint8Array();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try { await reader.cancel(); } catch {}
|
||||
throw new RequestBodyTooLargeError(maxBytes);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function readJsonBodyLimited(req: Request, maxBytes: number): Promise<any> {
|
||||
const bytes = await readBodyLimited(req, maxBytes);
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isSameOriginBrowserRequest } from "./request-origin";
|
||||
|
||||
describe("browser request origin validation", () => {
|
||||
const url = new URL("https://overlord.example.com/api/dashboard/ws");
|
||||
|
||||
test("accepts the exact origin", () => {
|
||||
const req = new Request(url, {
|
||||
headers: { Origin: "https://overlord.example.com", "Sec-Fetch-Site": "same-origin" },
|
||||
});
|
||||
expect(isSameOriginBrowserRequest(req, url, { requireOrigin: true })).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects sibling, null, malformed, and missing viewer origins", () => {
|
||||
for (const origin of ["https://evil.example.com", "null", "not a url"]) {
|
||||
const req = new Request(url, { headers: { Origin: origin } });
|
||||
expect(isSameOriginBrowserRequest(req, url, { requireOrigin: true })).toBe(false);
|
||||
}
|
||||
expect(isSameOriginBrowserRequest(new Request(url), url, { requireOrigin: true })).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects browser-declared same-site requests even without Origin", () => {
|
||||
const req = new Request(url, { headers: { "Sec-Fetch-Site": "same-site" } });
|
||||
expect(isSameOriginBrowserRequest(req, url)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
function configuredPublicOrigin(): string | null {
|
||||
const raw = String(process.env.OVERLORD_PUBLIC_ORIGIN || "").trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function expectedRequestOrigin(url: URL): string {
|
||||
const configured = configuredPublicOrigin();
|
||||
if (configured) return configured;
|
||||
const tlsOffload = ["1", "true", "yes", "on"].includes(
|
||||
String(process.env.OVERLORD_TLS_OFFLOAD || "").trim().toLowerCase(),
|
||||
);
|
||||
const protocol = tlsOffload ? "https:" : url.protocol;
|
||||
return `${protocol}//${url.host}`;
|
||||
}
|
||||
|
||||
export function isSameOriginBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: { requireOrigin?: boolean } = {},
|
||||
): boolean {
|
||||
const fetchSite = (req.headers.get("sec-fetch-site") || "").trim().toLowerCase();
|
||||
if (fetchSite === "cross-site" || fetchSite === "same-site") return false;
|
||||
|
||||
const rawOrigin = req.headers.get("origin");
|
||||
if (!rawOrigin) {
|
||||
return !options.requireOrigin && (fetchSite === "" || fetchSite === "none" || fetchSite === "same-origin");
|
||||
}
|
||||
if (rawOrigin === "null") return false;
|
||||
|
||||
try {
|
||||
return new URL(rawOrigin).origin === expectedRequestOrigin(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectUnsafeCrossOriginRequest(req: Request, url: URL): Response | null {
|
||||
if (!UNSAFE_METHODS.has(req.method.toUpperCase())) return null;
|
||||
if (isSameOriginBrowserRequest(req, url)) return null;
|
||||
return new Response("Forbidden: cross-origin request rejected", { status: 403 });
|
||||
}
|
||||
@@ -42,6 +42,9 @@ import {
|
||||
} from "../../users";
|
||||
import { getUserPermissions, requirePermission } from "../../rbac";
|
||||
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||
import { readJsonBodyLimited, RequestBodyTooLargeError } from "../request-body";
|
||||
|
||||
const LOGIN_BODY_MAX_BYTES = 16 * 1024;
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -86,11 +89,16 @@ export async function handleAuthRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const username = body?.user || "";
|
||||
const password = body?.pass || "";
|
||||
const body = await readJsonBodyLimited(req, LOGIN_BODY_MAX_BYTES);
|
||||
const username = typeof body?.user === "string" ? body.user : "";
|
||||
const password = typeof body?.pass === "string" ? body.pass : "";
|
||||
const mfaCode = typeof body?.mfaCode === "string" ? body.mfaCode : "";
|
||||
|
||||
if (username.length > 256 || password.length > 1_024 || mfaCode.length > 32) {
|
||||
recordFailedAttempt(ip);
|
||||
return Response.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await authenticateUser(username, password);
|
||||
|
||||
if (user) {
|
||||
@@ -177,6 +185,9 @@ export async function handleAuthRoutes(
|
||||
errorMessage: "Invalid credentials",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ ok: false, error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
logger.error("[auth] Login error:", error);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -309,7 +309,12 @@ export async function handleClientRoutes(
|
||||
return Response.json({ ok: false, disabled: true }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
|
||||
markThumbnailRequested(clientId);
|
||||
if (!markThumbnailRequested(clientId)) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "thumbnail request capacity exceeded" },
|
||||
{ status: 429, headers: deps.CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
const target = clientManager.getClient(clientId);
|
||||
const beforeVersion = getThumbnailVersion(clientId);
|
||||
if (target?.online) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
claimSharedFileDownload,
|
||||
deleteSharedFile,
|
||||
getSharedFile,
|
||||
insertSharedFile,
|
||||
} from "../../db";
|
||||
import {
|
||||
clearFileSharePasswordAttempts,
|
||||
consumeFileSharePasswordAttempt,
|
||||
FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS,
|
||||
FILE_SHARE_PASSWORD_MAX_ATTEMPTS,
|
||||
handleFileShareRoutes,
|
||||
} from "./file-share-routes";
|
||||
|
||||
const createdFileIds: string[] = [];
|
||||
|
||||
function insertTestFile(options: {
|
||||
maxDownloads?: number | null;
|
||||
expiresAt?: number | null;
|
||||
passwordHash?: string | null;
|
||||
} = {}): string {
|
||||
const id = `file-share-security-${crypto.randomUUID()}`;
|
||||
insertSharedFile({
|
||||
id,
|
||||
filename: "payload.txt",
|
||||
storedPath: `${process.env.TEMP || "."}/${id}/payload.txt`,
|
||||
size: 7,
|
||||
mimeType: "text/plain",
|
||||
uploadedBy: 0,
|
||||
uploadedByUsername: "test",
|
||||
passwordHash: options.passwordHash ?? null,
|
||||
maxDownloads: options.maxDownloads ?? null,
|
||||
downloadCount: 0,
|
||||
expiresAt: options.expiresAt ?? null,
|
||||
createdAt: Date.now(),
|
||||
description: null,
|
||||
});
|
||||
createdFileIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY;
|
||||
while (createdFileIds.length > 0) {
|
||||
const id = createdFileIds.pop();
|
||||
if (id) deleteSharedFile(id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("file-share public download security", () => {
|
||||
test("password verification attempts are bounded per peer and file", () => {
|
||||
const now = 1_000_000;
|
||||
const key = `203.0.113.10:${crypto.randomUUID()}`;
|
||||
const otherKey = `203.0.113.10:${crypto.randomUUID()}`;
|
||||
try {
|
||||
for (let i = 0; i < FILE_SHARE_PASSWORD_MAX_ATTEMPTS; i += 1) {
|
||||
expect(consumeFileSharePasswordAttempt(key, now).limited).toBe(false);
|
||||
}
|
||||
const limited = consumeFileSharePasswordAttempt(key, now);
|
||||
expect(limited.limited).toBe(true);
|
||||
expect(limited.retryAfter).toBe(FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS / 1000);
|
||||
expect(consumeFileSharePasswordAttempt(otherKey, now).limited).toBe(false);
|
||||
expect(
|
||||
consumeFileSharePasswordAttempt(key, now + FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS).limited,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
clearFileSharePasswordAttempts(key);
|
||||
clearFileSharePasswordAttempts(otherKey);
|
||||
}
|
||||
});
|
||||
|
||||
test("route rate-limits against the trusted peer address before password verification", async () => {
|
||||
const hash = await Bun.password.hash("correct horse battery staple", {
|
||||
algorithm: "bcrypt",
|
||||
cost: 4,
|
||||
});
|
||||
const id = insertTestFile({ passwordHash: hash });
|
||||
const ip = "198.51.100.44";
|
||||
const key = `${ip}:${id}`;
|
||||
for (let i = 0; i < FILE_SHARE_PASSWORD_MAX_ATTEMPTS; i += 1) {
|
||||
consumeFileSharePasswordAttempt(key);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`https://localhost/api/file-share/${id}/download`);
|
||||
const response = await handleFileShareRoutes(
|
||||
new Request(url, { headers: { "X-Download-Password": "wrong" } }),
|
||||
url,
|
||||
{
|
||||
FILE_SHARE_ROOT: process.env.TEMP || ".",
|
||||
requestIP: () => ({ address: ip }),
|
||||
},
|
||||
);
|
||||
expect(response?.status).toBe(429);
|
||||
expect(Number(response?.headers.get("Retry-After"))).toBeGreaterThan(0);
|
||||
} finally {
|
||||
clearFileSharePasswordAttempts(key);
|
||||
}
|
||||
});
|
||||
|
||||
test("supports legacy password query links unless explicitly disabled", async () => {
|
||||
const hash = await Bun.password.hash("correct horse battery staple", {
|
||||
algorithm: "bcrypt",
|
||||
cost: 4,
|
||||
});
|
||||
const id = insertTestFile({ passwordHash: hash });
|
||||
const url = new URL(
|
||||
`https://localhost/api/file-share/${id}/download?password=wrong`,
|
||||
);
|
||||
const deps = {
|
||||
FILE_SHARE_ROOT: process.env.TEMP || ".",
|
||||
requestIP: () => ({ address: "198.51.100.45" }),
|
||||
};
|
||||
|
||||
const legacy = await handleFileShareRoutes(new Request(url), url, deps);
|
||||
expect(legacy?.status).toBe(403);
|
||||
|
||||
process.env.OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY = "false";
|
||||
const rejected = await handleFileShareRoutes(new Request(url), url, deps);
|
||||
expect(rejected?.status).toBe(400);
|
||||
expect(await rejected?.json()).toEqual({
|
||||
error: "Password query parameters are disabled; use X-Download-Password",
|
||||
});
|
||||
});
|
||||
|
||||
test("atomically refuses downloads beyond the configured maximum", () => {
|
||||
const id = insertTestFile({ maxDownloads: 1 });
|
||||
expect(claimSharedFileDownload(id)).toBe(true);
|
||||
expect(claimSharedFileDownload(id)).toBe(false);
|
||||
expect(getSharedFile(id)?.downloadCount).toBe(1);
|
||||
});
|
||||
|
||||
test("does not claim an expired download", () => {
|
||||
const id = insertTestFile({ expiresAt: Date.now() - 1 });
|
||||
expect(claimSharedFileDownload(id)).toBe(false);
|
||||
expect(getSharedFile(id)?.downloadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
insertSharedFile,
|
||||
deleteSharedFile,
|
||||
updateSharedFile,
|
||||
incrementSharedFileDownloadCount,
|
||||
claimSharedFileDownload,
|
||||
} from "../../db";
|
||||
import { getUserById } from "../../users";
|
||||
import { checkPermission, requirePermission } from "../../rbac";
|
||||
@@ -18,8 +18,79 @@ import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security
|
||||
|
||||
type FileShareRouteDeps = {
|
||||
FILE_SHARE_ROOT: string;
|
||||
/** Must be the server's trusted-proxy-aware peer address resolver. */
|
||||
requestIP?: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
export const FILE_SHARE_PASSWORD_MAX_ATTEMPTS = 10;
|
||||
export const FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
|
||||
const FILE_SHARE_PASSWORD_LIMIT_MAX_KEYS = 10_000;
|
||||
const FILE_SHARE_PASSWORD_MAX_CHARS = 1_024;
|
||||
const FILE_SHARE_PASSWORD_PRUNE_INTERVAL_MS = 60_000;
|
||||
|
||||
function allowLegacyPasswordQuery(): boolean {
|
||||
const setting = String(process.env.OVERLORD_ALLOW_FILE_SHARE_PASSWORD_QUERY || "").trim();
|
||||
return !/^(?:0|false|no|off)$/i.test(setting);
|
||||
}
|
||||
|
||||
type FileSharePasswordAttempts = {
|
||||
attempts: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const fileSharePasswordAttempts = new Map<string, FileSharePasswordAttempts>();
|
||||
let lastFileSharePasswordPrune = 0;
|
||||
|
||||
function pruneFileSharePasswordAttempts(now: number): void {
|
||||
if (
|
||||
fileSharePasswordAttempts.size < FILE_SHARE_PASSWORD_LIMIT_MAX_KEYS
|
||||
&& now - lastFileSharePasswordPrune < FILE_SHARE_PASSWORD_PRUNE_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastFileSharePasswordPrune = now;
|
||||
for (const [key, state] of fileSharePasswordAttempts) {
|
||||
if (state.expiresAt <= now) fileSharePasswordAttempts.delete(key);
|
||||
}
|
||||
while (fileSharePasswordAttempts.size >= FILE_SHARE_PASSWORD_LIMIT_MAX_KEYS) {
|
||||
const oldestKey = fileSharePasswordAttempts.keys().next().value;
|
||||
if (typeof oldestKey !== "string") break;
|
||||
fileSharePasswordAttempts.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserves one expensive password verification before bcrypt is invoked. */
|
||||
export function consumeFileSharePasswordAttempt(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): { limited: boolean; retryAfter: number } {
|
||||
let state = fileSharePasswordAttempts.get(key);
|
||||
if (state && state.expiresAt <= now) {
|
||||
fileSharePasswordAttempts.delete(key);
|
||||
state = undefined;
|
||||
}
|
||||
if (state && state.attempts >= FILE_SHARE_PASSWORD_MAX_ATTEMPTS) {
|
||||
return {
|
||||
limited: true,
|
||||
retryAfter: Math.max(1, Math.ceil((state.expiresAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
if (!state) {
|
||||
pruneFileSharePasswordAttempts(now);
|
||||
fileSharePasswordAttempts.set(key, {
|
||||
attempts: 1,
|
||||
expiresAt: now + FILE_SHARE_PASSWORD_ATTEMPT_WINDOW_MS,
|
||||
});
|
||||
} else {
|
||||
state.attempts += 1;
|
||||
}
|
||||
return { limited: false, retryAfter: 0 };
|
||||
}
|
||||
|
||||
export function clearFileSharePasswordAttempts(key: string): void {
|
||||
fileSharePasswordAttempts.delete(key);
|
||||
}
|
||||
|
||||
function guessMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
@@ -110,14 +181,36 @@ export async function handleFileShareRoutes(
|
||||
}
|
||||
|
||||
if (file.passwordHash) {
|
||||
const providedPassword = req.headers.get("X-Download-Password") || url.searchParams.get("password") || "";
|
||||
const headerPassword = req.headers.get("X-Download-Password") || "";
|
||||
const queryPassword = url.searchParams.get("password") || "";
|
||||
if (!headerPassword && queryPassword && !allowLegacyPasswordQuery()) {
|
||||
return Response.json(
|
||||
{ error: "Password query parameters are disabled; use X-Download-Password" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const providedPassword = headerPassword || (allowLegacyPasswordQuery() ? queryPassword : "");
|
||||
if (!providedPassword) {
|
||||
return Response.json({ error: "Password required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const peerAddress = deps.requestIP?.(req)?.address?.trim() || "unknown";
|
||||
const passwordAttemptKey = `${peerAddress.slice(0, 128)}:${file.id}`;
|
||||
const limit = consumeFileSharePasswordAttempt(passwordAttemptKey);
|
||||
if (limit.limited) {
|
||||
return Response.json(
|
||||
{ error: "Too many password attempts. Try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
|
||||
);
|
||||
}
|
||||
if (providedPassword.length > FILE_SHARE_PASSWORD_MAX_CHARS) {
|
||||
return Response.json({ error: "Invalid password" }, { status: 403 });
|
||||
}
|
||||
const valid = await Bun.password.verify(providedPassword, file.passwordHash);
|
||||
if (!valid) {
|
||||
return Response.json({ error: "Invalid password" }, { status: 403 });
|
||||
}
|
||||
clearFileSharePasswordAttempts(passwordAttemptKey);
|
||||
}
|
||||
|
||||
const diskFile = Bun.file(file.storedPath);
|
||||
@@ -125,7 +218,16 @@ export async function handleFileShareRoutes(
|
||||
return Response.json({ error: "File not found on disk" }, { status: 404 });
|
||||
}
|
||||
|
||||
incrementSharedFileDownloadCount(file.id);
|
||||
if (!claimSharedFileDownload(file.id)) {
|
||||
const current = getSharedFile(file.id);
|
||||
if (!current) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
if (current.expiresAt && current.expiresAt < Date.now()) {
|
||||
return Response.json({ error: "File has expired" }, { status: 410 });
|
||||
}
|
||||
return Response.json({ error: "Download limit reached" }, { status: 410 });
|
||||
}
|
||||
|
||||
const sanitizedFilename = file.filename.replace(/[^\w.\-() ]/g, "_");
|
||||
|
||||
@@ -134,6 +236,11 @@ export async function handleFileShareRoutes(
|
||||
"Content-Type": file.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${sanitizedFilename}"`,
|
||||
"Content-Length": String(file.size),
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -979,6 +979,12 @@ export async function handleMiscRoutes(
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
const proxies = getActiveProxies().filter((p) =>
|
||||
canUserAccessClient(user.userId, user.role, p.clientId),
|
||||
);
|
||||
@@ -993,6 +999,12 @@ export async function handleMiscRoutes(
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
@@ -1034,6 +1046,12 @@ export async function handleMiscRoutes(
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../../auth";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
getUserNotificationSettings,
|
||||
} from "../../users";
|
||||
import { handleNotificationsConfigRoutes } from "./notifications-config-routes";
|
||||
|
||||
let adminUserId = 0;
|
||||
let adminToken = "";
|
||||
|
||||
const server = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
const deps = {
|
||||
getNotificationScreenshot: () => null,
|
||||
secureHeaders: () => ({}),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const created = await createUser(
|
||||
`nsa_${suffix}`,
|
||||
"Aa1!NotificationsSecurityPass_2026",
|
||||
"admin",
|
||||
"test",
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
adminUserId = created.userId!;
|
||||
adminToken = await generateToken(getUserById(adminUserId)!);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (adminUserId) deleteUser(adminUserId);
|
||||
});
|
||||
|
||||
function authenticatedJsonRequest(
|
||||
url: URL,
|
||||
method: string,
|
||||
body: string,
|
||||
declaredLength?: number,
|
||||
): Request {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (declaredLength !== undefined) headers["Content-Length"] = String(declaredLength);
|
||||
return new Request(url, { method, headers, body });
|
||||
}
|
||||
|
||||
describe("notification configuration request limits", () => {
|
||||
test("rejects oversized bodies at every authenticated mutation endpoint", async () => {
|
||||
const cases = [
|
||||
{ path: "/api/notifications/config", method: "PUT", bytes: 128 * 1024 + 1 },
|
||||
{ path: "/api/notifications/my-settings", method: "PUT", bytes: 64 * 1024 + 1 },
|
||||
{ path: "/api/notifications/my-settings/preview/webhook", method: "POST", bytes: 32 * 1024 + 1 },
|
||||
{ path: "/api/notifications/push-subscribe", method: "POST", bytes: 16 * 1024 + 1 },
|
||||
{ path: "/api/notifications/push-subscribe", method: "DELETE", bytes: 16 * 1024 + 1 },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const url = new URL(`https://localhost${entry.path}`);
|
||||
const response = await handleNotificationsConfigRoutes(
|
||||
authenticatedJsonRequest(url, entry.method, "{}", entry.bytes),
|
||||
url,
|
||||
server,
|
||||
deps,
|
||||
);
|
||||
expect(response?.status).toBe(413);
|
||||
expect(await response!.json()).toEqual({ error: "Request body too large" });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects oversized global Telegram credentials before persisting them", async () => {
|
||||
for (const body of [
|
||||
{ telegramBotToken: "t".repeat(513) },
|
||||
{ telegramChatId: "c".repeat(257) },
|
||||
]) {
|
||||
const url = new URL("https://localhost/api/notifications/config");
|
||||
const response = await handleNotificationsConfigRoutes(
|
||||
authenticatedJsonRequest(url, "PUT", JSON.stringify(body)),
|
||||
url,
|
||||
server,
|
||||
deps,
|
||||
);
|
||||
expect(response?.status).toBe(400);
|
||||
expect((await response!.json()).error).toContain("too long");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts per-user Telegram credentials at the limit and rejects larger replacements", async () => {
|
||||
const url = new URL("https://localhost/api/notifications/my-settings");
|
||||
const token = "t".repeat(512);
|
||||
const chatId = "c".repeat(256);
|
||||
const accepted = await handleNotificationsConfigRoutes(
|
||||
authenticatedJsonRequest(
|
||||
url,
|
||||
"PUT",
|
||||
JSON.stringify({ telegram_bot_token: token, telegram_chat_id: chatId }),
|
||||
),
|
||||
url,
|
||||
server,
|
||||
deps,
|
||||
);
|
||||
expect(accepted?.status).toBe(200);
|
||||
expect(getUserNotificationSettings(adminUserId)?.telegram_bot_token).toBe(token);
|
||||
expect(getUserNotificationSettings(adminUserId)?.telegram_chat_id).toBe(chatId);
|
||||
|
||||
for (const body of [
|
||||
{ telegram_bot_token: `${token}x` },
|
||||
{ telegram_chat_id: `${chatId}x` },
|
||||
]) {
|
||||
const rejected = await handleNotificationsConfigRoutes(
|
||||
authenticatedJsonRequest(url, "PUT", JSON.stringify(body)),
|
||||
url,
|
||||
server,
|
||||
deps,
|
||||
);
|
||||
expect(rejected?.status).toBe(400);
|
||||
expect((await rejected!.json()).error).toContain("too long");
|
||||
}
|
||||
|
||||
expect(getUserNotificationSettings(adminUserId)?.telegram_bot_token).toBe(token);
|
||||
expect(getUserNotificationSettings(adminUserId)?.telegram_chat_id).toBe(chatId);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { getConfig, updateNotificationsConfig } from "../../config";
|
||||
import { savePushSubscription, deletePushSubscriptionForUser, getPushSubscriptionsByUser } from "../../db";
|
||||
import { savePushSubscription, deletePushSubscriptionForUser } from "../../db";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import {
|
||||
canUserAccessClient,
|
||||
@@ -10,13 +10,33 @@ import {
|
||||
updateUserNotificationSettings,
|
||||
} from "../../users";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { consumePushSubscriptionMutationRateLimit } from "../../rateLimit";
|
||||
import {
|
||||
DEFAULT_WEBHOOK_TEMPLATE,
|
||||
DEFAULT_TELEGRAM_TEMPLATE,
|
||||
isPrivateOrInternalHostname,
|
||||
renderNotificationTemplate,
|
||||
} from "../notification-delivery";
|
||||
import { getVapidPublicKey } from "../web-push";
|
||||
import { fetchPublicHttpResponse, validatePublicHttpUrl } from "../url-security";
|
||||
import { readJsonBodyLimited, RequestBodyTooLargeError } from "../request-body";
|
||||
|
||||
const MAX_WEBHOOK_URL_LENGTH = 2_048;
|
||||
const MAX_NOTIFICATION_TEMPLATE_LENGTH = 16 * 1024;
|
||||
const MAX_PUSH_ENDPOINT_LENGTH = 4_096;
|
||||
const MAX_PUSH_KEY_LENGTH = 1_024;
|
||||
const MAX_TELEGRAM_BOT_TOKEN_LENGTH = 512;
|
||||
const MAX_TELEGRAM_CHAT_ID_LENGTH = 256;
|
||||
const MAX_NOTIFICATIONS_CONFIG_BODY_BYTES = 128 * 1024;
|
||||
const MAX_USER_NOTIFICATION_SETTINGS_BODY_BYTES = 64 * 1024;
|
||||
const MAX_WEBHOOK_PREVIEW_BODY_BYTES = 32 * 1024;
|
||||
const MAX_PUSH_SUBSCRIPTION_BODY_BYTES = 16 * 1024;
|
||||
|
||||
async function validateWebhookUrl(rawUrl: string): Promise<URL> {
|
||||
if (rawUrl.length > MAX_WEBHOOK_URL_LENGTH) {
|
||||
throw new Error("Webhook URL is too long");
|
||||
}
|
||||
return await validatePublicHttpUrl(rawUrl);
|
||||
}
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -109,8 +129,11 @@ export async function handleNotificationsConfigRoutes(
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = await readJsonBodyLimited(req, MAX_NOTIFICATIONS_CONFIG_BODY_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -176,17 +199,21 @@ export async function handleNotificationsConfigRoutes(
|
||||
? body.clipboardEnabled
|
||||
: currentConfig.clipboardEnabled || false;
|
||||
|
||||
if (telegramBotToken.length > MAX_TELEGRAM_BOT_TOKEN_LENGTH) {
|
||||
return Response.json({ error: "Telegram bot token is too long" }, { status: 400 });
|
||||
}
|
||||
if (telegramChatId.length > MAX_TELEGRAM_CHAT_ID_LENGTH) {
|
||||
return Response.json({ error: "Telegram chat ID is too long" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
if (!/^https?:$/.test(parsed.protocol)) {
|
||||
return Response.json(
|
||||
{ error: "Webhook URL must be http(s)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||
await validateWebhookUrl(webhookUrl);
|
||||
} catch (error: any) {
|
||||
return Response.json(
|
||||
{ error: error?.message || "Invalid webhook URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,8 +303,11 @@ export async function handleNotificationsConfigRoutes(
|
||||
if (req.method === "PUT") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = await readJsonBodyLimited(req, MAX_USER_NOTIFICATION_SETTINGS_BODY_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -290,30 +320,46 @@ export async function handleNotificationsConfigRoutes(
|
||||
const url_val = body.webhook_url.trim();
|
||||
if (url_val) {
|
||||
try {
|
||||
const parsed = new URL(url_val);
|
||||
if (!/^https?:$/.test(parsed.protocol)) {
|
||||
return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||
await validateWebhookUrl(url_val);
|
||||
} catch (error: any) {
|
||||
return Response.json(
|
||||
{ error: error?.message || "Invalid webhook URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
patch.webhook_url = url_val || null;
|
||||
}
|
||||
if (typeof body?.webhook_template === "string") {
|
||||
patch.webhook_template = body.webhook_template.trim() || null;
|
||||
const template = body.webhook_template.trim();
|
||||
if (template.length > MAX_NOTIFICATION_TEMPLATE_LENGTH) {
|
||||
return Response.json({ error: "Webhook template is too long" }, { status: 400 });
|
||||
}
|
||||
patch.webhook_template = template || null;
|
||||
}
|
||||
if (typeof body?.telegram_enabled === "boolean" || typeof body?.telegram_enabled === "number") {
|
||||
patch.telegram_enabled = body.telegram_enabled ? 1 : 0;
|
||||
}
|
||||
if (typeof body?.telegram_bot_token === "string") {
|
||||
patch.telegram_bot_token = body.telegram_bot_token.trim() || null;
|
||||
const token = body.telegram_bot_token.trim();
|
||||
if (token.length > MAX_TELEGRAM_BOT_TOKEN_LENGTH) {
|
||||
return Response.json({ error: "Telegram bot token is too long" }, { status: 400 });
|
||||
}
|
||||
patch.telegram_bot_token = token || null;
|
||||
}
|
||||
if (typeof body?.telegram_chat_id === "string") {
|
||||
patch.telegram_chat_id = body.telegram_chat_id.trim() || null;
|
||||
const chatId = body.telegram_chat_id.trim();
|
||||
if (chatId.length > MAX_TELEGRAM_CHAT_ID_LENGTH) {
|
||||
return Response.json({ error: "Telegram chat ID is too long" }, { status: 400 });
|
||||
}
|
||||
patch.telegram_chat_id = chatId || null;
|
||||
}
|
||||
if (typeof body?.telegram_template === "string") {
|
||||
patch.telegram_template = body.telegram_template.trim() || null;
|
||||
const template = body.telegram_template.trim();
|
||||
if (template.length > MAX_NOTIFICATION_TEMPLATE_LENGTH) {
|
||||
return Response.json({ error: "Telegram template is too long" }, { status: 400 });
|
||||
}
|
||||
patch.telegram_template = template || null;
|
||||
}
|
||||
if (typeof body?.client_event_webhook === "boolean" || typeof body?.client_event_webhook === "number") {
|
||||
patch.client_event_webhook = body.client_event_webhook ? 1 : 0;
|
||||
@@ -346,8 +392,11 @@ export async function handleNotificationsConfigRoutes(
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = await readJsonBodyLimited(req, MAX_WEBHOOK_PREVIEW_BODY_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -360,20 +409,16 @@ export async function handleNotificationsConfigRoutes(
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(webhookUrl);
|
||||
if (!/^https?:$/.test(parsed.protocol)) {
|
||||
return Response.json({ error: "Webhook URL must use http(s)" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isPrivateOrInternalHostname(parsed.hostname.toLowerCase())) {
|
||||
parsed = await validateWebhookUrl(webhookUrl);
|
||||
} catch (error: any) {
|
||||
return Response.json(
|
||||
{ error: "Webhook URL points at a private/internal address" },
|
||||
{ error: error?.message || "Invalid webhook URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (webhookTemplate.length > MAX_NOTIFICATION_TEMPLATE_LENGTH) {
|
||||
return Response.json({ error: "Webhook template is too long" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sampleRecord = {
|
||||
id: "preview-notification",
|
||||
@@ -432,13 +477,13 @@ export async function handleNotificationsConfigRoutes(
|
||||
],
|
||||
};
|
||||
sentPayload = JSON.stringify(discordPayload);
|
||||
response = await fetch(parsed.toString(), {
|
||||
response = await fetchPublicHttpResponse(parsed.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: sentPayload,
|
||||
});
|
||||
} else {
|
||||
response = await fetch(parsed.toString(), {
|
||||
response = await fetchPublicHttpResponse(parsed.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: jsonPayloadToSend,
|
||||
@@ -483,11 +528,27 @@ export async function handleNotificationsConfigRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" || req.method === "DELETE") {
|
||||
const rateLimit = consumePushSubscriptionMutationRateLimit(user.userId);
|
||||
if (rateLimit.limited) {
|
||||
return Response.json(
|
||||
{ error: "Push subscription update rate limit exceeded" },
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(rateLimit.retryAfter || 60) },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = await readJsonBodyLimited(req, MAX_PUSH_SUBSCRIPTION_BODY_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -498,16 +559,49 @@ export async function handleNotificationsConfigRoutes(
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
return Response.json({ error: "Missing subscription fields" }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
endpoint.length > MAX_PUSH_ENDPOINT_LENGTH ||
|
||||
p256dh.length > MAX_PUSH_KEY_LENGTH ||
|
||||
auth.length > MAX_PUSH_KEY_LENGTH
|
||||
) {
|
||||
return Response.json({ error: "Subscription field is too long" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const parsed = await validatePublicHttpUrl(endpoint);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return Response.json({ error: "Push endpoint must use HTTPS" }, { status: 400 });
|
||||
}
|
||||
} catch (error: any) {
|
||||
return Response.json(
|
||||
{ error: error?.message || "Invalid push endpoint" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
savePushSubscription(user.userId, endpoint, p256dh, auth);
|
||||
return Response.json({ ok: true });
|
||||
const saved = savePushSubscription(user.userId, endpoint, p256dh, auth);
|
||||
if (!saved.ok) {
|
||||
if (saved.reason === "global_limit") {
|
||||
return Response.json(
|
||||
{ error: "The server push subscription limit has been reached" },
|
||||
{ status: 503, headers: { "Retry-After": "300" } },
|
||||
);
|
||||
}
|
||||
return Response.json(
|
||||
{ error: "This push endpoint is already registered" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({ ok: true, replacedOldest: !!saved.evictedEndpoint });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = await readJsonBodyLimited(req, MAX_PUSH_SUBSCRIPTION_BODY_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -515,6 +609,9 @@ export async function handleNotificationsConfigRoutes(
|
||||
if (!endpoint) {
|
||||
return Response.json({ error: "Missing endpoint" }, { status: 400 });
|
||||
}
|
||||
if (endpoint.length > MAX_PUSH_ENDPOINT_LENGTH) {
|
||||
return Response.json({ error: "Subscription endpoint is too long" }, { status: 400 });
|
||||
}
|
||||
|
||||
deletePushSubscriptionForUser(user.userId, endpoint);
|
||||
return Response.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../../auth";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserExtraPermissions,
|
||||
} from "../../users";
|
||||
import { handleMiscRoutes } from "./misc-routes";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
let operatorToken = "";
|
||||
let viewerToken = "";
|
||||
|
||||
const deps = {
|
||||
CORS_HEADERS: {},
|
||||
SERVER_VERSION: "test",
|
||||
PUBLIC_ROOT: ".",
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
getConsoleSessionCount: () => 0,
|
||||
getRdSessionCount: () => 0,
|
||||
getFileBrowserSessionCount: () => 0,
|
||||
getProcessSessionCount: () => 0,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const suffix = crypto.randomUUID().slice(0, 8);
|
||||
const operator = await createUser(
|
||||
`proxy_operator_${suffix}`,
|
||||
"Aa1!ProxyRouteSecurityPass123",
|
||||
"operator",
|
||||
"test",
|
||||
);
|
||||
const viewer = await createUser(
|
||||
`proxy_viewer_${suffix}`,
|
||||
"Aa1!ProxyRouteSecurityPass123",
|
||||
"viewer",
|
||||
"test",
|
||||
);
|
||||
if (!operator.success || !operator.userId || !viewer.success || !viewer.userId) {
|
||||
throw new Error("failed to create proxy route test users");
|
||||
}
|
||||
createdUserIds.push(operator.userId, viewer.userId);
|
||||
operatorToken = await generateToken(getUserById(operator.userId)!);
|
||||
setUserExtraPermissions(viewer.userId, ["clients:control"]);
|
||||
viewerToken = await generateToken(getUserById(viewer.userId)!);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const id of createdUserIds) deleteUser(id);
|
||||
});
|
||||
|
||||
async function listProxies(token?: string): Promise<Response> {
|
||||
const url = new URL("https://localhost/api/proxy/list");
|
||||
const request = new Request(url, token
|
||||
? { headers: { Authorization: `Bearer ${token}` } }
|
||||
: undefined);
|
||||
const response = await handleMiscRoutes(request, url, deps);
|
||||
if (!response) throw new Error("proxy list route was not handled");
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("SOCKS proxy route authorization", () => {
|
||||
test("requires authentication", async () => {
|
||||
expect((await listProxies()).status).toBe(401);
|
||||
});
|
||||
|
||||
test("allows an operator with clients:control", async () => {
|
||||
expect((await listProxies(operatorToken)).status).toBe(200);
|
||||
});
|
||||
|
||||
test("retains the non-viewer role gate even with a direct permission grant", async () => {
|
||||
expect((await listProxies(viewerToken)).status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getConfig, updateRegistrationConfig } from "../../config";
|
||||
import { handleRegistrationRoutes } from "./registration-routes";
|
||||
|
||||
describe("public registration body limits", () => {
|
||||
test("rejects an oversized body before JSON buffering", async () => {
|
||||
const original = { ...getConfig().registration };
|
||||
await updateRegistrationConfig({ mode: "open" });
|
||||
try {
|
||||
const url = new URL("https://localhost/api/register");
|
||||
const response = await handleRegistrationRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(16 * 1024 + 1),
|
||||
},
|
||||
body: "{}",
|
||||
}),
|
||||
url,
|
||||
{ requestIP: () => ({ address: `198.51.100.${Math.floor(Math.random() * 200) + 1}` }) },
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(413);
|
||||
expect(await response!.json()).toMatchObject({ error: "Request body too large" });
|
||||
} finally {
|
||||
await updateRegistrationConfig(original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { AuditAction, logAudit } from "../../auditLog";
|
||||
import { getConfig } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { readJsonBodyLimited, RequestBodyTooLargeError } from "../request-body";
|
||||
import {
|
||||
registerUser,
|
||||
deleteUser,
|
||||
@@ -29,6 +30,7 @@ interface RegRateLimitEntry {
|
||||
const regRateLimitStore = new Map<string, RegRateLimitEntry>();
|
||||
const REG_MAX_ATTEMPTS = 5;
|
||||
const REG_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const REGISTRATION_BODY_MAX_BYTES = 16 * 1024;
|
||||
|
||||
function isRegRateLimited(ip: string): boolean {
|
||||
const entry = regRateLimitStore.get(ip);
|
||||
@@ -114,8 +116,11 @@ export async function handleRegistrationRoutes(
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = await readJsonBodyLimited(req, REGISTRATION_BODY_MAX_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return Response.json({ error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,24 @@ describe("WebRTC viewer authorization", () => {
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("rejects oversized SDP before proxying it upstream", async () => {
|
||||
const auth = await makeOperator();
|
||||
const url = new URL("https://localhost/api/webrtc/agents/client-a/desktop/whep");
|
||||
const response = await handleWebrtcRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
"Content-Length": String(1024 * 1024 + 1),
|
||||
},
|
||||
body: "offer",
|
||||
}),
|
||||
url,
|
||||
);
|
||||
|
||||
expect(response!.status).toBe(413);
|
||||
});
|
||||
|
||||
test("revokes only the matching user's tracked media sessions", async () => {
|
||||
const deleted: string[] = [];
|
||||
trackWebrtcViewerSession({
|
||||
|
||||
@@ -3,11 +3,13 @@ import { authenticateRequest } from "../../auth";
|
||||
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
|
||||
import { logger } from "../../logger";
|
||||
import { issueTurnIceServers } from "../turn-credentials";
|
||||
import { readBodyLimited, RequestBodyTooLargeError } from "../request-body";
|
||||
|
||||
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
|
||||
|
||||
|
||||
const PUBLISH_TOKEN_TTL_MS = 60_000;
|
||||
const MAX_WEBRTC_SIGNAL_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
type PublishToken = { clientId: string; expiresAt: number };
|
||||
const publishTokens = new Map<string, PublishToken>();
|
||||
@@ -149,7 +151,14 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
|
||||
|
||||
let body: ArrayBuffer | undefined;
|
||||
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "DELETE") {
|
||||
body = await req.arrayBuffer();
|
||||
try {
|
||||
body = (await readBodyLimited(req, MAX_WEBRTC_SIGNAL_BODY_BYTES)).buffer;
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return new Response("Payload Too Large", { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let upstreamResp: Response;
|
||||
|
||||
@@ -9,7 +9,7 @@ async function getGeoip() {
|
||||
}
|
||||
import { logAudit, AuditAction } from "../../auditLog";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuild, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
|
||||
import { clientExists, setOnlineState, setOfflineStates, upsertClientRow, upsertPendingClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getClientBuildOwnership, getBuild, getBuildByTag, computeClientSuspiciousFlags, type OfflineStateUpdate } from "../../db";
|
||||
import { getConfig } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import { metrics } from "../../metrics";
|
||||
@@ -241,7 +241,7 @@ type WsLifecycleDeps = {
|
||||
handleKeyloggerMessage: (clientId: string, payload: any) => void;
|
||||
handleKeylogArchiveMessage?: (clientId: string, payload: any, ws?: ServerWebSocket<SocketData>) => void;
|
||||
notifyRdInputLatency: (commandId: string) => void;
|
||||
handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined) => void;
|
||||
handleNotificationScreenshotFailure: (commandId: string | undefined, ok: boolean | undefined, message: string | undefined, clientId?: string) => void;
|
||||
handlePluginEvent: (clientId: string, payload: any) => void;
|
||||
handleNotification: (clientId: string, payload: any) => void;
|
||||
handleVoiceUplink: (clientId: string, payload: any) => void;
|
||||
@@ -289,22 +289,200 @@ type WsLifecycleDeps = {
|
||||
};
|
||||
|
||||
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
||||
const enrollmentTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const MAX_PRE_ENROLLMENT_MESSAGE_BYTES = 32 * 1024;
|
||||
|
||||
function clearEnrollmentTimeout(clientId: string) {
|
||||
const t = enrollmentTimeouts.get(clientId);
|
||||
export type EnrollmentAdmissionLimits = {
|
||||
maxActiveGlobal: number;
|
||||
maxActivePerIp: number;
|
||||
maxVerifyingGlobal: number;
|
||||
verificationAttemptsPerSecond: number;
|
||||
verificationAttemptBurst: number;
|
||||
};
|
||||
|
||||
export type EnrollmentAdmissionDenial =
|
||||
| "global_socket_limit"
|
||||
| "per_ip_socket_limit"
|
||||
| "not_admitted"
|
||||
| "already_verifying"
|
||||
| "verification_concurrency"
|
||||
| "verification_rate";
|
||||
|
||||
type EnrollmentAdmissionDecision =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: EnrollmentAdmissionDenial };
|
||||
|
||||
function normalizedAdmissionIp(value: unknown): string {
|
||||
if (typeof value !== "string") return "<unknown>";
|
||||
const clean = value.trim().slice(0, 128);
|
||||
return clean || "<unknown>";
|
||||
}
|
||||
|
||||
function positiveEnrollmentEnv(
|
||||
name: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
const parsed = Number(process.env[name]);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < min) return fallback;
|
||||
return Math.min(max, parsed);
|
||||
}
|
||||
|
||||
export function createEnrollmentAdmissionController(rawLimits: EnrollmentAdmissionLimits) {
|
||||
const normalizeLimit = (value: number): number =>
|
||||
Number.isSafeInteger(value) && value > 0 ? Math.floor(value) : 1;
|
||||
const limits: EnrollmentAdmissionLimits = {
|
||||
maxActiveGlobal: normalizeLimit(rawLimits.maxActiveGlobal),
|
||||
maxActivePerIp: normalizeLimit(rawLimits.maxActivePerIp),
|
||||
maxVerifyingGlobal: normalizeLimit(rawLimits.maxVerifyingGlobal),
|
||||
verificationAttemptsPerSecond: normalizeLimit(rawLimits.verificationAttemptsPerSecond),
|
||||
verificationAttemptBurst: normalizeLimit(rawLimits.verificationAttemptBurst),
|
||||
};
|
||||
const sockets = new Map<object, { ip: string; verifying: boolean }>();
|
||||
const activeByIp = new Map<string, number>();
|
||||
let verifying = 0;
|
||||
let attemptTokens = limits.verificationAttemptBurst;
|
||||
let attemptTokensUpdatedAt = 0;
|
||||
|
||||
function refillAttemptTokens(now: number): void {
|
||||
if (attemptTokensUpdatedAt === 0) {
|
||||
attemptTokensUpdatedAt = now;
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Math.max(0, Math.min(60_000, now - attemptTokensUpdatedAt));
|
||||
attemptTokens = Math.min(
|
||||
limits.verificationAttemptBurst,
|
||||
attemptTokens + elapsedMs * limits.verificationAttemptsPerSecond / 1_000,
|
||||
);
|
||||
attemptTokensUpdatedAt = now;
|
||||
}
|
||||
|
||||
return {
|
||||
admit(socket: object, ipValue: unknown): EnrollmentAdmissionDecision {
|
||||
if (sockets.has(socket)) return { ok: true };
|
||||
const ip = normalizedAdmissionIp(ipValue);
|
||||
if (sockets.size >= limits.maxActiveGlobal) {
|
||||
return { ok: false, reason: "global_socket_limit" };
|
||||
}
|
||||
const ipActive = activeByIp.get(ip) || 0;
|
||||
if (ipActive >= limits.maxActivePerIp) {
|
||||
return { ok: false, reason: "per_ip_socket_limit" };
|
||||
}
|
||||
sockets.set(socket, { ip, verifying: false });
|
||||
activeByIp.set(ip, ipActive + 1);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
beginVerification(socket: object, now = Date.now()): EnrollmentAdmissionDecision {
|
||||
const entry = sockets.get(socket);
|
||||
if (!entry) return { ok: false, reason: "not_admitted" };
|
||||
if (entry.verifying) return { ok: false, reason: "already_verifying" };
|
||||
if (verifying >= limits.maxVerifyingGlobal) {
|
||||
return { ok: false, reason: "verification_concurrency" };
|
||||
}
|
||||
refillAttemptTokens(now);
|
||||
if (attemptTokens < 1) {
|
||||
return { ok: false, reason: "verification_rate" };
|
||||
}
|
||||
attemptTokens -= 1;
|
||||
entry.verifying = true;
|
||||
verifying += 1;
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
release(socket: object): boolean {
|
||||
const entry = sockets.get(socket);
|
||||
if (!entry) return false;
|
||||
sockets.delete(socket);
|
||||
if (entry.verifying) verifying = Math.max(0, verifying - 1);
|
||||
const ipActive = Math.max(0, (activeByIp.get(entry.ip) || 0) - 1);
|
||||
if (ipActive === 0) activeByIp.delete(entry.ip);
|
||||
else activeByIp.set(entry.ip, ipActive);
|
||||
return true;
|
||||
},
|
||||
|
||||
stats(ipValue?: unknown) {
|
||||
const ip = ipValue === undefined ? undefined : normalizedAdmissionIp(ipValue);
|
||||
return {
|
||||
active: sockets.size,
|
||||
verifying,
|
||||
activeForIp: ip === undefined ? undefined : (activeByIp.get(ip) || 0),
|
||||
attemptTokens,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const enrollmentAdmission = createEnrollmentAdmissionController({
|
||||
// These defaults allow a large fleet to reconnect in bursts while bounding
|
||||
// attacker-retained challenges and expensive signature/build verification.
|
||||
maxActiveGlobal: positiveEnrollmentEnv("OVERLORD_MAX_PREAUTH_SOCKETS_GLOBAL", 100_000, 32, 100_000),
|
||||
maxActivePerIp: positiveEnrollmentEnv("OVERLORD_MAX_PREAUTH_SOCKETS_PER_IP", 10_000, 8, 10_000),
|
||||
maxVerifyingGlobal: positiveEnrollmentEnv("OVERLORD_MAX_ENROLLMENT_VERIFICATIONS", 4_096, 4, 4_096),
|
||||
verificationAttemptsPerSecond: positiveEnrollmentEnv(
|
||||
"OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPTS_PER_SECOND",
|
||||
100_000,
|
||||
8,
|
||||
100_000,
|
||||
),
|
||||
verificationAttemptBurst: positiveEnrollmentEnv(
|
||||
"OVERLORD_ENROLLMENT_VERIFICATION_ATTEMPT_BURST",
|
||||
200_000,
|
||||
16,
|
||||
200_000,
|
||||
),
|
||||
});
|
||||
|
||||
export function getEnrollmentAdmissionStats(ip?: string) {
|
||||
return enrollmentAdmission.stats(ip);
|
||||
}
|
||||
|
||||
function clearEnrollmentTimeout(ws: ServerWebSocket<SocketData>) {
|
||||
const t = ws.data.enrollmentTimeout;
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
enrollmentTimeouts.delete(clientId);
|
||||
ws.data.enrollmentTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseEnrollmentAdmission(ws: ServerWebSocket<SocketData>): void {
|
||||
enrollmentAdmission.release(ws);
|
||||
}
|
||||
|
||||
function rejectEnrollment(ws: ServerWebSocket<SocketData>): void {
|
||||
ws.data.enrollmentState = "rejected";
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
clearEnrollmentTimeout(ws);
|
||||
releaseEnrollmentAdmission(ws);
|
||||
}
|
||||
|
||||
const enrollmentAdmissionWarningAt = new Map<EnrollmentAdmissionDenial, number>();
|
||||
|
||||
function warnEnrollmentAdmissionDenied(reason: EnrollmentAdmissionDenial): void {
|
||||
const now = Date.now();
|
||||
const last = enrollmentAdmissionWarningAt.get(reason) || 0;
|
||||
if (now - last < 5_000) return;
|
||||
enrollmentAdmissionWarningAt.set(reason, now);
|
||||
logger.warn(`[purgatory] enrollment admission denied reason=${reason}`);
|
||||
}
|
||||
|
||||
export function decodeCanonicalBase64(value: unknown, expectedBytes: number): Uint8Array<ArrayBuffer> | null {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 256) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(value, "base64");
|
||||
if (decoded.length !== expectedBytes || decoded.toString("base64") !== value) return null;
|
||||
return Uint8Array.from(decoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
|
||||
try {
|
||||
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
|
||||
const sigBytes = Buffer.from(signatureBase64, "base64");
|
||||
const nonceBytes = Buffer.from(nonceBase64, "base64");
|
||||
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
|
||||
const pubKeyBytes = decodeCanonicalBase64(publicKeyBase64, 32);
|
||||
const sigBytes = decodeCanonicalBase64(signatureBase64, 64);
|
||||
const nonceBytes = decodeCanonicalBase64(nonceBase64, 32);
|
||||
if (!pubKeyBytes || !sigBytes || !nonceBytes) return false;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
pubKeyBytes,
|
||||
@@ -326,8 +504,81 @@ function computeKeyFingerprint(publicKeyBase64: string): string {
|
||||
|
||||
function sanitizeCrashString(value: unknown, maxLen: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const clean = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
|
||||
return clean ? clean.slice(0, maxLen) : undefined;
|
||||
const clean = value.slice(0, maxLen).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
|
||||
return clean || undefined;
|
||||
}
|
||||
|
||||
export const MAX_DISCONNECT_REASON_LENGTH = 128;
|
||||
export const MAX_DISCONNECT_DETAIL_LENGTH = 4_096;
|
||||
|
||||
export function normalizeDisconnectInfo(payload: unknown): {
|
||||
reason: string;
|
||||
detail?: string;
|
||||
} | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const data = payload as Record<string, unknown>;
|
||||
const reason = typeof data.reason === "string"
|
||||
? data.reason
|
||||
.slice(0, MAX_DISCONNECT_REASON_LENGTH)
|
||||
.replace(/[\x00-\x1F\x7F]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
: "";
|
||||
if (!reason) return null;
|
||||
const detail = sanitizeCrashString(data.detail, MAX_DISCONNECT_DETAIL_LENGTH);
|
||||
return { reason, ...(detail ? { detail } : {}) };
|
||||
}
|
||||
|
||||
export const MAX_PROXY_TUNNEL_CHUNK_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export function normalizeProxyTunnelChunk(value: unknown): Uint8Array | null {
|
||||
let bytes: Uint8Array;
|
||||
if (value instanceof Uint8Array) bytes = value;
|
||||
else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
|
||||
else if (ArrayBuffer.isView(value)) {
|
||||
bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return bytes.byteLength > 0 && bytes.byteLength <= MAX_PROXY_TUNNEL_CHUNK_BYTES
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
const CLIENT_INGRESS_BYTE_BURST = 128 * 1024 * 1024;
|
||||
const CLIENT_INGRESS_BYTES_PER_SECOND = 32 * 1024 * 1024;
|
||||
const CLIENT_INGRESS_MESSAGE_BURST = 512;
|
||||
const CLIENT_INGRESS_MESSAGES_PER_SECOND = 256;
|
||||
|
||||
export function consumeClientIngressBudget(
|
||||
data: SocketData,
|
||||
size: number,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (!Number.isFinite(size) || size < 0) return false;
|
||||
const previous = Number.isFinite(data.ingressBudgetUpdatedAt)
|
||||
? data.ingressBudgetUpdatedAt!
|
||||
: now;
|
||||
const elapsedMs = Math.max(0, Math.min(60_000, now - previous));
|
||||
const byteTokens = Math.min(
|
||||
CLIENT_INGRESS_BYTE_BURST,
|
||||
(Number.isFinite(data.ingressByteTokens) ? data.ingressByteTokens! : CLIENT_INGRESS_BYTE_BURST)
|
||||
+ elapsedMs * CLIENT_INGRESS_BYTES_PER_SECOND / 1000,
|
||||
);
|
||||
const messageTokens = Math.min(
|
||||
CLIENT_INGRESS_MESSAGE_BURST,
|
||||
(Number.isFinite(data.ingressMessageTokens) ? data.ingressMessageTokens! : CLIENT_INGRESS_MESSAGE_BURST)
|
||||
+ elapsedMs * CLIENT_INGRESS_MESSAGES_PER_SECOND / 1000,
|
||||
);
|
||||
data.ingressBudgetUpdatedAt = now;
|
||||
if (size > byteTokens || messageTokens < 1) {
|
||||
data.ingressByteTokens = byteTokens;
|
||||
data.ingressMessageTokens = messageTokens;
|
||||
return false;
|
||||
}
|
||||
data.ingressByteTokens = byteTokens - size;
|
||||
data.ingressMessageTokens = messageTokens - 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
||||
@@ -348,6 +599,14 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
||||
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
|
||||
if (role === "chat_viewer") return deps.handleChatViewerOpen(ws);
|
||||
|
||||
const admission = enrollmentAdmission.admit(ws, ip);
|
||||
if (!admission.ok) {
|
||||
warnEnrollmentAdmissionDenied(admission.reason);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(1013, "Enrollment capacity reached"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const id = clientId || uuidv4();
|
||||
ws.data.clientId = id;
|
||||
ws.data.ip = ip;
|
||||
@@ -356,17 +615,25 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceBase64 = Buffer.from(nonceBytes).toString("base64");
|
||||
ws.data.enrollmentNonce = nonceBase64;
|
||||
ws.data.enrollmentState = "challenged";
|
||||
|
||||
ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
|
||||
try {
|
||||
ws.send(encodeMessage({ type: "enrollment_challenge", nonce: nonceBase64 }));
|
||||
} catch {
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(1011, "Enrollment challenge failed"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
clearEnrollmentTimeout(id);
|
||||
clearEnrollmentTimeout(ws);
|
||||
const timeout = setTimeout(() => {
|
||||
enrollmentTimeouts.delete(id);
|
||||
ws.data.enrollmentTimeout = undefined;
|
||||
rejectEnrollment(ws);
|
||||
try {
|
||||
ws.close(4002, "enrollment_timeout");
|
||||
} catch {}
|
||||
}, ENROLLMENT_TIMEOUT_MS);
|
||||
enrollmentTimeouts.set(id, timeout);
|
||||
ws.data.enrollmentTimeout = timeout;
|
||||
}
|
||||
|
||||
export async function handleWebSocketMessage(
|
||||
@@ -379,12 +646,14 @@ export async function handleWebSocketMessage(
|
||||
const limit = getMaxPayloadLimit(role, deps.maxClientPayloadBytes, deps.maxViewerPayloadBytes);
|
||||
if (size > limit) {
|
||||
logger.warn(`[ws] closing socket due to oversized message (${size} > ${limit}) role=${role || "unknown"}`);
|
||||
if (role === "client" && ws.data.enrollmentState !== "authenticated") {
|
||||
rejectEnrollment(ws);
|
||||
}
|
||||
try {
|
||||
ws.close(1009, "Message too large");
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const socketRole = ws.data.role as string;
|
||||
if (isAuthenticatedViewerRole(ws.data.role) && !validateViewerAuthorization(ws)) return;
|
||||
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
|
||||
@@ -400,6 +669,19 @@ export async function handleWebSocketMessage(
|
||||
if (socketRole === "dashboard_viewer") return;
|
||||
if (socketRole === "chat_viewer") return deps.handleChatViewerMessage(ws, message);
|
||||
|
||||
if (
|
||||
socketRole === "client"
|
||||
&& ws.data.enrollmentState !== "authenticated"
|
||||
&& size > MAX_PRE_ENROLLMENT_MESSAGE_BYTES
|
||||
) {
|
||||
logger.warn(
|
||||
`[purgatory] closing pre-enrollment socket due to oversized message (${size} > ${MAX_PRE_ENROLLMENT_MESSAGE_BYTES})`,
|
||||
);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(1009, "Pre-enrollment message too large"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientId, ip } = ws.data;
|
||||
|
||||
let payload: WireMessage;
|
||||
@@ -420,37 +702,88 @@ export async function handleWebSocketMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
socketRole === "client"
|
||||
&& payloadType !== "hello"
|
||||
&& ws.data.enrollmentState !== "authenticated"
|
||||
) {
|
||||
logger.warn(`[purgatory] rejected ${payloadType} before hello completed for ${clientId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4002, "hello_required"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (socketRole === "client" && ws.data.enrollmentState === "authenticated") {
|
||||
const current = clientManager.getClient(clientId);
|
||||
if (!current || current.ws !== ws) {
|
||||
logger.warn(`[purgatory] rejected message from superseded socket for ${clientId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4004, "superseded"); } catch {}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const info = clientManager.getClient(clientId);
|
||||
|
||||
if (!info && payloadType !== "hello") return;
|
||||
if (info) info.lastSeen = Date.now();
|
||||
if (info && ws.data.enrollmentState === "authenticated") {
|
||||
info.lastSeen = Date.now();
|
||||
}
|
||||
|
||||
const client = info!;
|
||||
|
||||
try {
|
||||
switch (payloadType) {
|
||||
case "hello": {
|
||||
clearEnrollmentTimeout(clientId);
|
||||
const publicKeyInput = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
|
||||
const signatureInput = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
|
||||
const nonce = ws.data.enrollmentNonce;
|
||||
|
||||
const publicKey = typeof (payload as any).publicKey === "string" ? (payload as any).publicKey : "";
|
||||
const signature = typeof (payload as any).signature === "string" ? (payload as any).signature : "";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
if (ws.data.enrollmentState !== "challenged" || !nonce) {
|
||||
logger.warn(`[purgatory] unexpected second hello for ${clientId}`);
|
||||
if (ws.data.enrollmentState !== "authenticated") {
|
||||
rejectEnrollment(ws);
|
||||
} else {
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
clearEnrollmentTimeout(ws);
|
||||
}
|
||||
try { ws.close(4002, "unexpected_hello"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
// Consume the one-time challenge synchronously, before signature
|
||||
// verification yields, so concurrent hello messages cannot replay it.
|
||||
ws.data.enrollmentState = "verifying";
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
|
||||
const publicKeyBytes = decodeCanonicalBase64(publicKeyInput, 32);
|
||||
const signatureBytes = decodeCanonicalBase64(signatureInput, 64);
|
||||
if (!publicKeyBytes || !signatureBytes) {
|
||||
logger.warn(`[purgatory] missing publicKey/signature/nonce for ${clientId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4002, "invalid_signature"); } catch {}
|
||||
return;
|
||||
}
|
||||
const publicKey = publicKeyInput;
|
||||
const signature = signatureInput;
|
||||
|
||||
const verificationAdmission = enrollmentAdmission.beginVerification(ws);
|
||||
if (!verificationAdmission.ok) {
|
||||
warnEnrollmentAdmissionDenied(verificationAdmission.reason);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(1013, "Enrollment verification capacity reached"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyEd25519(publicKey, signature, nonce);
|
||||
if (ws.data.enrollmentState !== "verifying") return;
|
||||
if (!valid) {
|
||||
logger.warn(`[purgatory] invalid signature for ${clientId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4002, "invalid_signature"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
|
||||
const keyFingerprint = computeKeyFingerprint(publicKey);
|
||||
|
||||
const existing = lookupClientByPublicKey(publicKey);
|
||||
@@ -461,12 +794,10 @@ export async function handleWebSocketMessage(
|
||||
ws.data.clientId = existing.id;
|
||||
} else {
|
||||
enrollmentStatus = "pending";
|
||||
|
||||
const existingPk = getClientPublicKeyById(ws.data.clientId);
|
||||
if (existingPk && existingPk !== publicKey) {
|
||||
ws.data.clientId = keyFingerprint;
|
||||
logger.info(`[purgatory] ID collision detected — reassigned to ${keyFingerprint}`);
|
||||
}
|
||||
// Client IDs are attacker-controlled at the upgrade URL. Persist a
|
||||
// canonical identity derived from the proved key for every fresh
|
||||
// enrollment, not only when a claimed ID happens to collide.
|
||||
ws.data.clientId = keyFingerprint;
|
||||
}
|
||||
|
||||
if (enrollmentStatus !== "approved" && enrollmentStatus !== "denied" && enrollmentStatus !== "pending") {
|
||||
@@ -474,10 +805,19 @@ export async function handleWebSocketMessage(
|
||||
enrollmentStatus = "pending";
|
||||
}
|
||||
|
||||
const resolvedId = ws.data.clientId;
|
||||
const buildTag = typeof (payload as any).buildTag === "string"
|
||||
let resolvedId = ws.data.clientId;
|
||||
const reportedBuildTag = typeof (payload as any).buildTag === "string"
|
||||
? (payload as any).buildTag.trim()
|
||||
: "";
|
||||
const storedBuildTag = existing
|
||||
? getClientBuildOwnership(existing.id)?.buildTag || ""
|
||||
: "";
|
||||
const buildTag = storedBuildTag || reportedBuildTag;
|
||||
if (storedBuildTag && reportedBuildTag !== storedBuildTag) {
|
||||
logger.warn(
|
||||
`[build-block] client ${resolvedId} reported a changed or missing build tag; enforcing its enrolled build identity`,
|
||||
);
|
||||
}
|
||||
|
||||
let resolvedBuildId: string | null = null;
|
||||
let builtByUserId: number | undefined;
|
||||
@@ -486,6 +826,7 @@ export async function handleWebSocketMessage(
|
||||
|
||||
if (buildTag) {
|
||||
const verified = await verifyBuildToken(buildTag);
|
||||
if (ws.data.enrollmentState !== "verifying") return;
|
||||
if (verified) {
|
||||
resolvedBuildId = verified.bid;
|
||||
builtByUserId = verified.uid ?? undefined;
|
||||
@@ -521,6 +862,7 @@ export async function handleWebSocketMessage(
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4007, "build_blocked"); } catch {}
|
||||
return;
|
||||
}
|
||||
@@ -528,6 +870,7 @@ export async function handleWebSocketMessage(
|
||||
if (enrollmentStatus === "denied") {
|
||||
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4003, "denied"); } catch {}
|
||||
return;
|
||||
}
|
||||
@@ -561,10 +904,10 @@ export async function handleWebSocketMessage(
|
||||
|
||||
if (enrollmentStatus !== "approved") {
|
||||
const geoip = await getGeoip();
|
||||
if (ws.data.enrollmentState !== "verifying") return;
|
||||
const geo = ip ? geoip.lookup(ip) : undefined;
|
||||
const countryRaw = geo?.country || (payload as any).country || "ZZ";
|
||||
const country = /^[A-Z]{2}$/i.test(countryRaw) ? countryRaw.toUpperCase() : "ZZ";
|
||||
const initialTagForNewClient = existing || clientExists(resolvedId) ? undefined : initialClientTag;
|
||||
|
||||
const _s = (v: unknown, max = 256): string | undefined => {
|
||||
if (typeof v !== "string") return undefined;
|
||||
@@ -576,32 +919,51 @@ export async function handleWebSocketMessage(
|
||||
return n >= 0 && n <= 100 ? n : undefined;
|
||||
};
|
||||
const batteryPercent = _percent((payload as any).batteryPercent);
|
||||
upsertClientRow({
|
||||
id: resolvedId,
|
||||
hwid: _s((payload as any).hwid) || resolvedId,
|
||||
role: "client",
|
||||
ip: ip || undefined,
|
||||
host: _s((payload as any).host),
|
||||
os: _s((payload as any).os),
|
||||
arch: _s((payload as any).arch, 32),
|
||||
version: _s((payload as any).version, 64),
|
||||
user: _s((payload as any).user),
|
||||
monitors: (payload as any).monitors || undefined,
|
||||
cpu: _s((payload as any).cpu),
|
||||
gpu: _s((payload as any).gpu),
|
||||
ram: _s((payload as any).ram, 64),
|
||||
batteryPercent,
|
||||
batteryCharging: batteryPercent !== undefined ? !!(payload as any).batteryCharging : undefined,
|
||||
country,
|
||||
lastSeen: Date.now(),
|
||||
online: 0 as any,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
enrollmentStatus: "pending",
|
||||
buildTag: buildTag || undefined,
|
||||
builtByUserId,
|
||||
...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
|
||||
});
|
||||
const storePendingClient = (targetId: string): boolean => {
|
||||
const initialTagForNewClient = existing || clientExists(targetId)
|
||||
? undefined
|
||||
: initialClientTag;
|
||||
return upsertPendingClientRow({
|
||||
id: targetId,
|
||||
hwid: _s((payload as any).hwid) || targetId,
|
||||
role: "client",
|
||||
ip: ip || undefined,
|
||||
host: _s((payload as any).host),
|
||||
os: _s((payload as any).os),
|
||||
arch: _s((payload as any).arch, 32),
|
||||
version: _s((payload as any).version, 64),
|
||||
user: _s((payload as any).user),
|
||||
monitors: (payload as any).monitors || undefined,
|
||||
cpu: _s((payload as any).cpu),
|
||||
gpu: _s((payload as any).gpu),
|
||||
ram: _s((payload as any).ram, 64),
|
||||
batteryPercent,
|
||||
batteryCharging: batteryPercent !== undefined ? !!(payload as any).batteryCharging : undefined,
|
||||
country,
|
||||
lastSeen: Date.now(),
|
||||
online: 0 as any,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
enrollmentStatus: "pending",
|
||||
buildTag: buildTag || undefined,
|
||||
builtByUserId,
|
||||
...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
let storedPendingClient = storePendingClient(resolvedId);
|
||||
if (!storedPendingClient && resolvedId !== keyFingerprint) {
|
||||
resolvedId = keyFingerprint;
|
||||
ws.data.clientId = resolvedId;
|
||||
logger.info(`[purgatory] concurrent ID collision detected — reassigned to ${resolvedId}`);
|
||||
storedPendingClient = storePendingClient(resolvedId);
|
||||
}
|
||||
if (!storedPendingClient) {
|
||||
logger.warn(`[purgatory] unable to reserve identity for ${resolvedId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4002, "identity_collision"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
||||
@@ -625,17 +987,44 @@ export async function handleWebSocketMessage(
|
||||
country,
|
||||
});
|
||||
}
|
||||
rejectEnrollment(ws);
|
||||
setTimeout(() => { try { ws.close(4001, "pending"); } catch {} }, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let wasKnown = clientExists(resolvedId);
|
||||
const reserveApprovedIdentity = (targetId: string): boolean => upsertClientRow({
|
||||
id: targetId,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
enrollmentStatus: "approved",
|
||||
buildTag: buildTag || undefined,
|
||||
builtByUserId,
|
||||
online: 0 as any,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
|
||||
let storedApprovedIdentity = reserveApprovedIdentity(resolvedId);
|
||||
if (!storedApprovedIdentity && resolvedId !== keyFingerprint) {
|
||||
resolvedId = keyFingerprint;
|
||||
ws.data.clientId = resolvedId;
|
||||
wasKnown = clientExists(resolvedId);
|
||||
logger.info(`[purgatory] concurrent approved ID collision detected — reassigned to ${resolvedId}`);
|
||||
storedApprovedIdentity = reserveApprovedIdentity(resolvedId);
|
||||
}
|
||||
if (!storedApprovedIdentity) {
|
||||
logger.warn(`[purgatory] unable to reserve approved identity for ${resolvedId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4002, "identity_collision"); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const existingClient = clientManager.getClient(resolvedId);
|
||||
if (existingClient?.ws && existingClient.ws !== ws) {
|
||||
logger.info(`[purgatory] kicking existing socket for ${resolvedId} (superseded)`);
|
||||
try { existingClient.ws.close(4004, "superseded"); } catch {}
|
||||
clientManager.deleteClient(resolvedId);
|
||||
clearEnrollmentTimeout(resolvedId);
|
||||
stopAllProxiesForClient(resolvedId);
|
||||
deps.clearPendingNotificationScreenshots(resolvedId);
|
||||
deps.clearClientPluginState(resolvedId);
|
||||
@@ -658,7 +1047,6 @@ export async function handleWebSocketMessage(
|
||||
deps.webcamStreamingState.delete(resolvedId);
|
||||
}
|
||||
|
||||
const wasKnown = clientExists(resolvedId);
|
||||
ws.data.wasKnown = wasKnown;
|
||||
const initialTagForNewClient = wasKnown ? undefined : initialClientTag;
|
||||
|
||||
@@ -675,7 +1063,6 @@ export async function handleWebSocketMessage(
|
||||
enrollmentStatus: "approved" as any,
|
||||
...(initialTagForNewClient ? { customTag: initialTagForNewClient } : {}),
|
||||
};
|
||||
clientManager.addClient(resolvedId, infoObj);
|
||||
|
||||
queueClientDbUpdate({
|
||||
id: resolvedId,
|
||||
@@ -690,6 +1077,27 @@ export async function handleWebSocketMessage(
|
||||
});
|
||||
|
||||
await handleHello(infoObj, payload as Hello, ws, ip);
|
||||
if (ws.data.enrollmentState !== "verifying") {
|
||||
const current = clientManager.getClient(resolvedId);
|
||||
if (current?.ws === ws) {
|
||||
clientManager.deleteClient(resolvedId);
|
||||
clearClientSyncState(resolvedId);
|
||||
} else if (!current) {
|
||||
clearClientSyncState(resolvedId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const helloWinner = clientManager.getClient(resolvedId);
|
||||
if (helloWinner?.ws && helloWinner.ws !== ws) {
|
||||
logger.warn(`[purgatory] concurrent hello lost for ${resolvedId}`);
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(4004, "superseded"); } catch {}
|
||||
return;
|
||||
}
|
||||
ws.data.enrollmentState = "authenticated";
|
||||
clearEnrollmentTimeout(ws);
|
||||
releaseEnrollmentAdmission(ws);
|
||||
clientManager.addClient(resolvedId, infoObj);
|
||||
const lastCrashReason = sanitizeCrashString((payload as any).lastCrashReason, 64);
|
||||
if (lastCrashReason) {
|
||||
deps.handleCrashReport(resolvedId, {
|
||||
@@ -789,7 +1197,8 @@ export async function handleWebSocketMessage(
|
||||
} else if ((payload as any).data instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array((payload as any).data);
|
||||
} else if (ArrayBuffer.isView((payload as any).data)) {
|
||||
bytes = new Uint8Array((payload as any).data.buffer);
|
||||
const view = (payload as any).data as ArrayBufferView;
|
||||
bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
|
||||
const format = String((payload as any)?.header?.format || "jpeg");
|
||||
@@ -834,7 +1243,7 @@ export async function handleWebSocketMessage(
|
||||
case "command_result":
|
||||
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
|
||||
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
|
||||
if (pending) {
|
||||
if (pending?.clientId === client.id) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({
|
||||
ok: Boolean((payload as any).ok),
|
||||
@@ -850,6 +1259,7 @@ export async function handleWebSocketMessage(
|
||||
(payload as any).commandId,
|
||||
(payload as any).ok,
|
||||
(payload as any).message,
|
||||
client.id,
|
||||
);
|
||||
deps.handleFileBrowserMessage(client.id, payload);
|
||||
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
|
||||
@@ -863,7 +1273,7 @@ export async function handleWebSocketMessage(
|
||||
case "client_logs_result":
|
||||
if (typeof (payload as any).commandId === "string") {
|
||||
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
|
||||
if (pending) {
|
||||
if (pending?.clientId === client.id) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({
|
||||
ok: Boolean((payload as any).ok),
|
||||
@@ -895,6 +1305,7 @@ export async function handleWebSocketMessage(
|
||||
const cmdId = (payload as any).commandId;
|
||||
if (cmdId && deps.pendingScripts.has(cmdId)) {
|
||||
const pending = deps.pendingScripts.get(cmdId)!;
|
||||
if (pending.clientId !== client.id) break;
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({
|
||||
ok: (payload as any).ok,
|
||||
@@ -953,9 +1364,8 @@ export async function handleWebSocketMessage(
|
||||
break;
|
||||
case "proxy_data": {
|
||||
const connId = (payload as any).connectionId;
|
||||
const tunnelData = (payload as any).data;
|
||||
if (typeof connId === "string" && tunnelData) {
|
||||
const bytes = tunnelData instanceof Uint8Array ? tunnelData : new Uint8Array(tunnelData);
|
||||
const bytes = normalizeProxyTunnelChunk((payload as any).data);
|
||||
if (typeof connId === "string" && connId.length > 0 && connId.length <= 128 && bytes) {
|
||||
deps.handleProxyTunnelData(client.id, connId, bytes);
|
||||
}
|
||||
break;
|
||||
@@ -968,12 +1378,11 @@ export async function handleWebSocketMessage(
|
||||
break;
|
||||
}
|
||||
case "disconnect_info": {
|
||||
const reason = typeof (payload as any).reason === "string" ? (payload as any).reason : "";
|
||||
const detail = typeof (payload as any).detail === "string" ? (payload as any).detail : "";
|
||||
if (reason) {
|
||||
ws.data.disconnectReason = reason;
|
||||
ws.data.disconnectDetail = detail || undefined;
|
||||
logger.debug(`[disconnect_info] ${client.id} reason=${reason}`);
|
||||
const disconnect = normalizeDisconnectInfo(payload);
|
||||
if (disconnect) {
|
||||
ws.data.disconnectReason = disconnect.reason;
|
||||
ws.data.disconnectDetail = disconnect.detail;
|
||||
logger.debug(`[disconnect_info] ${client.id} reason=${disconnect.reason}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -981,7 +1390,11 @@ export async function handleWebSocketMessage(
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("[message] decode error", err);
|
||||
logger.error("[message] handler error", err);
|
||||
if (socketRole === "client" && ws.data.enrollmentState !== "authenticated") {
|
||||
rejectEnrollment(ws);
|
||||
try { ws.close(1011, "enrollment_error"); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -996,7 +1409,8 @@ export function handleWebSocketClose(
|
||||
const role = ws.data.role as string;
|
||||
const sessionId = ws.data.sessionId;
|
||||
|
||||
clearEnrollmentTimeout(clientId);
|
||||
clearEnrollmentTimeout(ws);
|
||||
releaseEnrollmentAdmission(ws);
|
||||
|
||||
if (role === "console_viewer") {
|
||||
if (sessionId) {
|
||||
@@ -1125,6 +1539,14 @@ export function handleWebSocketClose(
|
||||
return;
|
||||
}
|
||||
|
||||
// Until hello authentication completes, clientId is only an untrusted URL
|
||||
// claim. It must not drive cleanup for a real client with that ID.
|
||||
if (role === "client" && ws.data.enrollmentState !== "authenticated") {
|
||||
ws.data.enrollmentState = "rejected";
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentClient = clientManager.getClient(clientId);
|
||||
if (currentClient && currentClient.ws !== ws) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { handleWsUpgradeRoutes, normalizeClaimedClientId } from "./ws-upgrade-routes";
|
||||
|
||||
function makeServer(ip: string) {
|
||||
let upgraded = false;
|
||||
return {
|
||||
requestIP: () => ({ address: ip }),
|
||||
upgrade: () => {
|
||||
upgraded = true;
|
||||
return true;
|
||||
},
|
||||
wasUpgraded: () => upgraded,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebSocket browser origin boundary", () => {
|
||||
test("bounds and sanitizes the untrusted agent ID path segment", () => {
|
||||
expect(normalizeClaimedClientId("normal-client_1", () => "fallback")).toBe("normal-client_1");
|
||||
expect(normalizeClaimedClientId("..%2Funsafe", () => "fallback")).toBe("fallback");
|
||||
expect(normalizeClaimedClientId("x".repeat(129), () => "fallback")).toBe("fallback");
|
||||
expect(normalizeClaimedClientId("%E0%A4%A", () => "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
test("rejects sibling and missing origins on viewer sockets", async () => {
|
||||
const cases: HeadersInit[] = [
|
||||
{ Origin: "https://evil.example.com", "Sec-Fetch-Site": "same-site" },
|
||||
{},
|
||||
];
|
||||
for (const headers of cases) {
|
||||
const req = new Request("https://overlord.example.com/api/dashboard/ws", { headers });
|
||||
const server = makeServer(`198.51.100.${Math.floor(Math.random() * 100 + 1)}`);
|
||||
const response = await handleWsUpgradeRoutes(req, new URL(req.url), server, {
|
||||
isAuthorizedAgentRequest: () => false,
|
||||
});
|
||||
expect(response?.status).toBe(403);
|
||||
expect(server.wasUpgraded()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("allows native agent upgrades without an Origin header", async () => {
|
||||
const req = new Request("https://overlord.example.com/api/clients/native-agent/stream/ws");
|
||||
const server = makeServer("198.51.100.201");
|
||||
const response = await handleWsUpgradeRoutes(req, new URL(req.url), server, {
|
||||
isAuthorizedAgentRequest: () => true,
|
||||
});
|
||||
expect(response?.status).toBe(200);
|
||||
expect(server.wasUpgraded()).toBe(true);
|
||||
});
|
||||
|
||||
test("same-origin viewer request reaches authentication", async () => {
|
||||
const req = new Request("https://overlord.example.com/api/dashboard/ws", {
|
||||
headers: { Origin: "https://overlord.example.com", "Sec-Fetch-Site": "same-origin" },
|
||||
});
|
||||
const server = makeServer("198.51.100.202");
|
||||
const response = await handleWsUpgradeRoutes(req, new URL(req.url), server, {
|
||||
isAuthorizedAgentRequest: () => false,
|
||||
});
|
||||
expect(response?.status).toBe(401);
|
||||
expect(server.wasUpgraded()).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user