From f21fe34747da6b6ecd2abccf3a8c566e98895e11 Mon Sep 17 00:00:00 2001 From: calixteman Date: Sun, 2 Aug 2026 22:30:08 +0200 Subject: [PATCH] Safely serialize CSS font family names --- src/core/core_utils.js | 12 +++ src/core/xfa/html_utils.js | 8 +- src/display/font_loader.js | 8 +- src/shared/css_utils.js | 76 +++++++++++++++++ test/unit/clitests.json | 1 + test/unit/core_utils_spec.js | 24 ++++++ test/unit/font_loader_spec.js | 151 ++++++++++++++++++++++++++++++++++ test/unit/jasmine-boot.js | 1 + 8 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/shared/css_utils.js create mode 100644 test/unit/font_loader_spec.js diff --git a/src/core/core_utils.js b/src/core/core_utils.js index 92637503a..7c5b3025f 100644 --- a/src/core/core_utils.js +++ b/src/core/core_utils.js @@ -23,6 +23,7 @@ import { } from "../shared/util.js"; import { Dict, isName, isRefsEqual, Name, Ref, RefSet } from "./primitives.js"; import { BaseStream } from "./base_stream.js"; +import { CONTROL_CHAR_REGEXP } from "../shared/css_utils.js"; import { stringToPDFString } from "./string_utils.js"; const PDF_VERSION_REGEXP = /^[1-9]\.\d$/; @@ -561,6 +562,17 @@ function validateFontName(fontFamily, mustWarn = false) { } return false; } + // A 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 { // See https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident. for (const ident of fontFamily.split(/[ \t]+/)) { diff --git a/src/core/xfa/html_utils.js b/src/core/xfa/html_utils.js index c345d3f7c..9b1493604 100644 --- a/src/core/xfa/html_utils.js +++ b/src/core/xfa/html_utils.js @@ -28,6 +28,7 @@ import { import { createValidAbsoluteUrl, warn } from "../../shared/util.js"; import { getMeasurement, stripQuotes } from "./utils.js"; import { selectFont } from "./fonts.js"; +import { serializeFontFamily } from "../../shared/css_utils.js"; import { TextMeasure } from "./text.js"; import { XFAObject } from "./xfa_object.js"; @@ -597,13 +598,16 @@ function setFontFamily(xfaFont, node, fontFinder, style) { } 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); if (typeface) { const { fontFamily } = typeface.regular.cssFontInfo; if (fontFamily !== name) { - style.fontFamily = `"${fontFamily}"`; + style.fontFamily = serializeFontFamily(fontFamily); } const para = getCurrentPara(node); diff --git a/src/display/font_loader.js b/src/display/font_loader.js index 2303a5b95..853eb757d 100644 --- a/src/display/font_loader.js +++ b/src/display/font_loader.js @@ -22,6 +22,7 @@ import { warn, } from "../shared/util.js"; import { makePathFromDrawOPS } from "./display_utils.js"; +import { serializeFontFamily } from "../shared/css_utils.js"; class FontLoader { #systemFonts = new Set(); @@ -439,7 +440,7 @@ class FontFaceObject { css.style = `oblique ${this.cssFontInfo.italicAngle}deg`; } nativeFontFace = new FontFace( - this.cssFontInfo.fontFamily, + serializeFontFamily(this.cssFontInfo.fontFamily), this.data, css ); @@ -463,7 +464,10 @@ class FontFaceObject { if (this.cssFontInfo.italicAngle) { 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 to prevent arbitrary rule injection. + const fontFamily = serializeFontFamily(this.cssFontInfo.fontFamily); + rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`; } this._inspectFont?.(this, url); diff --git a/src/shared/css_utils.js b/src/shared/css_utils.js new file mode 100644 index 000000000..af5674e71 --- /dev/null +++ b/src/shared/css_utils.js @@ -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 , 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 + // 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 , rather than a 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 }; diff --git a/test/unit/clitests.json b/test/unit/clitests.json index eb9a782e3..eebd78272 100644 --- a/test/unit/clitests.json +++ b/test/unit/clitests.json @@ -26,6 +26,7 @@ "evaluator_spec.js", "event_utils_spec.js", "fetch_stream_spec.js", + "font_loader_spec.js", "font_substitutions_spec.js", "fonts_spec.js", "image_utils_spec.js", diff --git a/test/unit/core_utils_spec.js b/test/unit/core_utils_spec.js index fbf43be36..43d8ed41e 100644 --- a/test/unit/core_utils_spec.js +++ b/test/unit/core_utils_spec.js @@ -440,6 +440,30 @@ describe("core_utils", function () { 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 . + 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 () { const cssFontInfo = { fontFamily: "blah", diff --git a/test/unit/font_loader_spec.js b/test/unit/font_loader_spec.js new file mode 100644 index 000000000..86cb4f1d9 --- /dev/null +++ b/test/unit/font_loader_spec.js @@ -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 (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 , hence it's kept as-is. + expect(getFontFamily(rule)).toEqual(fontFamily); + }); + + it("serializes a font family which isn't a well-formed ", 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(); + } + }); + }); +}); diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index e2e3e78d5..c54dbe2ef 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -72,6 +72,7 @@ async function initializePDFJS(callback) { "pdfjs-test/unit/evaluator_spec.js", "pdfjs-test/unit/event_utils_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/fonts_spec.js", "pdfjs-test/unit/image_utils_spec.js",