diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte
index 0252fde..c120f0b 100644
--- a/src/lib/components/MarkdownEditor.svelte
+++ b/src/lib/components/MarkdownEditor.svelte
@@ -11,10 +11,14 @@
let { value, readonly = false, onsave }: Props = $props();
let editing = $state(false);
- let draft = $state(value);
+ let draft = $state('');
let previewing = $state(false);
let ready = $state(false);
+ $effect.pre(() => {
+ if (!editing) draft = value;
+ });
+
onMount(async () => {
await initDOMPurify();
ready = true;
@@ -31,11 +35,6 @@
editing = false;
previewing = false;
}
-
- // Keep draft in sync when value prop changes externally
- $effect(() => {
- if (!editing) draft = value;
- });
{#if editing && !readonly}
@@ -89,6 +88,8 @@
{ if (!readonly) editing = true; }}
>
{#if value}
diff --git a/src/lib/components/TagPicker.svelte b/src/lib/components/TagPicker.svelte
index 41c3539..b935cad 100644
--- a/src/lib/components/TagPicker.svelte
+++ b/src/lib/components/TagPicker.svelte
@@ -120,6 +120,7 @@
(showDropdown = false)}
>
@@ -150,6 +151,7 @@
class="w-5 h-5 rounded-full border-2 {newTagColor === color ? 'border-gray-600' : 'border-transparent'}"
style="background-color: {color}"
onclick={() => (newTagColor = color)}
+ aria-label="Select color {color}"
>
{/each}
diff --git a/src/lib/server/theme-css.ts b/src/lib/server/theme-css.ts
index 6e900aa..335032f 100644
--- a/src/lib/server/theme-css.ts
+++ b/src/lib/server/theme-css.ts
@@ -1,5 +1,10 @@
import type { TenantThemeData } from '../../app.d.js';
+/** Validates a theme background image URL is safe for CSS injection. */
+function isSafeCssUrl(url: string): boolean {
+ return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
+}
+
/**
* Maps theme fields to CSS custom property names.
* Light-prefixed fields go into :root {}, dark-prefixed into html.dark {}.
@@ -94,16 +99,16 @@ export function generateTenantCSS(theme: TenantThemeData | null | undefined): st
// Handle background images — generate class-level overrides
const extraRules: string[] = [];
- if (theme.lightPageBgImage) {
+ if (theme.lightPageBgImage && isSafeCssUrl(theme.lightPageBgImage)) {
extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`);
}
- if (theme.lightNavBgImage) {
+ if (theme.lightNavBgImage && isSafeCssUrl(theme.lightNavBgImage)) {
extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`);
}
- if (theme.darkPageBgImage) {
+ if (theme.darkPageBgImage && isSafeCssUrl(theme.darkPageBgImage)) {
extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`);
}
- if (theme.darkNavBgImage) {
+ if (theme.darkNavBgImage && isSafeCssUrl(theme.darkNavBgImage)) {
extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`);
}
diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts
index 74b032a..752cd54 100644
--- a/src/lib/utils/markdown.ts
+++ b/src/lib/utils/markdown.ts
@@ -1,11 +1,20 @@
import { marked } from 'marked';
+// Disable raw HTML passthrough in markdown to prevent XSS on server-side rendering
+marked.use({
+ renderer: {
+ html: () => '',
+ }
+});
+
export function renderMarkdown(text: string): string {
if (!text) return '';
const html = marked.parse(text, { async: false }) as string;
- if (typeof window === 'undefined') return html;
- const DOMPurify = (globalThis as any).__dompurify;
- if (DOMPurify) return DOMPurify.sanitize(html);
+ // DOMPurify provides a second layer of sanitization on the client
+ if (typeof window !== 'undefined') {
+ const DOMPurify = (globalThis as any).__dompurify;
+ if (DOMPurify) return DOMPurify.sanitize(html);
+ }
return html;
}
diff --git a/src/lib/utils/theme-persist.ts b/src/lib/utils/theme-persist.ts
index f5cb6e4..30061b9 100644
--- a/src/lib/utils/theme-persist.ts
+++ b/src/lib/utils/theme-persist.ts
@@ -1,5 +1,10 @@
import type { TenantThemeData } from '../../app.d.js';
+/** Validates a theme background image URL is safe for CSS injection. */
+function isSafeCssUrl(url: string): boolean {
+ return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
+}
+
/**
* Client-side CSS variable mapping — mirrors server-side theme-css.ts
*/
@@ -77,16 +82,16 @@ export function persistThemeCSS(theme: TenantThemeData) {
// Handle background images
const extraRules: string[] = [];
- if (theme.lightPageBgImage) {
+ if (theme.lightPageBgImage && isSafeCssUrl(theme.lightPageBgImage)) {
extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`);
}
- if (theme.lightNavBgImage) {
+ if (theme.lightNavBgImage && isSafeCssUrl(theme.lightNavBgImage)) {
extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`);
}
- if (theme.darkPageBgImage) {
+ if (theme.darkPageBgImage && isSafeCssUrl(theme.darkPageBgImage)) {
extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`);
}
- if (theme.darkNavBgImage) {
+ if (theme.darkNavBgImage && isSafeCssUrl(theme.darkNavBgImage)) {
extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`);
}
diff --git a/src/lib/utils/theme-preview.ts b/src/lib/utils/theme-preview.ts
index 538a5f6..6c3182d 100644
--- a/src/lib/utils/theme-preview.ts
+++ b/src/lib/utils/theme-preview.ts
@@ -1,5 +1,10 @@
import type { TenantThemeData } from '../../app.d.js';
+/** Validates a theme background image URL is safe for CSS injection. */
+function isSafeCssUrl(url: string): boolean {
+ return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
+}
+
const LIGHT_FIELD_MAP: Record
= {
lightPageBg: '--color-page-bg',
lightNavBg: '--color-nav-bg',
@@ -98,7 +103,7 @@ export function applyThemePreview(draft: TenantThemeData, mode: 'light' | 'dark'
// Handle bg images — set directly on target elements
const pageBgImage = (draft as any)[pageBgImageField];
- if (pageBgImage) {
+ if (pageBgImage && isSafeCssUrl(pageBgImage)) {
const pageEl = document.querySelector('.layout-page-bg') as HTMLElement;
if (pageEl) {
pageEl.style.backgroundImage = `url('${pageBgImage}')`;
@@ -106,7 +111,7 @@ export function applyThemePreview(draft: TenantThemeData, mode: 'light' | 'dark'
}
}
const navBgImage = (draft as any)[navBgImageField];
- if (navBgImage) {
+ if (navBgImage && isSafeCssUrl(navBgImage)) {
const navEl = document.querySelector('.layout-nav-bg') as HTMLElement;
if (navEl) {
navEl.style.backgroundImage = `url('${navBgImage}')`;
diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte
index 627121a..885c87d 100644
--- a/src/routes/account/+page.svelte
+++ b/src/routes/account/+page.svelte
@@ -8,12 +8,12 @@
let { data } = $props();
// Profile
- let name = $state(data.user.name);
- let email = $state(data.user.email);
+ let name = $state('');
+ let email = $state('');
let savingProfile = $state(false);
// Avatar
- let avatarUrl = $state(data.user.avatarUrl);
+ let avatarUrl = $state(null);
let cropFile = $state(null);
let fileInput: HTMLInputElement;
let uploadingAvatar = $state(false);
@@ -25,9 +25,16 @@
let savingPassword = $state(false);
// Sessions
- let sessionCount = $state(data.sessionCount);
+ let sessionCount = $state(0);
let revokingSessions = $state(false);
+ $effect.pre(() => {
+ name = data.user.name;
+ email = data.user.email;
+ avatarUrl = data.user.avatarUrl;
+ sessionCount = data.sessionCount;
+ });
+
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte
index e0feb01..1d85080 100644
--- a/src/routes/admin/+page.svelte
+++ b/src/routes/admin/+page.svelte
@@ -4,9 +4,14 @@
let { data } = $props();
- let users = $state(data.users.map((u: any) => ({ ...u })));
- let tags = $state(data.tags.map((t: any) => ({ ...t })));
- let categories = $state(data.categories.map((c: any) => ({ ...c })));
+ let users = $state([]);
+ let tags = $state([]);
+ let categories = $state([]);
+ $effect.pre(() => {
+ users = data.users.map((u: any) => ({ ...u }));
+ tags = data.tags.map((t: any) => ({ ...t }));
+ categories = data.categories.map((c: any) => ({ ...c }));
+ });
// Tag form
let newTagName = $state('');
@@ -472,7 +477,7 @@
/>