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

  1. Move the font file into the fonts/ directory and reference it as ./fonts/<name> so it can be inlined
  2. Fix the CSS path to remove '..' segments or absolute components pointing outside fonts/
  3. 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

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


AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20). Data as JSON: /api/errors/086356fd03e12e12. Report an issue: GitHub.