mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-08-04 05:17:24 +02:00
Safely serialize CSS font family names
This commit is contained in:
parent
ba7bf7b26c
commit
f21fe34747
@ -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 <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 {
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident.
|
||||
for (const ident of fontFamily.split(/[ \t]+/)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 <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);
|
||||
|
||||
76
src/shared/css_utils.js
Normal file
76
src/shared/css_utils.js
Normal 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 };
|
||||
@ -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",
|
||||
|
||||
@ -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 <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 () {
|
||||
const cssFontInfo = {
|
||||
fontFamily: "blah",
|
||||
|
||||
151
test/unit/font_loader_spec.js
Normal file
151
test/unit/font_loader_spec.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user