fix(opds): strip xml 1.0 illegal control characters from metadata

Remove invalid XML 1.0 characters from book metadata. These characters can cause lenient OPDS clients like KOReader to incorrectly parse the feed and shift entry boundaries onto the wrong acquisition link.

Closes #260
This commit is contained in:
neon
2026-06-06 22:15:58 +00:00
parent 0d6a27331e
commit 54ca05ef04
3 changed files with 149 additions and 1 deletions
@@ -17,6 +17,44 @@ describe('esc', () => {
it('leaves safe strings unchanged', () => {
expect(esc('hello world')).toBe('hello world');
});
it('strips XML 1.0 illegal control characters from metadata (issue #260)', () => {
const nul = String.fromCharCode(0);
const verticalTab = String.fromCharCode(0x0b);
const formFeed = String.fromCharCode(0x0c);
const unitSeparator = String.fromCharCode(0x1f);
expect(esc(`Children of Dune${verticalTab}`)).toBe('Children of Dune');
expect(esc(`Dune${nul} Chronicles`)).toBe('Dune Chronicles');
expect(esc(`Acme${formFeed}${unitSeparator} Press`)).toBe('Acme Press');
});
it('strips the noncharacters U+FFFE and U+FFFF', () => {
const noncharA = String.fromCharCode(0xfffe);
const noncharB = String.fromCharCode(0xffff);
expect(esc(`A${noncharA}B${noncharB}C`)).toBe('ABC');
});
it('preserves the control characters XML 1.0 allows (tab, LF, CR)', () => {
expect(esc('line1\n\tline2\r')).toBe('line1\n\tline2\r');
});
it('strips control characters and escapes entities together', () => {
const nul = String.fromCharCode(0);
expect(esc(`Rock${nul} & Roll`)).toBe('Rock & Roll');
});
it('strips unpaired surrogates from mis-decoded UTF-16 metadata', () => {
const loneHigh = String.fromCharCode(0xd800);
const loneLow = String.fromCharCode(0xdfff);
expect(esc(`Bad${loneHigh}Title`)).toBe('BadTitle');
expect(esc(`${loneLow}Edge`)).toBe('Edge');
});
it('preserves valid surrogate pairs (astral characters such as emoji)', () => {
expect(esc('Party 🎉 time')).toBe('Party 🎉 time');
expect(esc('𝐁𝐨𝐨𝐤')).toBe('𝐁𝐨𝐨𝐤');
});
});
describe('xmlEl', () => {
@@ -1,3 +1,5 @@
import { XMLParser } from 'fast-xml-parser';
import { OpdsService } from '../opds.service';
import type { OpdsBookEntry } from '../opds-book.service';
@@ -333,6 +335,95 @@ describe('OpdsService', () => {
});
});
describe('feed well-formedness (issue #260)', () => {
// XML 1.0 Char production forbids every code point below 0x20 except tab/LF/CR,
// plus the noncharacters U+FFFE/U+FFFF. A feed carrying any of these is not
// well-formed, and lenient OPDS clients (e.g. KOReader) recover by shifting
// entry boundaries, pairing one entry's title with a neighbouring entry's
// acquisition link, which downloads the wrong book.
/* eslint-disable no-control-regex -- detecting these characters is the purpose of this matcher */
const XML_1_0_INVALID = new RegExp(
'[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]',
);
/* eslint-enable no-control-regex */
function acquisitionHref(entry: Record<string, unknown>): string {
const links = entry.link as Array<Record<string, string>>;
const acq = links.find((link) => link['@_rel'] === 'http://opds-spec.org/acquisition');
return acq!['@_href'];
}
it('strips control characters from metadata so each title keeps its own download link', () => {
const service = makeService();
// Recent feed, newest first. "Children of Dune" was imported just before
// "Empire of AI"; its extracted metadata carries the exact junk that
// corrupted the feed in issue #260: a vertical tab and a lone surrogate in
// the title, and a NUL in the description.
const empire = sampleBook({
id: 501,
title: 'Empire of AI',
description: 'How AI was built',
seriesName: null,
seriesIndex: null,
files: [{ id: 9001, format: 'epub' }],
});
const children = sampleBook({
id: 500,
title: 'Children of Dune\u000B\uD800',
description: 'Book three of the Dune\u0000 Chronicles',
seriesName: null,
seriesIndex: null,
files: [{ id: 9000, format: 'epub' }],
});
const xml = service.generateAcquisitionFeed(
'Recent Books',
'urn:bookorbit:recent',
[empire, children],
2,
1,
50,
`${BASE}/recent?page=1&size=50`,
'test-token',
);
expect(XML_1_0_INVALID.test(xml)).toBe(false);
const parsed = new XMLParser({ ignoreAttributes: false, isArray: (name) => name === 'entry' || name === 'link' }).parse(xml);
const entries = parsed.feed.entry as Array<Record<string, unknown>>;
const byTitle = new Map(entries.map((entry) => [entry.title, entry]));
expect(acquisitionHref(byTitle.get('Empire of AI')!)).toContain('/501/download?fileId=9001');
expect(acquisitionHref(byTitle.get('Children of Dune')!)).toContain('/500/download?fileId=9000');
});
it('produces well-formed entries when titles, authors, and publisher contain control characters', () => {
const service = makeService();
const book = sampleBook({
title: 'Bad \u000BTitle',
authors: ['Jane\u0000 Doe'],
publisher: 'Acme\u001F Press',
});
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:bookorbit:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(XML_1_0_INVALID.test(xml)).toBe(false);
expect(xml).toContain('<title>Bad Title</title>');
expect(xml).toContain('<name>Jane Doe</name>');
expect(xml).toContain('<dc:publisher>Acme Press</dc:publisher>');
});
});
describe('XML escaping', () => {
it('escapes special characters in titles', () => {
const service = makeService();
+20 -1
View File
@@ -1,6 +1,25 @@
// Code points the XML 1.0 Char production forbids: C0 controls except tab/LF/CR
// and the noncharacters U+FFFE/U+FFFF. Ebook metadata extracted from EPUB/PDF/MOBI
// often carries these; leaving them in produces a not-well-formed feed that lenient
// OPDS clients mis-parse, shifting entry boundaries onto the wrong acquisition link.
// eslint-disable-next-line no-control-regex -- matching these control characters is the intent
const XML_INVALID_CHARS = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]', 'g');
// Unpaired surrogates are also illegal in XML 1.0 (the surrogate range is excluded
// from the Char production) and appear in metadata mis-decoded from UTF-16. Valid
// surrogate pairs (astral characters such as emoji) must be left intact.
const LONE_SURROGATES = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
export function esc(s: string | null | undefined): string {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
return s
.replace(XML_INVALID_CHARS, '')
.replace(LONE_SURROGATES, '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export function xmlEl(tag: string, text: string | null | undefined): string {