mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 10:43:01 +02:00
Initial bot safety warning pass
This commit is contained in:
@@ -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<StatisticsResponse>('/statistics'),
|
||||
|
||||
@@ -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({
|
||||
}}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
<Toaster position="top-right" />
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="max-w-[min(92vw,42rem)] gap-5"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="space-y-3 text-left">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle>Unprotected bot execution is enabled</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bots are enabled, and app-wide Basic Auth is not configured.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
To reduce that risk, either disable bots with{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_DISABLE_BOTS=true
|
||||
</code>{' '}
|
||||
or enable the built-in login with{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_USERNAME
|
||||
</code>{' '}
|
||||
and{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_PASSWORD
|
||||
</code>
|
||||
. Another external auth or access-control system is also acceptable.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-md border border-input bg-muted/20 p-4">
|
||||
<Checkbox
|
||||
checked={confirmedRisk}
|
||||
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
|
||||
aria-label="Acknowledge bot security risk"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm leading-6 text-foreground">
|
||||
I understand that continuing with my existing security setup may put me at risk on
|
||||
untrusted networks or if my home network is compromised.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={disablingBots}
|
||||
onClick={async () => {
|
||||
setDisablingBots(true);
|
||||
try {
|
||||
await api.disableBotsUntilRestart();
|
||||
setBotsDisabledLocally(true);
|
||||
toast.success('Bots disabled until restart');
|
||||
} catch (err) {
|
||||
toast.error('Failed to disable bots', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDisablingBots(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={!confirmedRisk || disablingBots}
|
||||
onClick={() => {
|
||||
writeAcknowledgedState();
|
||||
setAcknowledged(true);
|
||||
}}
|
||||
>
|
||||
Do Not Warn Me On This Device Again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1817,8 +1817,9 @@ export function SettingsFanoutSection({
|
||||
|
||||
{health?.bots_disabled && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
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.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
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(<SecurityWarningModal health={{ ...baseHealth, bots_disabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show when basic auth is enabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, basic_auth_enabled: true }} />);
|
||||
|
||||
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(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
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(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,8 @@ export interface HealthStatus {
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
fanout_statuses: Record<string, FanoutStatusEntry>;
|
||||
bots_disabled: boolean;
|
||||
bots_disabled_source?: 'env' | 'until_restart' | null;
|
||||
basic_auth_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FanoutConfig {
|
||||
|
||||
Reference in New Issue
Block a user