Safely serialize CSS font family names

This commit is contained in:
calixteman 2026-08-02 22:30:08 +02:00
parent ba7bf7b26c
commit f21fe34747
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
8 changed files with 277 additions and 4 deletions

View File

@ -23,6 +23,7 @@ import {
} from "../shared/util.js"; } from "../shared/util.js";
import { Dict, isName, isRefsEqual, Name, Ref, RefSet } from "./primitives.js"; import { Dict, isName, isRefsEqual, Name, Ref, RefSet } from "./primitives.js";
import { BaseStream } from "./base_stream.js"; import { BaseStream } from "./base_stream.js";
import { CONTROL_CHAR_REGEXP } from "../shared/css_utils.js";
import { stringToPDFString } from "./string_utils.js"; import { stringToPDFString } from "./string_utils.js";
const PDF_VERSION_REGEXP = /^[1-9]\.\d$/; const PDF_VERSION_REGEXP = /^[1-9]\.\d$/;
@ -561,6 +562,17 @@ function validateFontName(fontFamily, mustWarn = false) {
} }
return false; return false;
} }
// A <string> is terminated by a newline, which for CSS also includes the
// form feed character; see https://drafts.csswg.org/css-syntax/#newline.
// The font family is escaped before being used, see `serializeFontFamily`,
// hence this only prevents values that cannot sensibly name a font from
// being used at all (the unquoted case below is already this strict).
if (CONTROL_CHAR_REGEXP.test(fontFamily)) {
if (mustWarn) {
warn(`FontFamily contains control characters: ${fontFamily}.`);
}
return false;
}
} else { } else {
// See https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident. // See https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident.
for (const ident of fontFamily.split(/[ \t]+/)) { for (const ident of fontFamily.split(/[ \t]+/)) {

View File

@ -28,6 +28,7 @@ import {
import { createValidAbsoluteUrl, warn } from "../../shared/util.js"; import { createValidAbsoluteUrl, warn } from "../../shared/util.js";
import { getMeasurement, stripQuotes } from "./utils.js"; import { getMeasurement, stripQuotes } from "./utils.js";
import { selectFont } from "./fonts.js"; import { selectFont } from "./fonts.js";
import { serializeFontFamily } from "../../shared/css_utils.js";
import { TextMeasure } from "./text.js"; import { TextMeasure } from "./text.js";
import { XFAObject } from "./xfa_object.js"; import { XFAObject } from "./xfa_object.js";
@ -597,13 +598,16 @@ function setFontFamily(xfaFont, node, fontFinder, style) {
} }
const name = stripQuotes(xfaFont.typeface); const name = stripQuotes(xfaFont.typeface);
style.fontFamily = `"${name}"`; // Use the same serialization as the `@font-face` rule, resp. the `FontFace`
// instance, that the font is registered with; see `createFontFaceRule` and
// `createNativeFontFace` in `src/display/font_loader.js`.
style.fontFamily = serializeFontFamily(name);
const typeface = fontFinder.find(name); const typeface = fontFinder.find(name);
if (typeface) { if (typeface) {
const { fontFamily } = typeface.regular.cssFontInfo; const { fontFamily } = typeface.regular.cssFontInfo;
if (fontFamily !== name) { if (fontFamily !== name) {
style.fontFamily = `"${fontFamily}"`; style.fontFamily = serializeFontFamily(fontFamily);
} }
const para = getCurrentPara(node); const para = getCurrentPara(node);

View File

@ -22,6 +22,7 @@ import {
warn, warn,
} from "../shared/util.js"; } from "../shared/util.js";
import { makePathFromDrawOPS } from "./display_utils.js"; import { makePathFromDrawOPS } from "./display_utils.js";
import { serializeFontFamily } from "../shared/css_utils.js";
class FontLoader { class FontLoader {
#systemFonts = new Set(); #systemFonts = new Set();
@ -439,7 +440,7 @@ class FontFaceObject {
css.style = `oblique ${this.cssFontInfo.italicAngle}deg`; css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
} }
nativeFontFace = new FontFace( nativeFontFace = new FontFace(
this.cssFontInfo.fontFamily, serializeFontFamily(this.cssFontInfo.fontFamily),
this.data, this.data,
css css
); );
@ -463,7 +464,10 @@ class FontFaceObject {
if (this.cssFontInfo.italicAngle) { if (this.cssFontInfo.italicAngle) {
css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`; css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
} }
rule = `@font-face {font-family:"${this.cssFontInfo.fontFamily}";${css}src:${url}}`; // The font family originates from the PDF document, hence it must be
// serialized as a <string> to prevent arbitrary rule injection.
const fontFamily = serializeFontFamily(this.cssFontInfo.fontFamily);
rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`;
} }
this._inspectFont?.(this, url); this._inspectFont?.(this, url);

76
src/shared/css_utils.js Normal file
View File

@ -0,0 +1,76 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const CONTROL_CHAR_REGEXP = /\p{Cc}/u;
/**
* Checks if the given value already is a well-formed CSS <string>, i.e. a
* value which can be used verbatim since it cannot introduce delimiters.
* See https://drafts.csswg.org/css-syntax/#string-token-diagram.
* @param {string} str
* @returns {boolean}
*/
function isCSSString(str) {
const quote = str[0];
if (
str.length < 2 ||
(quote !== `"` && quote !== `'`) ||
str.at(-1) !== quote
) {
return false;
}
const end = str.length - 1;
for (let i = 1; i < end; i++) {
const char = str[i];
if (char === quote || CONTROL_CHAR_REGEXP.test(char)) {
return false;
}
if (char === "\\") {
// Skip the escaped character. A trailing backslash would instead escape
// the closing quote, and control characters must not occur in a CSS
// <string> even when escaped this way.
if (++i >= end || CONTROL_CHAR_REGEXP.test(str[i])) {
return false;
}
}
}
return true;
}
/**
* Serializes a font family, originating from the PDF document, such that it
* can safely be interpolated into CSS.
* @param {string} fontFamily
* @returns {string}
*/
function serializeFontFamily(fontFamily) {
if (isCSSString(fontFamily)) {
return fontFamily;
}
// Always emit a <string>, rather than a <custom-ident> sequence, since both
// denote the same family name but only the former cannot be mistaken for a
// generic family (e.g. `serif`) or a CSS-wide keyword (e.g. `inherit`);
// those are not valid font family names and would be ignored.
// Control characters use hexadecimal escapes, since CSS line terminators
// cannot be escaped by simply prefixing them with a backslash.
const escaped = fontFamily.replaceAll(/["\\\p{Cc}]/gu, char =>
char === `"` || char === "\\"
? `\\${char}`
: `\\${char.codePointAt(0).toString(16)} `
);
return `"${escaped}"`;
}
export { CONTROL_CHAR_REGEXP, serializeFontFamily };

View File

@ -26,6 +26,7 @@
"evaluator_spec.js", "evaluator_spec.js",
"event_utils_spec.js", "event_utils_spec.js",
"fetch_stream_spec.js", "fetch_stream_spec.js",
"font_loader_spec.js",
"font_substitutions_spec.js", "font_substitutions_spec.js",
"fonts_spec.js", "fonts_spec.js",
"image_utils_spec.js", "image_utils_spec.js",

View File

@ -440,6 +440,30 @@ describe("core_utils", function () {
expect(validateCSSFont(cssFontInfo)).toBeFalse(); expect(validateCSSFont(cssFontInfo)).toBeFalse();
}); });
it("Check font family containing control characters", function () {
const cssFontInfo = {
fontFamily: "",
fontWeight: 0,
italicAngle: 0,
};
// A form feed is a newline in CSS, hence it terminates the <string>.
cssFontInfo.fontFamily = `"blah\fblah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();
cssFontInfo.fontFamily = `"blah\x00blah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();
cssFontInfo.fontFamily = `"blah\tblah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();
cssFontInfo.fontFamily = `"blah\nblah"`;
expect(validateCSSFont(cssFontInfo)).toBeFalse();
cssFontInfo.fontFamily = `"blah blah"`;
expect(validateCSSFont(cssFontInfo)).toBeTrue();
});
it("Check font weight", function () { it("Check font weight", function () {
const cssFontInfo = { const cssFontInfo = {
fontFamily: "blah", fontFamily: "blah",

View File

@ -0,0 +1,151 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FontFaceObject } from "../../src/display/font_loader.js";
import { isNodeJS } from "../../src/shared/util.js";
describe("font_loader", function () {
describe("FontFaceObject", function () {
function createFontFaceObject(fontFamily) {
return new FontFaceObject({
cssFontInfo: { fontFamily, fontWeight: "400", italicAngle: "0" },
data: new Uint8Array([0x00]),
disableFontFace: false,
fontExtraProperties: false,
loadedName: "g_d0_f1",
mimetype: "font/opentype",
});
}
function getFontFamily(rule) {
const start = rule.indexOf("font-family:") + "font-family:".length;
return rule.slice(start, rule.indexOf(";font-weight:", start));
}
it("creates a font-face rule", function () {
expect(
getFontFamily(createFontFaceObject("Foo-Bar").createFontFaceRule())
).toEqual(`"Foo-Bar"`);
});
it("keeps an injected rule inside the font family <string> (issue GHSA-wxrh-xgw3-3wqf)", function () {
const fontFamily = `"};body{background-image:url(https://example.com/)};a{x:"`;
const rule = createFontFaceObject(fontFamily).createFontFaceRule();
// The value already is a well-formed <string>, hence it's kept as-is.
expect(getFontFamily(rule)).toEqual(fontFamily);
});
it("serializes a font family which isn't a well-formed <string>", function () {
expect(
getFontFamily(
createFontFaceObject(String.raw`Foo"Bar\Baz`).createFontFaceRule()
)
).toEqual(String.raw`"Foo\"Bar\\Baz"`);
// A trailing backslash would otherwise escape the closing quote.
expect(
getFontFamily(
createFontFaceObject(
String.raw`"};body{background-image:url(https://example.com/)};a{x:\"`
).createFontFaceRule()
)
).toEqual(
String.raw`"\"};body{background-image:url(https://example.com/)};a{x:\\\""`
);
// A trailing backslash would otherwise escape the following semi-colon,
// thus swallowing the `font-weight` declaration.
expect(
getFontFamily(createFontFaceObject("Foo\\").createFontFaceRule())
).toEqual(String.raw`"Foo\\"`);
});
it("escapes CSS line terminators", function () {
const rule = createFontFaceObject(
"safe\f}body{background-image:url(https://example.com/)}/*"
).createFontFaceRule();
expect(rule).not.toContain("\f");
expect(getFontFamily(rule)).toEqual(
String.raw`"safe\c }body{background-image:url(https://example.com/)}/*"`
);
});
it("quotes generic families and CSS-wide keywords", function () {
// Those are not valid font family names, hence the `font-family`
// descriptor would be ignored if they were emitted unquoted.
for (const fontFamily of ["serif", "monospace", "inherit", "initial"]) {
expect(
getFontFamily(createFontFaceObject(fontFamily).createFontFaceRule())
).toEqual(`"${fontFamily}"`);
}
});
it("uses the same font family in both font loading paths", function () {
const NativeFontFace = globalThis.FontFace;
globalThis.FontFace = function MockFontFace(family) {
this.family = family;
};
try {
for (const fontFamily of [`"Foo Bar"`, "Foo-Bar", "serif"]) {
const font = createFontFaceObject(fontFamily);
expect(font.createNativeFontFace().family).toEqual(
getFontFamily(font.createFontFaceRule())
);
}
} finally {
globalThis.FontFace = NativeFontFace;
}
});
it("cannot escape the @font-face rule", function () {
if (isNodeJS) {
pending("Document is not supported in Node.js.");
}
const style = document.createElement("style");
document.head.append(style);
try {
for (const fontFamily of [
`"};body{background-image:url(https://example.com/)};a{x:"`,
String.raw`"};body{background-image:url(https://example.com/)};a{x:\"`,
"safe\f}body{background-image:url(https://example.com/)}/*",
String.raw`Foo"Bar\Baz`,
"Foo\\",
"serif",
]) {
const rule = createFontFaceObject(fontFamily).createFontFaceRule();
style.sheet.insertRule(rule, style.sheet.cssRules.length);
const cssRule = [...style.sheet.cssRules].at(-1);
expect(cssRule.constructor.name)
.withContext(fontFamily)
.toEqual("CSSFontFaceRule");
// The `font-family` descriptor must be both present and complete,
// i.e. the value must not have been truncated nor dropped.
expect(cssRule.style.getPropertyValue("font-family"))
.withContext(fontFamily)
.not.toEqual("");
}
// No additional rules were injected.
expect(style.sheet.cssRules.length).toEqual(6);
} finally {
style.remove();
}
});
});
});

View File

@ -72,6 +72,7 @@ async function initializePDFJS(callback) {
"pdfjs-test/unit/evaluator_spec.js", "pdfjs-test/unit/evaluator_spec.js",
"pdfjs-test/unit/event_utils_spec.js", "pdfjs-test/unit/event_utils_spec.js",
"pdfjs-test/unit/fetch_stream_spec.js", "pdfjs-test/unit/fetch_stream_spec.js",
"pdfjs-test/unit/font_loader_spec.js",
"pdfjs-test/unit/font_substitutions_spec.js", "pdfjs-test/unit/font_substitutions_spec.js",
"pdfjs-test/unit/fonts_spec.js", "pdfjs-test/unit/fonts_spec.js",
"pdfjs-test/unit/image_utils_spec.js", "pdfjs-test/unit/image_utils_spec.js",