From 4c64b52ea0e00ba43f93e9714fbe2490f4ee0af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 20:50:48 +0000 Subject: [PATCH] [AI] Add @font-face support with data: URI embedding for custom themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable truly custom fonts in themes while maintaining zero runtime network requests. Theme authors can include font files in their GitHub repos, and fonts are automatically downloaded and embedded as data: URIs at install time — the same approach used for theme CSS itself. Security model: - @font-face blocks only allow data: URIs (no http/https/relative URLs) - Font MIME types are validated (font/woff2, font/ttf, etc.) - Individual font files capped at 2MB, total at 10MB - @font-face properties are allowlisted (font-family, src, font-weight, font-style, font-display, font-stretch, unicode-range only) - Font-family names from @font-face are available in --font-* variables - No runtime network requests — all fonts stored locally after install Key additions: - extractFontFaceBlocks(): parse @font-face from theme CSS - validateFontFaceBlock(): validate properties and data: URIs - splitDeclarations(): semicolon-aware parser that respects data: URIs - embedThemeFonts(): fetch font files from GitHub, convert to data: URIs - ThemeInstaller calls embedThemeFonts() during catalog theme installation - 30+ new test cases for @font-face validation and security edge cases Example theme CSS with custom fonts: @font-face { font-family: 'My Font'; src: url('./MyFont.woff2') format('woff2'); } :root { --font-body: 'My Font', sans-serif; } https://claude.ai/code/session_01D4ASLpcBCvWF1nzPLz9Tw5 --- .../components/settings/ThemeInstaller.tsx | 7 +- .../src/style/customThemes.test.ts | 273 +++++++- .../desktop-client/src/style/customThemes.ts | 616 ++++++++++++++++-- 3 files changed, 816 insertions(+), 80 deletions(-) diff --git a/packages/desktop-client/src/components/settings/ThemeInstaller.tsx b/packages/desktop-client/src/components/settings/ThemeInstaller.tsx index 651d6f8bc6..7826ee7144 100644 --- a/packages/desktop-client/src/components/settings/ThemeInstaller.tsx +++ b/packages/desktop-client/src/components/settings/ThemeInstaller.tsx @@ -17,6 +17,7 @@ import { Link } from '@desktop-client/components/common/Link'; import { FixedSizeList } from '@desktop-client/components/FixedSizeList'; import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog'; import { + embedThemeFonts, extractRepoOwner, fetchThemeCss, generateThemeId, @@ -166,8 +167,12 @@ export function ThemeInstaller({ setSelectedCatalogTheme(theme); const normalizedRepo = normalizeGitHubRepo(theme.repo); + // Fetch CSS and embed any referenced font files as data: URIs + const cssWithFonts = fetchThemeCss(theme.repo).then(css => + embedThemeFonts(css, theme.repo), + ); await installTheme({ - css: fetchThemeCss(theme.repo), + css: cssWithFonts, name: theme.name, repo: normalizedRepo, id: generateThemeId(normalizedRepo), diff --git a/packages/desktop-client/src/style/customThemes.test.ts b/packages/desktop-client/src/style/customThemes.test.ts index 899ad78448..f22082f362 100644 --- a/packages/desktop-client/src/style/customThemes.test.ts +++ b/packages/desktop-client/src/style/customThemes.test.ts @@ -2,12 +2,22 @@ import { describe, expect, it } from 'vitest'; import { + MAX_FONT_FILE_SIZE, parseInstalledTheme, SAFE_FONT_FAMILIES, validateThemeCss, } from './customThemes'; import type { InstalledTheme } from './customThemes'; +// Small valid woff2 data URI for testing (actual content doesn't matter for validation) +const TINY_WOFF2_BASE64 = 'AAAAAAAAAA=='; +const TINY_WOFF2_DATA_URI = `data:font/woff2;base64,${TINY_WOFF2_BASE64}`; +const FONT_FACE_BLOCK = `@font-face { + font-family: 'Test Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); + font-display: swap; +}`; + describe('validateThemeCss', () => { describe('valid CSS', () => { it('should accept valid :root with CSS variables', () => { @@ -78,7 +88,7 @@ describe('validateThemeCss', () => { }, ])('should reject $description', ({ css }) => { expect(() => validateThemeCss(css)).toThrow( - 'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', + 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.', ); }); }); @@ -94,7 +104,7 @@ describe('validateThemeCss', () => { color: red; }`, expectedError: - 'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', + 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.', }, { description: 'multiple selectors', @@ -105,7 +115,7 @@ describe('validateThemeCss', () => { --color-primary: #ffffff; }`, expectedError: - 'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', + 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.', }, { description: 'media queries', @@ -118,7 +128,7 @@ describe('validateThemeCss', () => { } }`, expectedError: - 'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', + 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.', }, { description: 'custom selector before :root', @@ -129,7 +139,7 @@ describe('validateThemeCss', () => { --color-primary: #007bff; }`, expectedError: - 'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', + 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.', }, ])('should reject CSS with $description', ({ css, expectedError }) => { expect(() => validateThemeCss(css)).toThrow(expectedError); @@ -275,7 +285,7 @@ describe('validateThemeCss', () => { }, ])('should reject $description', ({ css }) => { expect(() => validateThemeCss(css)).toThrow( - 'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.', + 'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.', ); }); }); @@ -959,7 +969,8 @@ describe('validateThemeCss - font properties (--font-*)', () => { { description: 'unknown/external font name', css: `:root { --font-body: 'Comic Sans MS'; }`, - expectedPattern: /Only safe system and web-safe fonts are allowed/, + expectedPattern: + /Only safe system\/web-safe fonts and fonts declared via @font-face/, }, { description: 'url() function in font value', @@ -984,7 +995,8 @@ describe('validateThemeCss - font properties (--font-*)', () => { { description: 'font with unknown name in stack', css: `:root { --font-body: 'My Custom Font', sans-serif; }`, - expectedPattern: /Only safe system and web-safe fonts are allowed/, + expectedPattern: + /Only safe system\/web-safe fonts and fonts declared via @font-face/, }, { description: 'empty font name between commas', @@ -994,7 +1006,8 @@ describe('validateThemeCss - font properties (--font-*)', () => { { description: 'random string that is not a font', css: `:root { --font-body: something-random; }`, - expectedPattern: /Only safe system and web-safe fonts are allowed/, + expectedPattern: + /Only safe system\/web-safe fonts and fonts declared via @font-face/, }, { description: 'Google Fonts URL attempt', @@ -1020,7 +1033,7 @@ describe('validateThemeCss - font properties (--font-*)', () => { it('should reject hex color in font property', () => { const css = `:root { --font-body: #007bff; }`; expect(() => validateThemeCss(css)).toThrow( - /Only safe system and web-safe fonts are allowed/, + /Only safe system\/web-safe fonts and fonts declared via @font-face/, ); }); @@ -1058,6 +1071,246 @@ describe('validateThemeCss - font properties (--font-*)', () => { }); }); +describe('validateThemeCss - @font-face blocks', () => { + describe('valid @font-face with data: URIs', () => { + it('should accept @font-face with data: URI and :root', () => { + const css = `${FONT_FACE_BLOCK} +:root { --font-body: 'Test Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept multiple @font-face blocks', () => { + const css = `@font-face { + font-family: 'Test Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Test Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} +:root { --font-body: 'Test Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept @font-face with font/woff MIME type', () => { + const css = `@font-face { + font-family: 'Woff Font'; + src: url('data:font/woff;base64,${TINY_WOFF2_BASE64}') format('woff'); +} +:root { --font-body: 'Woff Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept @font-face with font/ttf MIME type', () => { + const css = `@font-face { + font-family: 'TTF Font'; + src: url('data:font/ttf;base64,${TINY_WOFF2_BASE64}') format('truetype'); +} +:root { --font-body: 'TTF Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept @font-face with application/font-woff2 MIME type', () => { + const css = `@font-face { + font-family: 'App Font'; + src: url('data:application/font-woff2;base64,${TINY_WOFF2_BASE64}') format('woff2'); +} +:root { --font-body: 'App Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept @font-face with font-stretch', () => { + const css = `@font-face { + font-family: 'Stretch Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); + font-stretch: condensed; +} +:root { --font-body: 'Stretch Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept @font-face with unicode-range', () => { + const css = `@font-face { + font-family: 'Unicode Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); + unicode-range: U+0000-00FF; +} +:root { --font-body: 'Unicode Font', sans-serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should allow custom font name in --font-body after @font-face declaration', () => { + const css = `@font-face { + font-family: 'My Custom Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); +} +:root { --font-body: 'My Custom Font', Georgia, serif; }`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + + it('should accept @font-face alongside color variables', () => { + const css = `${FONT_FACE_BLOCK} +:root { + --color-primary: #007bff; + --font-body: 'Test Font', sans-serif; + --color-secondary: #6c757d; +}`; + expect(() => validateThemeCss(css)).not.toThrow(); + }); + }); + + describe('invalid @font-face - security', () => { + it('should reject @font-face with remote HTTP URL', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('https://evil.com/font.woff2') format('woff2'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/data: URIs/); + }); + + it('should reject @font-face with remote HTTPS URL', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('https://fonts.example.com/custom.woff2') format('woff2'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/data: URIs/); + }); + + it('should reject @font-face with relative URL (not embedded)', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('./fonts/custom.woff2') format('woff2'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/data: URIs/); + }); + + it('should reject @font-face with non-font MIME type', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('data:text/html;base64,${TINY_WOFF2_BASE64}') format('woff2'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/MIME type/); + }); + + it('should reject @font-face with javascript: protocol', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('javascript:alert(1)'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/data: URIs/); + }); + + it('should reject @font-face with data: image MIME type', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('data:image/svg+xml;base64,${TINY_WOFF2_BASE64}') format('woff2'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/MIME type/); + }); + + it('should reject @font-face with forbidden property', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); + content: 'malicious'; +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow( + /Invalid @font-face property/, + ); + }); + + it('should reject @font-face with invalid format hint', () => { + const css = `@font-face { + font-family: 'Bad Font'; + src: url('${TINY_WOFF2_DATA_URI}') format('svg'); +} +:root { --font-body: 'Bad Font', sans-serif; }`; + expect(() => validateThemeCss(css)).toThrow(/font format hint/); + }); + + it('should reject @font-face without font-family', () => { + const css = `@font-face { + src: url('${TINY_WOFF2_DATA_URI}') format('woff2'); +} +:root { --color-primary: #007bff; }`; + expect(() => validateThemeCss(css)).toThrow(/font-family/); + }); + + it('should reject @font-face without src', () => { + const css = `@font-face { + font-family: 'No Src Font'; +} +:root { --color-primary: #007bff; }`; + expect(() => validateThemeCss(css)).toThrow(/src/); + }); + + it('should reject font-family with special characters in name', () => { + const css = `@font-face { + font-family: 'Font