mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-08-04 05:17:24 +02:00
Merge pull request #21694 from calixteman/improve_font_familly_san
Safely serialize CSS font family names
This commit is contained in:
commit
b4ba666b0c
@ -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]+/)) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
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/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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user