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
102 changes: 90 additions & 12 deletions app/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,40 +220,89 @@ const openPdf = async (file) => {
};

const sanitizeBookDocument = (bookDocument) => {
bookDocument.querySelectorAll('script, iframe, object, embed, form, base, meta[http-equiv]').forEach((node) => node.remove());
bookDocument.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style, svg, math, audio, video, source').forEach((node) => node.remove());
bookDocument.querySelectorAll('*').forEach((node) => {
[...node.attributes].forEach((attribute) => {
const name = attribute.name.toLowerCase();
const value = attribute.value.trim();
if (name.startsWith('on') || name === 'srcdoc') node.removeAttribute(attribute.name);
if ((name === 'src' || name === 'href') && /^(?:https?:|javascript:|data:text\/html)/i.test(value)) {
if (name.startsWith('on') || ['srcdoc', 'style', 'srcset', 'poster', 'background', 'action', 'formaction', 'ping', 'xlink:href'].includes(name)) node.removeAttribute(attribute.name);
if ((name === 'src' && node.localName !== 'img') || (name === 'href' && !/^#[\w-]*$/.test(value))) {
node.removeAttribute(attribute.name);
}
});
});
return bookDocument;
};

// Resolve archive references without ever falling back to app files or network URLs.
const epubArchivePath = (base, reference, decode = true) => {
let value;
try { value = decode ? decodeURIComponent(reference.split('#')[0]) : reference; } catch { return null; }
if (!value || /[\\:?\u0000-\u001f\u007f]/.test(value) || value.startsWith('/')) return null;
const segments = base.split('/').slice(0, -1);
for (const segment of value.split('/')) {
if (!segment || segment === '.') continue;
if (segment === '..') {
if (!segments.length) return null;
segments.pop();
} else segments.push(segment);
}
return segments.join('/') || null;
};

const readEpubEntry = (entry, limit, type = 'string') => new Promise((resolve, reject) => {
if (!entry || entry.dir) return reject(new Error('EPUB chapter content is missing.'));
// Enforce the limit during inflation, including archives with incorrect size headers.
const stream = entry.internalStream('uint8array');
const chunks = [];
let size = 0;
let failed = false;
stream.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
failed = true;
stream.pause();
chunks.length = 0;
reject(new Error('EPUB content exceeds the safe size limit.'));
} else if (!failed) chunks.push(chunk);
}).on('error', reject).on('end', () => {
if (failed) return;
const bytes = new Uint8Array(size);
let offset = 0;
chunks.forEach((chunk) => { bytes.set(chunk, offset); offset += chunk.length; });
resolve(type === 'string' ? new TextDecoder().decode(bytes) : bytes);
}).resume();
});

const EPUB_IMAGE_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' };

const openEpub = async (file) => {
const generation = localLibraryGeneration;
if (file.size > 64 * 1024 * 1024) throw new Error('EPUB archive exceeds the safe size limit.');
const archive = await JSZip.loadAsync(await file.arrayBuffer());
if (generation !== localLibraryGeneration) return;
const entries = Object.values(archive.files);
if (entries.length > 5000 || entries.some((entry) => entry.unsafeOriginalName && epubArchivePath('', entry.unsafeOriginalName, false) !== entry.name)
|| entries.reduce((total, entry) => total + (entry._data?.uncompressedSize || 0), 0) > 128 * 1024 * 1024) {
throw new Error('EPUB archive exceeds safe resource limits or contains unsafe paths.');
}
const containerEntry = archive.file(/META-INF\/container\.xml$/i)[0];
if (!containerEntry) throw new Error('EPUB container metadata is missing.');
const parser = new DOMParser();
const container = parser.parseFromString(await containerEntry.async('string'), 'application/xml');
const packagePath = container.querySelector('rootfile')?.getAttribute('full-path');
const container = parser.parseFromString(await readEpubEntry(containerEntry, 1024 * 1024), 'application/xml');
const packagePath = epubArchivePath('', container.querySelector('rootfile')?.getAttribute('full-path') || '');
if (!packagePath) throw new Error('EPUB package metadata is missing.');
const packageEntry = archive.file(packagePath);
if (!packageEntry) throw new Error('EPUB package file is missing.');
const packageDocument = parser.parseFromString(await packageEntry.async('string'), 'application/xml');
const packageDocument = parser.parseFromString(await readEpubEntry(packageEntry, 1024 * 1024), 'application/xml');
const manifest = new Map(
[...packageDocument.querySelectorAll('manifest item')].map((item) => [item.getAttribute('id'), item.getAttribute('href')])
);
const packageDirectory = packagePath.includes('/') ? packagePath.slice(0, packagePath.lastIndexOf('/') + 1) : '';
const chapters = [...packageDocument.querySelectorAll('spine itemref')]
.map((item) => manifest.get(item.getAttribute('idref')))
.filter(Boolean)
.map((chapter) => packageDirectory + chapter);
.map((chapter) => epubArchivePath(packagePath, chapter));
if (chapters.some((chapter) => !chapter)) throw new Error('EPUB chapter path is unsafe.');
if (!chapters.length) throw new Error('EPUB chapter content is missing.');
if (generation !== localLibraryGeneration) return;
const state = { archive, chapters, chapterNumber: 0, loading: false };
Expand All @@ -271,19 +320,46 @@ const renderEpubChapter = async (chapterNumber) => {
state.loading = true;
previousPageButton.disabled = true;
nextPageButton.disabled = true;
const newUrls = new Set();
try {
const chapter = state.archive.file(state.chapters[chapterNumber]);
if (!chapter) throw new Error('EPUB chapter content is missing.');
const source = await chapter.async('string');
const source = await readEpubEntry(chapter, 4 * 1024 * 1024);
if (epubState !== state || readerMode !== 'epub') return;
const chapterDocument = sanitizeBookDocument(new DOMParser().parseFromString(source, 'text/html'));
epubDocument.replaceChildren(
...[...chapterDocument.body.childNodes].map((node) => document.importNode(node, true))
);
const resources = new Map();
let resourceBytes = 0;
for (const image of chapterDocument.querySelectorAll('img')) {
const path = epubArchivePath(state.chapters[chapterNumber], image.getAttribute('src') || '');
image.removeAttribute('src');
const mime = path && EPUB_IMAGE_TYPES[path.split('.').pop().toLowerCase()];
const entry = path && state.archive.file(path);
if (!mime || !entry) continue;
if (!resources.has(path)) {
if (resources.size >= 128) throw new Error('EPUB chapter contains too many image resources.');
const bytes = await readEpubEntry(entry, 16 * 1024 * 1024, 'bytes');
if (epubState !== state || readerMode !== 'epub') return;
resourceBytes += bytes.length;
if (resourceBytes > 64 * 1024 * 1024) throw new Error('EPUB chapter images exceed the safe size limit.');
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
newUrls.add(url);
resources.set(path, url);
}
image.setAttribute('src', resources.get(path));
}
if (epubState !== state || readerMode !== 'epub') return;
const fragment = document.createDocumentFragment();
for (const node of chapterDocument.body.childNodes) fragment.appendChild(document.importNode(node, true));
// Keep the previous chapter and its URLs intact until insertion succeeds.
epubDocument.replaceChildren(fragment);
releaseObjectUrls();
newUrls.forEach((url) => activeObjectUrls.add(url));
newUrls.clear();
state.chapterNumber = chapterNumber;
epubViewerShell.scrollTop = 0;
pageStatus.textContent = `Chapter ${chapterNumber + 1} of ${state.chapters.length}`;
} finally {
newUrls.forEach((url) => URL.revokeObjectURL(url));
state.loading = false;
if (epubState === state && readerMode === 'epub') {
previousPageButton.disabled = state.chapterNumber <= 0;
Expand Down Expand Up @@ -408,6 +484,8 @@ const openLocalFile = async (file) => {
comicArchive = null;
comicEntries = [];
comicPage.removeAttribute('src');
epubState = null;
epubDocument.replaceChildren();
readerMode = 'error';
playerPlaceholder.hidden = false;
placeholderTitle.textContent = 'Could not open this file';
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "harbor-desktop",
"version": "2.3.7",
"version": "2.3.8",
"description": "A consumer entertainment hub for watching, listening, reading, and playing.",
"desktopName": "com.harbor.desktop",
"main": "electron/main.cjs",
Expand Down
125 changes: 124 additions & 1 deletion tests/smoke-library.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,129 @@ const run = async () => {
assert.deepEqual(epubNavigation.stale, { mode: 'empty', text: '', toolbarHidden: true });
assert.deepEqual(epubNavigation.staleOpen, { mode: 'empty', text: '', toolbarHidden: true });

const epubResources = await evaluate(`(async () => {
const zip = new JSZip();
const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
zip.file('mimetype', 'application/epub+zip');
zip.file('META-INF/container.xml', '<container><rootfiles><rootfile full-path="OEBPS/book.opf"/></rootfiles></container>');
zip.file('OEBPS/book.opf', '<package><manifest><item id="a" href="text/first.xhtml"/><item id="b" href="text/second.xhtml"/></manifest><spine><itemref idref="a"/><itemref idref="b"/></spine></package>');
const chapter = '<html><body><h1>Illustrated</h1><img id="picture" src="../images/picture%20one.png"/><img id="duplicate" src="../images/./picture%20one.png"/><img src="//example.com/tracker.png"/><img src="file:///tmp/picture.png"/><img src="data:image/svg+xml,test"/><img src="../../../escape.png"/><img src="../images/missing.png"/><img src="../images/bad.png"/><p style="background:url(https://example.com)">text</p><svg><image href="https://example.com"/></svg><script>window.epubUnsafe = true</script><a href="javascript:alert(1)" onclick="alert(1)">unsafe</a></body></html>';
zip.file('OEBPS/text/first.xhtml', chapter);
zip.file('OEBPS/text/second.xhtml', chapter);
zip.file('OEBPS/images/picture one.png', pixel, { base64: true });
zip.file('OEBPS/images/bad.png', 'not an image');
const book = new File([await zip.generateAsync({ type: 'blob' })], 'illustrated.epub');
const originalRevoke = URL.revokeObjectURL;
const originalCreate = URL.createObjectURL;
const mimes = new Map();
const liveUrls = new Set(activeObjectUrls);
URL.createObjectURL = (blob) => { const url = originalCreate.call(URL, blob); mimes.set(url, blob.type); liveUrls.add(url); return url; };
const revoked = [];
URL.revokeObjectURL = (url) => { revoked.push(url); liveUrls.delete(url); originalRevoke.call(URL, url); };
const inspect = async () => {
const image = epubDocument.querySelector('#picture');
await image.decode();
const url = image.src;
const bad = epubDocument.querySelector('img[src]:last-of-type');
let invalidImageRejected = false;
try { await bad.decode(); } catch { invalidImageRejected = true; }
return { width: image.naturalWidth, mime: mimes.get(url), url,
duplicate: epubDocument.querySelector('#duplicate').src === url,
unsafe: epubDocument.querySelectorAll('script, svg, [onclick], [style], [href], img[src]:not([src^="blob:"])').length,
invalidImageRejected, urls: activeObjectUrls.size, live: liveUrls.size };
};
try {
await openLocalFile(book);
const first = await inspect();
await navigateEpubChapter(1);
const second = await inspect();
const firstReleased = revoked.includes(first.url);
await navigateEpubChapter(-1);
const previous = await inspect();
const secondReleased = revoked.includes(second.url);
resetLocalLibrary();
const reset = { released: revoked.includes(previous.url), urls: activeObjectUrls.size, live: liveUrls.size, text: epubDocument.textContent };
const stale = [];
for (const action of ['clear', 'replace']) {
await openLocalFile(book);
const entry = epubState.archive.file('OEBPS/images/bad.png');
const original = entry.internalStream;
let resume;
entry.internalStream = function (...args) {
const stream = original.apply(this, args);
const originalResume = stream.resume;
stream.resume = function () { resume = () => originalResume.call(stream); return stream; };
return stream;
};
const pending = navigateEpubChapter(1);
while (!resume) await new Promise(resolve => setTimeout(resolve, 5));
if (action === 'clear') resetLocalLibrary();
else await openLocalFile(new File(['audio'], 'new.mp3', { type: 'audio/mpeg' }));
const before = { mode: readerMode, urls: activeObjectUrls.size, text: epubDocument.textContent };
resume();
await pending;
if (liveUrls.size !== activeObjectUrls.size) throw new Error('Stale EPUB resource URLs leaked.');
stale.push({ before, after: { mode: readerMode, urls: activeObjectUrls.size, text: epubDocument.textContent } });
}
// Inflation is bounded even when metadata falsely advertises a small size.
const oversized = new JSZip();
oversized.file('large.txt', 'x'.repeat(2048));
const loaded = await JSZip.loadAsync(await oversized.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }));
loaded.file('large.txt')._data.uncompressedSize = 1;
let bounded = false;
try { await readEpubEntry(loaded.file('large.txt'), 100); } catch (error) { bounded = /size limit/.test(error.message); }
await openLocalFile(book);
epubState.archive.file('OEBPS/images/bad.png').internalStream = () => { throw new Error('Resource read failed'); };
const preserved = epubDocument.querySelector('#picture').src;
await navigateEpubChapter(1);
const failure = { preserved: epubDocument.querySelector('#picture').src === preserved, urls: activeObjectUrls.size, live: liveUrls.size, error: mediaDetails.textContent };
await openLocalFile(book);
const originalReplaceChildren = epubDocument.replaceChildren;
const previousImage = epubDocument.querySelector('#picture');
const previousUrls = [...liveUrls];
epubDocument.replaceChildren = () => { throw new Error('DOM insertion failed'); };
try { await navigateEpubChapter(1); } finally { epubDocument.replaceChildren = originalReplaceChildren; }
await previousImage.decode();
const insertionFailure = { sameImage: epubDocument.querySelector('#picture') === previousImage,
width: previousImage.naturalWidth, urlsUnchanged: JSON.stringify([...liveUrls]) === JSON.stringify(previousUrls),
activeUnchanged: JSON.stringify([...activeObjectUrls]) === JSON.stringify(previousUrls),
status: pageStatus.textContent, error: mediaDetails.textContent };
// This valid chapter is below the byte cap but exceeds JS call-argument limits.
epubState.archive.file('OEBPS/text/second.xhtml', '<html><body><h1>Many nodes</h1>' + '<br>'.repeat(300000) + '</body></html>');
await navigateEpubChapter(1);
const manyNodes = { nodes: epubDocument.querySelectorAll('br').length, heading: epubDocument.querySelector('h1')?.textContent,
status: pageStatus.textContent, urls: activeObjectUrls.size, live: liveUrls.size };
resetLocalLibrary();
const originalLoad = JSZip.loadAsync;
JSZip.loadAsync = async (...args) => {
const archive = await originalLoad(...args);
archive.file('OEBPS/images/bad.png').internalStream = () => { throw new Error('Initial resource failed'); };
return archive;
};
try { await openLocalFile(book); } finally { JSZip.loadAsync = originalLoad; }
const initialFailure = { mode: readerMode, text: epubDocument.textContent, urls: activeObjectUrls.size, live: liveUrls.size, stateCleared: epubState === null };
return { first, second, previous, firstReleased, secondReleased, reset, stale, bounded, failure, insertionFailure, manyNodes, initialFailure };
} finally { URL.revokeObjectURL = originalRevoke; URL.createObjectURL = originalCreate; resetLocalLibrary(); }
})()`);
for (const chapter of [epubResources.first, epubResources.second, epubResources.previous]) {
assert.equal(chapter.width, 1);
assert.equal(chapter.mime, 'image/png');
assert.equal(chapter.duplicate, true);
assert.equal(chapter.unsafe, 0);
assert.equal(chapter.urls, 2);
assert.equal(chapter.live, 2);
assert.equal(chapter.invalidImageRejected, true);
}
assert.equal(epubResources.firstReleased, true);
assert.equal(epubResources.secondReleased, true);
assert.deepEqual(epubResources.reset, { released: true, urls: 0, live: 0, text: '' });
for (const result of epubResources.stale) assert.deepEqual(result.after, result.before);
assert.equal(epubResources.bounded, true);
assert.deepEqual(epubResources.failure, { preserved: true, urls: 2, live: 2, error: 'Resource read failed' });
assert.deepEqual(epubResources.insertionFailure, { sameImage: true, width: 1, urlsUnchanged: true, activeUnchanged: true, status: 'Chapter 1 of 2', error: 'DOM insertion failed' });
assert.deepEqual(epubResources.manyNodes, { nodes: 300000, heading: 'Many nodes', status: 'Chapter 2 of 2', urls: 0, live: 0 });
assert.deepEqual(epubResources.initialFailure, { mode: 'error', text: '', urls: 0, live: 0, stateCleared: true });

const comic = await evaluate(`(async () => {
const zip = new JSZip();
const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
Expand Down Expand Up @@ -328,7 +451,7 @@ const run = async () => {
fs.writeFileSync(path.join(artifactDirectory, 'library-smoke.png'), Buffer.from(screenshot.data, 'base64'));
}
socket.close();
process.stdout.write(`${JSON.stringify({ shell, onboarding, epub, epubNavigation, comic, comicCancellation, pdf, gameBoundary, savedGameBoundary }, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ shell, onboarding, epub, epubNavigation, epubResources, comic, comicCancellation, pdf, gameBoundary, savedGameBoundary }, null, 2)}\n`);
};

run().catch((error) => {
Expand Down
2 changes: 1 addition & 1 deletion tv/lg/appinfo.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "com.harbor.tv",
"version": "2.3.7",
"version": "2.3.8",
"vendor": "Harbor",
"type": "web",
"main": "index.html",
Expand Down
2 changes: 1 addition & 1 deletion tv/samsung/config.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets" id="https://harbor.app/tv" version="2.3.7" viewmodes="maximized">
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets" id="https://harbor.app/tv" version="2.3.8" viewmodes="maximized">
<tizen:application id="HbrTV2026A.HarborTV" package="HbrTV2026A" required_version="3.0" />
<content src="index.html" />
<feature name="http://tizen.org/feature/screen.size.all" />
Expand Down
Loading