feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish

- Add outbound URL validation (SSRF) for webhook/Discord/Slack/ntfy/Matrix dispatch
- Template renderer: input/output caps and thread-based render timeout
- Webhook log filter: strip Authorization/signature/token-like headers; atomic prune
- Auth/JWT/backup/config tightening; misc frontend UX fixes
This commit is contained in:
2026-04-16 03:21:45 +03:00
parent 734e5c9340
commit f0739ca949
30 changed files with 567 additions and 105 deletions
+10 -1
View File
@@ -12,7 +12,7 @@
--color-background: #f8f9fb;
--color-foreground: #1a1a2e;
--color-muted: #eef0f4;
--color-muted-foreground: #6b7280;
--color-muted-foreground: #525866;
--color-border: #e2e4ea;
--color-primary: #0d9488;
--color-primary-foreground: #ffffff;
@@ -34,6 +34,15 @@
--font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--radius: 0.625rem;
/* Layered z-index scale — refer to these instead of ad-hoc numbers.
Ordered: base < sticky < dropdown < overlay < modal < tooltip < toast */
--z-base: 1;
--z-sticky: 10;
--z-dropdown: 30;
--z-overlay: 40;
--z-modal: 50;
--z-tooltip: 60;
--z-toast: 70;
}
/* Dark theme overrides */
+7
View File
@@ -4,6 +4,13 @@
const API_BASE = '/api';
/** Normalize a caught error to a user-safe message. */
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
if (err instanceof Error && err.message) return err.message;
if (typeof err === 'string' && err) return err;
return fallback;
}
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
+4 -2
View File
@@ -21,20 +21,22 @@
</script>
<button type="button" bind:this={btnEl}
class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[9px] font-bold leading-none
class="inline-flex items-center justify-center w-4 h-4 rounded-full text-[11px] font-bold leading-none
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]
transition-colors cursor-help align-middle ml-2 flex-shrink-0"
onmouseenter={show}
onmouseleave={hide}
onfocus={show}
onblur={hide}
aria-label={text}
title={text}
tabindex="0"
>?</button>
{#if visible}
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.75rem; white-space:normal; line-height:1.625; pointer-events:none;">
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.8125rem; white-space:normal; line-height:1.625; pointer-events:none;">
{text}
</div>
{/if}
+3 -3
View File
@@ -40,7 +40,7 @@ export interface ConfigField {
min?: number;
max?: number;
/** Default value for this field. */
defaultValue?: string | number;
defaultValue?: string | number | boolean;
}
// ── Event tracking (TrackingConfig form) ─────────────────────────────
@@ -60,14 +60,14 @@ export interface EventTrackingField {
export interface ExtraTrackingField {
key: string;
label: string;
type: 'number' | 'grid-select';
type: 'number' | 'grid-select' | 'toggle';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
hint?: string;
min?: number;
max?: number;
defaultValue?: string | number;
defaultValue?: string | number | boolean;
}
/** A feature section like periodic summary, scheduled assets, memory mode. */
+59 -13
View File
@@ -6,6 +6,7 @@
*/
import { createEntityCache } from './entity-cache.svelte';
import { api } from '$lib/api';
import type {
ServiceProvider,
NotificationTarget,
@@ -57,19 +58,56 @@ export const commandTrackersCache = createEntityCache<CommandTracker>('/command-
/** Actions — used by Actions page. */
export const actionsCache = createEntityCache<Action>('/actions');
/** Provider capabilities — used by Template Configs, Command Configs. */
export interface SlotDef {
name: string;
description: string;
required?: boolean;
}
export interface CommandDef {
name: string;
description?: string;
}
export interface ActionTypeDef {
key: string;
name: string;
description?: string;
[key: string]: unknown;
}
export interface ProviderCapabilities {
notification_slots?: SlotDef[];
command_slots?: SlotDef[];
commands?: CommandDef[];
action_types?: ActionTypeDef[];
[key: string]: unknown;
}
export type CapabilitiesMap = Record<string, ProviderCapabilities>;
/** Provider capabilities — used by Template Configs, Command Configs.
* Dedups concurrent fetches so two fast navigations do not double-hit the API. */
export const capabilitiesCache = (() => {
let data = $state<Record<string, any>>({});
let data = $state<CapabilitiesMap>({});
let fetchedAt = $state(0);
const TTL = 60_000; // 1 minute
let inflight: Promise<CapabilitiesMap> | null = null;
const TTL = 60_000;
return {
get items() { return data; },
async fetch(force = false): Promise<Record<string, any>> {
async fetch(force = false): Promise<CapabilitiesMap> {
if (!force && Object.keys(data).length > 0 && Date.now() - fetchedAt < TTL) return data;
const { api } = await import('$lib/api');
data = await api('/providers/capabilities');
fetchedAt = Date.now();
return data;
if (inflight) return inflight;
inflight = (async () => {
try {
data = await api<CapabilitiesMap>('/providers/capabilities');
fetchedAt = Date.now();
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
@@ -78,15 +116,23 @@ export const capabilitiesCache = (() => {
export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']);
let fetchedAt = $state(0);
const TTL = 300_000; // 5 minutes
let inflight: Promise<string[]> | null = null;
const TTL = 300_000;
return {
get items() { return data; },
async fetch(force = false): Promise<string[]> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
const { api } = await import('$lib/api');
data = await api('/settings/locales');
fetchedAt = Date.now();
return data;
if (inflight) return inflight;
inflight = (async () => {
try {
data = await api<string[]>('/settings/locales');
fetchedAt = Date.now();
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
+1 -1
View File
@@ -21,7 +21,7 @@
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<EmailBot | null>(null);
let confirmDeleteEmail = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let error = $state('');
const defaultEmailForm = () => ({
+1 -1
View File
@@ -21,7 +21,7 @@
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<MatrixBot | null>(null);
let confirmDeleteMatrix = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let error = $state('');
const defaultMatrixForm = () => ({
@@ -375,12 +375,14 @@
<div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')}
aria-label={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_override || ''}
@@ -388,7 +390,7 @@
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
@@ -57,15 +57,6 @@
favorites: 'mdiStar', people: 'mdiAccountGroup',
};
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
key: c.name,
icon: commandIcons[c.name] || 'mdiConsole',
}))
);
let hasCommands = $derived(providerCommands.length > 0);
const defaultForm = () => ({
name: '',
icon: '',
@@ -78,6 +69,15 @@
});
let form = $state(defaultForm());
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
key: c.name,
icon: commandIcons[c.name] || 'mdiConsole',
}))
);
let hasCommands = $derived(providerCommands.length > 0);
onMount(load);
async function load() {
try {
@@ -90,6 +90,15 @@
return 'empty';
}
const defaultForm = () => ({
provider_type: '',
name: '',
description: '',
icon: '',
slots: {} as Record<string, Record<string, string>>,
});
let form = $state(defaultForm());
// Provider capabilities
let allCapabilities = $state<Record<string, any>>({});
let providerTypes = $derived(Object.keys(allCapabilities));
@@ -102,15 +111,6 @@
: commandSlots
);
const defaultForm = () => ({
provider_type: '',
name: '',
description: '',
icon: '',
slots: {} as Record<string, Record<string, string>>,
});
let form = $state(defaultForm());
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
@@ -35,9 +35,6 @@
const providerItems = $derived(providers
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
const configItems = $derived(filteredConfigs()
.filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType)
.map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
const botItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
let loaded = $state(false);
let showForm = $state(false);
@@ -64,12 +61,15 @@
let form = $state(defaultForm());
// Filter command configs by selected provider's type
let filteredConfigs = $derived(() => {
let filteredConfigs = $derived.by(() => {
if (!form.provider_id) return commandConfigs;
const provider = providers.find(p => p.id === form.provider_id);
if (!provider) return commandConfigs;
return commandConfigs.filter(c => c.provider_type === provider.type);
});
const configItems = $derived(filteredConfigs
.filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType)
.map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
onMount(load);
async function load() {
@@ -352,7 +352,7 @@
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="flex items-center gap-2 mb-2 font-medium">
{#if validationResult.valid}
<MdiIcon name="mdiCheckCircle" size={14} class="text-green-600" />
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
{:else}
<MdiIcon name="mdiCloseCircle" size={14} />