Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions navigator/src/epub/frame/FrameBlobBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ const cssSelectorGenerator = (doc: Document) => scriptify(doc, cached("css-selec

// Note: we aren't blocking some of the events right now to try and be as nonintrusive as possible.
// For a more comprehensive implementation, see https://github.com/hackademix/noscript/blob/3a83c0e4a506f175e38b0342dad50cdca3eae836/src/content/syncFetchPolicy.js#L142
// The snippet of code at the beginning of this source is an attempt at defence against JS using persistent storage
const rBefore = (doc: Document) => scriptify(doc, cached("JS-Before", () => blobify(stripJS(`
const noop=()=>{},emptyObj={},emptyPromise=()=>Promise.resolve(void 0),fakeStorage={getItem:noop,setItem:noop,removeItem:noop,clear:noop,key:noop,length:0};["localStorage","sessionStorage"].forEach((e=>Object.defineProperty(window,e,{get:()=>fakeStorage,configurable:!0}))),Object.defineProperty(document,"cookie",{get:()=>"",set:noop,configurable:!0}),Object.defineProperty(window,"indexedDB",{get:()=>{},configurable:!0}),Object.defineProperty(window,"caches",{get:()=>emptyObj,configurable:!0}),Object.defineProperty(navigator,"storage",{get:()=>({persist:emptyPromise,persisted:emptyPromise,estimate:()=>Promise.resolve({quota:0,usage:0})}),configurable:!0}),Object.defineProperty(navigator,"serviceWorker",{get:()=>({register:emptyPromise,getRegistration:emptyPromise,ready:emptyPromise()}),configurable:!0});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vast majority of EPUB publications don't need localStorage, so in the real world it probably won't hurt to disable it, but just FYI: disabling localStorage kills interactive features in some EPUBs (reflow and fixed layout), for example where the user can enter their name for a personalised story, or where some progress data is stored to deliver a contextual user experience. In Thorium Desktop each publication has its own origin so the store is shared amongst all book chapters but doesn't leak out, there is some built-in isolation already (I guess the web reader cannot make this assumption).

Copy link
Contributor

@JayPanoz JayPanoz Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was quite involved in this topic – you may remember I was the one who accidentally discovered Apple Books.app used the same domain for all books and local storage was consequently global/shared, and they had to rush a new release as it went public on Twitter. 😶

At some point there were talks and some spec draft for suborigins or something like that.

I'm not holding my breath as it was a long long time ago and I'm not finding anything relevant. 😬

Edit: And well it wouldn't be a toolkit concern anyway.


window._readium_blockedEvents = [];
window._readium_blockEvents = true;
window._readium_eventBlocker = (e) => {
Expand Down Expand Up @@ -78,6 +81,24 @@ const rAfter = (doc: Document) => scriptify(doc, cached("JS-After", () => blobif
});`
), "text/javascript")));

const csp = (domains: string[]) => {
const d = domains.join(" ");
return [
// 'self' is useless because the document is loaded from a blob: URL
`upgrade-insecure-requests`,
`default-src ${d} blob:`,
`connect-src 'none'`, // No fetches to anywhere. TODO: change?
`script-src ${d} blob: 'unsafe-inline'`, // JS scripts
`style-src ${d} blob: 'unsafe-inline'`, // CSS styles
`img-src ${d} blob: data:`, // Images
`font-src ${d} blob: data:`, // Fonts
`object-src ${d} blob:`, // Despite not being recommended, still necessary in EPUBs for <object>
`child-src ${d}`, // <iframe>, web workers
`form-action 'none'`, // No form submissions
//`report-uri ?`,
].join("; ");
};

export default class FrameBlobBuider {
private readonly item: Link;
private readonly burl: string;
Expand All @@ -93,7 +114,7 @@ export default class FrameBlobBuider {

public async build(fxl = false): Promise<string> {
if(!this.item.mediaType.isHTML) {
if(this.item.mediaType.isBitmap) {
if(this.item.mediaType.isBitmap || this.item.mediaType.equals(MediaType.SVG)) {
return this.buildImageFrame();
} else
throw Error("Unsupported frame mediatype " + this.item.mediaType.string);
Expand All @@ -115,7 +136,7 @@ export default class FrameBlobBuider {
const details = perror.querySelector("div");
throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`);
}
return this.finalizeDOM(doc, this.burl, this.item.mediaType, fxl, this.cssProperties);
return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties);
}

private buildImageFrame(): string {
Expand All @@ -126,7 +147,7 @@ export default class FrameBlobBuider {
simg.alt = this.item.title || "";
simg.decoding = "async";
doc.body.appendChild(simg);
return this.finalizeDOM(doc, this.burl, this.item.mediaType, true);
return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, true);
}

// Has JS that may have side-effects when the document is loaded, without any user interaction
Expand Down Expand Up @@ -159,7 +180,7 @@ export default class FrameBlobBuider {
}
}

private finalizeDOM(doc: Document, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string {
private finalizeDOM(doc: Document, root: string | undefined, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string {
if(!doc) return "";

// Inject styles
Expand Down Expand Up @@ -233,8 +254,8 @@ export default class FrameBlobBuider {
) {
document.documentElement.dir = this.pub.metadata.effectiveReadingProgression;
} */
if(base !== undefined) {

if (base !== undefined) {
// Set all URL bases. Very convenient!
const b = doc.createElement("base");
b.href = base;
Expand All @@ -244,17 +265,24 @@ export default class FrameBlobBuider {

// Inject script to prevent in-publication scripts from executing until we want them to
const hasExecutable = this.hasExecutable(doc);
if(hasExecutable) doc.head.firstChild!.before(rBefore(doc));
if (hasExecutable) doc.head.firstChild!.before(rBefore(doc));
doc.head.firstChild!.before(cssSelectorGenerator(doc)); // CSS selector utility
if(hasExecutable) doc.head.appendChild(rAfter(doc)); // Another execution prevention script
if (hasExecutable) doc.head.appendChild(rAfter(doc)); // Another execution prevention script

// Add CSP
const meta = doc.createElement("meta");
meta.httpEquiv = "Content-Security-Policy";
meta.content = csp(root ? [root] : []);
meta.dataset.readium = "true";
doc.head.firstChild!.before(meta);


// Make blob from doc
return URL.createObjectURL(
new Blob([new XMLSerializer().serializeToString(doc)], {
type: mediaType.isHTML
? mediaType.string
: "application/xhtml+xml", // Fallback to XHTML
type: mediaType.isHTML
? mediaType.string
: "application/xhtml+xml", // Fallback to XHTML
})
);
}
Expand Down
1 change: 1 addition & 0 deletions navigator/src/epub/frame/FrameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class FrameManager {

constructor(source: string) {
this.frame = document.createElement("iframe");
this.frame.sandbox.value = "allow-same-origin allow-scripts";
this.frame.classList.add("readium-navigator-iframe");
this.frame.style.visibility = "hidden";
this.frame.style.setProperty("aria-hidden", "true");
Expand Down
1 change: 1 addition & 0 deletions navigator/src/epub/fxl/FXLFrameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class FXLFrameManager {
this.peripherals = peripherals;
this.debugHref = debugHref;
this.frame = document.createElement("iframe");
this.frame.sandbox.value = "allow-same-origin allow-scripts";
this.frame.classList.add("readium-navigator-iframe");
this.frame.classList.add("blank");
this.frame.scrolling = "no";
Expand Down