OpenCode commited on
Commit
65fdff2
·
1 Parent(s): 35943bd

Harden reader resource handling

Browse files
scripts/check_reader_release.mjs CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
  import path from "node:path";
3
  import crypto from "node:crypto";
4
  const args = Object.fromEntries(process.argv.slice(2).reduce((items, value, index, all) => value.startsWith("--") ? [...items, [value.slice(2), all[index + 1]?.startsWith("--") ? true : all[index + 1]]] : items, []));
5
- const required = ["reader.html", "reader.css", "reader.js", "reader-contract.js", "reader-store.js", "reader-request-manager.js", "reader-chapter-repository.js", "reader-scroll-anchor.js", "reader-section-virtualizer.js", "reader-runtime.js", "reader-format-adapters.js", "pdf-worker-wrapper.mjs", "foliate-reader/view.js"];
6
  const behavioral = ["createReaderRuntime", "createAdapterRegistry", "registerReaderFormatAdapters", "formatAdapters.activate(capability.mode)", "disableStream: true", "nextReaderGeneration(\"navigation\")"];
7
  const hash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"), normalized = (file) => fs.readFileSync(file, "utf8").replaceAll("/search/static/", "/static/").replace(/\s+/g, "");
8
  function assert(condition, message) { if (!condition) throw new Error(message); }
 
2
  import path from "node:path";
3
  import crypto from "node:crypto";
4
  const args = Object.fromEntries(process.argv.slice(2).reduce((items, value, index, all) => value.startsWith("--") ? [...items, [value.slice(2), all[index + 1]?.startsWith("--") ? true : all[index + 1]]] : items, []));
5
+ const required = ["reader.html", "reader.css", "reader.js", "reader-contract.js", "reader-store.js", "reader-request-manager.js", "reader-chapter-repository.js", "reader-scroll-anchor.js", "reader-section-virtualizer.js", "reader-runtime.js", "reader-format-adapters.js", "reader-security.js", "pdf-worker-wrapper.mjs", "foliate-reader/view.js"];
6
  const behavioral = ["createReaderRuntime", "createAdapterRegistry", "registerReaderFormatAdapters", "formatAdapters.activate(capability.mode)", "disableStream: true", "nextReaderGeneration(\"navigation\")"];
7
  const hash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"), normalized = (file) => fs.readFileSync(file, "utf8").replaceAll("/search/static/", "/static/").replace(/\s+/g, "");
8
  function assert(condition, message) { if (!condition) throw new Error(message); }
static/app.js CHANGED
@@ -961,7 +961,7 @@ function warmReaderIntent(rawUrl) {
961
  extension = (readerUrl.searchParams.get("ext") || "").toLowerCase();
962
  sourceUrl = readerUrl.searchParams.get("url") || "";
963
  } catch (_) { return; }
964
- const shellAssets = ["/static/reader.css", "/static/reader-contract.js", "/static/reader-store.js", "/static/reader-request-manager.js", "/static/reader-chapter-repository.js", "/static/reader-scroll-anchor.js", "/static/reader-section-virtualizer.js", "/static/reader-runtime.js", "/static/reader-format-adapters.js", "/static/reader.js"];
965
  const engineAssets = extension === "pdf"
966
  ? ["/static/vendor/pdf.min.f80490490320.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.8ab0e5e30031.mjs"]
967
  : extension === "epub" ? []
 
961
  extension = (readerUrl.searchParams.get("ext") || "").toLowerCase();
962
  sourceUrl = readerUrl.searchParams.get("url") || "";
963
  } catch (_) { return; }
964
+ const shellAssets = ["/static/reader.css", "/static/reader-contract.js", "/static/reader-store.js", "/static/reader-request-manager.js", "/static/reader-chapter-repository.js", "/static/reader-scroll-anchor.js", "/static/reader-section-virtualizer.js", "/static/reader-runtime.js", "/static/reader-format-adapters.js", "/static/reader-security.js", "/static/reader.js"];
965
  const engineAssets = extension === "pdf"
966
  ? ["/static/vendor/pdf.min.f80490490320.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.8ab0e5e30031.mjs"]
967
  : extension === "epub" ? []
static/reader-security.js CHANGED
@@ -7,5 +7,6 @@
7
  async function readText(response, limit, encoding = "utf-8") { return new TextDecoder(encoding).decode(await readBytes(response, limit)); }
8
  function inspectZip(bytes, limits = LIMITS) { bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); if (bytes.byteLength > limits.archiveCompressedBytes) throw error("READER_ARCHIVE_LIMIT"); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let entries = 0, compressed = 0, expanded = 0; for (let offset = 0; offset + 46 <= bytes.byteLength;) { const signature = view.getUint32(offset, true); if (signature !== 0x02014b50) { offset += 1; continue; } const packed = view.getUint32(offset + 20, true), unpacked = view.getUint32(offset + 24, true), nameLength = view.getUint16(offset + 28, true), extraLength = view.getUint16(offset + 30, true), commentLength = view.getUint16(offset + 32, true); if (packed === 0xffffffff || unpacked === 0xffffffff) throw error("READER_ARCHIVE_LIMIT"); entries += 1; compressed += packed; expanded += unpacked; if (entries > limits.archiveEntries || expanded > limits.archiveExpandedBytes || (packed === 0 ? unpacked > 0 : unpacked / packed > limits.archiveRatio)) throw error("READER_ARCHIVE_LIMIT"); offset += 46 + nameLength + extraLength + commentLength; } if (!entries) throw error("READER_ARCHIVE_INVALID"); return Object.freeze({ entries, compressed, expanded }); }
9
  function validateChapterManifest(manifest) { if (manifest?.version !== 1 || manifest?.kind !== "epub-chapters" || !Array.isArray(manifest.chapters) || !manifest.chapters.length) throw error("EPUB_INVALID"); if (manifest.chapters.length > LIMITS.chapters) throw error("READER_RESOURCE_LIMIT"); let declaredBytes = 0; for (const chapter of manifest.chapters) { const size = Number(chapter.bytes ?? chapter.size ?? 0); if (Number.isFinite(size) && size > 0) { if (size > LIMITS.chapterBytes) throw error("READER_RESOURCE_LIMIT"); declaredBytes += size; } } if (declaredBytes > LIMITS.chapterTotalBytes) throw error("READER_RESOURCE_LIMIT"); return manifest; }
10
- root.VoiceOfMLReaderSecurity = Object.freeze({ LIMITS, assertResponseSize, readBytes, readText, inspectZip, validateChapterManifest });
 
11
  })(typeof self !== "undefined" ? self : globalThis);
 
7
  async function readText(response, limit, encoding = "utf-8") { return new TextDecoder(encoding).decode(await readBytes(response, limit)); }
8
  function inspectZip(bytes, limits = LIMITS) { bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); if (bytes.byteLength > limits.archiveCompressedBytes) throw error("READER_ARCHIVE_LIMIT"); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let entries = 0, compressed = 0, expanded = 0; for (let offset = 0; offset + 46 <= bytes.byteLength;) { const signature = view.getUint32(offset, true); if (signature !== 0x02014b50) { offset += 1; continue; } const packed = view.getUint32(offset + 20, true), unpacked = view.getUint32(offset + 24, true), nameLength = view.getUint16(offset + 28, true), extraLength = view.getUint16(offset + 30, true), commentLength = view.getUint16(offset + 32, true); if (packed === 0xffffffff || unpacked === 0xffffffff) throw error("READER_ARCHIVE_LIMIT"); entries += 1; compressed += packed; expanded += unpacked; if (entries > limits.archiveEntries || expanded > limits.archiveExpandedBytes || (packed === 0 ? unpacked > 0 : unpacked / packed > limits.archiveRatio)) throw error("READER_ARCHIVE_LIMIT"); offset += 46 + nameLength + extraLength + commentLength; } if (!entries) throw error("READER_ARCHIVE_INVALID"); return Object.freeze({ entries, compressed, expanded }); }
9
  function validateChapterManifest(manifest) { if (manifest?.version !== 1 || manifest?.kind !== "epub-chapters" || !Array.isArray(manifest.chapters) || !manifest.chapters.length) throw error("EPUB_INVALID"); if (manifest.chapters.length > LIMITS.chapters) throw error("READER_RESOURCE_LIMIT"); let declaredBytes = 0; for (const chapter of manifest.chapters) { const size = Number(chapter.bytes ?? chapter.size ?? 0); if (Number.isFinite(size) && size > 0) { if (size > LIMITS.chapterBytes) throw error("READER_RESOURCE_LIMIT"); declaredBytes += size; } } if (declaredBytes > LIMITS.chapterTotalBytes) throw error("READER_RESOURCE_LIMIT"); return manifest; }
10
+ function isZipContainer(extension) { return ["epub", "fbz"].includes(String(extension || "").toLowerCase()); }
11
+ root.VoiceOfMLReaderSecurity = Object.freeze({ LIMITS, assertResponseSize, readBytes, readText, inspectZip, validateChapterManifest, isZipContainer });
12
  })(typeof self !== "undefined" ? self : globalThis);
static/reader.js CHANGED
@@ -573,6 +573,7 @@ function loadScript(url) {
573
  }
574
  function fetchWithReaderTimeout(url, timeoutMs = READER_PROXY_TIMEOUT_MS) { return readerRequestManager.request(url, timeoutMs); }
575
  function fetchReaderUrl(rawUrl) {
 
576
  const proxyUrl = `/api/reader-content?url=${encodeURIComponent(rawUrl)}`;
577
  return fetchWithReaderTimeout(proxyUrl).then((response) => response.ok ? response : fetchWithReaderTimeout(rawUrl), () => fetchWithReaderTimeout(rawUrl));
578
  }
@@ -593,7 +594,7 @@ function fail(message, code = "READER_PARSE") { if (readerLifecycle.disposed) re
593
  async function renderPdfPages(prepared) {
594
  const response = await prepared; if (!response.ok) throw new Error(`HTTP ${response.status}`);
595
  const manifest = JSON.parse(await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)), entries = (manifest.pages || []).map((item) => ({ page: Number(item.page), path: String(item.path || "") })).sort((a, b) => a.page - b.page), rootMatch = entries[0]?.path.match(/^(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/[0-9a-f]{16})\/pages\//);
596
- if (manifest.version !== 1 || manifest.kind !== "pdf-pages" || !rootMatch || !entries.length || entries.some((item, index) => item.page !== index + 1 || !new RegExp(`^objects/${rootMatch[1]}/pages/page-[0-9]{6}\\.webp$`).test(item.path))) throw new Error("PDF_MANIFEST_INVALID");
597
  pdfPageManifest = { entries }; updateDocumentState({ pageCount: entries.length }); pageInput.max = String(documentState.pageCount); document.querySelector("#page-total").textContent = `/ ${documentState.pageCount}`; status.textContent = `${documentState.pageCount} 页`;
598
  const visibleObserver = new IntersectionObserver((items) => {
599
  const intersecting = items.filter((entry) => entry.isIntersecting).sort((a, b) => {
@@ -1089,7 +1090,7 @@ async function renderFoliate(prepared) {
1089
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
1090
  await import("/static/foliate-reader/view.js");
1091
  const bytes = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes);
1092
- VoiceOfMLReaderSecurity.inspectZip(bytes);
1093
  const view = document.createElement("foliate-view"),
1094
  stream = document.createElement("div");
1095
  view.className = "foliate-reader-view";
 
573
  }
574
  function fetchWithReaderTimeout(url, timeoutMs = READER_PROXY_TIMEOUT_MS) { return readerRequestManager.request(url, timeoutMs); }
575
  function fetchReaderUrl(rawUrl) {
576
+ if (String(rawUrl).includes("/api/reader-bucket-resource?")) return fetchWithReaderTimeout(rawUrl);
577
  const proxyUrl = `/api/reader-content?url=${encodeURIComponent(rawUrl)}`;
578
  return fetchWithReaderTimeout(proxyUrl).then((response) => response.ok ? response : fetchWithReaderTimeout(rawUrl), () => fetchWithReaderTimeout(rawUrl));
579
  }
 
594
  async function renderPdfPages(prepared) {
595
  const response = await prepared; if (!response.ok) throw new Error(`HTTP ${response.status}`);
596
  const manifest = JSON.parse(await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes)), entries = (manifest.pages || []).map((item) => ({ page: Number(item.page), path: String(item.path || "") })).sort((a, b) => a.page - b.page), rootMatch = entries[0]?.path.match(/^(objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/[0-9a-f]{16})\/pages\//);
597
+ if (manifest.version !== 1 || manifest.kind !== "pdf-pages" || !rootMatch || !entries.length || entries.some((item, index) => item.page !== index + 1 || !new RegExp(`^${rootMatch[1]}/pages/page-[0-9]{6}\\.webp$`).test(item.path))) throw new Error("PDF_MANIFEST_INVALID");
598
  pdfPageManifest = { entries }; updateDocumentState({ pageCount: entries.length }); pageInput.max = String(documentState.pageCount); document.querySelector("#page-total").textContent = `/ ${documentState.pageCount}`; status.textContent = `${documentState.pageCount} 页`;
599
  const visibleObserver = new IntersectionObserver((items) => {
600
  const intersecting = items.filter((entry) => entry.isIntersecting).sort((a, b) => {
 
1090
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
1091
  await import("/static/foliate-reader/view.js");
1092
  const bytes = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.archiveCompressedBytes);
1093
+ if (VoiceOfMLReaderSecurity.isZipContainer(extension)) VoiceOfMLReaderSecurity.inspectZip(bytes);
1094
  const view = document.createElement("foliate-view"),
1095
  stream = document.createElement("div");
1096
  view.className = "foliate-reader-view";
static/sw.js CHANGED
@@ -11,6 +11,7 @@ const PRECACHE_URLS = [
11
  "/static/reader-section-virtualizer.js",
12
  "/static/reader-runtime.js",
13
  "/static/reader-format-adapters.js",
 
14
  "/static/reader.html",
15
  "/static/reader.css",
16
  "/static/reader.js",
@@ -33,6 +34,7 @@ const READER_RUNTIME_PATHS = new Set([
33
  "/static/reader-section-virtualizer.js",
34
  "/static/reader-runtime.js",
35
  "/static/reader-format-adapters.js",
 
36
  "/static/reader.css",
37
  "/static/reader.js"
38
  ]);
 
11
  "/static/reader-section-virtualizer.js",
12
  "/static/reader-runtime.js",
13
  "/static/reader-format-adapters.js",
14
+ "/static/reader-security.js",
15
  "/static/reader.html",
16
  "/static/reader.css",
17
  "/static/reader.js",
 
34
  "/static/reader-section-virtualizer.js",
35
  "/static/reader-runtime.js",
36
  "/static/reader-format-adapters.js",
37
+ "/static/reader-security.js",
38
  "/static/reader.css",
39
  "/static/reader.js"
40
  ]);