vomebook commited on
Commit
b7aa7d7
·
verified ·
1 Parent(s): 1200707

fix(security): sanitize Reader CSS references with a CSS-syntax scanner

Browse files
static/reader.js CHANGED
@@ -366,6 +366,7 @@ const readerContext = {
366
  get returnHistoryKey() { return returnHistoryKey; },
367
  get runFullSearch() { return runFullSearch; },
368
  get sanitizeEpubDocument() { return sanitizeEpubDocument; }, set sanitizeEpubDocument(value) { sanitizeEpubDocument = value; },
 
369
  get saveDeadline() { return saveDeadline; }, set saveDeadline(value) { saveDeadline = value; },
370
  get saveProgress() { return saveProgress; }, set saveProgress(value) { saveProgress = value; },
371
  get saveTimer() { return saveTimer; }, set saveTimer(value) { saveTimer = value; },
@@ -644,7 +645,7 @@ let {
644
  } = createReaderPdfTextRuntime(readerContext);
645
 
646
  let {
647
- normalizeEpubDocument, sanitizeReaderDocument, sanitizeEpubDocument, parseChapterDocument,
648
  epubContentBody, renderText, sanitizeOfflineHtml, buildDiscourseDocument,
649
  prepareHtmlDocument, buildHtmlFrameSource, waitForSpreadsheetImages, renderHtml,
650
  detectHtmlEncoding, repairHtmlContrast
 
366
  get returnHistoryKey() { return returnHistoryKey; },
367
  get runFullSearch() { return runFullSearch; },
368
  get sanitizeEpubDocument() { return sanitizeEpubDocument; }, set sanitizeEpubDocument(value) { sanitizeEpubDocument = value; },
369
+ get sanitizeFoliateStyles() { return sanitizeFoliateStyles; }, set sanitizeFoliateStyles(value) { sanitizeFoliateStyles = value; },
370
  get saveDeadline() { return saveDeadline; }, set saveDeadline(value) { saveDeadline = value; },
371
  get saveProgress() { return saveProgress; }, set saveProgress(value) { saveProgress = value; },
372
  get saveTimer() { return saveTimer; }, set saveTimer(value) { saveTimer = value; },
 
645
  } = createReaderPdfTextRuntime(readerContext);
646
 
647
  let {
648
+ normalizeEpubDocument, sanitizeReaderDocument, sanitizeEpubDocument, sanitizeFoliateStyles, parseChapterDocument,
649
  epubContentBody, renderText, sanitizeOfflineHtml, buildDiscourseDocument,
650
  prepareHtmlDocument, buildHtmlFrameSource, waitForSpreadsheetImages, renderHtml,
651
  detectHtmlEncoding, repairHtmlContrast
static/reader/formats/reader-foliate-format.mjs CHANGED
@@ -160,7 +160,7 @@ export function createReaderFoliateFormat(readerContext) {
160
  const sourceNodes = new WeakMap();
161
  while (original.nextNode() && cloned.nextNode())
162
  sourceNodes.set(cloned.currentNode, original.currentNode);
163
- return { doc: readerContext.sanitizeEpubDocument(doc), sourceNodes };
164
  }
165
 
166
  function foliateSearchAnchor(source, sourceNodes, body, renderedBody) {
@@ -334,7 +334,11 @@ export function createReaderFoliateFormat(readerContext) {
334
  const { doc, sourceNodes } = readerContext.cloneFoliateDocument(source);
335
  readerContext.assertReaderActive();
336
  readerContext.rewriteFoliateResources(doc, section);
337
- const styles = [...doc.querySelectorAll("style,link")].map((node) => node.cloneNode(true));
 
 
 
 
338
  for (const node of doc.querySelectorAll("style,link")) node.remove();
339
  const article = document.createElement("article");
340
  article.dataset.section = String(index);
 
160
  const sourceNodes = new WeakMap();
161
  while (original.nextNode() && cloned.nextNode())
162
  sourceNodes.set(cloned.currentNode, original.currentNode);
163
+ return { doc: readerContext.sanitizeEpubDocument(doc, { keepBlobImports: true }), sourceNodes };
164
  }
165
 
166
  function foliateSearchAnchor(source, sourceNodes, body, renderedBody) {
 
334
  const { doc, sourceNodes } = readerContext.cloneFoliateDocument(source);
335
  readerContext.assertReaderActive();
336
  readerContext.rewriteFoliateResources(doc, section);
337
+ // Packaged CSS is loaded from Foliate blob: URLs; inline it sanitized so
338
+ // remote url()/@import references cannot bypass the archive-only policy.
339
+ const styles = await readerContext.sanitizeFoliateStyles([...doc.querySelectorAll("style,link")]);
340
+ signal.throwIfAborted();
341
+ readerContext.assertReaderActive();
342
  for (const node of doc.querySelectorAll("style,link")) node.remove();
343
  const article = document.createElement("article");
344
  article.dataset.section = String(index);
static/reader/formats/reader-html-format.mjs CHANGED
@@ -25,7 +25,7 @@ export function createReaderHtmlFormat(readerContext) {
25
  return doc;
26
  }
27
 
28
- function sanitizeReaderDocument(doc, { preserveEpubStyles = false } = {}) {
29
  const blocked = preserveEpubStyles
30
  ? "script,iframe,object,embed,base,form"
31
  : "script,iframe,object,embed,base,form,link,style";
@@ -33,17 +33,12 @@ export function createReaderHtmlFormat(readerContext) {
33
  if (preserveEpubStyles) {
34
  for (const link of doc.querySelectorAll("link")) {
35
  const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/);
36
- if (!rel.includes("stylesheet") || !/^blob:/i.test(link.getAttribute("href") || ""))
 
37
  link.remove();
38
  }
39
- for (const style of doc.querySelectorAll("style")) {
40
- if (
41
- /(?:@import\s+|url\s*\(\s*[\"']?)(?:https?:|\/\/|javascript:)/i.test(
42
- style.textContent || ""
43
- )
44
- )
45
- style.remove();
46
- }
47
  }
48
  for (const element of doc.querySelectorAll("*"))
49
  for (const attribute of [...element.attributes]) {
@@ -60,12 +55,347 @@ export function createReaderHtmlFormat(readerContext) {
60
  (urlAttribute && /^(?:javascript|vbscript):/i.test(schemeValue))
61
  )
62
  element.removeAttribute(attribute.name);
 
 
 
 
63
  }
64
  return doc;
65
  }
66
 
67
- function sanitizeEpubDocument(doc) {
68
- return readerContext.sanitizeReaderDocument(readerContext.normalizeEpubDocument(doc), { preserveEpubStyles: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  }
70
 
71
  function parseChapterDocument(text) {
@@ -111,7 +441,7 @@ export function createReaderHtmlFormat(readerContext) {
111
  });
112
  const template = document.createElement("template");
113
  template.content.appendChild(clean);
114
- const stripUrls = (value) => value.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, "");
115
  for (const style of template.content.querySelectorAll("style")) {
116
  const value = style.textContent, stripped = stripUrls(value);
117
  if (stripped !== value) style.textContent = stripped;
@@ -319,6 +649,8 @@ export function createReaderHtmlFormat(readerContext) {
319
  normalizeEpubDocument,
320
  sanitizeReaderDocument,
321
  sanitizeEpubDocument,
 
 
322
  parseChapterDocument,
323
  epubContentBody,
324
  renderText,
 
25
  return doc;
26
  }
27
 
28
+ function sanitizeReaderDocument(doc, { preserveEpubStyles = false, keepBlobImports = false } = {}) {
29
  const blocked = preserveEpubStyles
30
  ? "script,iframe,object,embed,base,form"
31
  : "script,iframe,object,embed,base,form,link,style";
 
33
  if (preserveEpubStyles) {
34
  for (const link of doc.querySelectorAll("link")) {
35
  const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/);
36
+ if (!rel.includes("stylesheet") || rel.includes("alternate") ||
37
+ !/^blob:/i.test(link.getAttribute("href") || ""))
38
  link.remove();
39
  }
40
+ for (const style of doc.querySelectorAll("style"))
41
+ style.textContent = sanitizeCssReferences(style.textContent || "", { keepBlobImports });
 
 
 
 
 
 
42
  }
43
  for (const element of doc.querySelectorAll("*"))
44
  for (const attribute of [...element.attributes]) {
 
55
  (urlAttribute && /^(?:javascript|vbscript):/i.test(schemeValue))
56
  )
57
  element.removeAttribute(attribute.name);
58
+ else if (CSS_VALUE_ATTRIBUTES.has(name)) {
59
+ const clean = sanitizeCssReferences(attribute.value);
60
+ if (clean !== attribute.value) element.setAttribute(attribute.name, clean);
61
+ }
62
  }
63
  return doc;
64
  }
65
 
66
+ // Style attributes and SVG presentation attributes that accept CSS url().
67
+ const CSS_VALUE_ATTRIBUTES = new Set([
68
+ "style", "fill", "stroke", "filter", "mask", "clip-path",
69
+ "marker-start", "marker-mid", "marker-end", "cursor"
70
+ ]);
71
+ // Functions whose string arguments are resource URLs (CSS Images 4/Fonts 4).
72
+ const CSS_REFERENCE_FUNCTIONS = new Set([
73
+ "url", "src", "image", "image-set", "-webkit-image-set", "cross-fade", "-webkit-cross-fade"
74
+ ]);
75
+ const CSS_BLOB_STYLESHEET_MAX_BYTES = 4 * 1024 * 1024;
76
+ const CSS_IMPORT_MAX_DEPTH = 4;
77
+ const CSS_IMPORT_MAX_PER_SHEET = 32;
78
+
79
+ function cssNameChar(code) {
80
+ return (code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a) ||
81
+ (code >= 0x30 && code <= 0x39) || code === 0x2d || code === 0x5f || code >= 0x80;
82
+ }
83
+
84
+ function cssWhitespace(code) {
85
+ return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
86
+ }
87
+
88
+ // Resource policy: fragments, blob:, data: and same-origin relative paths only.
89
+ // data: cannot fetch, blob: is an unguessable same-origin object, and any scheme
90
+ // (https:, javascript:, file:...) or protocol-relative reference is rejected
91
+ // after control/whitespace normalization, matching URL parser leniency.
92
+ function safeCssReference(raw, { strict = false } = {}) {
93
+ if (strict) return false;
94
+ const value = String(raw || "").replace(/[\u0000-\u0020\u007f-\u009f]/g, "");
95
+ if (!value || value.startsWith("#") || /^(?:blob|data):/i.test(value)) return true;
96
+ if (/^[\\/]{2}/.test(value)) return false;
97
+ return !/^[a-z][a-z0-9+.-]*:/i.test(value);
98
+ }
99
+
100
+ // Minimal CSS Syntax 3 scanner: decodes escapes and honours strings/comments
101
+ // exactly where the browser does, so escaped names (`\75rl(`), comments
102
+ // (`@import/**/"x"`) and quoted references cannot hide a remote fetch.
103
+ // Unsafe url()/image-set()/image() become `none` ("" in strict mode), unsafe
104
+ // custom-property URL strings become "", and unsafe
105
+ // @import rules are removed. Everything else is copied byte-for-byte.
106
+ function scanCss(source, { strict = false, keepBlobImports = false, blobImports = null, nonce = "" } = {}) {
107
+ const css = String(source || "");
108
+ const length = css.length;
109
+ const edits = [];
110
+ const stack = [];
111
+ let i = 0, customValueDepth = -1, imports = 0;
112
+ // Offline HTML historically deletes references; EPUB styles keep a valid
113
+ // `none` so shorthand declarations stay parseable.
114
+ const removed = strict ? "" : "none";
115
+
116
+ const readEscape = () => {
117
+ // css[i] is a backslash that is not followed by a newline.
118
+ i++;
119
+ if (i >= length) return "\ufffd";
120
+ let hex = "";
121
+ while (hex.length < 6 && i < length && /[0-9a-f]/i.test(css[i])) hex += css[i++];
122
+ if (hex) {
123
+ if (i < length && css[i] === "\r" && css[i + 1] === "\n") i += 2;
124
+ else if (i < length && cssWhitespace(css.charCodeAt(i))) i++;
125
+ const code = parseInt(hex, 16);
126
+ return code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)
127
+ ? "\ufffd" : String.fromCodePoint(code);
128
+ }
129
+ const char = String.fromCodePoint(css.codePointAt(i));
130
+ i += char.length;
131
+ return char;
132
+ };
133
+ const validEscape = (at) => css[at] === "\\" && at + 1 < length && !/[\n\r\f]/.test(css[at + 1]);
134
+ const readName = () => {
135
+ let name = "";
136
+ while (i < length) {
137
+ if (cssNameChar(css.charCodeAt(i))) name += css[i++];
138
+ else if (validEscape(i)) name += readEscape();
139
+ else break;
140
+ }
141
+ return name;
142
+ };
143
+ const readString = () => {
144
+ const quote = css[i++];
145
+ let value = "";
146
+ while (i < length) {
147
+ const char = css[i];
148
+ if (char === quote) { i++; return value; }
149
+ if (char === "\n" || char === "\r" || char === "\f") return value; // bad-string
150
+ if (char === "\\") {
151
+ if (i + 1 >= length) { i++; continue; }
152
+ if (css[i + 1] === "\r" && css[i + 2] === "\n") { i += 3; continue; }
153
+ if (/[\n\r\f]/.test(css[i + 1])) { i += 2; continue; }
154
+ value += readEscape();
155
+ continue;
156
+ }
157
+ value += char;
158
+ i++;
159
+ }
160
+ return value;
161
+ };
162
+ const skipComment = () => {
163
+ const end = css.indexOf("*/", i + 2);
164
+ i = end < 0 ? length : end + 2;
165
+ };
166
+ // Unquoted url( token; `i` is just after "(". Returns null for bad-url.
167
+ const readUrlToken = () => {
168
+ let value = "", bad = false;
169
+ while (i < length) {
170
+ const code = css.charCodeAt(i);
171
+ if (css[i] === ")") { i++; return bad ? null : value; }
172
+ if (cssWhitespace(code)) {
173
+ while (i < length && cssWhitespace(css.charCodeAt(i))) i++;
174
+ if (css[i] !== ")" && i < length) bad = true;
175
+ continue;
176
+ }
177
+ if (css[i] === "\"" || css[i] === "'" || css[i] === "(" || code <= 0x08 ||
178
+ code === 0x0b || (code >= 0x0e && code <= 0x1f) || code === 0x7f) {
179
+ bad = true; i++; continue;
180
+ }
181
+ if (css[i] === "\\") {
182
+ if (validEscape(i)) value += readEscape();
183
+ else { bad = true; i++; }
184
+ continue;
185
+ }
186
+ value += css[i++];
187
+ }
188
+ return bad ? null : value;
189
+ };
190
+ // Skip to the end of an @import prelude (`;`) or past a stray `{...}` block.
191
+ const skipAtRule = () => {
192
+ let depth = 0;
193
+ while (i < length) {
194
+ const char = css[i];
195
+ if (char === "/" && css[i + 1] === "*") { skipComment(); continue; }
196
+ if (char === "\"" || char === "'") { readString(); continue; }
197
+ if (char === "\\") { if (validEscape(i)) readEscape(); else i++; continue; }
198
+ i++;
199
+ if (char === "(" || char === "[" || char === "{") depth++;
200
+ else if (char === ")" || char === "]") depth = Math.max(0, depth - 1);
201
+ else if (char === "}") { if (--depth <= 0) return; }
202
+ else if (char === ";" && depth === 0) return;
203
+ }
204
+ };
205
+ const handleImport = (start) => {
206
+ // `i` is just after "@import".
207
+ let target = null, cursor;
208
+ while (i < length) {
209
+ if (cssWhitespace(css.charCodeAt(i))) { i++; continue; }
210
+ if (css[i] === "/" && css[i + 1] === "*") { skipComment(); continue; }
211
+ break;
212
+ }
213
+ if (css[i] === "\"" || css[i] === "'") target = readString();
214
+ else {
215
+ cursor = i;
216
+ const name = cssNameChar(css.charCodeAt(i)) || validEscape(i) ? readName() : "";
217
+ if (name.toLowerCase() === "url" && css[i] === "(") {
218
+ i++;
219
+ while (i < length && cssWhitespace(css.charCodeAt(i))) i++;
220
+ if (css[i] === "\"" || css[i] === "'") {
221
+ target = readString();
222
+ while (i < length && cssWhitespace(css.charCodeAt(i))) i++;
223
+ if (css[i] === ")") i++; else target = null;
224
+ } else target = readUrlToken();
225
+ } else i = cursor;
226
+ }
227
+ const conditionStart = i;
228
+ skipAtRule();
229
+ const end = i;
230
+ const condition = css.slice(conditionStart, end).replace(/;\s*$/, "").trim();
231
+ const value = target === null ? "" : target.replace(/[\u0000-\u0020\u007f-\u009f]/g, "");
232
+ const blob = /^blob:/i.test(value);
233
+ // Same-origin relative sheets are allowed; data: CSS is never imported.
234
+ const relative = !!value && !blob && !/^data:/i.test(value) && !value.startsWith("#") &&
235
+ safeCssReference(value);
236
+ if (!strict && blob && blobImports && imports < CSS_IMPORT_MAX_PER_SHEET &&
237
+ /^[\w\s(),:.\-]*$/.test(condition) && !/^(?:layer|supports)\b/i.test(condition)) {
238
+ blobImports.push({ url: value, media: condition });
239
+ edits.push([start, end, `/*${nonce}:${blobImports.length - 1}*/`]);
240
+ imports++;
241
+ } else if (strict || target === null || !(relative || (blob && keepBlobImports && !blobImports)))
242
+ edits.push([start, end, ""]);
243
+ };
244
+
245
+ while (i < length) {
246
+ const char = css[i], code = css.charCodeAt(i);
247
+ if (char === "/" && css[i + 1] === "*") { skipComment(); continue; }
248
+ if (char === "\"" || char === "'") {
249
+ const start = i, value = readString();
250
+ const frame = stack[stack.length - 1];
251
+ // Custom properties can later be substituted into image-set()/image();
252
+ // treat absolute or protocol-relative strings there as references.
253
+ const reference = frame?.reference || (customValueDepth >= 0 &&
254
+ /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i.test(value.replace(/[\u0000-\u0020\u007f-\u009f]/g, "")));
255
+ if (reference && !safeCssReference(value, { strict })) {
256
+ if (frame?.reference) frame.unsafe = true;
257
+ else edits.push([start, i, "\"\""]);
258
+ }
259
+ continue;
260
+ }
261
+ if (char === "@" && (cssNameChar(css.charCodeAt(i + 1)) || validEscape(i + 1))) {
262
+ const start = i;
263
+ i++;
264
+ const name = readName().toLowerCase();
265
+ if (name === "import" && !stack.length) handleImport(start);
266
+ continue;
267
+ }
268
+ if (cssNameChar(code) || validEscape(i) ||
269
+ (char === "-" && (cssNameChar(css.charCodeAt(i + 1)) || validEscape(i + 1)))) {
270
+ const start = i, name = readName().toLowerCase();
271
+ if (css[i] === "(") {
272
+ i++;
273
+ if (name === "url") {
274
+ let probe = i;
275
+ while (probe < length && cssWhitespace(css.charCodeAt(probe))) probe++;
276
+ if (css[probe] !== "\"" && css[probe] !== "'") {
277
+ i = probe;
278
+ const value = readUrlToken();
279
+ if (value === null || !safeCssReference(value, { strict })) {
280
+ edits.push([start, i, removed]);
281
+ const parent = stack[stack.length - 1];
282
+ if (parent?.reference) parent.unsafe = true;
283
+ }
284
+ continue;
285
+ }
286
+ }
287
+ stack.push({ start, name, reference: CSS_REFERENCE_FUNCTIONS.has(name), unsafe: false });
288
+ continue;
289
+ }
290
+ if (name.startsWith("--") && customValueDepth < 0) {
291
+ let probe = i;
292
+ while (probe < length && cssWhitespace(css.charCodeAt(probe))) probe++;
293
+ if (css[probe] === ":") { customValueDepth = stack.length; i = probe + 1; }
294
+ }
295
+ continue;
296
+ }
297
+ if (char === "\\") { i++; continue; } // invalid escape
298
+ if (char === "(") { stack.push({ start: i, name: "", reference: false, unsafe: false }); i++; continue; }
299
+ if (char === ")") {
300
+ const frame = stack.pop();
301
+ i++;
302
+ if (frame?.unsafe) {
303
+ edits.push([frame.start, i, removed]);
304
+ const parent = stack[stack.length - 1];
305
+ if (parent?.reference) parent.unsafe = true;
306
+ }
307
+ continue;
308
+ }
309
+ if ((char === ";" || char === "}") && customValueDepth >= 0 && stack.length <= customValueDepth) customValueDepth = -1;
310
+ if (char === "{" || char === "}") stack.length = 0;
311
+ i++;
312
+ }
313
+ for (const frame of stack) if (frame.unsafe) edits.push([frame.start, length, removed]);
314
+ if (!edits.length) return css;
315
+ edits.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
316
+ let output = "", cursor = 0;
317
+ for (const [start, end, replacement] of edits) {
318
+ if (start < cursor) continue; // nested inside an earlier replacement
319
+ output += css.slice(cursor, start) + replacement;
320
+ cursor = end;
321
+ }
322
+ return output + css.slice(cursor);
323
+ }
324
+
325
+ function sanitizeCssReferences(css, options = {}) {
326
+ return scanCss(css, options);
327
+ }
328
+
329
+ async function readBlobStylesheet(url) {
330
+ try {
331
+ const response = await fetch(url);
332
+ if (!response.ok) return null;
333
+ const declared = Number(response.headers.get("content-length") || 0);
334
+ if (declared > CSS_BLOB_STYLESHEET_MAX_BYTES) return null;
335
+ const blob = await response.blob();
336
+ return blob.size > CSS_BLOB_STYLESHEET_MAX_BYTES ? null : await blob.text();
337
+ } catch (_) {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ // Replace blob @imports with their sanitized contents, preserving cascade
343
+ // position and media conditions. Cycles, excessive depth and unreadable
344
+ // sheets are dropped rather than loaded unsanitized.
345
+ async function inlineCssImports(css, depth = 0, ancestors = new Set()) {
346
+ const blobImports = [];
347
+ const nonce = `reader-import-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
348
+ const sanitized = scanCss(css, { blobImports, nonce });
349
+ if (!blobImports.length) return sanitized;
350
+ const bodies = await Promise.all(blobImports.map(async ({ url, media }) => {
351
+ if (depth >= CSS_IMPORT_MAX_DEPTH || ancestors.has(url)) return "";
352
+ const text = await readBlobStylesheet(url);
353
+ if (text === null) return "";
354
+ const body = (await inlineCssImports(text, depth + 1, new Set([...ancestors, url])))
355
+ .replace(/^\ufeff/, "").replace(/^\s*@charset\s+"[^"]*"\s*;/i, "");
356
+ return media && !/^all$/i.test(media) ? `@media ${media} {\n${body}\n}` : body;
357
+ }));
358
+ return sanitized.replace(new RegExp(`/\\*${nonce}:(\\d+)\\*/`, "g"), (_, index) => bodies[Number(index)] ?? "");
359
+ }
360
+
361
+ // Foliate rewrites chapter stylesheets to blob: URLs. Load them as sanitized
362
+ // inline styles so remote references inside packaged CSS cannot escape the
363
+ // archive-only resource policy. Results are cached per blob URL.
364
+ const foliateStyleCache = new Map();
365
+ const FOLIATE_STYLE_CACHE_MAX = 64;
366
+
367
+ async function sanitizeFoliateStyles(nodes) {
368
+ const output = [];
369
+ for (const node of nodes) {
370
+ if (node.localName === "link") {
371
+ const href = node.getAttribute("href") || "";
372
+ if (!/^blob:/i.test(href)) continue;
373
+ let pending = foliateStyleCache.get(href);
374
+ if (!pending) {
375
+ pending = readBlobStylesheet(href).then(text => text === null ? null : inlineCssImports(text, 0, new Set([href])));
376
+ pending.then(text => { if (text === null) foliateStyleCache.delete(href); });
377
+ foliateStyleCache.set(href, pending);
378
+ while (foliateStyleCache.size > FOLIATE_STYLE_CACHE_MAX)
379
+ foliateStyleCache.delete(foliateStyleCache.keys().next().value);
380
+ }
381
+ const text = await pending;
382
+ if (text === null) continue;
383
+ const style = document.createElement("style");
384
+ const media = node.getAttribute("media");
385
+ if (media) style.setAttribute("media", media);
386
+ style.textContent = text;
387
+ output.push(style);
388
+ } else {
389
+ const style = node.cloneNode(false);
390
+ style.textContent = await inlineCssImports(node.textContent || "");
391
+ output.push(style);
392
+ }
393
+ }
394
+ return output;
395
+ }
396
+
397
+ function sanitizeEpubDocument(doc, options = {}) {
398
+ return readerContext.sanitizeReaderDocument(readerContext.normalizeEpubDocument(doc), { ...options, preserveEpubStyles: true });
399
  }
400
 
401
  function parseChapterDocument(text) {
 
441
  });
442
  const template = document.createElement("template");
443
  template.content.appendChild(clean);
444
+ const stripUrls = (value) => sanitizeCssReferences(value, { strict: true });
445
  for (const style of template.content.querySelectorAll("style")) {
446
  const value = style.textContent, stripped = stripUrls(value);
447
  if (stripped !== value) style.textContent = stripped;
 
649
  normalizeEpubDocument,
650
  sanitizeReaderDocument,
651
  sanitizeEpubDocument,
652
+ sanitizeCssReferences,
653
+ sanitizeFoliateStyles,
654
  parseChapterDocument,
655
  epubContentBody,
656
  renderText,
tests/test_reader_css_sanitizer.mjs ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Offline contract for Reader CSS reference sanitization (EPUB/chapter styles).
2
+ import assert from "node:assert/strict";
3
+ import { createReaderHtmlFormat } from "../static/reader/formats/reader-html-format.mjs";
4
+
5
+ const { sanitizeCssReferences: clean } = createReaderHtmlFormat({});
6
+ const cases = [
7
+ // [input, expected, options]
8
+ ["p{background:url(https://x.test/a.png)}", "p{background:none}"],
9
+ ["p{background:URL( 'https://x.test/a.png' )}", "p{background:none}"],
10
+ ["p{background:\\75 rl(https://x.test/a.png)}", "p{background:none}"],
11
+ ["p{background:\\75rl(//x.test/a.png)}", "p{background:none}"],
12
+ ["p{background:url(ht\\74ps://x.test/a.png)}", "p{background:none}"],
13
+ ["p{background:url(\"java\\9script:x\")}", "p{background:none}"],
14
+ ["p{background:url(h\ttps://x.test)}", "p{background:none}"],
15
+ ["p{background:image-set(\"https://x.test/a.png\" 1x)}", "p{background:none}"],
16
+ ["p{background:-webkit-image-set(url(https://x.test/a.png) 1x)}", "p{background:none}"],
17
+ ["p{--bg:\"https://x.test/a.png\";background:image-set(var(--bg) 1x)}", "p{--bg:\"\";background:image-set(var(--bg) 1x)}"],
18
+ ["@import \"https://x.test/a.css\";p{}", "p{}"],
19
+ ["@import/**/url(https://x.test/a.css) screen;p{}", "p{}"],
20
+ ["@\\69mport 'https://x.test/a.css';p{}", "p{}"],
21
+ ["@import url(data:text/css,p{});p{}", "p{}"],
22
+ ["@import \"blob:https://site/abc\";p{}", "p{}"],
23
+ ["@import \"blob:https://site/abc\";p{}", "@import \"blob:https://site/abc\";p{}", { keepBlobImports: true }],
24
+ ["@import \"local.css\";p{}", "@import \"local.css\";p{}"],
25
+ // Safe references and non-reference strings stay byte-for-byte.
26
+ ["p{background:url(blob:https://site/1)}", null],
27
+ ["p{background:url(\"data:image/svg+xml,<svg/>\")}", null],
28
+ ["@font-face{src:url(\"blob:https://site/f\") format(\"woff2\"),local(\"Noto\")}", null],
29
+ ["p{background:url(images/a.png)} q{fill:url(#grad)}", null],
30
+ ["p::before{content:\"https://example.com\"}", null],
31
+ ["p{content:\"url(https://x.test)\"} /* url(https://x.test) */", null],
32
+ ["p{background:url(https://x.test/a.png)}", "p{background:none}", { strict: false }],
33
+ // Strict (offline HTML) removes every reference.
34
+ ["p{background:url(#a)}@import 'x.css';", "p{background:}", { strict: true }],
35
+ ["<p style>background:url(javascript:bad);color:#111", "<p style>background:;color:#111", { strict: true }],
36
+ ];
37
+ let failures = 0;
38
+ for (const [input, expected, options] of cases) {
39
+ const actual = clean(input, options);
40
+ try {
41
+ assert.equal(actual, expected ?? input);
42
+ } catch (error) {
43
+ failures++;
44
+ console.error(`FAIL ${JSON.stringify(input)}\n got ${JSON.stringify(actual)}\n expected ${JSON.stringify(expected ?? input)}`);
45
+ }
46
+ }
47
+ // Linear-time behaviour on adversarial nesting/escapes.
48
+ const big = "p{background:" + "image-set(".repeat(20000) + "\"https://x.test\"" + ")".repeat(20000) + "}" + "\\".repeat(50000);
49
+ const started = Date.now();
50
+ assert.ok(!clean(big).includes("https://x.test"));
51
+ assert.ok(Date.now() - started < 2000, "scanner must stay linear");
52
+ if (failures) {
53
+ console.error(`${failures} CSS sanitizer case(s) failed`);
54
+ process.exit(1);
55
+ }
56
+ console.log(`reader CSS sanitizer: ${cases.length} cases passed`);
tests/test_reader_refactor.py CHANGED
@@ -2686,6 +2686,41 @@ class ReaderRefactorTest(unittest.TestCase):
2686
  probe.click()
2687
  self.assertFalse(self.page.evaluate("() => window.__readerSecurityMarker === 17"))
2688
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2689
  def expose_html_contrast(self):
2690
  def instrument(route):
2691
  response = route.fetch()
 
2686
  probe.click()
2687
  self.assertFalse(self.page.evaluate("() => window.__readerSecurityMarker === 17"))
2688
 
2689
+ def test_epub_css_cannot_load_remote_references_but_keeps_packaged_styles(self):
2690
+ css = ("@import url(https://css.remote.test/a.css);\n"
2691
+ "@import '\\68ttps://css.remote.test/b.css';\n"
2692
+ "p.linked { letter-spacing: 3px; background-image: url(https://img.remote.test/a.png); }\n"
2693
+ "p.escaped { background-image: \\75rl(//img.remote.test/b.png); }\n"
2694
+ "p.set { background-image: image-set('https://img.remote.test/c.png' 1x); }\n"
2695
+ "p.local { background-image: url(dot.png); }\n")
2696
+ dot = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/6XWbqkAAAAASUVORK5CYII=")
2697
+ body = support.zip_bytes({
2698
+ "META-INF/container.xml": '<?xml version="1.0"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
2699
+ "OEBPS/content.opf": '<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">css-security</dc:identifier><dc:title>CSS</dc:title><dc:language>en</dc:language></metadata><manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="style" href="style.css" media-type="text/css"/><item id="dot" href="dot.png" media-type="image/png"/></manifest><spine><itemref idref="chapter"/></spine></package>',
2700
+ "OEBPS/style.css": css,
2701
+ "OEBPS/dot.png": dot,
2702
+ "OEBPS/chapter.xhtml": '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>CSS</title><link rel="stylesheet" href="style.css"/><style>p.inline { background-image: \\75 rl("https://img.remote.test/d.png"); }</style></head><body><p class="linked">linked</p><p class="escaped">escaped</p><p class="set">set</p><p class="local">local</p><p class="inline">inline</p><p class="attr" style="background-image:url(&quot;https://img.remote.test/e.png&quot;)">attr</p></body></html>',
2703
+ }, ("mimetype", "application/epub+zip"))
2704
+ remote = []
2705
+ self.page.on("request", lambda request: remote.append(request.url) if ".remote.test" in request.url else None)
2706
+ self.serve(body, "application/epub+zip")
2707
+ self.open(self.reader_url("epub", name="css-security"))
2708
+ self.page.wait_for_function("""() => {
2709
+ const root = document.querySelector('article[data-section]')?.shadowRoot;
2710
+ const p = root?.querySelector('p.linked');
2711
+ return p && getComputedStyle(p).letterSpacing === '3px';
2712
+ }""")
2713
+ styles = self.page.evaluate("""() => {
2714
+ const root = document.querySelector('article[data-section]').shadowRoot;
2715
+ return Object.fromEntries([...root.querySelectorAll('p')].map(p =>
2716
+ [p.className, getComputedStyle(p).backgroundImage]));
2717
+ }""")
2718
+ for name in ("linked", "escaped", "set", "inline", "attr"):
2719
+ self.assertEqual(styles[name], "none", name)
2720
+ self.assertRegex(styles["local"], r"^url\(\"blob:")
2721
+ self.page.wait_for_timeout(300)
2722
+ self.assertEqual(remote, [])
2723
+
2724
  def expose_html_contrast(self):
2725
  def instrument(route):
2726
  response = route.fetch()