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
+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;
},
};
})();