From 7c988ae3d09781c3141af93efc9b95c0c5c5de96 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 23 Mar 2026 15:16:04 -0700 Subject: [PATCH 1/5] Initial bot safety warning pass --- app/fanout/manager.py | 50 +++++- app/routers/fanout.py | 45 +++-- app/routers/health.py | 17 +- frontend/src/api.ts | 8 + frontend/src/components/AppShell.tsx | 2 + .../src/components/SecurityWarningModal.tsx | 165 ++++++++++++++++++ .../settings/SettingsFanoutSection.tsx | 5 +- frontend/src/components/ui/dialog.tsx | 20 ++- frontend/src/test/fanoutSection.test.tsx | 9 + .../src/test/securityWarningModal.test.tsx | 98 +++++++++++ frontend/src/types.ts | 2 + tests/e2e/helpers/api.ts | 3 + tests/test_disable_bots.py | 103 ++++++++++- tests/test_fanout_hitlist.py | 8 +- 14 files changed, 499 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/SecurityWarningModal.tsx create mode 100644 frontend/src/test/securityWarningModal.test.tsx diff --git a/app/fanout/manager.py b/app/fanout/manager.py index b393a89..d58abbf 100644 --- a/app/fanout/manager.py +++ b/app/fanout/manager.py @@ -82,6 +82,21 @@ class FanoutManager: def __init__(self) -> None: self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope) self._restart_locks: dict[str, asyncio.Lock] = {} + self._bots_disabled_until_restart = False + + def get_bots_disabled_source(self) -> str | None: + """Return why bot modules are unavailable, if at all.""" + from app.config import settings as server_settings + + if server_settings.disable_bots: + return "env" + if self._bots_disabled_until_restart: + return "until_restart" + return None + + def bots_disabled_effective(self) -> bool: + """Return True when bot modules should be treated as unavailable.""" + return self.get_bots_disabled_source() is not None async def load_from_db(self) -> None: """Read enabled fanout_configs and instantiate modules.""" @@ -99,13 +114,14 @@ class FanoutManager: config_blob = cfg["config"] scope = cfg["scope"] - # Skip bot modules when bots are disabled server-wide - if config_type == "bot": - from app.config import settings as server_settings - - if server_settings.disable_bots: - logger.info("Skipping bot module %s (bots disabled by server config)", config_id) - return + # Skip bot modules when bots are disabled server-wide or until restart. + if config_type == "bot" and self.bots_disabled_effective(): + logger.info( + "Skipping bot module %s (bots disabled: %s)", + config_id, + self.get_bots_disabled_source(), + ) + return cls = _MODULE_TYPES.get(config_type) if cls is None: @@ -240,6 +256,26 @@ class FanoutManager: } return result + async def disable_bots_until_restart(self) -> str: + """Stop active bot modules and prevent them from starting again until restart.""" + source = self.get_bots_disabled_source() + if source == "env": + return source + + self._bots_disabled_until_restart = True + + from app.repository.fanout import _configs_cache + + bot_ids = [ + config_id + for config_id in list(self._modules) + if _configs_cache.get(config_id, {}).get("type") == "bot" + ] + for config_id in bot_ids: + await self.remove_config(config_id) + + return "until_restart" + # Module-level singleton fanout_manager = FanoutManager() diff --git a/app/routers/fanout.py b/app/routers/fanout.py index 36c7d40..d5c8cb7 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -9,8 +9,8 @@ import string from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field -from app.config import settings as server_settings from app.fanout.bot_exec import _analyze_bot_signature +from app.fanout.manager import fanout_manager from app.repository.fanout import FanoutConfigRepository logger = logging.getLogger(__name__) @@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict: return {"messages": messages, "raw_packets": raw_packets} +def _bot_system_disabled_detail() -> str | None: + source = fanout_manager.get_bots_disabled_source() + if source == "env": + return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)" + if source == "until_restart": + return "Bot system disabled until the server restarts" + return None + + @router.get("") async def list_fanout_configs() -> list[dict]: """List all fanout configs.""" @@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict: detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}", ) - if body.type == "bot" and server_settings.disable_bots: - raise HTTPException(status_code=403, detail="Bot system disabled by server configuration") + if body.type == "bot": + disabled_detail = _bot_system_disabled_detail() + if disabled_detail: + raise HTTPException(status_code=403, detail=disabled_detail) normalized_config = _validate_and_normalize_config(body.type, body.config) scope = _enforce_scope(body.type, body.scope) @@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict: # Start the module if enabled if cfg["enabled"]: - from app.fanout.manager import fanout_manager - await fanout_manager.reload_config(cfg["id"]) logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name) @@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict if existing is None: raise HTTPException(status_code=404, detail="Fanout config not found") - if existing["type"] == "bot" and server_settings.disable_bots: - raise HTTPException(status_code=403, detail="Bot system disabled by server configuration") + if existing["type"] == "bot": + disabled_detail = _bot_system_disabled_detail() + if disabled_detail: + raise HTTPException(status_code=403, detail=disabled_detail) kwargs = {} if body.name is not None: @@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict raise HTTPException(status_code=404, detail="Fanout config not found") # Reload the module to pick up changes - from app.fanout.manager import fanout_manager - await fanout_manager.reload_config(config_id) logger.info("Updated fanout config %s", config_id) @@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict: raise HTTPException(status_code=404, detail="Fanout config not found") # Stop the module first - from app.fanout.manager import fanout_manager - await fanout_manager.remove_config(config_id) await FanoutConfigRepository.delete(config_id) logger.info("Deleted fanout config %s", config_id) return {"deleted": True} + + +@router.post("/bots/disable-until-restart") +async def disable_bots_until_restart() -> dict: + """Stop active bot modules and prevent them from running again until restart.""" + source = await fanout_manager.disable_bots_until_restart() + + from app.services.radio_runtime import radio_runtime as radio_manager + from app.websocket import broadcast_health + + broadcast_health(radio_manager.is_connected, radio_manager.connection_info) + return { + "status": "ok", + "bots_disabled": True, + "bots_disabled_source": source, + } diff --git a/app/routers/health.py b/app/routers/health.py index 2ceb39e..7b62564 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -1,5 +1,5 @@ import os -from typing import Any +from typing import Any, Literal from fastapi import APIRouter from pydantic import BaseModel @@ -37,6 +37,8 @@ class HealthResponse(BaseModel): oldest_undecrypted_timestamp: int | None fanout_statuses: dict[str, dict[str, str]] = {} bots_disabled: bool = False + bots_disabled_source: Literal["env", "until_restart"] | None = None + basic_auth_enabled: bool = False def _clean_optional_str(value: object) -> str | None: @@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None: return cleaned or None +def _read_optional_bool_setting(name: str) -> bool: + value = getattr(settings, name, False) + return value if isinstance(value, bool) else False + + async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict: """Build the health status payload used by REST endpoint and WebSocket broadcasts.""" app_build_info = get_app_build_info() @@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) # Fanout module statuses fanout_statuses: dict[str, Any] = {} + bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None try: from app.fanout.manager import fanout_manager fanout_statuses = fanout_manager.get_statuses() + manager_bots_disabled_source = fanout_manager.get_bots_disabled_source() + if manager_bots_disabled_source is not None: + bots_disabled_source = manager_bots_disabled_source except Exception: pass @@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) "database_size_mb": db_size_mb, "oldest_undecrypted_timestamp": oldest_ts, "fanout_statuses": fanout_statuses, - "bots_disabled": settings.disable_bots, + "bots_disabled": bots_disabled_source is not None, + "bots_disabled_source": bots_disabled_source, + "basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"), } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0c70c54..ab7644b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -343,6 +343,14 @@ export const api = { fetchJson<{ deleted: boolean }>(`/fanout/${id}`, { method: 'DELETE', }), + disableBotsUntilRestart: () => + fetchJson<{ + status: string; + bots_disabled: boolean; + bots_disabled_source: 'env' | 'until_restart'; + }>('/fanout/bots/disable-until-restart', { + method: 'POST', + }), // Statistics getStatistics: () => fetchJson('/statistics'), diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1da675c..9b50faf 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane'; import { NewMessageModal } from './NewMessageModal'; import { ContactInfoPane } from './ContactInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane'; +import { SecurityWarningModal } from './SecurityWarningModal'; import { Toaster } from './ui/sonner'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { @@ -289,6 +290,7 @@ export function AppShell({ }} /> + diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx new file mode 100644 index 0000000..9a3e9e1 --- /dev/null +++ b/frontend/src/components/SecurityWarningModal.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; + +import { api } from '../api'; +import type { HealthStatus } from '../types'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; +import { toast } from './ui/sonner'; + +const STORAGE_KEY = 'meshcore_security_warning_acknowledged'; + +function readAcknowledgedState(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + return window.localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function writeAcknowledgedState(): void { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.setItem(STORAGE_KEY, 'true'); + } catch { + // Best effort only; the warning will continue to show if localStorage is unavailable. + } +} + +interface SecurityWarningModalProps { + health: HealthStatus | null; +} + +export function SecurityWarningModal({ health }: SecurityWarningModalProps) { + const [acknowledged, setAcknowledged] = useState(readAcknowledgedState); + const [confirmedRisk, setConfirmedRisk] = useState(false); + const [disablingBots, setDisablingBots] = useState(false); + const [botsDisabledLocally, setBotsDisabledLocally] = useState(false); + + const shouldWarn = + health !== null && + health.bots_disabled !== true && + health.basic_auth_enabled !== true && + !botsDisabledLocally && + !acknowledged; + + useEffect(() => { + if (!shouldWarn) { + setConfirmedRisk(false); + } + }, [shouldWarn]); + + if (!shouldWarn) { + return null; + } + + return ( + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + +
+
+ Unprotected bot execution is enabled + + Bots are enabled, and app-wide Basic Auth is not configured. + +
+ +
+

+ Without one of those protections, or another access-control layer in front of + RemoteTerm, anyone on your local network who can reach this app can run Python code on + the computer hosting this instance. +

+

+ This is only safe on protected or isolated networks with appropriate access control. If + your network is untrusted or later compromised, this setup may expose the host system to + arbitrary code execution. +

+

+ To reduce that risk, either disable bots with{' '} + + MESHCORE_DISABLE_BOTS=true + {' '} + or enable the built-in login with{' '} + + MESHCORE_BASIC_AUTH_USERNAME + {' '} + and{' '} + + MESHCORE_BASIC_AUTH_PASSWORD + + . Another external auth or access-control system is also acceptable. +

+

+ If you just want a temporary safety measure while you learn the system, you can use the + button below to disable bots until the server restarts. That is only a temporary guard; + permanent protection through Basic Auth or env-based bot disablement is still + encouraged. +

+
+ + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 6e0abc1..a68a4a7 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1817,8 +1817,9 @@ export function SettingsFanoutSection({ {health?.bots_disabled && (
- Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations - cannot be created or modified. + {health.bots_disabled_source === 'until_restart' + ? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.' + : 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
)} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 802b213..575df20 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +interface DialogContentProps extends React.ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +> { + hideCloseButton?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, hideCloseButton = false, ...props }, ref) => ( {children} - - - Close - + {!hideCloseButton && ( + + + Close + + )} )); diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index defd708..6a54d5f 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -114,6 +114,15 @@ describe('SettingsFanoutSection', () => { }); }); + it('shows restart-scoped bots disabled messaging when disabled until restart', async () => { + renderSection({ + health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }, + }); + await waitFor(() => { + expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument(); + }); + }); + it('hides bot option from add integration menu when bots_disabled', async () => { renderSection({ health: { ...baseHealth, bots_disabled: true } }); await waitFor(() => { diff --git a/frontend/src/test/securityWarningModal.test.tsx b/frontend/src/test/securityWarningModal.test.tsx new file mode 100644 index 0000000..7bf26e8 --- /dev/null +++ b/frontend/src/test/securityWarningModal.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + disableBotsUntilRestart: vi.fn(), + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: { + disableBotsUntilRestart: mocks.disableBotsUntilRestart, + }, +})); + +vi.mock('../components/ui/sonner', () => ({ + toast: mocks.toast, +})); + +import { SecurityWarningModal } from '../components/SecurityWarningModal'; +import type { HealthStatus } from '../types'; + +const baseHealth: HealthStatus = { + status: 'degraded', + radio_connected: false, + radio_initializing: false, + connection_info: null, + database_size_mb: 1.2, + oldest_undecrypted_timestamp: null, + fanout_statuses: {}, + bots_disabled: false, + basic_auth_enabled: false, +}; + +describe('SecurityWarningModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + mocks.disableBotsUntilRestart.mockResolvedValue({ + status: 'ok', + bots_disabled: true, + bots_disabled_source: 'until_restart', + }); + }); + + it('shows the warning when bots are enabled and basic auth is off', () => { + render(); + + expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' }) + ).toBeDisabled(); + }); + + it('does not show when bots are disabled', () => { + render(); + + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('does not show when basic auth is enabled', () => { + render(); + + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('persists dismissal only after the checkbox is acknowledged', async () => { + const user = userEvent.setup(); + + render(); + + const dismissButton = screen.getByRole('button', { + name: 'Do Not Warn Me On This Device Again', + }); + await user.click(screen.getByLabelText('Acknowledge bot security risk')); + expect(dismissButton).toBeEnabled(); + + await user.click(dismissButton); + + expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true'); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); + + it('disables bots until restart from the warning modal', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' })); + + expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1); + expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart'); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 062170d..95ea3b4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -78,6 +78,8 @@ export interface HealthStatus { oldest_undecrypted_timestamp: number | null; fanout_statuses: Record; bots_disabled: boolean; + bots_disabled_source?: 'env' | 'until_restart' | null; + basic_auth_enabled?: boolean; } export interface FanoutConfig { diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index ca0f998..36d25f7 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -23,6 +23,9 @@ export interface HealthStatus { radio_connected: boolean; radio_initializing: boolean; connection_info: string | null; + bots_disabled?: boolean; + bots_disabled_source?: 'env' | 'until_restart' | null; + basic_auth_enabled?: boolean; } export function getHealth(): Promise { diff --git a/tests/test_disable_bots.py b/tests/test_disable_bots.py index 6f0d0ff..f607ba1 100644 --- a/tests/test_disable_bots.py +++ b/tests/test_disable_bots.py @@ -1,16 +1,17 @@ -"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag. +"""Tests for bot-disable enforcement. Verifies that when disable_bots=True: - POST /api/fanout with type=bot returns 403 - Health endpoint includes bots_disabled=True """ -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from app.config import Settings +from app.repository.fanout import FanoutConfigRepository from app.routers.fanout import FanoutConfigCreate, create_fanout_config from app.routers.health import build_health_data @@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint: @pytest.mark.asyncio async def test_bot_create_returns_403_when_disabled(self, test_db): """POST /api/fanout with type=bot returns 403.""" - with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" + ): with pytest.raises(HTTPException) as exc_info: await create_fanout_config( FanoutConfigCreate( @@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint: @pytest.mark.asyncio async def test_mqtt_create_allowed_when_bots_disabled(self, test_db): """Non-bot fanout configs can still be created when bots are disabled.""" - with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" + ): # Create as disabled so fanout_manager.reload_config is not called result = await create_fanout_config( FanoutConfigCreate( @@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint: ) assert result["type"] == "mqtt_private" + @pytest.mark.asyncio + async def test_bot_create_returns_403_when_disabled_until_restart(self, test_db): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", + return_value="until_restart", + ): + with pytest.raises(HTTPException) as exc_info: + await create_fanout_config( + FanoutConfigCreate( + type="bot", + name="Test Bot", + config={"code": "def bot(**k): pass"}, + enabled=False, + ) + ) + + assert exc_info.value.status_code == 403 + assert "until the server restarts" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_disable_bots_until_restart_endpoint(self, test_db): + from app.routers.fanout import disable_bots_until_restart + + await FanoutConfigRepository.create( + config_type="bot", + name="Test Bot", + config={"code": "def bot(**k): pass"}, + scope={"messages": "all", "raw_packets": "none"}, + enabled=True, + ) + + with ( + patch( + "app.routers.fanout.fanout_manager.disable_bots_until_restart", + new=AsyncMock(return_value="until_restart"), + ) as mock_disable, + patch("app.websocket.broadcast_health") as mock_broadcast_health, + patch("app.services.radio_runtime.radio_runtime") as mock_radio_runtime, + ): + mock_radio_runtime.is_connected = True + mock_radio_runtime.connection_info = "TCP: 1.2.3.4:4000" + + result = await disable_bots_until_restart() + + mock_disable.assert_awaited_once() + mock_broadcast_health.assert_called_once_with(True, "TCP: 1.2.3.4:4000") + assert result == { + "status": "ok", + "bots_disabled": True, + "bots_disabled_source": "until_restart", + } + class TestDisableBotsHealthEndpoint: """Test that bots_disabled is exposed in health data.""" @pytest.mark.asyncio async def test_health_includes_bots_disabled_true(self, test_db): - with patch("app.routers.health.settings", MagicMock(disable_bots=True, database_path="x")): + with patch( + "app.routers.health.settings", + MagicMock(disable_bots=True, basic_auth_enabled=False, database_path="x"), + ): with patch("app.routers.health.os.path.getsize", return_value=0): data = await build_health_data(True, "TCP: 1.2.3.4:4000") @@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint: @pytest.mark.asyncio async def test_health_includes_bots_disabled_false(self, test_db): - with patch("app.routers.health.settings", MagicMock(disable_bots=False, database_path="x")): + with patch( + "app.routers.health.settings", + MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"), + ): with patch("app.routers.health.os.path.getsize", return_value=0): data = await build_health_data(True, "TCP: 1.2.3.4:4000") assert data["bots_disabled"] is False + + @pytest.mark.asyncio + async def test_health_includes_basic_auth_enabled(self, test_db): + with patch( + "app.routers.health.settings", + MagicMock(disable_bots=False, basic_auth_enabled=True, database_path="x"), + ): + with patch("app.routers.health.os.path.getsize", return_value=0): + data = await build_health_data(True, "TCP: 1.2.3.4:4000") + + assert data["basic_auth_enabled"] is True + + @pytest.mark.asyncio + async def test_health_includes_runtime_bot_disable_source(self, test_db): + with ( + patch( + "app.routers.health.settings", + MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"), + ), + patch("app.routers.health.os.path.getsize", return_value=0), + patch("app.fanout.manager.fanout_manager") as mock_fm, + ): + mock_fm.get_statuses.return_value = {} + mock_fm.get_bots_disabled_source.return_value = "until_restart" + data = await build_health_data(True, "TCP: 1.2.3.4:4000") + + assert data["bots_disabled"] is True + assert data["bots_disabled_source"] == "until_restart" diff --git a/tests/test_fanout_hitlist.py b/tests/test_fanout_hitlist.py index e7eec4d..51023b4 100644 --- a/tests/test_fanout_hitlist.py +++ b/tests/test_fanout_hitlist.py @@ -593,7 +593,9 @@ class TestDisableBotsPatchGuard: ) # Now try to update with bots disabled - with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" + ): with pytest.raises(HTTPException) as exc_info: await update_fanout_config( cfg["id"], @@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard: enabled=False, ) - with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): + with patch( + "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env" + ): with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock): result = await update_fanout_config( cfg["id"], From 36eeeae64d5111759aa734b16a5474340809b0de Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 23 Mar 2026 18:27:42 -0700 Subject: [PATCH 2/5] Protect against uncheck race condition --- .../src/components/SecurityWarningModal.tsx | 6 +++++ .../src/test/securityWarningModal.test.tsx | 23 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx index 9a3e9e1..ae93147 100644 --- a/frontend/src/components/SecurityWarningModal.tsx +++ b/frontend/src/components/SecurityWarningModal.tsx @@ -55,6 +55,12 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) { } }, [shouldWarn]); + useEffect(() => { + if (health?.bots_disabled !== true) { + setBotsDisabledLocally(false); + } + }, [health?.bots_disabled, health?.bots_disabled_source]); + if (!shouldWarn) { return null; } diff --git a/frontend/src/test/securityWarningModal.test.tsx b/frontend/src/test/securityWarningModal.test.tsx index 7bf26e8..287555a 100644 --- a/frontend/src/test/securityWarningModal.test.tsx +++ b/frontend/src/test/securityWarningModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -95,4 +95,25 @@ describe('SecurityWarningModal', () => { expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart'); expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); }); + + it('shows the warning again after temporary bot disable disappears on a later health update', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' })); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument(); + + rerender(); + + await waitFor(() => { + expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument(); + }); + }); }); From e6e7267eb1de75a0795aab82f8ae1b00fbaccacc Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 23 Mar 2026 18:33:37 -0700 Subject: [PATCH 3/5] Fix mobile modal --- .../src/components/SecurityWarningModal.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx index ae93147..0d6f43e 100644 --- a/frontend/src/components/SecurityWarningModal.tsx +++ b/frontend/src/components/SecurityWarningModal.tsx @@ -69,7 +69,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) { event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} > @@ -89,7 +89,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) { RemoteTerm, anyone on your local network who can reach this app can run Python code on the computer hosting this instance.

-

+

This is only safe on protected or isolated networks with appropriate access control. If your network is untrusted or later compromised, this setup may expose the host system to arbitrary code execution. @@ -117,23 +117,10 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {

- -
+
+ +
+ +