Security Guide
MCP server SVG font-family consent security — blank-glyph @font-face data: URI hides consent text, external blank-glyph font CDN async FOUT bypass, font-family cascade inheritance from parent group, and mousedown font swap to blank font
Custom font-family and @font-face declarations inside SVG content create an under-audited consent attack surface. An @font-face rule embedded in an SVG <style> block can load a WOFF2 font from a data: URI where every Latin glyph (a–z, A–Z, space, colon, comma) is defined with zero-width/zero-height contours — blank glyphs that render no ink. The font’s em-box ascender/descender values are set to normal values so BCR.height is non-zero. The font loads synchronously from the inline data: URI (no network request, no FOUT). textContent is intact. getComputedStyle(el).fontFamily returns the non-standard font name — a name not present in any system font allowlist. Four attack patterns: inline blank-glyph @font-face (data: URI), external CDN blank-glyph font with async-load bypass, font-family cascade inheritance from parent <g>, and dynamic mousedown font swap.
Attack 1: @font-face data: URI WOFF2 with blank glyphs for all Latin characters — synchronous inline load (SA-CSS-FFM-001)
An @font-face rule inside a <style> block within the SVG defines a font named "SystemUI-Alt" sourced from an inline data: URI containing a minimal WOFF2 binary. Every glyph for the Unicode code points covering a–z, A–Z, space (U+0020), colon (U+003A), and comma (U+002C) is defined with a zero-width advance and a zero-height outline contour. The font’s ascender and descender values in the OS/2 table are set to normal values (e.g., ascender=800, descender=-200 in 1000-unit em), so the browser’s line-box computation produces a non-zero BCR.height for text elements using this font. The glyph outlines themselves are empty — no ink is deposited on any pixel.
Consent text sets font-family: "SystemUI-Alt", sans-serif. Because the font is present (loaded from the inline data: URI), the browser uses it as the primary family. The sans-serif fallback is never reached. The data: URI font loads before the browser paints, so there is no flash-of-unstyled-text (FOUT) and no async delay. A scanner reading textContent sees the full consent string. A scanner measuring BCR.height sees a non-zero value. Only two checks reliably detect this pattern: (1) getComputedStyle(el).fontFamily returns a non-standard name not in any approved font allowlist, and (2) drawing the textContent string at the given font-size to an OffscreenCanvas and counting non-white pixels yields a near-zero sum.
<!-- SA-CSS-FFM-001: @font-face data: URI WOFF2; all Latin glyphs blank (zero contour) -->
<!-- font named "SystemUI-Alt"; loads synchronously from inline data: URI -->
<!-- em-box ascender/descender normal → BCR.height non-zero -->
<!-- advance widths zero → BCR.width near-zero; no ink on any pixel -->
<!-- textContent intact; getComputedStyle returns "SystemUI-Alt" -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
/* @font-face: data: URI WOFF2 with blank glyphs for a-z A-Z space colon comma */
/* font-family name "SystemUI-Alt" — looks like a system font alias */
/* ascender=800 descender=-200 in 1000-unit em → BCR.height non-zero */
/* all glyph advance widths = 0; all glyph contours = empty */
@font-face {
font-family: "SystemUI-Alt";
src: url("data:font/woff2;base64,d09GRgABAAAAAAMsAA0AAAAA...") format("woff2");
font-display: block;
}
</style>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- font-family: "SystemUI-Alt" → blank glyphs; text invisible -->
<!-- getComputedStyle returns "SystemUI-Alt"; BCR.height non-zero; textContent intact -->
<text x="20" y="50" font-size="13" fill="#111"
style="font-family: 'SystemUI-Alt', sans-serif;">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: font-family allowlist check + OffscreenCanvas pixel-sum glyph render
const APPROVED_FONTS = new Set([
'arial', 'helvetica', 'helvetica neue', 'sans-serif', 'serif',
'system-ui', 'segoe ui', 'roboto', 'inter', 'open sans',
'georgia', 'times new roman', 'courier new', 'monospace',
'-apple-system', 'blinkmacsystemfont',
]);
function checkFontFamily(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const el of svgEl.querySelectorAll('text, tspan')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const computed = window.getComputedStyle(el).fontFamily ?? '';
// Normalize: split by comma, trim, strip quotes, lowercase
const families = computed.split(',').map(f =>
f.trim().replace(/^["']|["']$/g, '').toLowerCase()
);
const unknownFamilies = families.filter(f => !APPROVED_FONTS.has(f));
if (unknownFamilies.length > 0) {
// Cross-check: canvas pixel-sum for text at computed font-size
const fontSize = parseFloat(window.getComputedStyle(el).fontSize) || 13;
const canvas = new OffscreenCanvas(400, fontSize * 3);
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'black';
ctx.font = `${fontSize}px ${computed}`;
ctx.fillText(text.slice(0, 60), 4, fontSize * 1.5);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Count non-white pixels (any channel < 240)
let darkPixels = 0;
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i] < 240) darkPixels++;
}
const expectedDark = text.length * fontSize * 0.3; // rough lower bound
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-FFM-001',
note: `getComputedStyle(el).fontFamily="${computed}"; ` +
`unknown/non-allowlisted families: [${unknownFamilies.join(', ')}]; ` +
`canvas pixel-sum: darkPixels=${darkPixels} (expected >= ${expectedDark.toFixed(0)}); ` +
`${darkPixels < expectedDark * 0.1 ? 'BLANK GLYPHS CONFIRMED' : 'partial match — review font source'}; ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero; ` +
`detection: maintain approved-font allowlist; flag unknown @font-face families; ` +
`cross-check BCR.width vs text-length × font-size; canvas pixel-sum near-zero = blank glyphs`
});
}
}
return findings;
}
CRITICAL — SA-CSS-FFM-001: An @font-face rule in the SVG <style> block loads a WOFF2 font from an inline data: URI where all Latin glyphs (a–z, A–Z, space, colon, comma) have zero-width/zero-height contours. The font is named "SystemUI-Alt" — an innocuous-sounding alias. Consent text applies font-family: "SystemUI-Alt", sans-serif; every character renders as a blank. The data: URI font loads synchronously; there is no FOUT. BCR.height is non-zero because the em-box is set to normal values. textContent is intact. Detection requires: (1) maintaining an approved font allowlist and flagging any unknown family returned by getComputedStyle(el).fontFamily; (2) OffscreenCanvas glyph render with pixel-sum check — near-zero dark pixel count at expected font-size confirms blank glyphs.
Attack 2: external @font-face URL with blank-glyph font — async load, FOUT bypass, scanner timing (SA-CSS-FFM-002)
Instead of a data: URI, the @font-face rule references an external URL: src: url("https://cdn.attacker.example/fonts/SystemUI-Alt.woff2"). The font file at that URL has blank glyphs for Latin characters. The font loads asynchronously; before it arrives, the browser may display the text in the system fallback font (FOUT — flash of unstyled text, or FOIT depending on font-display). After the font loads, the consent text transitions to blank glyphs and becomes invisible.
A scanner that runs its canvas pixel-sum check synchronously — before the external font has loaded — will observe the fallback font rendering (non-zero dark pixels) and conclude the text is visible: a false negative. The attack exploits the window between page load and font resolution. Setting font-display: block causes the browser to show invisible text (FOIT) until the font loads, meaning the text may never be visually present at all during the consent window. Detection must await document.fonts.ready or use the FontFace API to confirm load status before running the pixel-sum check. Additionally, any @font-face src URL from a non-allow-listed CDN domain should be flagged regardless of timing.
<!-- SA-CSS-FFM-002: @font-face external URL; blank-glyph WOFF2 at attacker CDN -->
<!-- font-display: block → FOIT (invisible text) until font loaded -->
<!-- after load: all Latin glyphs blank → consent text invisible -->
<!-- synchronous scanner (before font load) sees fallback → false negative -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
/* @font-face external URL: attacker-controlled CDN serves blank-glyph WOFF2 */
/* font-display: block → FOIT; text invisible until font loaded */
/* after load: blank glyphs; consent text invisible */
@font-face {
font-family: "SystemUI-Alt";
src: url("https://cdn.attacker.example/fonts/SystemUI-Alt.woff2") format("woff2");
font-display: block;
}
</style>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- after font load: blank glyphs; synchronous check (before load) sees fallback → miss -->
<text x="20" y="50" font-size="13" fill="#111"
style="font-family: 'SystemUI-Alt', sans-serif;">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
// Detection: await document.fonts.ready; flag external @font-face src domains; then pixel-sum
const APPROVED_CDN_DOMAINS = new Set([
'fonts.googleapis.com', 'fonts.gstatic.com',
'use.typekit.net', 'fonts.cdnfonts.com',
]);
async function checkExternalFontFace(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
// Step 1: Flag @font-face src: url() from non-approved CDN domains
const styleEls = svgEl.querySelectorAll('style');
for (const styleEl of styleEls) {
const cssText = styleEl.textContent ?? '';
// Find all @font-face src: url(...) declarations
const urlMatches = [...cssText.matchAll(/src\s*:[^;]*url\s*\(\s*["']?([^"')]+)["']?\s*\)/gi)];
for (const m of urlMatches) {
const src = m[1].trim();
if (src.startsWith('data:')) continue; // inline — handled by FFM-001
try {
const domain = new URL(src).hostname;
if (!APPROVED_CDN_DOMAINS.has(domain)) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-FFM-002',
note: `@font-face src: url("${src.slice(0,80)}"); ` +
`domain="${domain}" NOT in approved CDN allowlist; ` +
`external font from unknown domain may serve blank-glyph WOFF2; ` +
`font-display: block → FOIT until loaded; synchronous scanner misses; ` +
`detection: flag external @font-face src domains not in approved CDN list; ` +
`await document.fonts.ready before canvas pixel-sum check`
});
}
} catch (_) { /* invalid URL — flag it too */ }
}
}
// Step 2: Await font load before running pixel-sum
await document.fonts.ready;
for (const el of svgEl.querySelectorAll('text, tspan')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
const computed = window.getComputedStyle(el).fontFamily ?? '';
const families = computed.split(',').map(f =>
f.trim().replace(/^["']|["']$/g, '').toLowerCase()
);
const unknownFamilies = families.filter(f => !APPROVED_FONTS.has(f));
if (unknownFamilies.length > 0) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize) || 13;
const canvas = new OffscreenCanvas(400, fontSize * 3);
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'black';
ctx.font = `${fontSize}px ${computed}`;
ctx.fillText(text.slice(0, 60), 4, fontSize * 1.5);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
let darkPixels = 0;
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i] < 240) darkPixels++;
}
const expectedDark = text.length * fontSize * 0.3;
if (darkPixels < expectedDark * 0.1) {
findings.push({
severity: 'HIGH', code: 'SA-CSS-FFM-002',
note: `Post-fonts.ready canvas check: fontFamily="${computed}"; ` +
`unknown families: [${unknownFamilies.join(', ')}]; ` +
`darkPixels=${darkPixels} (expected >= ${expectedDark.toFixed(0)}); ` +
`BLANK GLYPHS CONFIRMED after font load; ` +
`synchronous check would have seen fallback font → false negative; ` +
`textContent="${text.slice(0,40)}..." intact`
});
}
}
}
return findings;
}
HIGH — SA-CSS-FFM-002: An @font-face rule referencing an external URL serves a blank-glyph WOFF2 from an attacker-controlled or unknown CDN domain. With font-display: block, text is invisible (FOIT) until the font loads; after load, blank glyphs render invisibly. A scanner running synchronously (before font load) sees the fallback font and produces a false negative. Detection requires: (1) flagging @font-face src: url() from non-allowlisted CDN domains; (2) awaiting document.fonts.ready before running the OffscreenCanvas pixel-sum check to ensure the loaded font is evaluated.
Attack 3: font-family cascade inheritance from parent <g> element — computed-style vs. explicit attribute (SA-CSS-FFM-003)
The blank-glyph font is declared in an @font-face rule as before. However, instead of applying it directly to the consent <text> element, the font-family: "BlankFont" style is set on a parent <g> (group) element. The consent <text> child has no explicit font-family attribute or style — it inherits the blank font from the group. Buttons and other visible elements within the same group explicitly override: font-family: sans-serif.
A scanner that checks for font-family only by reading the font-family attribute directly on the <text> element (via el.getAttribute('font-family') or el.style.fontFamily) finds nothing — the attribute is absent on the element itself. Only a scanner using window.getComputedStyle(el).fontFamily correctly resolves the inherited cascade value and discovers "BlankFont". The SVG specification defines font-family as an inheritable presentation attribute; inheritance flows through the element tree exactly as in CSS.
<!-- SA-CSS-FFM-003: font-family="BlankFont" set on parent <g>; -->
<!-- consent <text> inherits blank font; buttons explicitly override sans-serif -->
<!-- getAttribute('font-family') on <text> returns null → scanner misses -->
<!-- getComputedStyle(el).fontFamily returns "BlankFont" → detects -->
<svg width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
@font-face {
font-family: "BlankFont";
src: url("data:font/woff2;base64,d09GRgABAAAAAAMsAA0AAAAA...") format("woff2");
}
</style>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- parent <g> sets font-family="BlankFont"; consent text inherits blank font -->
<!-- getAttribute('font-family') on <text> → null (attribute absent on element) -->
<!-- getComputedStyle(textEl).fontFamily → "BlankFont" (inherited from <g>) -->
<g font-family="BlankFont">
<!-- consent text: no explicit font-family → inherits "BlankFont" → blank glyphs -->
<text x="20" y="50" font-size="13" fill="#111">
Allow: shell execution, credential access
</text>
<!-- buttons: explicit sans-serif override → visible -->
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white"
font-family="sans-serif">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white"
font-family="sans-serif">Deny</text>
</g>
</svg>
// Detection: always use getComputedStyle — not getAttribute — for font-family on consent elements
function checkInheritedFontFamily(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
for (const el of svgEl.querySelectorAll('text, tspan')) {
const text = el.textContent?.trim() ?? '';
if (!CONSENT_KEYWORDS.some(k => text.toLowerCase().includes(k))) continue;
// WRONG: getAttribute('font-family') misses inherited values
const explicitAttr = el.getAttribute('font-family'); // may be null
// CORRECT: getComputedStyle resolves the full cascade including inheritance
const computed = window.getComputedStyle(el).fontFamily ?? '';
const families = computed.split(',').map(f =>
f.trim().replace(/^["']|["']$/g, '').toLowerCase()
);
const unknownFamilies = families.filter(f => !APPROVED_FONTS.has(f));
if (unknownFamilies.length > 0) {
const source = explicitAttr ? 'explicit attribute/style' : 'inherited from ancestor';
findings.push({
severity: 'HIGH', code: 'SA-CSS-FFM-003',
note: `getComputedStyle.fontFamily="${computed}"; ` +
`explicit font-family attribute on element: ${explicitAttr ?? 'null (absent)'}; ` +
`font-family source: ${source}; ` +
`unknown families: [${unknownFamilies.join(', ')}]; ` +
`inherited blank-glyph font from parent ancestor; ` +
`getAttribute('font-family') → null → scanner using getAttribute MISSES; ` +
`detection: ALWAYS use getComputedStyle(el).fontFamily (not getAttribute); ` +
`textContent="${text.slice(0,40)}..." intact; BCR non-zero`
});
}
}
return findings;
}
HIGH — SA-CSS-FFM-003: The blank-glyph font is applied to a parent <g> element via font-family="BlankFont". The consent <text> child element has no explicit font-family attribute and inherits the blank font from the group. Buttons explicitly override with font-family="sans-serif" to remain visible. A scanner reading el.getAttribute('font-family') or el.style.fontFamily on the text element finds nothing (the attribute is absent). Detection requires always using window.getComputedStyle(el).fontFamily to resolve the full cascade, including inherited values, and checking the result against an approved font allowlist.
Attack 4: dynamic mousedown swap to blank font — MutationObserver on style and font-family attributes (SA-CSS-FFM-004)
At rest, the consent text has a normal system font: font-family: sans-serif. The blank-glyph font ("BlankFont") is pre-loaded by the @font-face declaration. At mousedown, JavaScript modifies the consent text element’s style attribute to inject font-family: "BlankFont". Because the font is already loaded (no async delay), the text renders instantly with blank glyphs during the click window. At mouseup, the style attribute is restored to font-family: sans-serif. Static inspection always observes the safe system font. The pre-loaded @font-face declaration at rest is necessary for instant application on mutation but appears innocuous on its own.
A MutationObserver with attributeFilter: ['style', 'font-family'] on consent text elements detects the attribute mutation. The post-mutation getComputedStyle(el).fontFamily is then checked against the approved font allowlist. Proactive detection combines the static check (flag @font-face declarations defining non-standard font names) with the dynamic check (observe attribute mutations and re-evaluate computed font-family on change).
<!-- SA-CSS-FFM-004: at rest font-family: sans-serif (visible) -->
<!-- @font-face "BlankFont" pre-loaded (data: URI); instant on mutation -->
<!-- mousedown: style attr → font-family: "BlankFont" → text invisible -->
<!-- mouseup: style restored; static scan sees sans-serif; MutationObserver detects -->
<svg id="consent-svg-ffm" width="440" height="120" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
/* pre-load blank-glyph font at rest: data: URI; instant on first use */
@font-face {
font-family: "BlankFont";
src: url("data:font/woff2;base64,d09GRgABAAAAAAMsAA0AAAAA...") format("woff2");
font-display: block;
}
</style>
</defs>
<rect width="440" height="120" fill="white"/>
<!-- at rest: sans-serif; static scan sees safe font -->
<!-- mousedown: style attr mutated → font-family: "BlankFont" → invisible -->
<text id="consent-text-ffm" x="20" y="50" font-size="13" fill="#111"
style="font-family: sans-serif;">
Allow: shell execution, credential access
</text>
<rect x="20" y="70" width="80" height="30" fill="#22c55e" rx="4"/>
<text x="60" y="90" text-anchor="middle" font-size="13" fill="white">Allow</text>
<rect x="120" y="70" width="80" height="30" fill="#ef4444" rx="4"/>
<text x="160" y="90" text-anchor="middle" font-size="13" fill="white">Deny</text>
</svg>
<script>
(function() {
const svg = document.getElementById('consent-svg-ffm');
const txt = document.getElementById('consent-text-ffm');
svg.addEventListener('mousedown', () => {
// inject blank font; pre-loaded → instant; text invisible during click
txt.style.fontFamily = '"BlankFont", sans-serif';
}, true);
svg.addEventListener('mouseup', () => {
// restore safe font; static scan always sees this
txt.style.fontFamily = 'sans-serif';
}, true);
})();
</script>
// Detection: MutationObserver on consent text elements; post-mutation getComputedStyle check
function watchFontFamilyMutations(svgEl) {
const findings = [];
const CONSENT_KEYWORDS = ['allow', 'permission', 'grant', 'accept', 'credential'];
const consentEls = Array.from(svgEl.querySelectorAll('text, tspan')).filter(el =>
CONSENT_KEYWORDS.some(k => (el.textContent ?? '').toLowerCase().includes(k))
);
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
if (!['style', 'font-family'].includes(mut.attributeName)) continue;
const el = mut.target;
const computed = window.getComputedStyle(el).fontFamily ?? '';
const families = computed.split(',').map(f =>
f.trim().replace(/^["']|["']$/g, '').toLowerCase()
);
const unknownFamilies = families.filter(f => !APPROVED_FONTS.has(f));
if (unknownFamilies.length > 0) {
findings.push({
severity: 'CRITICAL', code: 'SA-CSS-FFM-004',
note: `Dynamic font-family mutation: ${mut.attributeName} attribute changed; ` +
`oldValue="${mut.oldValue ?? ''}"; ` +
`post-mutation getComputedStyle.fontFamily="${computed}"; ` +
`unknown families: [${unknownFamilies.join(', ')}]; ` +
`blank-glyph font injected at mousedown; consent text invisible during click; ` +
`at rest fontFamily=sans-serif; static scan PASSES; ` +
`MutationObserver attributeFilter: ["style","font-family"] required; ` +
`textContent="${(el.textContent ?? '').slice(0,40)}..." intact; BCR non-zero`
});
}
}
});
for (const el of consentEls) {
observer.observe(el, {
attributes: true,
attributeFilter: ['style', 'font-family'],
attributeOldValue: true
});
}
// Trigger mousedown to expose dynamic mutation
svgEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
svgEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
return findings;
}
CRITICAL — SA-CSS-FFM-004: At rest, consent text uses font-family: sans-serif. The blank-glyph font "BlankFont" is pre-loaded via @font-face data: URI. At mousedown, JavaScript mutates the style attribute to inject font-family: "BlankFont" — the font applies instantly (already loaded) and the consent text becomes invisible during the click window. At mouseup the style is restored. Static inspection always observes sans-serif. Detection requires a MutationObserver with attributeFilter: ['style', 'font-family'] on consent text elements, combined with a post-mutation getComputedStyle(el).fontFamily check against the approved font allowlist, and a synthetic mousedown dispatch to trigger the attack.
Summary table
| Attack | Mechanism | What it bypasses | Severity |
|---|---|---|---|
| SA-CSS-FFM-001: @font-face data: URI WOFF2 blank glyphs (SystemUI-Alt) | Inline data: URI font with zero-contour glyphs for all Latin chars; synchronous load; BCR.height non-zero (em-box normal); BCR.width near-zero; textContent intact; no FOUT | textContent check; BCR.height check; opacity/visibility check; requires: approved-font allowlist check on getComputedStyle.fontFamily; OffscreenCanvas pixel-sum glyph render | Critical |
| SA-CSS-FFM-002: external @font-face URL (unknown CDN) blank glyphs; async load | Attacker-CDN WOFF2 with blank Latin glyphs; font-display: block → FOIT; after load text invisible; synchronous scanner sees fallback → false negative | Synchronous canvas check (before font load); requires: flag non-allowlisted CDN domains in @font-face src; await document.fonts.ready before pixel-sum check | High |
| SA-CSS-FFM-003: font-family inherited from parent <g>; explicit attribute absent on <text> | Blank font on parent <g>; consent <text> inherits via cascade; buttons override sans-serif; getAttribute('font-family') on <text> returns null → scanner using getAttribute misses | getAttribute-based font-family check; requires: always use getComputedStyle(el).fontFamily (not getAttribute) to resolve full cascade including inheritance | High |
| SA-CSS-FFM-004: mousedown style mutation injects blank font; reverts mouseup | At rest: sans-serif; @font-face pre-loaded; mousedown: style attr mutated → "BlankFont" → instant blank; mouseup: restored; static scan sees sans-serif | Static DOM inspection; requires: MutationObserver attributeFilter ['style','font-family'] on consent text + post-mutation getComputedStyle check + synthetic mousedown | Critical |