santifer/career-ops · warning
⚠️ Font reference escapes fonts/, keeping original referenc
Error message
⚠️ Font reference escapes fonts/, keeping original reference: ${name} What it means
inlineLocalFonts() in generate-pdf.mjs rewrites CSS url(./fonts/...) references into base64 data URLs. As a containment check, it resolves each font name against the fonts/ directory and rejects any that would escape it (a path with '..' segments or an absolute component); the offending reference is kept as-is and warned about, so the font simply does not get inlined.
Source
Thrown at generate-pdf.mjs:1394
*
* @param {string} html - HTML that may reference url('./fonts/<file>').
* @returns {Promise<string>} HTML with local font references inlined.
*/
const _fontDataUrlCache = new Map();
export async function inlineLocalFonts(html) {
const FONT_REF = /url\(\s*(['"]?)\.\/fonts\/([^'")\s]+)\1\s*\)/g;
const MIME = { woff2: 'font/woff2', woff: 'font/woff', otf: 'font/otf', ttf: 'font/ttf' };
const fontsDir = resolve(__dirname, 'fonts');
const names = [...new Set([...html.matchAll(FONT_REF)].map((m) => m[2]))];
const dataUrls = new Map();
for (const name of names) {
// Containment check: ".." segments and absolute names (./fonts//etc/passwd)
// would otherwise resolve outside fonts/.
const fontPath = resolve(fontsDir, name);
const rel = relative(fontsDir, fontPath);
if (rel.startsWith('..') || isAbsolute(rel)) {
console.warn(`⚠️ Font reference escapes fonts/, keeping original reference: ${name}`);
continue;
}
if (_fontDataUrlCache.has(fontPath)) {
dataUrls.set(name, _fontDataUrlCache.get(fontPath));
continue;
}
try {
const buf = await readFile(fontPath);
const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
const dataUrl = `url('data:${MIME[ext] || 'application/octet-stream'};base64,${buf.toString('base64')}')`;
_fontDataUrlCache.set(fontPath, dataUrl);
dataUrls.set(name, dataUrl);
} catch (err) {
if (err?.code !== 'ENOENT') throw err;
console.warn(`⚠️ Font file not found, keeping original reference: fonts/${name}`);
}
}
return html.replace(FONT_REF, (match, _quote, name) => dataUrls.get(name) || match);View on GitHub (pinned to 60398d6549)
Solutions
- Move the font file into the fonts/ directory and reference it as ./fonts/<name> so it can be inlined
- Fix the CSS path to remove '..' segments or absolute components pointing outside fonts/
- If the external reference is intentional (e.g. a CDN font), accept that it is not inlined and verify the PDF renders the fallback font acceptably
Example fix
/* before */
@font-face { src: url('./fonts/../assets/inter.woff2'); }
/* after — font moved into fonts/ */
@font-face { src: url('./fonts/inter.woff2'); } Defensive patterns
Strategy: validation
Validate before calling
import { resolve, relative, isAbsolute } from 'node:path';
function staysInsideFontsDir(fontsDir, name) {
const rel = relative(fontsDir, resolve(fontsDir, name));
return !(rel.startsWith('..') || isAbsolute(rel));
} Prevention
- Keep all inlined font files inside fonts/ and reference them as ./fonts/<file>
- Audit template CSS for ../ or absolute url() paths after copying snippets from other projects
When it happens
Trigger: CSS containing url('./fonts/../secret.woff2') or a crafted name like ./fonts//etc/passwd — relative(fontsDir, fontPath) starts with '..' or is absolute, the containment branch fires, and the original reference survives into the rendered HTML (typically breaking the font in the PDF).
Common situations: Hand-edited template CSS with ../ paths to fonts stored outside fonts/; copied CSS from another project whose font layout differs; deliberately directory-traversal-shaped test fixtures probing the inliner.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- Unsupported template format: ${format} (expected html or tex
- Refusing to write the cover letter outside output/: ${raw}
- ⚠️ ${message} (proceeding — --allow-reorder set)
- ⚠️ config/profile.yml cv.sections lists only "${displayTitl
- ⚠️ config/profile.yml cv.sections lists "${shown}" more tha
AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20).
Data as JSON: /api/errors/086356fd03e12e12.
Report an issue: GitHub.