[AI] Add secure custom font support for custom themes

Implement safe font-family references in custom themes via CSS variables
(--font-body, --font-mono, --font-heading, etc.) validated against a
curated allowlist of system-installed and web-safe fonts.

Security approach: Only fonts already present on the user's OS or bundled
with the app are allowed. No @font-face, no url(), no external font
loading — this prevents third-party tracking via font requests while
still enabling meaningful font customization in themes.

Key changes:
- Add SAFE_FONT_FAMILIES allowlist (~80 fonts: generic families, bundled
  fonts, and common system fonts across platforms)
- Add validateFontFamilyValue() for comma-separated font stack validation
- Route --font-{body,mono,heading,family,ui,display,code} properties
  through the font validator instead of the color validator
- Update index.html to use var(--font-body, ...) with current Inter
  Variable stack as fallback
- Add comprehensive tests for valid/invalid font values and security
  edge cases (url injection, javascript:, expression(), etc.)

https://claude.ai/code/session_01D4ASLpcBCvWF1nzPLz9Tw5
This commit is contained in:
Claude
2026-03-19 20:35:53 +00:00
parent f5a72448bd
commit a16b4107a7
3 changed files with 383 additions and 11 deletions

View File

@@ -31,7 +31,8 @@
body,
button,
input {
font-family:
font-family: var(
--font-body,
'Inter Variable',
-apple-system,
BlinkMacSystemFont,
@@ -45,7 +46,8 @@
'Helvetica Neue',
'Helvetica',
'Arial',
sans-serif;
sans-serif
);
}
a {
@@ -67,7 +69,8 @@
input,
textarea {
font-size: 1em;
font-family:
font-family: var(
--font-body,
'Inter Variable',
-apple-system,
BlinkMacSystemFont,
@@ -81,7 +84,8 @@
'Helvetica Neue',
'Helvetica',
'Arial',
sans-serif;
sans-serif
);
font-feature-settings: 'ss01', 'ss04';
}

View File

@@ -1,7 +1,11 @@
// oxlint-disable eslint/no-script-url
import { describe, expect, it } from 'vitest';
import { parseInstalledTheme, validateThemeCss } from './customThemes';
import {
parseInstalledTheme,
SAFE_FONT_FAMILIES,
validateThemeCss,
} from './customThemes';
import type { InstalledTheme } from './customThemes';
describe('validateThemeCss', () => {
@@ -779,12 +783,6 @@ describe('validateThemeCss', () => {
--spacing: 10px 20px;
}`,
},
{
description: 'value with comma-separated values',
css: `:root {
--font-family: Arial, sans-serif;
}`,
},
{
description: 'property name with invalid characters',
css: `:root {
@@ -868,6 +866,198 @@ describe('validateThemeCss', () => {
});
});
describe('validateThemeCss - font properties (--font-*)', () => {
describe('valid font-family values', () => {
it.each([
{
description: 'single generic family',
css: `:root { --font-body: sans-serif; }`,
},
{
description: 'single generic family (serif)',
css: `:root { --font-body: serif; }`,
},
{
description: 'single generic family (monospace)',
css: `:root { --font-body: monospace; }`,
},
{
description: 'system-ui keyword',
css: `:root { --font-body: system-ui; }`,
},
{
description: 'bundled font (Inter Variable)',
css: `:root { --font-body: Inter Variable; }`,
},
{
description: 'quoted bundled font',
css: `:root { --font-body: 'Inter Variable'; }`,
},
{
description: 'double-quoted bundled font',
css: `:root { --font-body: "Inter Variable"; }`,
},
{
description: 'web-safe font (Georgia)',
css: `:root { --font-body: Georgia; }`,
},
{
description: 'web-safe font (Times New Roman) quoted',
css: `:root { --font-body: 'Times New Roman'; }`,
},
{
description: 'comma-separated font stack',
css: `:root { --font-body: Georgia, serif; }`,
},
{
description: 'full font stack with multiple fonts',
css: `:root { --font-body: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }`,
},
{
description: 'monospace font stack',
css: `:root { --font-mono: 'Fira Code', Consolas, Monaco, monospace; }`,
},
{
description: 'case-insensitive matching (arial)',
css: `:root { --font-body: arial; }`,
},
{
description: 'case-insensitive matching (GEORGIA)',
css: `:root { --font-body: GEORGIA; }`,
},
{
description: 'macOS system font',
css: `:root { --font-body: 'SF Pro', -apple-system, sans-serif; }`,
},
{
description: 'mixed with color variables',
css: `:root {
--color-primary: #007bff;
--font-body: Georgia, serif;
--color-secondary: #6c757d;
}`,
},
{
description: '--font-mono property',
css: `:root { --font-mono: 'JetBrains Mono', 'Fira Code', monospace; }`,
},
{
description: '--font-heading property',
css: `:root { --font-heading: Palatino, 'Book Antiqua', serif; }`,
},
{
description: 'empty value',
css: `:root { --font-body: ; }`,
},
])('should accept CSS with $description', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
describe('invalid font-family values - security', () => {
it.each([
{
description: 'unknown/external font name',
css: `:root { --font-body: 'Comic Sans MS'; }`,
expectedPattern: /Only safe system and web-safe fonts are allowed/,
},
{
description: 'url() function in font value',
css: `:root { --font-body: url('https://evil.com/font.woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'url() with data: URI',
css: `:root { --font-body: url(data:font/woff2;base64,abc123); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'javascript: in font value',
css: `:root { --font-body: javascript:alert(1); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'expression() in font value',
css: `:root { --font-body: expression(alert(1)); }`,
expectedPattern: /function calls are not allowed/,
},
{
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/,
},
{
description: 'empty font name between commas',
css: `:root { --font-body: Arial, , sans-serif; }`,
expectedPattern: /empty font name/,
},
{
description: 'random string that is not a font',
css: `:root { --font-body: something-random; }`,
expectedPattern: /Only safe system and web-safe fonts are allowed/,
},
{
description: 'Google Fonts URL attempt',
css: `:root { --font-body: url(https://fonts.googleapis.com/css2?family=Roboto); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'local() function',
css: `:root { --font-body: local(Arial); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'format() function',
css: `:root { --font-body: format('woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
])('should reject CSS with $description', ({ css, expectedPattern }) => {
expect(() => validateThemeCss(css)).toThrow(expectedPattern);
});
});
describe('font properties do not accept color-style values', () => {
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/,
);
});
it('should reject rgb() in font property', () => {
const css = `:root { --font-body: rgb(0, 0, 0); }`;
expect(() => validateThemeCss(css)).toThrow(/function calls/);
});
});
describe('SAFE_FONT_FAMILIES allowlist', () => {
it('should contain common generic families', () => {
expect(SAFE_FONT_FAMILIES.has('sans-serif')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('serif')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('monospace')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('system-ui')).toBe(true);
});
it('should contain the bundled fonts', () => {
expect(SAFE_FONT_FAMILIES.has('Inter Variable')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('Redacted Script')).toBe(true);
});
it('should contain common web-safe fonts', () => {
expect(SAFE_FONT_FAMILIES.has('Arial')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('Georgia')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('Courier New')).toBe(true);
expect(SAFE_FONT_FAMILIES.has('Verdana')).toBe(true);
});
it('should not contain arbitrary fonts', () => {
expect(SAFE_FONT_FAMILIES.has('Comic Sans MS')).toBe(false);
expect(SAFE_FONT_FAMILIES.has('Papyrus')).toBe(false);
expect(SAFE_FONT_FAMILIES.has('My Custom Font')).toBe(false);
});
});
});
describe('parseInstalledTheme', () => {
describe('valid theme JSON', () => {
it('should parse valid theme with all required fields', () => {

View File

@@ -79,6 +79,176 @@ export async function fetchDirectCss(url: string): Promise<string> {
return response.text();
}
/**
* Allowlist of safe font families for custom themes.
*
* Security rationale: We ONLY allow system-installed and bundled fonts.
* This avoids any network requests that could be used for user tracking
* (e.g., Google Fonts, Adobe Fonts, or any external font URL could leak
* user IP addresses, timing data, and user-agent strings to third parties).
*
* No @font-face, no url(), no external resources — just fonts the OS
* or the app already ships.
*/
export const SAFE_FONT_FAMILIES: ReadonlySet<string> = new Set([
// === CSS generic font families ===
'sans-serif',
'serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-sans-serif',
'ui-serif',
'ui-monospace',
'ui-rounded',
'math',
'emoji',
// === Bundled with Actual ===
'Inter Variable',
'Redacted Script',
// === Common web-safe / system fonts ===
// Sans-serif
'Arial',
'Helvetica',
'Helvetica Neue',
'Verdana',
'Geneva',
'Tahoma',
'Trebuchet MS',
'Segoe UI',
'Roboto',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Oxygen',
'Lucida Grande',
'Lucida Sans Unicode',
'Lucida Sans',
'DejaVu Sans',
'Noto Sans',
'Liberation Sans',
'Calibri',
'Gill Sans',
'Optima',
'Futura',
'Century Gothic',
'Franklin Gothic Medium',
// macOS
'SF Pro',
'SF Pro Display',
'SF Pro Text',
'SF Pro Rounded',
'-apple-system',
'BlinkMacSystemFont',
// Serif
'Georgia',
'Times New Roman',
'Times',
'Palatino',
'Palatino Linotype',
'Book Antiqua',
'Garamond',
'Cambria',
'Constantia',
'Baskerville',
'Hoefler Text',
'Didot',
'Bodoni MT',
'Rockwell',
'DejaVu Serif',
'Noto Serif',
'Liberation Serif',
// macOS
'New York',
'Charter',
'Iowan Old Style',
// Monospace
'Courier New',
'Courier',
'Consolas',
'Monaco',
'Menlo',
'Andale Mono',
'Lucida Console',
'DejaVu Sans Mono',
'Noto Sans Mono',
'Liberation Mono',
'Source Code Pro',
'Fira Mono',
'Fira Code',
'JetBrains Mono',
'IBM Plex Mono',
// macOS
'SF Mono',
]);
/**
* Normalised lookup: lower-cased font name → canonical (display) name.
* Used for case-insensitive matching while preserving original casing.
*/
const SAFE_FONT_FAMILIES_LOWER: ReadonlyMap<string, string> = new Map(
[...SAFE_FONT_FAMILIES].map(f => [f.toLowerCase(), f]),
);
/**
* Validate a font-family value for a --font-* CSS variable.
*
* Accepts a comma-separated list of font names. Each font name is
* matched case-insensitively against the safe font allowlist.
* Quoted or unquoted font names are both accepted.
*
* Examples of accepted values:
* Georgia, serif
* 'Fira Code', monospace
* "SF Pro", -apple-system, sans-serif
* system-ui
*/
function validateFontFamilyValue(value: string, property: string): void {
const trimmed = value.trim();
if (!trimmed) return; // empty values are allowed
// Split on commas, then validate each font name
const families = trimmed.split(',');
for (const raw of families) {
// Strip leading/trailing whitespace and optional quotes
let name = raw.trim();
if (
(name.startsWith("'") && name.endsWith("'")) ||
(name.startsWith('"') && name.endsWith('"'))
) {
name = name.slice(1, -1).trim();
}
if (!name) {
throw new Error(
`Invalid font-family value for "${property}": empty font name in comma-separated list.`,
);
}
// Reject anything that looks like a function call (url(), etc.)
if (/\(/.test(name)) {
throw new Error(
`Invalid font-family value for "${property}": function calls are not allowed. Only safe font names are permitted.`,
);
}
// Case-insensitive lookup
if (!SAFE_FONT_FAMILIES_LOWER.has(name.toLowerCase())) {
throw new Error(
`Invalid font-family value "${name}" for "${property}". Only safe system and web-safe fonts are allowed. ` +
`External fonts are not permitted to protect user privacy.`,
);
}
}
}
/** Only var(--custom-property-name) is allowed; no fallbacks. Variable name: -- then [a-zA-Z0-9_-]+ (no trailing dash). */
const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i;
@@ -92,8 +262,16 @@ function isValidSimpleVarValue(value: string): boolean {
/**
* Validate that a CSS property value only contains allowed content (allowlist approach).
* Allows: colors (hex, rgb/rgba, hsl/hsla), lengths, numbers, keywords, and var(--name) only (no fallbacks).
* Font properties (--font-*) are validated against a safe font family allowlist instead.
*/
function validatePropertyValue(value: string, property: string): void {
// Font-family properties use a dedicated validator: comma-separated safe font names only.
// We match specific property name patterns rather than all --font-* to avoid
// catching unrelated variables like --font-weight or --font-size.
if (/^--font-(body|mono|heading|family|ui|display|code)$/i.test(property)) {
validateFontFamilyValue(value, property);
return;
}
if (!value || value.length === 0) {
return; // Empty values are allowed
}