diff --git a/src/core/core_utils.js b/src/core/core_utils.js index 368859ec54655..7c5b3025f1a90 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$/; @@ -341,7 +342,8 @@ function lookupNormalRect(arr, fallback) { * each part of the path. */ function parseXFAPath(path) { - const positionPattern = /(.+)\[(\d+)\]$/; + // Anchoring prevents retrying the match at every character. + const positionPattern = /^(.+)\[(\d+)\]$/; return path.split(".").map(component => { const m = component.match(positionPattern); if (m) { @@ -560,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/writer.js b/src/core/writer.js index 3395d1a02ff17..d12292d94834d 100644 --- a/src/core/writer.js +++ b/src/core/writer.js @@ -148,6 +148,31 @@ async function writeArray(array, buffer, transform) { buffer.push("]"); } +// The exponential notation isn't valid in a PDF, hence a number is always +// written with all its digits. +function numberToPDFString(value) { + // `toFixed` uses the exponential notation from 1e21 on, so such a number is + // written thanks to BigInt: it's necessarily an integer, and `isInteger` also + // rules out NaN and ±Infinity for which BigInt would throw. + if (Number.isInteger(value) && Math.abs(value) >= 1e21) { + return BigInt(value).toString(); + } + + // Below that limit `toFixed(10)` never uses the exponential notation (unlike + // `toString` which uses it under 1e-6) and it rounds the value: it always + // adds 10 decimals, hence scan them backwards to remove the trailing zeros, + // and then the dot itself when none of the decimals is left. + const str = value.toFixed(10); + let end = str.length; + while (str[end - 1] === "0") { + end--; + } + if (str[end - 1] === ".") { + end--; + } + return str.slice(0, end); +} + async function writeValue(value, buffer, transform) { if (value instanceof Name) { buffer.push(`/${escapePDFName(value.name)}`); @@ -165,9 +190,7 @@ async function writeValue(value, buffer, transform) { // matrices (e.g. [0.000008 0 0 0.000008 0 0]). // The numbers must be "rounded" only when pdf.js is producing them and the // current transformation matrix is well known. - // toFixed(10) avoids scientific notation and rounds; the replace removes - // trailing zeros (and a trailing dot for integers). - buffer.push(value.toFixed(10).replace(/\.?0+$/, "")); + buffer.push(numberToPDFString(value)); } else if (typeof value === "boolean") { buffer.push(value.toString()); } else if (value instanceof Dict) { diff --git a/src/core/xfa/html_utils.js b/src/core/xfa/html_utils.js index c345d3f7c3aec..9b1493604027f 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 2303a5b954e41..853eb757de31f 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 0000000000000..af5674e71c1cb --- /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/autolinker_spec.js b/test/unit/autolinker_spec.js index 5348ab1eb34fe..4ea9a5b64c5be 100644 --- a/test/unit/autolinker_spec.js +++ b/test/unit/autolinker_spec.js @@ -220,4 +220,24 @@ describe("autolinker", function () { ["john.doe@uni-cityname.tld", "mailto:john.doe@uni-cityname.tld"], ]); }); + + it("should find emails with the longest parts allowed by the RFCs", function () { + const local = "a".repeat(64); + const label = "b".repeat(63); + testLinks([[`${local}@${label}.com`, `mailto:${local}@${label}.com`]]); + }); + + it("shouldn't find emails with parts longer than allowed by the RFCs", function () { + expect( + Autolinker.findLinks(`${"a".repeat(107)}@${"a".repeat(80)}.com`) + ).toEqual([]); + }); + + it("should handle a long run of characters before an @ efficiently", function () { + const text = `${"a".repeat(50000)}@`; + + const startTime = performance.now(); + expect(Autolinker.findLinks(text)).toEqual([]); + expect(performance.now() - startTime).toBeLessThan(1000); + }); }); diff --git a/test/unit/clitests.json b/test/unit/clitests.json index eb9a782e3d5c6..eebd782728dc4 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 ac83bf50c467d..43d8ed41e46a2 100644 --- a/test/unit/core_utils_spec.js +++ b/test/unit/core_utils_spec.js @@ -244,6 +244,32 @@ describe("core_utils", function () { { name: "BAR", pos: 456 }, ]); }); + + it("should ignore a malformed position", function () { + expect(parseXFAPath("foo[].bar[1x].oof[].[3]")).toEqual([ + { name: "foo[]", pos: 0 }, + { name: "bar[1x]", pos: 0 }, + { name: "oof[]", pos: 0 }, + { name: "[3]", pos: 0 }, + ]); + }); + + it("should keep the longest name when a component has several brackets", function () { + expect(parseXFAPath("foo[1][2]")).toEqual([{ name: "foo[1]", pos: 2 }]); + }); + + it("should handle a long component efficiently", function () { + // Looking for the position with a leading `.+` is quadratic in the + // length of a component which doesn't end with one. + const name = "a".repeat(200000); + + const startTime = performance.now(); + const parsedPath = parseXFAPath(name); + const duration = performance.now() - startTime; + + expect(parsedPath).toEqual([{ name, pos: 0 }]); + expect(duration).toBeLessThan(1000); + }); }); describe("recoverJsURL", function () { @@ -414,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 0000000000000..86cb4f1d9faba --- /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 e2e3e78d56de1..c54dbe2ef50aa 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", diff --git a/test/unit/writer_spec.js b/test/unit/writer_spec.js index b6d12cdd351f1..68a3ad23ef1d1 100644 --- a/test/unit/writer_spec.js +++ b/test/unit/writer_spec.js @@ -295,10 +295,20 @@ describe("Writer", function () { }); it("should not use scientific notation for very large numbers", async function () { - // JavaScript produces scientific notation above ~1e21 but such values - // are unlikely in PDFs; values below that threshold must be plain. + // JavaScript's toString() and toFixed() produce scientific notation from + // 1e21 on, which is invalid PDF: such a number must be written with all + // its digits, which are the exact ones of the underlying double. expect(await serialize(1e10)).toEqual("10000000000"); expect(await serialize(1.5e6)).toEqual("1500000"); + expect(await serialize(1e20)).toEqual("100000000000000000000"); + expect(await serialize(1e21)).toEqual("1000000000000000000000"); + // Removing the trailing zeros of the exponent used to change the value: + // "1e+30" was written "1e+3" and "1e+100" was written "1e+1". + expect(await serialize(1e30)).toEqual("1000000000000000019884624838656"); + expect(await serialize(-1e30)).toEqual( + "-1000000000000000019884624838656" + ); + expect((await serialize(1e100)).length).toEqual(101); }); it("should round to at most 10 decimal places", async function () { diff --git a/web/autolinker.js b/web/autolinker.js index 407025fb2c8b3..e51288557722f 100644 --- a/web/autolinker.js +++ b/web/autolinker.js @@ -136,10 +136,13 @@ class Autolinker { static #numericTLDRegex; static findLinks(text) { - // Regex can be tested and verified at https://regex101.com/r/rXoLiT/2. + // Regex can be tested and verified at https://regex101.com/r/riHjvK/1. + // The email parts are bounded to keep the scan linear: a local part can't + // exceed 64 characters (RFC 5321, section 4.5.3.1.1) and a domain label + // can't exceed 63 (RFC 1035, section 2.3.4). this.#regex ??= // eslint-disable-next-line regexp/no-super-linear-backtracking - /\b(?:https?:\/\/|mailto:|www\.)(?:[\S--[\p{P}<>]]|\/|[\S--[\[\]]]+[\S--[\p{P}<>]])+|(?=\p{L})[\S--[@\p{Ps}\p{Pe}<>]]+@([\S--[[\p{P}--\-]<>]]+(?:\.[\S--[[\p{P}--\-]<>]]+)+)/gv; + /\b(?:https?:\/\/|mailto:|www\.)(?:[\S--[\p{P}<>]]|\/|[\S--[\[\]]]+[\S--[\p{P}<>]])+|(?=\p{L})[\S--[@\p{Ps}\p{Pe}<>]]{1,64}@([\S--[[\p{P}--\-]<>]]{1,63}(?:\.[\S--[[\p{P}--\-]<>]]{1,63})+)/gv; const [normalizedText, diffs] = normalize(text, { ignoreDashEOL: true }); const matches = normalizedText.matchAll(this.#regex);