Skip to content
Draft
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
2 changes: 1 addition & 1 deletion navigator/src/dom/_readium_cssSelectorGenerator.js

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions navigator/src/dom/_readium_executionCleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
(function() {
if(window.onload) window.onload = new Proxy(window.onload, {
apply: function(target, receiver, args) {
if(!window._readium_blockEvents) {
Reflect.apply(target, receiver, args);
return;
}
_readium_blockedEvents.push([
0, target, receiver, args
]);
}
});
})();
65 changes: 65 additions & 0 deletions navigator/src/dom/_readium_executionPrevention.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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
(function() {
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
});

window._readium_blockedEvents = [];
window._readium_blockEvents = true;
window._readium_eventBlocker = (e) => {
if(!window._readium_blockEvents) return;
e.preventDefault();
e.stopImmediatePropagation();
_readium_blockedEvents.push([
1, e, e.currentTarget || e.target
]);
};
window.addEventListener("DOMContentLoaded", window._readium_eventBlocker, true);
window.addEventListener("load", window._readium_eventBlocker, true);
})();
4 changes: 4 additions & 0 deletions navigator/src/dom/_readium_webpubProperties.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// WebPub-specific setup - no execution blocking needed
window._readium_blockedEvents = [];
window._readium_blockEvents = false; // WebPub doesn't need event blocking
window._readium_eventBlocker = null;
28 changes: 26 additions & 2 deletions navigator/src/epub/EpubNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ import { EpubPreferencesEditor } from "./preferences/EpubPreferencesEditor";
import { ReadiumCSS } from "./css/ReadiumCSS";
import { RSProperties, UserProperties } from "./css/Properties";
import { getContentWidth } from "../helpers/dimensions";
import { Injector } from "../injection/Injector";
import { createReadiumEpubRules } from "../injection/epubInjectables";
import { IInjectablesConfig } from "../injection/Injectable";

export type ManagerEventKey = "zoom";

export interface EpubNavigatorConfiguration {
preferences: IEpubPreferences;
defaults: IEpubDefaults;
injectables?: IInjectablesConfig;
}

export interface EpubNavigatorListeners {
Expand Down Expand Up @@ -65,6 +69,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
private _settings: EpubSettings;
private _css: ReadiumCSS;
private _preferencesEditor: EpubPreferencesEditor | null = null;
private readonly _injector: Injector | null = null;

private resizeObserver: ResizeObserver;

Expand Down Expand Up @@ -106,6 +111,15 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
this._layout = EpubNavigator.determineLayout(pub, !!this._settings.scroll);
this.currentProgression = pub.metadata.effectiveReadingProgression;

// Combine Readium rules with user-provided injectables
const readiumRules = createReadiumEpubRules(pub.metadata);
const userConfig = configuration.injectables || { rules: [], allowedDomains: [] };

this._injector = new Injector({
rules: [...readiumRules, ...userConfig.rules],
allowedDomains: userConfig.allowedDomains
});

// We use a resizeObserver cos’ the container parent may not be the width of
// the document/window e.g. app using a docking system with left and right panels.
// If we observe this.container, that won’t obviously work since we set its width.
Expand Down Expand Up @@ -136,14 +150,24 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
if (!this.positions?.length)
this.positions = await this.pub.positionsFromManifest();
if(this._layout === Layout.fixed) {
this.framePool = new FXLFramePoolManager(this.container, this.positions, this.pub);
this.framePool = new FXLFramePoolManager(
this.container,
this.positions,
this.pub,
this._injector
);
this.framePool.listener = (key: CommsEventKey | ManagerEventKey, data: unknown) => {
this.eventListener(key, data);
}
} else {
await this.updateCSS(false);
const cssProperties = this.compileCSSProperties(this._css);
this.framePool = new FramePoolManager(this.container, this.positions, cssProperties);
this.framePool = new FramePoolManager(
this.container,
this.positions,
cssProperties,
this._injector
);
}

if(this.currentLocation === undefined)
Expand Down
168 changes: 37 additions & 131 deletions navigator/src/epub/frame/FrameBlobBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,6 @@
import { MediaType } from "@readium/shared";
import { Link, Publication } from "@readium/shared";

// Readium CSS imports
// The "?inline" query is to prevent some bundlers from injecting these into the page (e.g. vite)
// @ts-ignore
import readiumCSSAfter from "@readium/css/css/dist/ReadiumCSS-after.css?inline";
// @ts-ignore
import readiumCSSBefore from "@readium/css/css/dist/ReadiumCSS-before.css?inline";
// @ts-ignore
import readiumCSSDefault from "@readium/css/css/dist/ReadiumCSS-default.css?inline";

// Import the pre-built CSS selector generator
// This has to be injected because you need to be in the iframe's context for it to work properly
import cssSelectorGeneratorContent from "../../dom/_readium_cssSelectorGenerator.js?raw";

// Utilities
const blobify = (source: string, type: string) => URL.createObjectURL(new Blob([source], { type }));
const stripJS = (source: string) => source.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n/g, "").replace(/\s+/g, " ");
const stripCSS = (source: string) => source.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, '').replace(/ {2,}/g, ' ')
// Fully resolve absolute local URLs created by bundlers since it's going into a blob
.replace(/url\((?!(https?:)?\/\/)("?)\/([^\)]+)/g, `url($2${window.location.origin}/$3`);
const scriptify = (doc: Document, source: string) => {
const s = doc.createElement("script");
s.dataset.readium = "true";
s.src = source.startsWith("blob:") ? source : blobify(source, "text/javascript");
return s;
}
const styleify = (doc: Document, source: string) => {
const s = doc.createElement("link");
s.dataset.readium = "true";
s.rel = "stylesheet";
s.type = "text/css";
s.href = source.startsWith("blob:") ? source : blobify(source, "text/css");
return s;
}

type CacheFunction = () => string;
const resourceBlobCache = new Map<string, string>();
const cached = (key: string, cacher: CacheFunction) => {
if(resourceBlobCache.has(key)) return resourceBlobCache.get(key)!;
const value = cacher();
resourceBlobCache.set(key, value);
return value;
};

const cssSelectorGenerator = (doc: Document) => scriptify(doc, cached("css-selector-generator", () => blobify(
cssSelectorGeneratorContent,
"text/javascript"
)));

// 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});

window._readium_blockedEvents = [];
window._readium_blockEvents = true;
window._readium_eventBlocker = (e) => {
if(!window._readium_blockEvents) return;
e.preventDefault();
e.stopImmediatePropagation();
_readium_blockedEvents.push([
1, e, e.currentTarget || e.target
]);
};
window.addEventListener("DOMContentLoaded", window._readium_eventBlocker, true);
window.addEventListener("load", window._readium_eventBlocker, true);`
), "text/javascript")));
const rAfter = (doc: Document) => scriptify(doc, cached("JS-After", () => blobify(stripJS(`
if(window.onload) window.onload = new Proxy(window.onload, {
apply: function(target, receiver, args) {
if(!window._readium_blockEvents) {
Reflect.apply(target, receiver, args);
return;
}
_readium_blockedEvents.push([
0, target, receiver, args
]);
}
});`
), "text/javascript")));
import { Injector } from "../../injection/Injector";

const csp = (domains: string[]) => {
const d = domains.join(" ");
Expand All @@ -105,12 +25,22 @@ export default class FrameBlobBuider {
private readonly burl: string;
private readonly pub: Publication;
private readonly cssProperties?: { [key: string]: string };

constructor(pub: Publication, baseURL: string, item: Link, cssProperties?: { [key: string]: string }) {
private readonly injector: Injector | null = null;

constructor(
pub: Publication,
baseURL: string,
item: Link,
options: {
cssProperties?: { [key: string]: string };
injector?: Injector | null;
}
) {
this.pub = pub;
this.item = item;
this.burl = item.toURL(baseURL) || "";
this.cssProperties = cssProperties;
this.cssProperties = options.cssProperties;
this.injector = options.injector ?? null;
}

public async build(fxl = false): Promise<string> {
Expand All @@ -128,15 +58,23 @@ export default class FrameBlobBuider {
// Load the HTML resource
const txt = await this.pub.get(this.item).readAsString();
if(!txt) throw new Error(`Failed reading item ${this.item.href}`);

const doc = new DOMParser().parseFromString(
txt,
this.item.mediaType.string as DOMParserSupportedType
);

const perror = doc.querySelector("parsererror");
if(perror) {
if (perror) {
const details = perror.querySelector("div");
throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`);
}

// Apply resource injections if injection service is provided
if (this.injector) {
await this.injector.injectForDocument(doc, this.item);
}

return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties);
}

Expand All @@ -151,29 +89,6 @@ export default class FrameBlobBuider {
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
private hasExecutable(doc: Document): boolean {
// This is not a 100% comprehensive check of all possibilities for JS execution,
// but it covers what the prevention scripts cover. Other possibilities include:
// - <iframe> src
// - <img> with onload/onerror
// - <meta http-equiv="refresh" content="xxx">
return (
!!doc.querySelector("script") || // Any <script> elements
!!doc.querySelector("body[onload]:not(body[onload=''])") // <body> that executes JS on load
);
}

private hasStyle(doc: Document): boolean {
if(
doc.querySelector("link[rel='stylesheet']") || // Any CSS link
doc.querySelector("style") || // Any <style> element
doc.querySelector("[style]:not([style=''])") // Any element with style attribute set
) return true;

return false;
}

private setProperties(cssProperties: { [key: string]: string }, doc: Document) {
for (const key in cssProperties) {
const value = cssProperties[key];
Expand All @@ -184,22 +99,20 @@ export default class FrameBlobBuider {
private finalizeDOM(doc: Document, root: string | undefined, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string {
if(!doc) return "";

// Inject styles
if(!fxl) {
// Readium CSS Before
const rcssBefore = styleify(doc, cached("ReadiumCSS-before", () => blobify(stripCSS(readiumCSSBefore), "text/css")));
doc.head.firstChild ? doc.head.firstChild.before(rcssBefore) : doc.head.appendChild(rcssBefore);

// Readium CSS defaults
if(!this.hasStyle(doc))
rcssBefore.after(styleify(doc, cached("ReadiumCSS-default", () => blobify(stripCSS(readiumCSSDefault), "text/css"))))
// Get allowed domains from injector if it exists
const allowedDomains = this.injector?.getAllowedDomains?.() || [];

// Always include the root domain if provided
const domains = [...new Set([
...(root ? [root] : []),
...allowedDomains
])].filter(Boolean);

// Readium CSS After
doc.head.appendChild(styleify(doc, cached("ReadiumCSS-after", () => blobify(stripCSS(readiumCSSAfter), "text/css"))));
// CSS and script injection is now handled by the Injector system

if (cssProperties) {
this.setProperties(cssProperties, doc);
}
// Apply CSS properties if provided (only for reflowable)
if (cssProperties && !fxl) {
this.setProperties(cssProperties, doc);
}

// Set all <img> elements to high priority
Expand Down Expand Up @@ -264,20 +177,13 @@ export default class FrameBlobBuider {
doc.head.firstChild!.before(b);
}

// 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));
doc.head.firstChild!.before(cssSelectorGenerator(doc)); // CSS selector utility
if (hasExecutable) doc.head.appendChild(rAfter(doc)); // Another execution prevention script

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


// Make blob from doc
return URL.createObjectURL(
new Blob([new XMLSerializer().serializeToString(doc)], {
Expand Down
Loading