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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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") ||
|
|
|
|
| 37 |
link.remove();
|
| 38 |
}
|
| 39 |
-
for (const style of doc.querySelectorAll("style"))
|
| 40 |
-
|
| 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 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
| 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("https://img.remote.test/e.png")">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()
|