Skip to content

Commit 5ca6026

Browse files
committed
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.
1 parent cb53dbe commit 5ca6026

7 files changed

Lines changed: 94 additions & 43 deletions

File tree

src/display/font_loader.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { makePathFromDrawOPS } from "./display_utils.js";
2626
class FontLoader {
2727
#systemFonts = new Set();
2828

29+
#styleSheet = null;
30+
2931
constructor({
3032
ownerDocument = globalThis.document,
3133
styleElement = null, // For testing only.
@@ -55,14 +57,38 @@ class FontLoader {
5557
}
5658

5759
insertRule(rule) {
60+
const styleSheet = this.#getStyleSheet();
61+
styleSheet.insertRule(rule, styleSheet.cssRules.length);
62+
}
63+
64+
#getStyleSheet() {
65+
if (this.#styleSheet) {
66+
return this.#styleSheet;
67+
}
68+
69+
// Constructable stylesheets aren't blocked by CSP inline-style checks.
70+
// Use the constructor from the document's own window, since
71+
// `this._document` may belong to a different window (e.g. a print iframe)
72+
// and a constructable stylesheet can only be adopted by the document it was
73+
// created for.
74+
const StyleSheet =
75+
this._document.defaultView?.CSSStyleSheet || globalThis.CSSStyleSheet;
76+
if (!this.styleElement && StyleSheet) {
77+
const { adoptedStyleSheets } = this._document;
78+
if (adoptedStyleSheets) {
79+
const styleSheet = new StyleSheet();
80+
adoptedStyleSheets.push(styleSheet);
81+
return (this.#styleSheet = styleSheet);
82+
}
83+
}
84+
5885
if (!this.styleElement) {
5986
this.styleElement = this._document.createElement("style");
6087
this._document.documentElement
6188
.getElementsByTagName("head")[0]
6289
.append(this.styleElement);
6390
}
64-
const styleSheet = this.styleElement.sheet;
65-
styleSheet.insertRule(rule, styleSheet.cssRules.length);
91+
return (this.#styleSheet = this.styleElement.sheet);
6692
}
6793

6894
clear() {
@@ -72,6 +98,16 @@ class FontLoader {
7298
this.nativeFontFaces.clear();
7399
this.#systemFonts.clear();
74100

101+
if (this.#styleSheet) {
102+
const { adoptedStyleSheets } = this._document;
103+
if (adoptedStyleSheets?.includes(this.#styleSheet)) {
104+
this._document.adoptedStyleSheets = adoptedStyleSheets.filter(
105+
styleSheet => styleSheet !== this.#styleSheet
106+
);
107+
}
108+
this.#styleSheet = null;
109+
}
110+
75111
if (this.styleElement) {
76112
// Note: ChildNode.remove doesn't throw if the parentNode is undefined.
77113
this.styleElement.remove();

test/integration/viewer_spec.mjs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,17 +1973,14 @@ describe("PDF viewer", () => {
19731973
null,
19741974
{
19751975
earlySetup: () => {
1976-
// Capture state while window.print() runs — the print service's
1977-
// destroy() removes the @page stylesheet right after, on the
1978-
// afterprint event.
1976+
// Capture state during window.print(): destroy() removes the
1977+
// @page stylesheet from adoptedStyleSheets right afterwards.
19791978
window._pageRuleApplied = null;
19801979
window.print = () => {
1981-
window._pageRuleApplied = [
1982-
...document.querySelectorAll("style"),
1983-
].some(
1980+
window._pageRuleApplied = document.adoptedStyleSheets.some(
19841981
s =>
1985-
s.sheet?.cssRules.length > 0 &&
1986-
[...s.sheet.cssRules].some(r => r.cssText.includes("@page"))
1982+
s.cssRules.length > 0 &&
1983+
[...s.cssRules].some(r => r.cssText.includes("@page"))
19871984
);
19881985
};
19891986
},
@@ -2007,12 +2004,8 @@ describe("PDF viewer", () => {
20072004
await closePages(pages);
20082005
});
20092006

2010-
// The print service injects an inline
2011-
// <style>@page { size: WxH pt }</style> to match the PDF's page
2012-
// dimensions. If the CSP `style-src-elem` directive blocks inline
2013-
// <style> elements, the element is created but its content is never
2014-
// parsed — `sheet.cssRules` stays empty and the @page rule has no
2015-
// effect. See web/viewer.html.
2007+
// The @page rule is injected via a constructable stylesheet, which is
2008+
// exempt from CSP, so the strict policy in web/viewer.html applies it.
20162009
it("must apply the injected @page rule (no CSP block)", async () => {
20172010
await Promise.all(
20182011
pages.map(async ([browserName, page]) => {

test/unit/custom_spec.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import { buildGetDocumentParams } from "./test_utils.js";
17+
import { FontLoader } from "../../src/display/font_loader.js";
1718
import { getDocument } from "../../src/display/api.js";
1819

1920
function getTopLeftPixel(canvasContext) {
@@ -206,4 +207,35 @@ describe("custom ownerDocument", function () {
206207
canvasFactory.destroy(canvasAndCtx);
207208
expect(style.remove.called).toBe(true);
208209
});
210+
211+
it("should use a constructable stylesheet for CSS font rules", function () {
212+
const rule =
213+
'@font-face {font-family:"foo";src:url(data:font/opentype;base64,AA==)}';
214+
215+
class MockCSSStyleSheet {
216+
cssRules = [];
217+
218+
insertRule(cssRule, index) {
219+
this.cssRules.splice(index, 0, cssRule);
220+
}
221+
}
222+
223+
const ownerDocument = {
224+
adoptedStyleSheets: [],
225+
defaultView: {
226+
CSSStyleSheet: MockCSSStyleSheet,
227+
},
228+
fonts: null,
229+
};
230+
const fontLoader = new FontLoader({ ownerDocument });
231+
232+
fontLoader.insertRule(rule);
233+
234+
expect(ownerDocument.adoptedStyleSheets.length).toBe(1);
235+
expect(ownerDocument.adoptedStyleSheets[0].cssRules).toEqual([rule]);
236+
237+
fontLoader.clear();
238+
239+
expect(ownerDocument.adoptedStyleSheets.length).toBe(0);
240+
});
209241
});

web/firefox_print_service.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,9 @@ class FirefoxPrintService {
170170
// Insert a @page + size rule to make sure that the page size is correctly
171171
// set. Note that we assume that all pages have the same size, because
172172
// variable-size pages are scaled down to the initial page size in Firefox.
173-
this.pageStyleSheet = document.createElement("style");
174-
this.pageStyleSheet.textContent = `@page { size: ${width}pt ${height}pt;}`;
175-
body.append(this.pageStyleSheet);
173+
this.pageStyleSheet = new CSSStyleSheet();
174+
this.pageStyleSheet.replaceSync(`@page { size: ${width}pt ${height}pt;}`);
175+
document.adoptedStyleSheets.push(this.pageStyleSheet);
176176

177177
if (pdfDocument.isPureXfa) {
178178
getXfaHtmlForPrinting(printContainer, pdfDocument);
@@ -203,7 +203,9 @@ class FirefoxPrintService {
203203
body.removeAttribute("data-pdfjsprinting");
204204

205205
if (this.pageStyleSheet) {
206-
this.pageStyleSheet.remove();
206+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
207+
styleSheet => styleSheet !== this.pageStyleSheet
208+
);
207209
this.pageStyleSheet = null;
208210
}
209211
}

web/pdf_print_service.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ class PDFPrintService {
124124
// In browsers where @page + size is not supported, the next stylesheet
125125
// will be ignored and the user has to select the correct paper size in
126126
// the UI if wanted.
127-
this.pageStyleSheet = document.createElement("style");
128-
this.pageStyleSheet.textContent = `@page { size: ${width}pt ${height}pt;}`;
129-
body.append(this.pageStyleSheet);
127+
this.pageStyleSheet = new CSSStyleSheet();
128+
this.pageStyleSheet.replaceSync(`@page { size: ${width}pt ${height}pt;}`);
129+
document.adoptedStyleSheets.push(this.pageStyleSheet);
130130
}
131131

132132
destroy() {
@@ -141,7 +141,9 @@ class PDFPrintService {
141141
body.removeAttribute("data-pdfjsprinting");
142142

143143
if (this.pageStyleSheet) {
144-
this.pageStyleSheet.remove();
144+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
145+
styleSheet => styleSheet !== this.pageStyleSheet
146+
);
145147
this.pageStyleSheet = null;
146148
}
147149
if (this._blobURLs) {

web/viewer-geckoview.html

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,10 @@
2626
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
2727
<title>PDF.js viewer</title>
2828

29-
<!--
30-
The print service injects an inline <style>@page { size: … }</style>
31-
at print time (web/pdf_print_service.js, web/firefox_print_service.js)
32-
to match the PDF's page dimensions. Since the size varies per PDF the
33-
content can't be pre-hashed, so style-src-elem allows 'unsafe-inline'.
34-
Inline style="…" attributes stay blocked via style-src (no fallback).
35-
-->
3629
<!--#if MOZCENTRAL-->
3730
<!--<meta
3831
http-equiv="Content-Security-Policy"
39-
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';"
32+
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';"
4033
/>-->
4134
<!--#endif-->
4235

web/viewer.html

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,33 +29,26 @@
2929
<!--#endif-->
3030
<title>PDF.js viewer</title>
3131

32-
<!--
33-
The print service injects an inline <style>@page { size: … }</style>
34-
at print time (web/pdf_print_service.js, web/firefox_print_service.js)
35-
to match the PDF's page dimensions. Since the size varies per PDF the
36-
content can't be pre-hashed, so style-src-elem allows 'unsafe-inline'.
37-
Inline style="…" attributes stay blocked via style-src (no fallback).
38-
-->
3932
<!--#if MOZCENTRAL-->
4033
<!--<link rel="icon" type="image/svg+xml" href="chrome://global/skin/icons/pdf.svg" />-->
4134
<!--<meta
4235
http-equiv="Content-Security-Policy"
43-
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';"
36+
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';"
4437
/>-->
4538
<!--#elif TESTING-->
4639
<!--<meta
4740
http-equiv="Content-Security-Policy"
48-
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';"
41+
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';"
4942
/>-->
5043
<!--#elif CHROME-->
5144
<!--<meta
5245
http-equiv="Content-Security-Policy"
53-
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';"
46+
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';"
5447
/>-->
5548
<!--#else-->
5649
<!--<meta
5750
http-equiv="Content-Security-Policy"
58-
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';"
51+
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';"
5952
/>-->
6053
<!--#endif-->
6154

0 commit comments

Comments
 (0)