mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 10:33:02 -05:00
[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:
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user