Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
f29278ef29 Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 10:46:11 +00:00
8 changed files with 74 additions and 900 deletions

View File

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

View File

@@ -1,10 +1,10 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
/kcab/*
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
/*.wasm
Content-Type: application/wasm

View File

@@ -17,7 +17,6 @@ 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,
@@ -167,12 +166,8 @@ 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: cssWithFonts,
css: fetchThemeCss(theme.repo),
name: theme.name,
repo: normalizedRepo,
id: generateThemeId(normalizedRepo),

View File

@@ -1,23 +1,9 @@
// oxlint-disable eslint/no-script-url
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
embedThemeFonts,
MAX_FONT_FILE_SIZE,
parseInstalledTheme,
validateThemeCss,
} from './customThemes';
import { parseInstalledTheme, 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', () => {
@@ -88,7 +74,7 @@ describe('validateThemeCss', () => {
},
])('should reject $description', ({ css }) => {
expect(() => validateThemeCss(css)).toThrow(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
});
});
@@ -104,7 +90,7 @@ describe('validateThemeCss', () => {
color: red;
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
{
description: 'multiple selectors',
@@ -115,7 +101,7 @@ describe('validateThemeCss', () => {
--color-primary: #ffffff;
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
{
description: 'media queries',
@@ -128,7 +114,7 @@ describe('validateThemeCss', () => {
}
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
{
description: 'custom selector before :root',
@@ -139,7 +125,7 @@ describe('validateThemeCss', () => {
--color-primary: #007bff;
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
])('should reject CSS with $description', ({ css, expectedError }) => {
expect(() => validateThemeCss(css)).toThrow(expectedError);
@@ -285,7 +271,7 @@ describe('validateThemeCss', () => {
},
])('should reject $description', ({ css }) => {
expect(() => validateThemeCss(css)).toThrow(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
});
});
@@ -793,6 +779,12 @@ 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 {
@@ -876,337 +868,6 @@ describe('validateThemeCss', () => {
});
});
describe('validateThemeCss - font properties (--font-*)', () => {
describe('valid font-family values', () => {
it.each([
{
description: 'single generic family',
css: `:root { --font-family: sans-serif; }`,
},
{
description: 'single generic family (serif)',
css: `:root { --font-family: serif; }`,
},
{
description: 'single generic family (monospace)',
css: `:root { --font-family: monospace; }`,
},
{
description: 'system-ui keyword',
css: `:root { --font-family: system-ui; }`,
},
{
description: 'bundled font (Inter Variable)',
css: `:root { --font-family: Inter Variable; }`,
},
{
description: 'quoted bundled font',
css: `:root { --font-family: 'Inter Variable'; }`,
},
{
description: 'double-quoted bundled font',
css: `:root { --font-family: "Inter Variable"; }`,
},
{
description: 'web-safe font (Georgia)',
css: `:root { --font-family: Georgia; }`,
},
{
description: 'web-safe font (Times New Roman) quoted',
css: `:root { --font-family: 'Times New Roman'; }`,
},
{
description: 'comma-separated font stack',
css: `:root { --font-family: Georgia, serif; }`,
},
{
description: 'full font stack with multiple fonts',
css: `:root { --font-family: '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-family: arial; }`,
},
{
description: 'case-insensitive matching (GEORGIA)',
css: `:root { --font-family: GEORGIA; }`,
},
{
description: 'macOS system font',
css: `:root { --font-family: 'SF Pro', -apple-system, sans-serif; }`,
},
{
description: 'mixed with color variables',
css: `:root {
--color-primary: #007bff;
--font-family: 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; }`,
},
])('should accept CSS with $description', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
describe('invalid font-family values - security', () => {
it.each([
{
description: 'empty value',
css: `:root { --font-family: ; }`,
expectedPattern: /value must not be empty/,
},
{
description: 'url() function in font value',
css: `:root { --font-family: url('https://evil.com/font.woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'url() with data: URI',
css: `:root { --font-family: url(data:font/woff2;base64,abc123); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'expression() in font value',
css: `:root { --font-family: expression(alert(1)); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'empty font name between commas',
css: `:root { --font-family: Arial, , sans-serif; }`,
expectedPattern: /empty font name/,
},
{
description: 'Google Fonts URL attempt',
css: `:root { --font-family: url(https://fonts.googleapis.com/css2?family=Roboto); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'local() function',
css: `:root { --font-family: local(Arial); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'format() function',
css: `:root { --font-family: format('woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'rgb() function in font property',
css: `:root { --font-family: rgb(0, 0, 0); }`,
expectedPattern: /function calls are not allowed/,
},
])('should reject CSS with $description', ({ css, expectedPattern }) => {
expect(() => validateThemeCss(css)).toThrow(expectedPattern);
});
});
describe('any font name is valid (no allowlist)', () => {
it.each([
{
description: 'Comic Sans MS',
css: `:root { --font-family: 'Comic Sans MS'; }`,
},
{
description: 'custom font name',
css: `:root { --font-family: 'My Custom Font', sans-serif; }`,
},
{
description: 'arbitrary string',
css: `:root { --font-family: something-random; }`,
},
{ description: 'Papyrus', css: `:root { --font-family: Papyrus; }` },
])('should accept $description as a font name', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
});
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-family: '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-family: '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-family: '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-family: '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-family: '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-family: '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-family: 'Unicode Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should allow custom font name in --font-family after @font-face declaration', () => {
const css = `@font-face {
font-family: 'My Custom Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --font-family: '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-family: '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-family: '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-family: '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-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with javascript: protocol', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('javascript:alert(1)');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should accept any font name in --font-family (no allowlist)', () => {
const css = `@font-face {
font-family: 'Declared Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --font-family: 'Undeclared Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should reject oversized font data', () => {
// Create a base64 string that would decode to > MAX_FONT_FILE_SIZE
const oversizedBase64 = 'A'.repeat(
Math.ceil((MAX_FONT_FILE_SIZE * 4) / 3) + 100,
);
const css = `@font-face {
font-family: 'Big Font';
src: url('data:font/woff2;base64,${oversizedBase64}') format('woff2');
}
:root { --font-family: 'Big Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/maximum size/);
});
});
describe('CSS without @font-face still works', () => {
it('should accept plain :root without @font-face', () => {
const css = `:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should reject other at-rules (not @font-face)', () => {
const css = `@import url('other.css');
:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).toThrow();
});
it('should reject @media outside :root', () => {
const css = `@media (max-width: 600px) { :root { --color-primary: #ff0000; } }
:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).toThrow();
});
});
});
describe('parseInstalledTheme', () => {
describe('valid theme JSON', () => {
it('should parse valid theme with all required fields', () => {
@@ -1472,123 +1133,3 @@ describe('parseInstalledTheme', () => {
});
});
});
describe('embedThemeFonts', () => {
const mockFetch = (
responseBody: ArrayBuffer,
ok = true,
status = 200,
): typeof globalThis.fetch =>
vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
arrayBuffer: () => Promise.resolve(responseBody),
} as Partial<Response>);
const tinyBuffer = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]).buffer;
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should rewrite url() references to data URIs', async () => {
vi.stubGlobal('fetch', mockFetch(tinyBuffer));
const css = `@font-face {
font-family: 'Test';
src: url('fonts/test.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toContain('data:font/woff2;base64,');
expect(result).not.toContain('fonts/test.woff2');
expect(result).toContain(':root');
});
it('should handle quoted filenames with spaces', async () => {
vi.stubGlobal('fetch', mockFetch(tinyBuffer));
const css = `@font-face {
font-family: 'Inter';
src: url("Inter Variable.woff2") format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toContain('data:font/woff2;base64,');
expect(result).not.toContain('Inter Variable.woff2');
});
it('should reject path traversal with ".."', async () => {
const css = `@font-face {
font-family: 'Evil';
src: url('../escape/font.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'is not allowed',
);
});
it('should reject root-anchored paths', async () => {
const css = `@font-face {
font-family: 'Evil';
src: url('/etc/passwd') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'is not allowed',
);
});
it('should reject oversized font files', async () => {
const oversized = new ArrayBuffer(MAX_FONT_FILE_SIZE + 1);
vi.stubGlobal('fetch', mockFetch(oversized));
const css = `@font-face {
font-family: 'Big';
src: url('big.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'exceeds maximum size',
);
});
it('should reject when total font size exceeds budget', async () => {
// Each font is under the per-file limit but together they exceed the total
// Use MAX_FONT_FILE_SIZE (2MB) per font, need 6 to exceed 10MB total
const bigBuffer = new ArrayBuffer(MAX_FONT_FILE_SIZE);
vi.stubGlobal('fetch', mockFetch(bigBuffer));
const fontBlocks = Array.from(
{ length: 6 },
(_, i) => `@font-face {
font-family: 'Font${i}';
src: url('font${i}.woff2') format('woff2');
}`,
).join('\n');
const css = `${fontBlocks}\n:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'Total embedded font data exceeds maximum',
);
});
it('should return CSS unchanged when no url() refs exist', async () => {
const css = `@font-face {
font-family: 'Test';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toBe(css);
});
});

View File

@@ -79,63 +79,6 @@ export async function fetchDirectCss(url: string): Promise<string> {
return response.text();
}
/** Strip surrounding single or double quotes from a string. */
function stripQuotes(s: string): string {
const t = s.trim();
if (
(t.startsWith("'") && t.endsWith("'")) ||
(t.startsWith('"') && t.endsWith('"'))
) {
return t.slice(1, -1).trim();
}
return t;
}
/**
* Validate a font-family value for a --font-* CSS variable.
*
* Any font name is allowed — referencing a font the user doesn't have
* installed simply triggers the browser's normal fallback behaviour
* (no network requests). The only things we block are function calls
* (url(), expression(), etc.) because those could load external resources
* or execute expressions.
*
* Quoted or unquoted font names are both accepted.
*
* Examples of accepted values:
* Georgia, serif
* 'Fira Code', monospace
* "My Theme Font", sans-serif
*/
function validateFontFamilyValue(value: string, property: string): void {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(
`Invalid font-family value for "${property}": value must not be empty.`,
);
}
// Split on commas, then validate each font name
const families = trimmed.split(',');
for (const raw of families) {
const name = stripQuotes(raw);
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(), expression(), etc.)
if (/\(/.test(name)) {
throw new Error(
`Invalid font-family value for "${property}": function calls are not allowed. Only font names are permitted.`,
);
}
}
}
/** 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;
@@ -149,15 +92,8 @@ 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 properties use a dedicated validator that accepts any font name
// but rejects function calls (url(), expression(), etc.).
if (/^--font-/i.test(property)) {
validateFontFamilyValue(value, property);
return;
}
if (!value || value.length === 0) {
return; // Empty values are allowed
}
@@ -209,152 +145,79 @@ function validatePropertyValue(value: string, property: string): void {
);
}
// ─── @font-face validation ──────────────────────────────────────────────────
/** Maximum size of a single base64-encoded font (bytes of decoded data). 2 MB. */
export const MAX_FONT_FILE_SIZE = 2 * 1024 * 1024;
/** Maximum total size of all embedded font data across all @font-face blocks. 10 MB. */
export const MAX_TOTAL_FONT_SIZE = 10 * 1024 * 1024;
/**
* Extract @font-face blocks from CSS. Returns the blocks and the remaining CSS.
* Only matches top-level @font-face blocks (not nested inside other rules).
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations.
* Must contain exactly :root { ... } and nothing else.
* Returns the validated CSS or throws an error.
*/
function extractFontFaceBlocks(css: string): {
fontFaceBlocks: string[];
remaining: string;
} {
const fontFaceBlocks: string[] = [];
let remaining = css;
export function validateThemeCss(css: string): string {
// Strip multi-line comments before validation
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// Extract @font-face { ... } blocks one at a time using indexOf-based
// parsing. Each iteration removes the matched block from `remaining`.
for (;;) {
const atIdx = remaining.indexOf('@font-face');
if (atIdx === -1) break;
const openBrace = remaining.indexOf('{', atIdx);
if (openBrace === -1) break;
const closeBrace = remaining.indexOf('}', openBrace + 1);
if (closeBrace === -1) break;
fontFaceBlocks.push(remaining.substring(openBrace + 1, closeBrace).trim());
remaining =
remaining.substring(0, atIdx) + remaining.substring(closeBrace + 1);
}
return { fontFaceBlocks, remaining: remaining.trim() };
}
/**
* Validate @font-face blocks: only data: URIs allowed (no remote URLs).
* Enforces size limits to prevent DoS.
*/
function validateFontFaceBlocks(fontFaceBlocks: string[]): void {
let totalSize = 0;
// Match url() with quoted or unquoted content. Quoted URLs use a non-greedy
// match up to the closing quote; unquoted URLs match non-whitespace/non-paren.
const urlRegex = /url\(\s*(?:'([^']*)'|"([^"]*)"|([^'")\s]+))\s*\)/g;
for (const block of fontFaceBlocks) {
urlRegex.lastIndex = 0;
let match;
while ((match = urlRegex.exec(block)) !== null) {
const uri = (match[1] ?? match[2] ?? match[3]).trim();
if (!uri.startsWith('data:')) {
throw new Error(
'Invalid font src: only data: URIs are allowed in @font-face. ' +
'Remote URLs (http/https) are not permitted to protect user privacy. ' +
'Font files are automatically embedded when installing from GitHub.',
);
}
// Estimate decoded size from base64 content
const base64Match = uri.match(/;base64,(.+)$/);
if (base64Match) {
const size = Math.ceil((base64Match[1].length * 3) / 4);
if (size > MAX_FONT_FILE_SIZE) {
throw new Error(
`Font file exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024}MB.`,
);
}
totalSize += size;
}
}
}
if (totalSize > MAX_TOTAL_FONT_SIZE) {
// Must contain exactly :root { ... } and nothing else
// Find :root { ... } and extract content, then check there's nothing after
const rootMatch = cleaned.match(/^:root\s*\{/);
if (!rootMatch) {
throw new Error(
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
}
/**
* Split CSS declarations by semicolons, but respect quoted strings and url() contents.
* This is needed because data: URIs contain semicolons (e.g., "data:font/woff2;base64,...").
*/
function splitDeclarations(content: string): string[] {
const declarations: string[] = [];
let start = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let parenDepth = 0;
// Find the opening brace after :root
const rootStart = cleaned.indexOf(':root');
const openBrace = cleaned.indexOf('{', rootStart);
for (let i = 0; i < content.length; i++) {
const ch = content[i];
if (ch === "'" && !inDoubleQuote && parenDepth === 0) {
inSingleQuote = !inSingleQuote;
} else if (ch === '"' && !inSingleQuote && parenDepth === 0) {
inDoubleQuote = !inDoubleQuote;
} else if (ch === '(' && !inSingleQuote && !inDoubleQuote) {
parenDepth++;
} else if (
ch === ')' &&
!inSingleQuote &&
!inDoubleQuote &&
parenDepth > 0
) {
parenDepth--;
}
if (ch === ';' && !inSingleQuote && !inDoubleQuote && parenDepth === 0) {
const trimmed = content.substring(start, i).trim();
if (trimmed) declarations.push(trimmed);
start = i + 1;
}
if (openBrace === -1) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const trimmed = content.substring(start).trim();
if (trimmed) declarations.push(trimmed);
// Find the first closing brace (nested blocks will be caught by the check below)
const closeBrace = cleaned.indexOf('}', openBrace + 1);
return declarations;
}
if (closeBrace === -1) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// ─── :root block validation ─────────────────────────────────────────────────
// Extract content inside :root { ... }
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
/**
* Validate the content inside a :root { ... } block.
* Only CSS custom properties (--*) with safe values are allowed.
*/
function validateRootContent(rootContent: string): void {
// Check for forbidden at-rules inside :root
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that)
// Comprehensive list of CSS at-rules that should not be allowed
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
// @namespace, @page, @layer, @container, @scope, and any other at-rules
if (/@[a-z-]+/i.test(rootContent)) {
throw new Error(
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
);
}
// Check for nested blocks
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content
if (/\{/.test(rootContent)) {
throw new Error(
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
);
}
for (const decl of splitDeclarations(rootContent)) {
// Check that there's nothing after the closing brace
const afterRoot = cleaned.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Parse declarations and validate each one
const declarations = rootContent
.split(';')
.map(d => d.trim())
.filter(d => d.length > 0);
for (const decl of declarations) {
const colonIndex = decl.indexOf(':');
if (colonIndex === -1) {
throw new Error(`Invalid CSS declaration: "${decl}"`);
@@ -408,220 +271,9 @@ function validateRootContent(rootContent: string): void {
const value = decl.substring(colonIndex + 1).trim();
validatePropertyValue(value, property);
}
}
// ─── Main validation entry point ────────────────────────────────────────────
/**
* Validate theme CSS. Accepts:
* 1. Optional @font-face blocks (with data: URI fonts only)
* 2. Exactly one :root { ... } block with CSS variable declarations
*
* @font-face blocks must appear before :root.
* Returns the validated CSS or throws an error.
*/
export function validateThemeCss(css: string): string {
// Strip multi-line comments before validation
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// Extract @font-face blocks (if any) from the CSS
const { fontFaceBlocks, remaining } = extractFontFaceBlocks(cleaned);
// Validate @font-face blocks (reject remote URLs, enforce size limits)
validateFontFaceBlocks(fontFaceBlocks);
// Now validate the remaining CSS (should be exactly :root { ... })
const rootMatch = remaining.match(/^:root\s*\{/);
if (!rootMatch) {
// If there are @font-face blocks but no :root, that's an error
// If there's nothing at all, that's also an error
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const rootStart = remaining.indexOf(':root');
const openBrace = remaining.indexOf('{', rootStart);
if (openBrace === -1) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const closeBrace = remaining.indexOf('}', openBrace + 1);
if (closeBrace === -1) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const rootContent = remaining.substring(openBrace + 1, closeBrace).trim();
// Validate :root content
validateRootContent(rootContent);
// Check nothing after :root
const afterRoot = remaining.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Return the comment-stripped CSS — this is what was actually validated,
// so we inject exactly what we checked (defense-in-depth).
return cleaned;
}
// ─── Font embedding (install-time) ─────────────────────────────────────────
/** Map of file extensions to font MIME types for data: URI construction. */
const FONT_EXTENSION_MIME: Record<string, string> = {
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.otf': 'font/opentype',
};
/** Convert an ArrayBuffer to a base64 string using chunked processing. */
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
// Process in 8 KB chunks to avoid excessive string concatenation
for (let i = 0; i < bytes.length; i += 8192) {
const chunk = bytes.subarray(i, Math.min(i + 8192, bytes.length));
chunks.push(String.fromCharCode(...chunk));
}
return btoa(chunks.join(''));
}
/**
* Embed fonts referenced in @font-face blocks by fetching them from a GitHub
* repo and converting to data: URIs.
*
* This runs at install time only. Relative URL references like
* `url('./fonts/MyFont.woff2')` are resolved relative to the repo's root
* directory and fetched from GitHub's raw content API.
*
* The returned CSS has all font URLs replaced with self-contained data: URIs,
* so no network requests are needed at runtime.
*
* @param css - The raw theme CSS (may contain relative url() references)
* @param repo - GitHub repo in "owner/repo" format
* @returns CSS with all font URLs replaced by data: URIs
*/
export async function embedThemeFonts(
css: string,
repo: string,
): Promise<string> {
const baseUrl = `https://raw.githubusercontent.com/${repo}/refs/heads/main/`;
// Collect all url() references that need fetching across all @font-face blocks
const urlRegex = /url\(\s*(?:(['"])([^'"]*?)\1|([^'")\s]+))\s*\)/g;
type FontRef = {
fullMatch: string;
quote: string;
path: string;
cleanPath: string;
mime: string;
};
const fontRefs: FontRef[] = [];
// Use extractFontFaceBlocks-style indexOf parsing to find @font-face blocks
// and their url() references, without duplicating the regex approach
let searchCss = css;
let offset = 0;
for (;;) {
const atIdx = searchCss.indexOf('@font-face', 0);
if (atIdx === -1) break;
const openBrace = searchCss.indexOf('{', atIdx);
if (openBrace === -1) break;
const closeBrace = searchCss.indexOf('}', openBrace + 1);
if (closeBrace === -1) break;
const blockContent = searchCss.substring(openBrace + 1, closeBrace);
// Find url() references within this block
let urlMatch;
urlRegex.lastIndex = 0;
while ((urlMatch = urlRegex.exec(blockContent)) !== null) {
const fullMatch = urlMatch[0];
const quote = urlMatch[1] || '';
const path = urlMatch[2] ?? urlMatch[3];
// Skip data: URIs — already embedded
if (path.startsWith('data:')) continue;
if (/^https?:\/\//i.test(path)) {
throw new Error(
`Remote font URL "${path}" is not allowed. Only relative paths to fonts in the same GitHub repo are supported.`,
);
}
const cleanPath = path.replace(/^\.\//, '');
if (cleanPath.startsWith('/') || cleanPath.includes('..')) {
throw new Error(
`Font path "${path}" is not allowed. Only relative paths within the repo are supported (no "/" prefix or ".." segments).`,
);
}
const ext = cleanPath.substring(cleanPath.lastIndexOf('.')).toLowerCase();
const mime = FONT_EXTENSION_MIME[ext];
if (!mime) {
throw new Error(
`Unsupported font file extension "${ext}". Supported: ${Object.keys(FONT_EXTENSION_MIME).join(', ')}.`,
);
}
fontRefs.push({ fullMatch, quote, path, cleanPath, mime });
}
offset = closeBrace + 1;
searchCss = searchCss.substring(offset);
}
if (fontRefs.length === 0) return css;
// Fetch fonts sequentially to enforce a running total size budget
const fetched: { ref: FontRef; dataUri: string }[] = [];
let totalBytes = 0;
for (const ref of fontRefs) {
const fontUrl = baseUrl + ref.cleanPath;
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch font file "${ref.cleanPath}" from ${fontUrl}: ${response.status} ${response.statusText}`,
);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_FONT_FILE_SIZE) {
throw new Error(
`Font file "${ref.cleanPath}" exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024}MB.`,
);
}
totalBytes += buffer.byteLength;
if (totalBytes > MAX_TOTAL_FONT_SIZE) {
throw new Error(
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
);
}
const base64 = arrayBufferToBase64(buffer);
fetched.push({ ref, dataUri: `data:${ref.mime};base64,${base64}` });
}
// Replace each url() reference with its data: URI
let result = css;
for (const { ref, dataUri } of fetched) {
const q = ref.quote || "'";
result = result.replace(ref.fullMatch, `url(${q}${dataUri}${q})`);
}
return result;
// Return the original CSS (with :root wrapper) so it can be injected properly
return css.trim();
}
/**

View File

@@ -128,10 +128,6 @@ app.get('/metrics', (_req, res) => {
app.use((req, res, next) => {
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
res.set(
'Content-Security-Policy',
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
);
next();
});
if (process.env.NODE_ENV === 'development') {

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [MatissJanis]
---
Custom Themes: allow using a custom font

View File

@@ -17056,9 +17056,9 @@ __metadata:
linkType: hard
"flatted@npm:^3.2.9":
version: 3.3.3
resolution: "flatted@npm:3.3.3"
checksum: 10/8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe
version: 3.4.2
resolution: "flatted@npm:3.4.2"
checksum: 10/a9e78fe5c2c1fcd98209a015ccee3a6caa953e01729778e83c1fe92e68601a63e1e69cd4e573010ca99eaf585a581b80ccf1018b99283e6cbc2117bcba1e030f
languageName: node
linkType: hard