#759·camoufox

measureText and @font-face src: local() disagree under a per-context font mask

Author: RayenMlayehCreated Sep 6, 2026Updated Sep 6, 2026

Describe the bug

Under a per-context font mask, measureText() and @font-face { src: local(...) } disagree about which families exist. A page can read both and compare them; no reference font list or known-good machine is needed, and a genuine Firefox cannot produce a disagreement because both routes resolve from the same font list.

The local() answer is the host's real font set, so the mismatch also identifies the underlying OS directly.

Measured

152.0.4-beta.30, Windows 11 host, raw binary, fingerprint from generate_context_fingerprint(os="macos") via add_init_script. Probe is 7 Windows-only families, checked twice per family — once by width, once by loading a FontFace with a local() source:

launch navigator.platform measureText src: local() disagree
no init script (baseline) Win32 7/7 7/7 0
per-context mask (init script only) MacIntel 0/7 7/7 7
global mask (AsyncCamoufox, CAMOU_CONFIG["fonts"]) MacIntel 0/7 0/7 0

Row 1 shows agreement is the honest behaviour and the probe is sound. Row 3 shows font-hijacker.patch closes both routes when a global allowlist is set. Only the per-context path splits.

Cause

Everything on the FontFace path routes through the helper in layout/style/FontFaceImpl.h:

cpp
inline bool IsFontAllowed(const nsACString& aFontName) {
  ...
  return MaskConfig::IsFontAllowed(fontName);
}

and MaskConfig::IsFontAllowed (additions/camoucfg/MaskConfig.hpp) is:

cpp
inline bool IsFontAllowed(std::string_view family) {
  const auto& fonts = FontAllowlist();
  if (fonts.empty()) return true;      // <-- no global allowlist: everything allowed
  ...
}

In per-context mode there is no global fonts allowlist, so the helper returns true for every family. FontFace::Status(), FontFace::Load() and FontFaceImpl::SetStatus() all report Loaded. The same is true one layer down in gfxUserFontSet::LoadNext, where the local() lookup is skipped only when gfxPlatformFontList::MaskedFontListAppliesTo() is true — which also keys off the global allowlist.

FontListManager — the per-context list window.setFontList() populates — is never consulted on any of these paths. It is wired into gfxPlatformFontList::FindAndAddFamiliesLocked only, which is why the width route is masked and the local() route is not.

The same gap likely applies to GlobalFontFallback (per-character system fallback), which font-hijacker.patch covers for the global mask and nothing covers for the per-context one. I have not measured that route, so I am not claiming it.

Scope

  • Not affected: Camoufox() / AsyncCamoufox(). launch_options() sets a global fonts allowlist, and font-hijacker.patch applies it to all three routes.
  • Affected: the per-context injection workflow in docs/per-context-patches.md — identity from the init script, no global allowlist.

Related to #757, which is the same architectural gap seen from a different angle: the per-context list is enforced at one call site where the global list is enforced at three.

To Reproduce

bash
pip install camoufox && camoufox fetch
python font_local_route_probe.py <path-to-camoufox-binary>

Version

152.0.4-beta.30.

font_local_route_probe.py
python
"""Reproduction: measureText() and @font-face src: local() disagree under a
per-context font mask.

Both routes answer "is this family installed". In a genuine Firefox they resolve
from the same font list, so they cannot disagree. Camoufox masks one of them:

  measureText / document.fonts.check  ->  gfxPlatformFontList::FindAndAddFamiliesLocked
                                          -> filtered by FontListManager  (masked)
  @font-face { src: local(...) }      ->  FontFace::Status / gfxUserFontSet::LoadNext
                                          -> mozilla::dom::IsFontAllowed   (global only)

MaskConfig::IsFontAllowed returns true for every family when no global
CAMOU_CONFIG["fonts"] allowlist is set, which is the per-context case.

    pip install camoufox && camoufox fetch
    python font_local_route_probe.py <path-to-camoufox-binary>
"""

import asyncio
import sys

from playwright.async_api import async_playwright

from camoufox.async_api import AsyncCamoufox
from camoufox.fingerprints import generate_context_fingerprint

TWO_ROUTES = """async (fonts) => {
  const c = document.createElement('canvas').getContext('2d');
  const s = 'mmmmmmmmmmlli';
  c.font = '72px monospace';
  const base = c.measureText(s).width;
  const out = [];
  for (const f of fonts) {
    c.font = '72px "' + f + '", monospace';
    const byWidth = c.measureText(s).width !== base;

    let byLocal = false;
    try {
      const face = new FontFace('probe_' + Math.random().toString(36).slice(2),
                                'local("' + f + '")');
      await face.load();
      byLocal = face.status === 'loaded';
    } catch (e) { byLocal = false; }

    out.push({font: f, width: byWidth, local: byLocal});
  }
  return out;
}"""

WINDOWS_ONLY = ["Segoe UI", "Calibri", "Cambria", "MS Gothic",
                "Franklin Gothic Medium", "Sylfaen", "Segoe UI Symbol"]


def report(tag, plat, rows):
    dis = [r["font"] for r in rows if r["width"] != r["local"]]
    w = sum(r["width"] for r in rows)
    l = sum(r["local"] for r in rows)
    n = len(rows)
    print(f"  {tag:<44} {plat:<9} measureText={w}/{n}  local()={l}/{n}  disagree={len(dis)}")
    return dis


async def read(page):
    await page.goto("about:blank")
    return (await page.evaluate("() => navigator.platform"),
            await page.evaluate(TWO_ROUTES, WINDOWS_ONLY))


async def main(binary):
    fp = generate_context_fingerprint(os="macos")
    print(f"probe: {len(WINDOWS_ONLY)} Windows-only families, macOS mask\n")
    print(f"  {'launch':<44} {'platform':<9} routes")

    async with async_playwright() as p:
        b = await p.firefox.launch(executable_path=binary, headless=True)

        plat, rows = await read(await b.new_page())
        report("control: no init script (unmasked)", plat, rows)

        ctx = await b.new_context()
        await ctx.add_init_script(fp["init_script"])
        plat, rows = await read(await ctx.new_page())
        leaked = report("per-context mask (init script only)", plat, rows)

        await b.close()

    async with AsyncCamoufox(headless=True, os="macos", i_know_what_im_doing=True) as br:
        plat, rows = await read(await br.new_page())
        report("global mask (AsyncCamoufox)", plat, rows)

    print()
    if leaked:
        print("REPRODUCED. Under the per-context mask these families are hidden from")
        print("measureText but loadable through src: local():")
        for f in leaked:
            print(f"  {f}")


asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else sys.exit("pass the binary path")))