Skip to content

Commit 99be3d7

Browse files
committed
Harden Markdown sanitization and image policy
1 parent b006862 commit 99be3d7

26 files changed

Lines changed: 268 additions & 102 deletions

frontend/src/lib/components/Toggle.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
size = 'medium', // 'small' | 'medium' | 'large'
66
label = null,
77
labelPosition = 'right', // 'left' | 'right'
8+
ariaLabel = null,
89
onchange = null,
910
id = undefined,
1011
dataTestid = undefined,
@@ -57,7 +58,7 @@
5758
data-testid={dataTestid}
5859
role="switch"
5960
aria-checked={checked}
60-
aria-label="Toggle"
61+
aria-label={ariaLabel || 'Toggle'}
6162
{disabled}
6263
class="relative inline-flex items-center shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--ds-border-focused)]
6364
disabled:opacity-50 disabled:cursor-not-allowed {currentSize.button} {className}"

frontend/src/lib/editors/milkdown-link-sanitizer.js

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,21 @@
33

44
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
55
import { $prose } from '@milkdown/kit/utils';
6+
import { isSafeMarkdownURL } from '../utils/markdown-url-policy.ts';
67

7-
const SAFE_URL_SCHEMES = /^(https?:|mailto:|tel:|page:|#|\/)/i;
8-
const SAFE_IMAGE_URL_SCHEMES = /^(https?:|blob:|#|\/)/i;
9-
10-
/**
11-
* Check whether a URL is safe to navigate to.
12-
* Allows http(s), mailto, tel, fragment (#), and relative URLs.
13-
* Blocks javascript:, vbscript:, data:, and any other dangerous scheme.
14-
* @param {string} url
15-
* @returns {boolean}
16-
*/
17-
function isSafeUrlWithSchemes(url, safeSchemes) {
8+
function isEmptyUrl(url) {
189
if (!url) return true;
19-
const trimmed = url.trim();
20-
if (trimmed === '') return true;
21-
// Reject protocol-relative URLs (`//evil.com`) — browsers resolve these against the
22-
// current page's scheme, so they're effectively external navigation without a scheme.
23-
if (trimmed.startsWith('//')) return false;
24-
// Relative URLs (no scheme) are safe
25-
if (!trimmed.includes(':')) return true;
26-
return safeSchemes.test(trimmed);
10+
return url.trim() === '';
2711
}
2812

13+
/** @param {string} url */
2914
export function isSafeUrl(url) {
30-
return isSafeUrlWithSchemes(url, SAFE_URL_SCHEMES);
15+
return isEmptyUrl(url) || isSafeMarkdownURL(url);
3116
}
3217

18+
/** @param {string} url */
3319
export function isSafeImageUrl(url) {
34-
return isSafeUrlWithSchemes(url, SAFE_IMAGE_URL_SCHEMES);
20+
return isEmptyUrl(url) || isSafeMarkdownURL(url, { image: true, allowBlobImage: true });
3521
}
3622

3723
const linkSanitizerPluginKey = new PluginKey('link-sanitizer');

frontend/src/lib/locales/ar/admin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,10 @@ export default {
464464
pluginExecution: 'تنفيذ أوامر واجهة سطر أوامر الإضافات',
465465
pluginExecutionDesc: 'السماح للإضافات بتنفيذ أوامر shell على الخادم',
466466
pluginExecutionWarning: 'تحذير: تفعيل هذا الإعداد يسمح للإضافات بتنفيذ أوامر shell. تقتصر الأوامر على مجلد كل إضافة لأسباب أمنية. فعّل فقط إذا كنت تثق بجميع الإضافات المثبتة.',
467+
externalImages: 'صور Markdown الخارجية',
468+
externalImagesDesc: 'السماح لمحتوى Markdown بتحميل الصور من أي مضيف HTTP أو HTTPS',
469+
externalImagesRefresh: 'تُطبّق التغييرات عند تحميل المستخدمين للصفحة أو تحديثها. تظل صور Unsplash وAtlassian ومضيفات Jira المُعدّة والمرفقات والصور المضمّنة متاحة عند التعطيل.',
470+
externalImagesWarning: 'قد تكشف الصور الخارجية عناوين IP وتفاصيل المتصفح للمشاهدين، وقد ترسل طلبات إلى خدمات يمكن الوصول إليها من شبكاتهم. فعّل هذا الخيار فقط إذا كنت تثق بمؤلفي المحتوى.',
467471
failedToLoad: 'فشل في تحميل إعدادات الأمان',
468472
failedToSave: 'فشل في حفظ الإعدادات',
469473
},

frontend/src/lib/locales/de/admin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,10 @@ export default {
468468
pluginExecution: 'Plugin-CLI-Befehlsausführung',
469469
pluginExecutionDesc: 'Plugins erlauben, Shell-Befehle auf dem Server auszuführen',
470470
pluginExecutionWarning: 'Warnung: Das Aktivieren dieser Einstellung erlaubt Plugins, Shell-Befehle auszuführen.',
471+
externalImages: 'Externe Markdown-Bilder',
472+
externalImagesDesc: 'Markdown-Inhalten erlauben, Bilder von beliebigen HTTP- oder HTTPS-Hosts zu laden',
473+
externalImagesRefresh: 'Änderungen gelten, sobald Benutzer eine Seite neu laden. Unsplash, Atlassian, konfigurierte Jira-Hosts, Anhänge und eingebettete Bilder bleiben bei deaktivierter Einstellung verfügbar.',
474+
externalImagesWarning: 'Externe Bilder können IP-Adressen und Browserdetails von Betrachtern offenlegen und Anfragen an Dienste senden, die aus deren Netzwerk erreichbar sind. Nur aktivieren, wenn Sie den Autoren der Inhalte vertrauen.',
471475
failedToLoad: 'Sicherheitseinstellungen konnten nicht geladen werden',
472476
failedToSave: 'Einstellungen konnten nicht gespeichert werden',
473477
},

frontend/src/lib/locales/en/admin.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,12 @@ export default {
459459
pluginExecutionDesc: 'Allow plugins to execute shell commands on the server',
460460
pluginExecutionWarning:
461461
"Warning: Enabling this setting allows plugins to execute shell commands. Commands are restricted to each plugin's own directory for security. Only enable if you trust all installed plugins.",
462+
externalImages: 'External Markdown Images',
463+
externalImagesDesc: 'Allow Markdown content to load images from any HTTP or HTTPS host',
464+
externalImagesRefresh:
465+
'Changes apply when users next load or refresh a page. Unsplash, Atlassian, configured Jira hosts, attachments, and embedded images remain available when disabled.',
466+
externalImagesWarning:
467+
"External images can reveal viewer IP addresses and browser details, and can send requests to services reachable from a viewer's network. Enable only if you trust content authors.",
462468
failedToLoad: 'Failed to load security settings',
463469
failedToSave: 'Failed to save settings',
464470
},

frontend/src/lib/locales/es/admin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ export default {
463463
pluginExecution: 'Ejecución de comandos CLI de plugins',
464464
pluginExecutionDesc: 'Permitir a los plugins ejecutar comandos shell en el servidor',
465465
pluginExecutionWarning: 'Advertencia: Habilitar esta configuración permite a los plugins ejecutar comandos shell. Los comandos están restringidos al directorio de cada plugin por seguridad. Solo habilite si confía en todos los plugins instalados.',
466+
externalImages: 'Imágenes externas en Markdown',
467+
externalImagesDesc: 'Permitir que el contenido Markdown cargue imágenes desde cualquier host HTTP o HTTPS',
468+
externalImagesRefresh: 'Los cambios se aplican cuando los usuarios vuelven a cargar o actualizan una página. Unsplash, Atlassian, los hosts de Jira configurados, los adjuntos y las imágenes incrustadas siguen disponibles cuando está desactivado.',
469+
externalImagesWarning: 'Las imágenes externas pueden revelar las direcciones IP y los detalles del navegador de los visitantes, y enviar solicitudes a servicios accesibles desde su red. Actívelo solo si confía en los autores del contenido.',
466470
failedToLoad: 'Error al cargar la configuración de seguridad',
467471
failedToSave: 'Error al guardar la configuración',
468472
},

frontend/src/lib/locales/pt-BR/admin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ export default {
463463
pluginExecution: 'Execução de Comandos CLI de Plugins',
464464
pluginExecutionDesc: 'Permitir que plugins executem comandos shell no servidor',
465465
pluginExecutionWarning: 'Aviso: Ativar esta configuração permite que plugins executem comandos shell. Os comandos são restritos ao diretório de cada plugin por segurança. Ative apenas se confiar em todos os plugins instalados.',
466+
externalImages: 'Imagens externas em Markdown',
467+
externalImagesDesc: 'Permitir que conteúdo Markdown carregue imagens de qualquer host HTTP ou HTTPS',
468+
externalImagesRefresh: 'As alterações entram em vigor quando os usuários carregam ou atualizam uma página. Unsplash, Atlassian, hosts do Jira configurados, anexos e imagens incorporadas continuam disponíveis quando desativado.',
469+
externalImagesWarning: 'Imagens externas podem revelar endereços IP e detalhes do navegador dos visitantes e enviar solicitações a serviços acessíveis pela rede deles. Ative somente se confiar nos autores do conteúdo.',
466470
failedToLoad: 'Falha ao carregar configurações de segurança',
467471
failedToSave: 'Falha ao salvar configurações',
468472
},

frontend/src/lib/locales/zh-CN/admin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ export default {
364364
pluginExecution: '插件 CLI 命令执行',
365365
pluginExecutionDesc: '允许插件在服务器上执行 Shell 命令',
366366
pluginExecutionWarning: '警告:启用此设置允许插件执行 Shell 命令。出于安全考虑,命令限制在每个插件自己的目录中。仅在信任所有已安装插件时启用。',
367+
externalImages: '外部 Markdown 图片',
368+
externalImagesDesc: '允许 Markdown 内容从任意 HTTP 或 HTTPS 主机加载图片',
369+
externalImagesRefresh: '更改会在用户下次加载或刷新页面时生效。禁用时,Unsplash、Atlassian、已配置的 Jira 主机、附件和嵌入图片仍可使用。',
370+
externalImagesWarning: '外部图片可能泄露查看者的 IP 地址和浏览器信息,并向其网络可访问的服务发送请求。仅在信任内容作者时启用。',
367371
failedToLoad: '加载安全设置失败',
368372
failedToSave: '保存设置失败',
369373
},

frontend/src/lib/settings/SecuritySettings.svelte

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script>
22
import { onMount } from 'svelte';
3-
import { Shield, Calendar, Loader2, Terminal, Key, Users, UserCog, AlertTriangle, ChevronDown, ChevronUp } from '@lucide/svelte';
3+
import { Shield, Calendar, Image as ImageIcon, Loader2, Terminal, Key, Users, UserCog, AlertTriangle, ChevronDown, ChevronUp } from '@lucide/svelte';
44
import { agentSecurity, getSecuritySettings, updateSecuritySettings, authPolicy } from '../api.js';
55
import AgentSecurityAllowlistEditor from './AgentSecurityAllowlistEditor.svelte';
66
import Toggle from '../components/Toggle.svelte';
@@ -19,6 +19,7 @@
1919
2020
let calendarFeedEnabled = $state(true);
2121
let pluginCliExecEnabled = $state(false);
22+
let allowExternalImages = $state(false);
2223
let allowUserManagedAgents = $state(false);
2324
let maxAgentsPerUser = $state(5);
2425
let workspaceManagedAgents = $state(true);
@@ -111,6 +112,7 @@
111112
const settings = await getSecuritySettings();
112113
calendarFeedEnabled = settings.calendar_feed_enabled ?? true;
113114
pluginCliExecEnabled = settings.plugin_cli_exec_enabled ?? false;
115+
allowExternalImages = settings.allow_external_images ?? false;
114116
allowUserManagedAgents = settings.allow_user_managed_agents ?? false;
115117
maxAgentsPerUser = settings.max_agents_per_user ?? 5;
116118
workspaceManagedAgents = settings.workspace_managed_agents ?? true;
@@ -151,15 +153,18 @@
151153
await updateSecuritySettings({
152154
calendar_feed_enabled: calendarFeedEnabled,
153155
plugin_cli_exec_enabled: pluginCliExecEnabled,
156+
allow_external_images: allowExternalImages,
154157
allow_user_managed_agents: allowUserManagedAgents,
155158
max_agents_per_user: maxAgentsPerUser,
156159
workspace_managed_agents: workspaceManagedAgents,
157160
api_key_creation_policy: apiKeyCreationPolicy,
158161
api_key_allowed_group_ids: apiKeyAllowedGroupIds
159162
});
163+
return true;
160164
} catch (err) {
161165
errorToast(t('settings.security.failedToSave'));
162166
console.error('Failed to save settings:', err);
167+
return false;
163168
} finally {
164169
saving = false;
165170
}
@@ -192,6 +197,13 @@
192197
await saveSettings();
193198
}
194199
200+
async function handleExternalImagesToggle(newValue) {
201+
allowExternalImages = newValue;
202+
if (!(await saveSettings())) {
203+
allowExternalImages = !newValue;
204+
}
205+
}
206+
195207
async function handleUserManagedAgentsToggle(newValue) {
196208
allowUserManagedAgents = newValue;
197209
await saveSettings();
@@ -290,6 +302,45 @@
290302
</div>
291303
</Panel>
292304
305+
<!-- External Markdown Images -->
306+
<div class="mt-4">
307+
<Panel padding="spacious">
308+
<div class="flex items-start gap-4">
309+
<div class="p-2 rounded-lg" style="background-color: var(--ds-background-neutral);">
310+
<ImageIcon class="w-5 h-5" style="color: var(--ds-icon);" />
311+
</div>
312+
<div class="min-w-0 flex-1">
313+
<div class="flex items-center justify-between gap-4">
314+
<div class="min-w-0">
315+
<h3 class="text-base font-medium" style="color: var(--ds-text);">
316+
{t('settings.security.externalImages')}
317+
</h3>
318+
<p class="text-sm mt-1" style="color: var(--ds-text-subtle);">
319+
{t('settings.security.externalImagesDesc')}
320+
</p>
321+
<p class="text-xs mt-2" style="color: var(--ds-text-subtle);">
322+
{t('settings.security.externalImagesRefresh')}
323+
</p>
324+
</div>
325+
<Toggle
326+
bind:checked={allowExternalImages}
327+
ariaLabel={t('settings.security.externalImages')}
328+
dataTestid="external-images-toggle"
329+
disabled={saving}
330+
onchange={handleExternalImagesToggle}
331+
/>
332+
</div>
333+
334+
{#if allowExternalImages}
335+
<div class="mt-3">
336+
<AlertBox variant="warning" message={t('settings.security.externalImagesWarning')} />
337+
</div>
338+
{/if}
339+
</div>
340+
</div>
341+
</Panel>
342+
</div>
343+
293344
<!-- Plugin CLI Execution Settings -->
294345
<div class="mt-4">
295346
<Panel padding="spacious">
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const rasterDataURI = /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/]+=*$/i;
2+
3+
const commonURLSchemes = ['http', 'https'] as const;
4+
const linkOnlyURLSchemes = ['mailto', 'tel'] as const;
5+
6+
export const markdownURLSchemes = [
7+
...commonURLSchemes,
8+
...linkOnlyURLSchemes,
9+
'page',
10+
'data',
11+
] as const;
12+
13+
const commonSchemes = new Set<string>(commonURLSchemes);
14+
const linkOnlySchemes = new Set<string>(linkOnlyURLSchemes);
15+
16+
function hasUnsafeCharacters(value: string): boolean {
17+
if (value.includes('\\') || /%5c/i.test(value)) return true;
18+
for (const character of value) {
19+
const code = character.charCodeAt(0);
20+
if (code <= 0x20 || code === 0x7f) return true;
21+
}
22+
return false;
23+
}
24+
25+
type MarkdownURLOptions = {
26+
image?: boolean;
27+
allowBlobImage?: boolean;
28+
};
29+
30+
/** Validate Markdown destinations used by readonly rendering and the editor. */
31+
export function isSafeMarkdownURL(
32+
value: string,
33+
{ image = false, allowBlobImage = false }: MarkdownURLOptions = {}
34+
): boolean {
35+
if (!value || value.startsWith('//') || hasUnsafeCharacters(value)) return false;
36+
if (value.startsWith('#') || value.startsWith('/')) return true;
37+
if (image && rasterDataURI.test(value)) return true;
38+
if (image && allowBlobImage && /^blob:https?:/i.test(value)) return true;
39+
40+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
41+
if (!scheme) return /^[^/:?#\\][^:\\]*$/.test(value);
42+
if (commonSchemes.has(scheme)) return true;
43+
if (!image && linkOnlySchemes.has(scheme)) return true;
44+
return !image && scheme === 'page' && /^page:[0-9]+$/i.test(value);
45+
}

0 commit comments

Comments
 (0)