measureText and @font-face src: local() disagree under a per-context font mask
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:
inline bool IsFontAllowed(const nsACString& aFontName) {
...
return MaskConfig::IsFontAllowed(fontName);
}and MaskConfig::IsFontAllowed (additions/camoucfg/MaskConfig.hpp) is:
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 globalfontsallowlist, andfont-hijacker.patchapplies 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
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"""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")))Source: daijro/camoufox