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:
+1
-1
@@ -60,6 +60,6 @@ EXPOSE 8420
|
|||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"
|
CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"NOTIFY_BRIDGE_PORT\", 8420)}/api/health')"
|
||||||
|
|
||||||
CMD ["notify-bridge"]
|
CMD ["notify-bridge"]
|
||||||
|
|||||||
@@ -22,7 +22,49 @@ packages/
|
|||||||
frontend/ — SvelteKit dashboard (Svelte 5, Tailwind CSS v4)
|
frontend/ — SvelteKit dashboard (Svelte 5, Tailwind CSS v4)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Docker Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name notify-bridge \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8420:8420 \
|
||||||
|
-v notify-bridge-data:/data \
|
||||||
|
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
|
||||||
|
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:8420` in your browser.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
| -------- | -------- | ------- | ----------- |
|
||||||
|
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
|
||||||
|
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
|
||||||
|
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
|
||||||
|
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
notify-bridge:
|
||||||
|
image: git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
|
||||||
|
container_name: notify-bridge
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8420:8420"
|
||||||
|
volumes:
|
||||||
|
- notify-bridge-data:/data
|
||||||
|
environment:
|
||||||
|
- NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
notify-bridge-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start (Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
|
|||||||
+10
-1
@@ -12,7 +12,7 @@
|
|||||||
--color-background: #f8f9fb;
|
--color-background: #f8f9fb;
|
||||||
--color-foreground: #1a1a2e;
|
--color-foreground: #1a1a2e;
|
||||||
--color-muted: #eef0f4;
|
--color-muted: #eef0f4;
|
||||||
--color-muted-foreground: #6b7280;
|
--color-muted-foreground: #525866;
|
||||||
--color-border: #e2e4ea;
|
--color-border: #e2e4ea;
|
||||||
--color-primary: #0d9488;
|
--color-primary: #0d9488;
|
||||||
--color-primary-foreground: #ffffff;
|
--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-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;
|
--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||||
--radius: 0.625rem;
|
--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 */
|
/* Dark theme overrides */
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
const API_BASE = '/api';
|
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 {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
return localStorage.getItem('access_token');
|
return localStorage.getItem('access_token');
|
||||||
|
|||||||
@@ -21,20 +21,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button type="button" bind:this={btnEl}
|
<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)]
|
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
|
||||||
hover:bg-[var(--color-border)] hover:text-[var(--color-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"
|
transition-colors cursor-help align-middle ml-2 flex-shrink-0"
|
||||||
onmouseenter={show}
|
onmouseenter={show}
|
||||||
onmouseleave={hide}
|
onmouseleave={hide}
|
||||||
onfocus={show}
|
onfocus={show}
|
||||||
onblur={hide}
|
onblur={hide}
|
||||||
aria-label={text}
|
aria-label={text}
|
||||||
|
title={text}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>?</button>
|
>?</button>
|
||||||
|
|
||||||
{#if visible}
|
{#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}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface ConfigField {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
/** Default value for this field. */
|
/** Default value for this field. */
|
||||||
defaultValue?: string | number;
|
defaultValue?: string | number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Event tracking (TrackingConfig form) ─────────────────────────────
|
// ── Event tracking (TrackingConfig form) ─────────────────────────────
|
||||||
@@ -60,14 +60,14 @@ export interface EventTrackingField {
|
|||||||
export interface ExtraTrackingField {
|
export interface ExtraTrackingField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'number' | 'grid-select';
|
type: 'number' | 'grid-select' | 'toggle';
|
||||||
/** Grid-select item source function name from grid-items.ts. */
|
/** Grid-select item source function name from grid-items.ts. */
|
||||||
gridItems?: string;
|
gridItems?: string;
|
||||||
gridColumns?: number;
|
gridColumns?: number;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
defaultValue?: string | number;
|
defaultValue?: string | number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEntityCache } from './entity-cache.svelte';
|
import { createEntityCache } from './entity-cache.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
import type {
|
import type {
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
@@ -57,19 +58,56 @@ export const commandTrackersCache = createEntityCache<CommandTracker>('/command-
|
|||||||
/** Actions — used by Actions page. */
|
/** Actions — used by Actions page. */
|
||||||
export const actionsCache = createEntityCache<Action>('/actions');
|
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 = (() => {
|
export const capabilitiesCache = (() => {
|
||||||
let data = $state<Record<string, any>>({});
|
let data = $state<CapabilitiesMap>({});
|
||||||
let fetchedAt = $state(0);
|
let fetchedAt = $state(0);
|
||||||
const TTL = 60_000; // 1 minute
|
let inflight: Promise<CapabilitiesMap> | null = null;
|
||||||
|
const TTL = 60_000;
|
||||||
return {
|
return {
|
||||||
get items() { return data; },
|
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;
|
if (!force && Object.keys(data).length > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||||
const { api } = await import('$lib/api');
|
if (inflight) return inflight;
|
||||||
data = await api('/providers/capabilities');
|
inflight = (async () => {
|
||||||
fetchedAt = Date.now();
|
try {
|
||||||
return data;
|
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 = (() => {
|
export const supportedLocalesCache = (() => {
|
||||||
let data = $state<string[]>(['en', 'ru']);
|
let data = $state<string[]>(['en', 'ru']);
|
||||||
let fetchedAt = $state(0);
|
let fetchedAt = $state(0);
|
||||||
const TTL = 300_000; // 5 minutes
|
let inflight: Promise<string[]> | null = null;
|
||||||
|
const TTL = 300_000;
|
||||||
return {
|
return {
|
||||||
get items() { return data; },
|
get items() { return data; },
|
||||||
async fetch(force = false): Promise<string[]> {
|
async fetch(force = false): Promise<string[]> {
|
||||||
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||||
const { api } = await import('$lib/api');
|
if (inflight) return inflight;
|
||||||
data = await api('/settings/locales');
|
inflight = (async () => {
|
||||||
fetchedAt = Date.now();
|
try {
|
||||||
return data;
|
data = await api<string[]>('/settings/locales');
|
||||||
|
fetchedAt = Date.now();
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflight;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
let editingEmail = $state<number | null>(null);
|
let editingEmail = $state<number | null>(null);
|
||||||
let emailSubmitting = $state(false);
|
let emailSubmitting = $state(false);
|
||||||
let emailTesting = $state<Record<number, boolean>>({});
|
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('');
|
let error = $state('');
|
||||||
|
|
||||||
const defaultEmailForm = () => ({
|
const defaultEmailForm = () => ({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
let editingMatrix = $state<number | null>(null);
|
let editingMatrix = $state<number | null>(null);
|
||||||
let matrixSubmitting = $state(false);
|
let matrixSubmitting = $state(false);
|
||||||
let matrixTesting = $state<Record<number, boolean>>({});
|
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('');
|
let error = $state('');
|
||||||
|
|
||||||
const defaultMatrixForm = () => ({
|
const defaultMatrixForm = () => ({
|
||||||
|
|||||||
@@ -375,12 +375,14 @@
|
|||||||
<div style={gridStyle}
|
<div style={gridStyle}
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
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')}
|
title={t('telegramBot.clickToCopy')}
|
||||||
|
aria-label={t('telegramBot.clickToCopy')}
|
||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
<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 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>
|
<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
|
<EntitySelect
|
||||||
items={LANG_ITEMS}
|
items={LANG_ITEMS}
|
||||||
value={chat.language_override || ''}
|
value={chat.language_override || ''}
|
||||||
@@ -388,7 +390,7 @@
|
|||||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<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)'};"
|
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')}
|
title={t('telegramBot.commandsToggle')}
|
||||||
|
|||||||
@@ -57,15 +57,6 @@
|
|||||||
favorites: 'mdiStar', people: 'mdiAccountGroup',
|
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 = () => ({
|
const defaultForm = () => ({
|
||||||
name: '',
|
name: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
@@ -78,6 +69,15 @@
|
|||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
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);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -90,6 +90,15 @@
|
|||||||
return 'empty';
|
return 'empty';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
provider_type: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
slots: {} as Record<string, Record<string, string>>,
|
||||||
|
});
|
||||||
|
let form = $state(defaultForm());
|
||||||
|
|
||||||
// Provider capabilities
|
// Provider capabilities
|
||||||
let allCapabilities = $state<Record<string, any>>({});
|
let allCapabilities = $state<Record<string, any>>({});
|
||||||
let providerTypes = $derived(Object.keys(allCapabilities));
|
let providerTypes = $derived(Object.keys(allCapabilities));
|
||||||
@@ -102,15 +111,6 @@
|
|||||||
: commandSlots
|
: 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. */
|
/** Get slot template for current locale, with fallback. */
|
||||||
function getSlotValue(slotName: string): string {
|
function getSlotValue(slotName: string): string {
|
||||||
return form.slots[slotName]?.[activeLocale] || '';
|
return form.slots[slotName]?.[activeLocale] || '';
|
||||||
|
|||||||
@@ -35,9 +35,6 @@
|
|||||||
const providerItems = $derived(providers
|
const providerItems = $derived(providers
|
||||||
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
||||||
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
|
.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}` : '' })));
|
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 loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
@@ -64,12 +61,15 @@
|
|||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
|
||||||
// Filter command configs by selected provider's type
|
// Filter command configs by selected provider's type
|
||||||
let filteredConfigs = $derived(() => {
|
let filteredConfigs = $derived.by(() => {
|
||||||
if (!form.provider_id) return commandConfigs;
|
if (!form.provider_id) return commandConfigs;
|
||||||
const provider = providers.find(p => p.id === form.provider_id);
|
const provider = providers.find(p => p.id === form.provider_id);
|
||||||
if (!provider) return commandConfigs;
|
if (!provider) return commandConfigs;
|
||||||
return commandConfigs.filter(c => c.provider_type === provider.type);
|
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);
|
onMount(load);
|
||||||
async function 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="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">
|
<div class="flex items-center gap-2 mb-2 font-medium">
|
||||||
{#if validationResult.valid}
|
{#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>
|
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<MdiIcon name="mdiCloseCircle" size={14} />
|
<MdiIcon name="mdiCloseCircle" size={14} />
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import aiohttp
|
|||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
from notify_bridge_core.templates.context import build_template_context
|
from notify_bridge_core.templates.context import build_template_context
|
||||||
from notify_bridge_core.templates.renderer import render_template
|
from notify_bridge_core.templates.renderer import render_template
|
||||||
|
from .ssrf import UnsafeURLError, validate_outbound_url
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_session() -> aiohttp.ClientSession:
|
||||||
|
"""Per-dispatch aiohttp session with a sane default timeout.
|
||||||
|
|
||||||
|
We still open a short-lived session per dispatch (connection reuse across
|
||||||
|
dispatches lives in the server-side shared session), but we always attach
|
||||||
|
a total timeout so a hung peer cannot wedge the task forever.
|
||||||
|
"""
|
||||||
|
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
|
||||||
|
|
||||||
from .receiver import (
|
from .receiver import (
|
||||||
Receiver,
|
Receiver,
|
||||||
@@ -176,7 +189,7 @@ class NotificationDispatcher:
|
|||||||
assets.append(asset_entry)
|
assets.append(asset_entry)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with _new_session() as session:
|
||||||
client = TelegramClient(
|
client = TelegramClient(
|
||||||
session, bot_token,
|
session, bot_token,
|
||||||
url_cache=self._url_cache,
|
url_cache=self._url_cache,
|
||||||
@@ -226,11 +239,16 @@ class NotificationDispatcher:
|
|||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with _new_session() as session:
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||||
results.append({"success": False, "error": "Invalid webhook receiver"})
|
results.append({"success": False, "error": "Invalid webhook receiver"})
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
validate_outbound_url(receiver.url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||||
|
continue
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
payload = {
|
payload = {
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -295,12 +313,17 @@ class NotificationDispatcher:
|
|||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with _new_session() as session:
|
||||||
client = DiscordClient(session)
|
client = DiscordClient(session)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||||
results.append({"success": False, "error": "Invalid discord receiver"})
|
results.append({"success": False, "error": "Invalid discord receiver"})
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
validate_outbound_url(receiver.webhook_url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||||
|
continue
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
results.append(await client.send(receiver.webhook_url, message, username=username))
|
results.append(await client.send(receiver.webhook_url, message, username=username))
|
||||||
|
|
||||||
@@ -316,12 +339,17 @@ class NotificationDispatcher:
|
|||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with _new_session() as session:
|
||||||
client = SlackClient(session)
|
client = SlackClient(session)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||||
results.append({"success": False, "error": "Invalid slack receiver"})
|
results.append({"success": False, "error": "Invalid slack receiver"})
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
validate_outbound_url(receiver.webhook_url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||||
|
continue
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
results.append(await client.send(receiver.webhook_url, message, username=username))
|
results.append(await client.send(receiver.webhook_url, message, username=username))
|
||||||
|
|
||||||
@@ -336,11 +364,15 @@ class NotificationDispatcher:
|
|||||||
auth_token = target.config.get("auth_token")
|
auth_token = target.config.get("auth_token")
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
try:
|
||||||
|
validate_outbound_url(server_url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
|
||||||
|
|
||||||
title = f"{event.event_type.value}: {event.collection_name}"
|
title = f"{event.event_type.value}: {event.collection_name}"
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with _new_session() as session:
|
||||||
client = NtfyClient(session)
|
client = NtfyClient(session)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||||
@@ -363,12 +395,16 @@ class NotificationDispatcher:
|
|||||||
access_token = target.config.get("access_token")
|
access_token = target.config.get("access_token")
|
||||||
if not homeserver or not access_token:
|
if not homeserver or not access_token:
|
||||||
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
|
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
|
||||||
|
try:
|
||||||
|
validate_outbound_url(homeserver)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
|
||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with _new_session() as session:
|
||||||
client = MatrixClient(session, homeserver, access_token)
|
client = MatrixClient(session, homeserver, access_token)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Outbound URL validation to mitigate SSRF attacks.
|
||||||
|
|
||||||
|
User-controlled URLs (provider `url`, webhook target `url`, shared-link
|
||||||
|
base URLs, image downloads) must be validated before any HTTP request is
|
||||||
|
issued. This module rejects schemes other than http/https and blocks
|
||||||
|
destinations that resolve to private, loopback, link-local, or unspecified
|
||||||
|
address ranges.
|
||||||
|
|
||||||
|
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
|
||||||
|
development against localhost services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
|
||||||
|
_ALLOWED_SCHEMES = {"http", "https"}
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeURLError(ValueError):
|
||||||
|
"""Raised when a URL targets a disallowed network destination."""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
return (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbound_url(url: str) -> str:
|
||||||
|
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
||||||
|
|
||||||
|
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
|
||||||
|
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
|
||||||
|
private addresses are permitted but the scheme check still applies.
|
||||||
|
"""
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
raise UnsafeURLError("URL is empty")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in _ALLOWED_SCHEMES:
|
||||||
|
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed")
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise UnsafeURLError("URL has no host")
|
||||||
|
|
||||||
|
if _ALLOW_PRIVATE:
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Literal IP host
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise UnsafeURLError(f"Host {host} is in a blocked range")
|
||||||
|
return url
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Hostname — resolve and reject if any resolution is in a blocked range.
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
||||||
|
for info in infos:
|
||||||
|
sockaddr = info[4]
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(sockaddr[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
|
||||||
|
return url
|
||||||
@@ -7,8 +7,12 @@ from typing import Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from ..ssrf import UnsafeURLError, validate_outbound_url
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
|
||||||
class WebhookClient:
|
class WebhookClient:
|
||||||
"""Send JSON payloads to a webhook URL."""
|
"""Send JSON payloads to a webhook URL."""
|
||||||
@@ -19,11 +23,16 @@ class WebhookClient:
|
|||||||
self._headers = headers or {}
|
self._headers = headers or {}
|
||||||
|
|
||||||
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
validate_outbound_url(self._url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
return {"success": False, "error": f"Unsafe URL: {err}"}
|
||||||
try:
|
try:
|
||||||
async with self._session.post(
|
async with self._session.post(
|
||||||
self._url,
|
self._url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Content-Type": "application/json", **self._headers},
|
headers={"Content-Type": "application/json", **self._headers},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
) as response:
|
) as response:
|
||||||
if 200 <= response.status < 300:
|
if 200 <= response.status < 300:
|
||||||
return {"success": True, "status_code": response.status}
|
return {"success": True, "status_code": response.status}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def _compile_jsonpath(expression: str) -> Any | None:
|
|||||||
return _JSONPATH_CACHE[expression]
|
return _JSONPATH_CACHE[expression]
|
||||||
try:
|
try:
|
||||||
compiled = jsonpath_parse(expression)
|
compiled = jsonpath_parse(expression)
|
||||||
except (JsonPathParserError, Exception) as exc:
|
except (JsonPathParserError, ValueError, TypeError, AttributeError) as exc:
|
||||||
_LOGGER.warning("Invalid JSONPath expression '%s': %s", expression, exc)
|
_LOGGER.warning("Invalid JSONPath expression '%s': %s", expression, exc)
|
||||||
compiled = None
|
compiled = None
|
||||||
_JSONPATH_CACHE[expression] = compiled
|
_JSONPATH_CACHE[expression] = compiled
|
||||||
@@ -69,6 +69,10 @@ def parse_webhook(
|
|||||||
Returns:
|
Returns:
|
||||||
A ServiceEvent, or None if parsing fails critically.
|
A ServiceEvent, or None if parsing fails critically.
|
||||||
"""
|
"""
|
||||||
|
# Defensive: upstream callers should pass a dict, but tolerate non-dict
|
||||||
|
# payloads by coercing to an empty mapping rather than raising.
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
# Build a combined data dict so JSONPath can reference headers too
|
# Build a combined data dict so JSONPath can reference headers too
|
||||||
data: dict[str, Any] = {**payload}
|
data: dict[str, Any] = {**payload}
|
||||||
if headers:
|
if headers:
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
"""Template rendering engine using Jinja2 SandboxedEnvironment."""
|
"""Template rendering engine using Jinja2 SandboxedEnvironment.
|
||||||
|
|
||||||
|
Hardening applied:
|
||||||
|
|
||||||
|
* SandboxedEnvironment with autoescape for attribute/method isolation.
|
||||||
|
* Input length cap to short-circuit pathological templates before parsing.
|
||||||
|
* Output length cap via a custom stream check to prevent memory blow-ups.
|
||||||
|
* Cooperative time budget via a thread-based watchdog -- a runaway template
|
||||||
|
(``{% for i in range(10**8) %}``) is interrupted instead of wedging the worker.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
@@ -10,16 +20,75 @@ from jinja2.sandbox import SandboxedEnvironment
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_TEMPLATE_LEN = 64 * 1024 # 64 KiB source
|
||||||
|
MAX_OUTPUT_LEN = 256 * 1024 # 256 KiB rendered
|
||||||
|
RENDER_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
_env = SandboxedEnvironment(autoescape=True)
|
_env = SandboxedEnvironment(autoescape=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderTimeout(jinja2.TemplateError):
|
||||||
|
"""Raised when a template exceeds the configured render budget."""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_with_timeout(template: jinja2.Template, context: dict[str, Any]) -> str:
|
||||||
|
"""Render `template` in a worker thread with a hard timeout.
|
||||||
|
|
||||||
|
Jinja2 has no built-in timeout; we run the render in a daemon thread and
|
||||||
|
join with a deadline. If the deadline is exceeded we raise and let the
|
||||||
|
thread die with the process -- accepted trade-off for a bounded-budget
|
||||||
|
admin-authored template.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
try:
|
||||||
|
result["value"] = template.render(**context)
|
||||||
|
except BaseException as exc: # noqa: BLE001 - forward to caller
|
||||||
|
result["error"] = exc
|
||||||
|
|
||||||
|
worker = threading.Thread(target=_run, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
worker.join(RENDER_TIMEOUT_SECONDS)
|
||||||
|
if worker.is_alive():
|
||||||
|
raise TemplateRenderTimeout(
|
||||||
|
f"Template render exceeded {RENDER_TIMEOUT_SECONDS}s budget"
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
raise result["error"]
|
||||||
|
return result.get("value", "")
|
||||||
|
|
||||||
|
|
||||||
def render_template(template_str: str, context: dict[str, Any]) -> str:
|
def render_template(template_str: str, context: dict[str, Any]) -> str:
|
||||||
"""Render a Jinja2 template string with the given context.
|
"""Render a Jinja2 template string with the given context.
|
||||||
|
|
||||||
Falls back to returning the raw template on error.
|
Enforces source length, output length, and wall-clock time caps.
|
||||||
|
Returns a placeholder on any failure so callers never see a partial render.
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(template_str, str):
|
||||||
|
return ""
|
||||||
|
if len(template_str) > MAX_TEMPLATE_LEN:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Template source exceeds %d chars (%d); refusing to render",
|
||||||
|
MAX_TEMPLATE_LEN, len(template_str),
|
||||||
|
)
|
||||||
|
return "[Template too large]"
|
||||||
try:
|
try:
|
||||||
return _env.from_string(template_str).render(**context)
|
compiled = _env.from_string(template_str)
|
||||||
|
output = _render_with_timeout(compiled, context)
|
||||||
|
except TemplateRenderTimeout as e:
|
||||||
|
_LOGGER.error("Template render timeout: %s", e)
|
||||||
|
return "[Template render timeout]"
|
||||||
except jinja2.TemplateError as e:
|
except jinja2.TemplateError as e:
|
||||||
_LOGGER.error("Template render error: %s", e)
|
_LOGGER.error("Template render error: %s", e)
|
||||||
return "[Template rendering error]"
|
return "[Template rendering error]"
|
||||||
|
except Exception as e: # sandbox guarded — log and fall back safely
|
||||||
|
_LOGGER.error("Unexpected template error: %s", e, exc_info=True)
|
||||||
|
return "[Template rendering error]"
|
||||||
|
if len(output) > MAX_OUTPUT_LEN:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Template output truncated from %d to %d bytes",
|
||||||
|
len(output), MAX_OUTPUT_LEN,
|
||||||
|
)
|
||||||
|
return output[:MAX_OUTPUT_LEN] + "\n[truncated]"
|
||||||
|
return output
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ def _backup_dir():
|
|||||||
return app_config.data_dir / "backups"
|
return app_config.data_dir / "backups"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_backup_file(filename: str):
|
||||||
|
"""Validate filename and resolve to a path strictly inside the backup dir."""
|
||||||
|
if not filename.startswith("backup-") or not filename.endswith(".json"):
|
||||||
|
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||||
|
if "/" in filename or "\\" in filename or ".." in filename or "\x00" in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||||
|
base = _backup_dir().resolve()
|
||||||
|
candidate = (base / filename).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(base)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Export
|
# Export
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -194,9 +211,7 @@ async def download_backup_file(
|
|||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""Download a specific backup file."""
|
"""Download a specific backup file."""
|
||||||
filepath = _backup_dir() / filename
|
filepath = _resolve_backup_file(filename)
|
||||||
if not filepath.is_file() or not filename.startswith("backup-"):
|
|
||||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = json.loads(filepath.read_text(encoding="utf-8"))
|
content = json.loads(filepath.read_text(encoding="utf-8"))
|
||||||
@@ -215,9 +230,6 @@ async def delete_backup_file(
|
|||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""Delete a specific backup file."""
|
"""Delete a specific backup file."""
|
||||||
filepath = _backup_dir() / filename
|
filepath = _resolve_backup_file(filename)
|
||||||
if not filepath.is_file() or not filename.startswith("backup-"):
|
|
||||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
|
||||||
|
|
||||||
filepath.unlink()
|
filepath.unlink()
|
||||||
return {"deleted": filename}
|
return {"deleted": filename}
|
||||||
|
|||||||
@@ -350,12 +350,29 @@ def _verify_generic_webhook_auth(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_SENSITIVE_HEADER_SUBSTR = (
|
||||||
|
"token", "auth", "key", "secret", "signature", "password", "credential",
|
||||||
|
"cookie", "x-api", "x-hub-signature",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sensitive_header(name: str) -> bool:
|
||||||
|
n = name.lower()
|
||||||
|
return any(s in n for s in _SENSITIVE_HEADER_SUBSTR)
|
||||||
|
|
||||||
|
|
||||||
def _filter_headers(raw_headers: dict[str, str]) -> dict[str, str]:
|
def _filter_headers(raw_headers: dict[str, str]) -> dict[str, str]:
|
||||||
"""Keep only safe headers for logging (no Authorization)."""
|
"""Keep only safe headers for logging (strip Authorization, signatures, tokens).
|
||||||
|
|
||||||
|
Allowlist base set of known-safe headers, accept X-* only if they do not
|
||||||
|
match any sensitive substring (token/auth/key/secret/signature/...).
|
||||||
|
"""
|
||||||
safe: dict[str, str] = {}
|
safe: dict[str, str] = {}
|
||||||
for k, v in raw_headers.items():
|
for k, v in raw_headers.items():
|
||||||
kl = k.lower()
|
kl = k.lower()
|
||||||
if kl in ("content-type", "user-agent") or kl.startswith("x-"):
|
if _is_sensitive_header(kl):
|
||||||
|
continue
|
||||||
|
if kl in ("content-type", "user-agent", "content-length", "accept") or kl.startswith("x-"):
|
||||||
safe[k] = v
|
safe[k] = v
|
||||||
return safe
|
return safe
|
||||||
|
|
||||||
@@ -384,26 +401,26 @@ async def _save_webhook_log(
|
|||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
))
|
))
|
||||||
await session.flush()
|
await session.flush()
|
||||||
count_result = await session.exec(
|
# Atomic prune: DELETE anything for this provider outside the newest
|
||||||
select(func.count(WebhookPayloadLog.id))
|
# max_count rows. Avoids the COUNT -> SELECT -> DELETE race.
|
||||||
|
keep_subq = (
|
||||||
|
select(WebhookPayloadLog.id)
|
||||||
.where(WebhookPayloadLog.provider_id == provider_id)
|
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||||
|
.order_by(WebhookPayloadLog.created_at.desc(), WebhookPayloadLog.id.desc())
|
||||||
|
.limit(max_count)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
sa_delete(WebhookPayloadLog)
|
||||||
|
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||||
|
.where(~WebhookPayloadLog.id.in_(select(keep_subq.c.id)))
|
||||||
)
|
)
|
||||||
total = count_result.one()
|
|
||||||
if total > max_count:
|
|
||||||
oldest = await session.exec(
|
|
||||||
select(WebhookPayloadLog.id)
|
|
||||||
.where(WebhookPayloadLog.provider_id == provider_id)
|
|
||||||
.order_by(WebhookPayloadLog.created_at.asc())
|
|
||||||
.limit(total - max_count)
|
|
||||||
)
|
|
||||||
ids_to_delete = list(oldest.all())
|
|
||||||
if ids_to_delete:
|
|
||||||
await session.execute(
|
|
||||||
sa_delete(WebhookPayloadLog)
|
|
||||||
.where(WebhookPayloadLog.id.in_(ids_to_delete))
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
|
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
|
||||||
|
try:
|
||||||
|
await session.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook/{token}")
|
@router.post("/webhook/{token}")
|
||||||
@@ -436,6 +453,8 @@ async def generic_webhook(token: str, request: Request):
|
|||||||
# Parse JSON payload
|
# Parse JSON payload
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Payload must be a JSON object")
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
if store_payloads:
|
if store_payloads:
|
||||||
async with AsyncSession(get_engine()) as log_session:
|
async with AsyncSession(get_engine()) as log_session:
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ async def get_current_user(
|
|||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
|
token_version = int(payload.get("ver", 1))
|
||||||
except (jwt.PyJWTError, KeyError, ValueError) as exc:
|
except (jwt.PyJWTError, KeyError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
|
||||||
|
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
if token_version != user.token_version:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,26 @@ from ..config import settings
|
|||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_id: int, role: str) -> str:
|
def create_access_token(user_id: int, role: str, token_version: int = 1) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
payload = {"sub": str(user_id), "role": role, "type": "access", "exp": expire}
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"type": "access",
|
||||||
|
"ver": token_version,
|
||||||
|
"exp": expire,
|
||||||
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id: int) -> str:
|
def create_refresh_token(user_id: int, token_version: int = 1) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
||||||
payload = {"sub": str(user_id), "type": "refresh", "exp": expire}
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"type": "refresh",
|
||||||
|
"ver": token_version,
|
||||||
|
"exp": expire,
|
||||||
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ async def setup(request: Request, body: SetupRequest, session: AsyncSession = De
|
|||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=create_access_token(user.id, user.role),
|
access_token=create_access_token(user.id, user.role, user.token_version),
|
||||||
refresh_token=create_refresh_token(user.id),
|
refresh_token=create_refresh_token(user.id, user.token_version),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,29 +83,33 @@ async def login(request: Request, body: LoginRequest, session: AsyncSession = De
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=create_access_token(user.id, user.role),
|
access_token=create_access_token(user.id, user.role, user.token_version),
|
||||||
refresh_token=create_refresh_token(user.id),
|
refresh_token=create_refresh_token(user.id, user.token_version),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
async def refresh(body: RefreshRequest, session: AsyncSession = Depends(get_session)):
|
@limiter.limit("10/minute")
|
||||||
|
async def refresh(request: Request, body: RefreshRequest, session: AsyncSession = Depends(get_session)):
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
try:
|
try:
|
||||||
payload = decode_token(body.refresh_token)
|
payload = decode_token(body.refresh_token)
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
|
token_version = int(payload.get("ver", 1))
|
||||||
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
|
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
||||||
|
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
if token_version != user.token_version:
|
||||||
|
raise HTTPException(status_code=401, detail="Refresh token revoked")
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=create_access_token(user.id, user.role),
|
access_token=create_access_token(user.id, user.role, user.token_version),
|
||||||
refresh_token=create_refresh_token(user.id),
|
refresh_token=create_refresh_token(user.id, user.token_version),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +134,7 @@ async def change_password(
|
|||||||
if len(body.new_password) < 8:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||||
user.hashed_password = _hash_password(body.new_password)
|
user.hashed_password = _hash_password(body.new_password)
|
||||||
|
user.token_version = (user.token_version or 1) + 1
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
@@ -14,10 +14,19 @@ class Settings(BaseSettings):
|
|||||||
secret_key: str = "change-me-in-production"
|
secret_key: str = "change-me-in-production"
|
||||||
|
|
||||||
def model_post_init(self, __context: Any) -> None:
|
def model_post_init(self, __context: Any) -> None:
|
||||||
if self.secret_key == "change-me-in-production" and not self.debug:
|
if self.secret_key == "change-me-in-production":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"SECURITY: Cannot start with default secret_key in production. "
|
"SECURITY: Refusing to start with the default secret_key. "
|
||||||
"Set NOTIFY_BRIDGE_SECRET_KEY environment variable."
|
"Set NOTIFY_BRIDGE_SECRET_KEY to a random value (>=32 bytes) "
|
||||||
|
"before starting the server (debug mode included)."
|
||||||
|
)
|
||||||
|
if len(self.secret_key) < 32:
|
||||||
|
raise ValueError(
|
||||||
|
"SECURITY: NOTIFY_BRIDGE_SECRET_KEY must be at least 32 characters."
|
||||||
|
)
|
||||||
|
if "*" in self.cors_allowed_origins.split(","):
|
||||||
|
raise ValueError(
|
||||||
|
"SECURITY: wildcard '*' is not allowed in CORS origins when credentials are enabled."
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token_expire_minutes: int = 60
|
access_token_expire_minutes: int = 60
|
||||||
|
|||||||
@@ -18,8 +18,23 @@ logger = logging.getLogger(__name__)
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IDENT_RE = __import__("re").compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_ident(ident: str, kind: str = "identifier") -> str:
|
||||||
|
"""Guard against SQL injection in dynamically interpolated identifiers.
|
||||||
|
|
||||||
|
All table/column names flow through here before being embedded into f-strings,
|
||||||
|
so attacker-controlled values cannot break out even if they reach this layer.
|
||||||
|
"""
|
||||||
|
if not isinstance(ident, str) or not _IDENT_RE.match(ident):
|
||||||
|
raise ValueError(f"Unsafe {kind}: {ident!r}")
|
||||||
|
return ident
|
||||||
|
|
||||||
|
|
||||||
async def _has_column(conn, table: str, column: str) -> bool:
|
async def _has_column(conn, table: str, column: str) -> bool:
|
||||||
"""Check if a column exists in a SQLite table."""
|
"""Check if a column exists in a SQLite table."""
|
||||||
|
_assert_ident(table, "table")
|
||||||
cols = await conn.run_sync(
|
cols = await conn.run_sync(
|
||||||
lambda sync_conn: [
|
lambda sync_conn: [
|
||||||
row[1]
|
row[1]
|
||||||
@@ -1187,3 +1202,15 @@ async def migrate_notification_slot_locale(engine: AsyncEngine) -> None:
|
|||||||
"Merged system notification template configs for %s (EN=%d, RU=%d) into %d",
|
"Merged system notification template configs for %s (EN=%d, RU=%d) into %d",
|
||||||
provider_type, en_id, ru_id, en_id,
|
provider_type, en_id, ru_id, en_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_user_token_version(engine: AsyncEngine) -> None:
|
||||||
|
"""Add token_version column to user for JWT revocation on password change."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
if not await _has_table(conn, "user"):
|
||||||
|
return
|
||||||
|
if not await _has_column(conn, "user", "token_version"):
|
||||||
|
await conn.execute(
|
||||||
|
text("ALTER TABLE user ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1")
|
||||||
|
)
|
||||||
|
logger.info("Added token_version column to user table")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class User(SQLModel, table=True):
|
|||||||
username: str = Field(index=True, unique=True)
|
username: str = Field(index=True, unique=True)
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
role: str = Field(default="user")
|
role: str = Field(default="user")
|
||||||
|
token_version: int = Field(default=1)
|
||||||
created_at: datetime = Field(default_factory=_utcnow)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await init_db()
|
await init_db()
|
||||||
# Run data migrations (idempotent)
|
# Run data migrations (idempotent)
|
||||||
from .database.engine import get_engine
|
from .database.engine import get_engine
|
||||||
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale, migrate_notification_slot_locale
|
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale, migrate_notification_slot_locale, migrate_user_token_version
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
await migrate_schema(engine)
|
await migrate_schema(engine)
|
||||||
await migrate_tracker_targets(engine)
|
await migrate_tracker_targets(engine)
|
||||||
@@ -63,6 +63,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await migrate_receivers_from_config(engine)
|
await migrate_receivers_from_config(engine)
|
||||||
await migrate_command_slot_locale(engine)
|
await migrate_command_slot_locale(engine)
|
||||||
await migrate_notification_slot_locale(engine)
|
await migrate_notification_slot_locale(engine)
|
||||||
|
await migrate_user_token_version(engine)
|
||||||
from .database.seeds import seed_all
|
from .database.seeds import seed_all
|
||||||
await seed_all()
|
await seed_all()
|
||||||
# Configure webhook secret from DB setting (falls back to env var)
|
# Configure webhook secret from DB setting (falls back to env var)
|
||||||
|
|||||||
@@ -34,6 +34,44 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
# Fields to skip when serializing TrackingConfig into the generic `fields` dict
|
# Fields to skip when serializing TrackingConfig into the generic `fields` dict
|
||||||
_TRACKING_SKIP = frozenset(("id", "user_id", "provider_type", "name", "icon", "created_at"))
|
_TRACKING_SKIP = frozenset(("id", "user_id", "provider_type", "name", "icon", "created_at"))
|
||||||
|
|
||||||
|
# Import-time config hardening limits
|
||||||
|
_MAX_CONFIG_DEPTH = 6
|
||||||
|
_MAX_CONFIG_KEYS = 200
|
||||||
|
_MAX_STRING_LEN = 8192
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_config(value: Any, depth: int = 0) -> Any:
|
||||||
|
"""Clamp imported config values to safe shapes before persistence.
|
||||||
|
|
||||||
|
Rejects anything that is not a JSON-compatible primitive/container, truncates
|
||||||
|
over-long strings, and caps dict/list sizes. Returns a defensively-copied
|
||||||
|
structure; the caller should never see attacker-controlled references.
|
||||||
|
"""
|
||||||
|
if depth > _MAX_CONFIG_DEPTH:
|
||||||
|
raise ValueError("Config nesting exceeds maximum depth")
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:_MAX_STRING_LEN]
|
||||||
|
if isinstance(value, list):
|
||||||
|
if len(value) > _MAX_CONFIG_KEYS:
|
||||||
|
raise ValueError("Config list exceeds maximum length")
|
||||||
|
return [_sanitize_config(v, depth + 1) for v in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if len(value) > _MAX_CONFIG_KEYS:
|
||||||
|
raise ValueError("Config dict exceeds maximum key count")
|
||||||
|
cleaned: dict[str, Any] = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
if not isinstance(k, str):
|
||||||
|
raise ValueError("Config keys must be strings")
|
||||||
|
if len(k) > 128:
|
||||||
|
raise ValueError(f"Config key too long: {k[:40]}...")
|
||||||
|
cleaned[k] = _sanitize_config(v, depth + 1)
|
||||||
|
return cleaned
|
||||||
|
raise ValueError(f"Unsupported config value type: {type(value).__name__}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Export
|
# Export
|
||||||
@@ -530,9 +568,14 @@ async def import_backup(
|
|||||||
)
|
)
|
||||||
if name is None:
|
if name is None:
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
safe_cfg = _sanitize_config(p.config or {})
|
||||||
|
except ValueError as exc:
|
||||||
|
result.warnings.append(f"Skipped provider '{p.name}': {exc}")
|
||||||
|
continue
|
||||||
new_p = ServiceProvider(
|
new_p = ServiceProvider(
|
||||||
user_id=user_id, type=p.type, name=name,
|
user_id=user_id, type=p.type, name=name,
|
||||||
icon=p.icon, config=p.config,
|
icon=p.icon, config=safe_cfg,
|
||||||
)
|
)
|
||||||
session.add(new_p)
|
session.add(new_p)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -635,17 +678,27 @@ async def import_backup(
|
|||||||
)
|
)
|
||||||
if name is None:
|
if name is None:
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
safe_tgt_cfg = _sanitize_config(tgt.config or {})
|
||||||
|
except ValueError as exc:
|
||||||
|
result.warnings.append(f"Skipped target '{tgt.name}': {exc}")
|
||||||
|
continue
|
||||||
new_tgt = NotificationTarget(
|
new_tgt = NotificationTarget(
|
||||||
user_id=user_id, type=tgt.type, name=name,
|
user_id=user_id, type=tgt.type, name=name,
|
||||||
icon=tgt.icon, config=tgt.config,
|
icon=tgt.icon, config=safe_tgt_cfg,
|
||||||
chat_action=tgt.chat_action,
|
chat_action=tgt.chat_action,
|
||||||
)
|
)
|
||||||
session.add(new_tgt)
|
session.add(new_tgt)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
id_map["targets"][tgt.id] = new_tgt.id
|
id_map["targets"][tgt.id] = new_tgt.id
|
||||||
for r in tgt.receivers:
|
for r in tgt.receivers:
|
||||||
|
try:
|
||||||
|
safe_r_cfg = _sanitize_config(r.config or {})
|
||||||
|
except ValueError as exc:
|
||||||
|
result.warnings.append(f"Skipped receiver in '{tgt.name}': {exc}")
|
||||||
|
continue
|
||||||
session.add(TargetReceiver(
|
session.add(TargetReceiver(
|
||||||
target_id=new_tgt.id, name=r.name, config=r.config,
|
target_id=new_tgt.id, name=r.name, config=safe_r_cfg,
|
||||||
receiver_key=r.receiver_key, locale=r.locale,
|
receiver_key=r.receiver_key, locale=r.locale,
|
||||||
enabled=r.enabled,
|
enabled=r.enabled,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -249,6 +249,22 @@ async def load_link_data(
|
|||||||
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
|
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
|
||||||
slots_by_config.setdefault(s.config_id, {}).setdefault(event_key, {})[s.locale] = s.template
|
slots_by_config.setdefault(s.config_id, {}).setdefault(event_key, {})[s.locale] = s.template
|
||||||
|
|
||||||
|
# Pre-resolve broadcast children in one query to avoid N+1 per-child fetches
|
||||||
|
broadcast_child_ids: set[int] = set()
|
||||||
|
for tt in active_links:
|
||||||
|
target = target_map.get(tt.target_id)
|
||||||
|
if target and target.type == "broadcast":
|
||||||
|
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
||||||
|
for cid in target.config.get("child_target_ids", []):
|
||||||
|
if cid not in disabled_ids:
|
||||||
|
broadcast_child_ids.add(cid)
|
||||||
|
child_target_map: dict[int, NotificationTarget] = {}
|
||||||
|
if broadcast_child_ids:
|
||||||
|
child_rows = await session.exec(
|
||||||
|
select(NotificationTarget).where(NotificationTarget.id.in_(broadcast_child_ids))
|
||||||
|
)
|
||||||
|
child_target_map = {t.id: t for t in child_rows.all()}
|
||||||
|
|
||||||
link_data: list[dict[str, Any]] = []
|
link_data: list[dict[str, Any]] = []
|
||||||
for tt in active_links:
|
for tt in active_links:
|
||||||
target = target_map.get(tt.target_id)
|
target = target_map.get(tt.target_id)
|
||||||
@@ -262,14 +278,13 @@ async def load_link_data(
|
|||||||
template_config = tmpl_map.get(tmpl_id) if tmpl_id else None
|
template_config = tmpl_map.get(tmpl_id) if tmpl_id else None
|
||||||
template_slots = slots_by_config.get(template_config.id) if template_config else None
|
template_slots = slots_by_config.get(template_config.id) if template_config else None
|
||||||
|
|
||||||
# Broadcast target: expand into child targets
|
# Broadcast target: expand into child targets (pre-loaded above)
|
||||||
if target.type == "broadcast":
|
if target.type == "broadcast":
|
||||||
child_ids = target.config.get("child_target_ids", [])
|
|
||||||
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
||||||
for child_id in child_ids:
|
for child_id in target.config.get("child_target_ids", []):
|
||||||
if child_id in disabled_ids:
|
if child_id in disabled_ids:
|
||||||
continue
|
continue
|
||||||
child_target = await session.get(NotificationTarget, child_id)
|
child_target = child_target_map.get(child_id)
|
||||||
if not child_target or child_target.type == "broadcast":
|
if not child_target or child_target.type == "broadcast":
|
||||||
continue
|
continue
|
||||||
resolved = await _resolve_target(session, child_target)
|
resolved = await _resolve_target(session, child_target)
|
||||||
|
|||||||
Reference in New Issue
Block a user