Drop 'unsafe-inline' from the CSP style-src directives

The print service injected the per-PDF `@page { size }` rule as an inline
<style> element, which required 'unsafe-inline' on style-src-elem.

Inject it through a constructable CSSStyleSheet attached to
document.adoptedStyleSheets instead. Constructable stylesheets aren't
subject to style-src's inline restrictions in browsers.
This commit is contained in:
Calixte Denizet 2026-06-08 22:14:52 +02:00 committed by calixteman
parent cb53dbecb9
commit 5ca6026d80
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
7 changed files with 94 additions and 43 deletions

View File

@ -26,6 +26,8 @@ import { makePathFromDrawOPS } from "./display_utils.js";
class FontLoader {
#systemFonts = new Set();
#styleSheet = null;
constructor({
ownerDocument = globalThis.document,
styleElement = null, // For testing only.
@ -55,14 +57,38 @@ class FontLoader {
}
insertRule(rule) {
const styleSheet = this.#getStyleSheet();
styleSheet.insertRule(rule, styleSheet.cssRules.length);
}
#getStyleSheet() {
if (this.#styleSheet) {
return this.#styleSheet;
}
// Constructable stylesheets aren't blocked by CSP inline-style checks.
// Use the constructor from the document's own window, since
// `this._document` may belong to a different window (e.g. a print iframe)
// and a constructable stylesheet can only be adopted by the document it was
// created for.
const StyleSheet =
this._document.defaultView?.CSSStyleSheet || globalThis.CSSStyleSheet;
if (!this.styleElement && StyleSheet) {
const { adoptedStyleSheets } = this._document;
if (adoptedStyleSheets) {
const styleSheet = new StyleSheet();
adoptedStyleSheets.push(styleSheet);
return (this.#styleSheet = styleSheet);
}
}
if (!this.styleElement) {
this.styleElement = this._document.createElement("style");
this._document.documentElement
.getElementsByTagName("head")[0]
.append(this.styleElement);
}
const styleSheet = this.styleElement.sheet;
styleSheet.insertRule(rule, styleSheet.cssRules.length);
return (this.#styleSheet = this.styleElement.sheet);
}
clear() {
@ -72,6 +98,16 @@ class FontLoader {
this.nativeFontFaces.clear();
this.#systemFonts.clear();
if (this.#styleSheet) {
const { adoptedStyleSheets } = this._document;
if (adoptedStyleSheets?.includes(this.#styleSheet)) {
this._document.adoptedStyleSheets = adoptedStyleSheets.filter(
styleSheet => styleSheet !== this.#styleSheet
);
}
this.#styleSheet = null;
}
if (this.styleElement) {
// Note: ChildNode.remove doesn't throw if the parentNode is undefined.
this.styleElement.remove();

View File

@ -1973,17 +1973,14 @@ describe("PDF viewer", () => {
null,
{
earlySetup: () => {
// Capture state while window.print() runs — the print service's
// destroy() removes the @page stylesheet right after, on the
// afterprint event.
// Capture state during window.print(): destroy() removes the
// @page stylesheet from adoptedStyleSheets right afterwards.
window._pageRuleApplied = null;
window.print = () => {
window._pageRuleApplied = [
...document.querySelectorAll("style"),
].some(
window._pageRuleApplied = document.adoptedStyleSheets.some(
s =>
s.sheet?.cssRules.length > 0 &&
[...s.sheet.cssRules].some(r => r.cssText.includes("@page"))
s.cssRules.length > 0 &&
[...s.cssRules].some(r => r.cssText.includes("@page"))
);
};
},
@ -2007,12 +2004,8 @@ describe("PDF viewer", () => {
await closePages(pages);
});
// The print service injects an inline
// <style>@page { size: WxH pt }</style> to match the PDF's page
// dimensions. If the CSP `style-src-elem` directive blocks inline
// <style> elements, the element is created but its content is never
// parsed — `sheet.cssRules` stays empty and the @page rule has no
// effect. See web/viewer.html.
// The @page rule is injected via a constructable stylesheet, which is
// exempt from CSP, so the strict policy in web/viewer.html applies it.
it("must apply the injected @page rule (no CSP block)", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {

View File

@ -14,6 +14,7 @@
*/
import { buildGetDocumentParams } from "./test_utils.js";
import { FontLoader } from "../../src/display/font_loader.js";
import { getDocument } from "../../src/display/api.js";
function getTopLeftPixel(canvasContext) {
@ -206,4 +207,35 @@ describe("custom ownerDocument", function () {
canvasFactory.destroy(canvasAndCtx);
expect(style.remove.called).toBe(true);
});
it("should use a constructable stylesheet for CSS font rules", function () {
const rule =
'@font-face {font-family:"foo";src:url(data:font/opentype;base64,AA==)}';
class MockCSSStyleSheet {
cssRules = [];
insertRule(cssRule, index) {
this.cssRules.splice(index, 0, cssRule);
}
}
const ownerDocument = {
adoptedStyleSheets: [],
defaultView: {
CSSStyleSheet: MockCSSStyleSheet,
},
fonts: null,
};
const fontLoader = new FontLoader({ ownerDocument });
fontLoader.insertRule(rule);
expect(ownerDocument.adoptedStyleSheets.length).toBe(1);
expect(ownerDocument.adoptedStyleSheets[0].cssRules).toEqual([rule]);
fontLoader.clear();
expect(ownerDocument.adoptedStyleSheets.length).toBe(0);
});
});

View File

@ -170,9 +170,9 @@ class FirefoxPrintService {
// Insert a @page + size rule to make sure that the page size is correctly
// set. Note that we assume that all pages have the same size, because
// variable-size pages are scaled down to the initial page size in Firefox.
this.pageStyleSheet = document.createElement("style");
this.pageStyleSheet.textContent = `@page { size: ${width}pt ${height}pt;}`;
body.append(this.pageStyleSheet);
this.pageStyleSheet = new CSSStyleSheet();
this.pageStyleSheet.replaceSync(`@page { size: ${width}pt ${height}pt;}`);
document.adoptedStyleSheets.push(this.pageStyleSheet);
if (pdfDocument.isPureXfa) {
getXfaHtmlForPrinting(printContainer, pdfDocument);
@ -203,7 +203,9 @@ class FirefoxPrintService {
body.removeAttribute("data-pdfjsprinting");
if (this.pageStyleSheet) {
this.pageStyleSheet.remove();
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
styleSheet => styleSheet !== this.pageStyleSheet
);
this.pageStyleSheet = null;
}
}

View File

@ -124,9 +124,9 @@ class PDFPrintService {
// In browsers where @page + size is not supported, the next stylesheet
// will be ignored and the user has to select the correct paper size in
// the UI if wanted.
this.pageStyleSheet = document.createElement("style");
this.pageStyleSheet.textContent = `@page { size: ${width}pt ${height}pt;}`;
body.append(this.pageStyleSheet);
this.pageStyleSheet = new CSSStyleSheet();
this.pageStyleSheet.replaceSync(`@page { size: ${width}pt ${height}pt;}`);
document.adoptedStyleSheets.push(this.pageStyleSheet);
}
destroy() {
@ -141,7 +141,9 @@ class PDFPrintService {
body.removeAttribute("data-pdfjsprinting");
if (this.pageStyleSheet) {
this.pageStyleSheet.remove();
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
styleSheet => styleSheet !== this.pageStyleSheet
);
this.pageStyleSheet = null;
}
if (this._blobURLs) {

View File

@ -26,17 +26,10 @@ See https://github.com/adobe-type-tools/cmap-resources
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>PDF.js viewer</title>
<!--
The print service injects an inline <style>@page { size: }</style>
at print time (web/pdf_print_service.js, web/firefox_print_service.js)
to match the PDF's page dimensions. Since the size varies per PDF the
content can't be pre-hashed, so style-src-elem allows 'unsafe-inline'.
Inline style="…" attributes stay blocked via style-src (no fallback).
-->
<!--#if MOZCENTRAL-->
<!--<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src resource: 'wasm-unsafe-eval'; worker-src resource:; style-src resource:; style-src-elem resource: 'unsafe-inline'; img-src resource: blob: data:; font-src resource:; connect-src resource:; base-uri 'none'; form-action 'none';"
content="default-src 'none'; script-src resource: 'wasm-unsafe-eval'; worker-src resource:; style-src resource:; img-src resource: blob: data:; font-src resource:; connect-src resource:; base-uri 'none'; form-action 'none';"
/>-->
<!--#endif-->

View File

@ -29,33 +29,26 @@ See https://github.com/adobe-type-tools/cmap-resources
<!--#endif-->
<title>PDF.js viewer</title>
<!--
The print service injects an inline <style>@page { size: }</style>
at print time (web/pdf_print_service.js, web/firefox_print_service.js)
to match the PDF's page dimensions. Since the size varies per PDF the
content can't be pre-hashed, so style-src-elem allows 'unsafe-inline'.
Inline style="…" attributes stay blocked via style-src (no fallback).
-->
<!--#if MOZCENTRAL-->
<!--<link rel="icon" type="image/svg+xml" href="chrome://global/skin/icons/pdf.svg" />-->
<!--<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src resource: 'wasm-unsafe-eval'; worker-src resource:; style-src resource:; style-src-elem resource: 'unsafe-inline'; img-src resource: blob: data:; font-src resource:; connect-src resource:; base-uri 'none'; form-action 'none';"
content="default-src 'none'; script-src resource: 'wasm-unsafe-eval'; worker-src resource:; style-src resource:; img-src resource: blob: data:; font-src resource:; connect-src resource:; base-uri 'none'; form-action 'none';"
/>-->
<!--#elif TESTING-->
<!--<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'self'; form-action 'none';"
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'self'; form-action 'none';"
/>-->
<!--#elif CHROME-->
<!--<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * file: chrome-extension: blob: data: filesystem: drive:; base-uri 'self'; form-action 'none';"
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * file: chrome-extension: blob: data: filesystem: drive:; base-uri 'self'; form-action 'none';"
/>-->
<!--#else-->
<!--<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'none'; form-action 'none';"
content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self'; img-src 'self' blob: data:; font-src 'self' data:; connect-src * blob: data:; base-uri 'none'; form-action 'none';"
/>-->
<!--#endif-->