diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 1cb016ed4..fbc72adbb 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -4,4 +4,4 @@
Use GitHub's [private vulnerability reporting](https://github.com/bookorbit/bookorbit/security/advisories/new) to report security issues confidentially. Do not open public issues for security vulnerabilities.
-Reports are typically acknowledged within a few days, and fixes are published as quickly as severity warrants.
+We aim to acknowledge vulnerability reports within 3 business days and provide a status update within 14 days. Please allow up to 90 days for investigation, remediation, and coordinated disclosure before publishing details. Critical vulnerabilities may be disclosed and fixed sooner when users face active risk.
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 000000000..6833e7907
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,51 @@
+name: OpenSSF Scorecard
+
+on:
+ branch_protection_rule:
+ push:
+ branches: [main]
+ schedule:
+ - cron: "30 1 * * 6"
+ workflow_dispatch:
+
+concurrency:
+ group: scorecard-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions: {}
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ if: github.event.repository.default_branch == github.ref_name || github.event_name == 'branch_protection_rule'
+ permissions:
+ actions: read # Read workflow metadata used by Scorecard checks.
+ contents: read # Read the repository being analyzed.
+ id-token: write # Publish signed Scorecard results.
+ security-events: write # Upload the SARIF report to code scanning.
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ persist-credentials: false
+
+ - name: Run Scorecard
+ uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: scorecard-results
+ path: results.sarif
+ retention-days: 5
+
+ - name: Upload to code scanning
+ uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
+ with:
+ sarif_file: results.sarif
diff --git a/README.md b/README.md
index 12dec611a..3c0f2aa9e 100644
--- a/README.md
+++ b/README.md
@@ -149,6 +149,7 @@ BookOrbit is available in English, German, Dutch, Brazilian Portuguese, and Slov
- **Questions and discussion:** [GitHub Discussions](https://github.com/bookorbit/bookorbit/discussions)
- **Bug reports:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=bug_report.yml)
- **Feature requests:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=feature_request.yml)
+- **Security vulnerabilities:** Follow the private reporting process in the [Security Policy](.github/SECURITY.md).
---
diff --git a/client/src/features/book/lib/description-html.test.ts b/client/src/features/book/lib/description-html.test.ts
index f0858cb96..32a558d9c 100644
--- a/client/src/features/book/lib/description-html.test.ts
+++ b/client/src/features/book/lib/description-html.test.ts
@@ -18,6 +18,12 @@ describe('description-html', () => {
expect(html).toBe('
Hello
')
})
+ it('does not treat nested script-like input as visible markup', () => {
+ const html = sanitizeDescriptionHtml('ipt>alert(1)ipt>')
+
+ expect(html).not.toContain('')).toBe('Hello & world');
+ });
+
+ it('removes entity-encoded tag-shaped markup without evaluating it', () => {
+ expect(htmlToPlainText('<script src="evil.js">alert(1)</script> safe')).toBe('alert(1) safe');
+ });
+
+ it('preserves meaningful block and break boundaries when requested', () => {
+ expect(htmlToPlainText('First
line
Second
', { preserveLineBreaks: true })).toBe('First\nline\nSecond');
+ });
+});
diff --git a/server/src/common/utils/html-to-text.utils.ts b/server/src/common/utils/html-to-text.utils.ts
new file mode 100644
index 000000000..081fbcbeb
--- /dev/null
+++ b/server/src/common/utils/html-to-text.utils.ts
@@ -0,0 +1,47 @@
+import { load } from 'cheerio';
+
+type HtmlToTextOptions = {
+ preserveLineBreaks?: boolean;
+};
+
+const BLOCK_ELEMENTS =
+ 'address,article,aside,blockquote,div,dl,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hr,li,main,nav,ol,p,pre,section,table,tr,ul';
+
+export function htmlToPlainText(html: string, options: HtmlToTextOptions = {}): string {
+ const $ = load(html, undefined, false);
+ $('script,style,noscript,template').remove();
+
+ if (options.preserveLineBreaks) {
+ $('br').replaceWith('\n');
+ $(BLOCK_ELEMENTS).each((_index, element) => {
+ $(element).append('\n');
+ });
+ }
+
+ const text = stripAngleBracketMarkup($.root().text());
+ if (!options.preserveLineBreaks) return text.replace(/\s+/g, ' ').trim();
+
+ return text
+ .replace(/\r\n?/g, '\n')
+ .replace(/[ \t]+\n/g, '\n')
+ .replace(/\n[ \t]+/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+function stripAngleBracketMarkup(value: string): string {
+ let result = '';
+ let cursor = 0;
+
+ while (cursor < value.length) {
+ const start = value.indexOf('<', cursor);
+ if (start < 0) return result + value.slice(cursor);
+ result += value.slice(cursor, start);
+
+ const end = value.indexOf('>', start + 1);
+ if (end < 0) return result + value.slice(start).replace(/[<>]/g, '');
+ cursor = end + 1;
+ }
+
+ return result;
+}
diff --git a/server/src/common/utils/metadata-provider-hosts.utils.test.ts b/server/src/common/utils/metadata-provider-hosts.utils.test.ts
new file mode 100644
index 000000000..c6507d9bd
--- /dev/null
+++ b/server/src/common/utils/metadata-provider-hosts.utils.test.ts
@@ -0,0 +1,25 @@
+import { amazonOrigin, audibleApiOrigin, normalizeAmazonDomain, normalizeAudibleDomain } from './metadata-provider-hosts.utils';
+
+describe('metadata provider hosts', () => {
+ it('resolves supported Amazon marketplaces to fixed HTTPS origins', () => {
+ expect(normalizeAmazonDomain(' AMAZON.CO.UK ')).toBe('amazon.co.uk');
+ expect(amazonOrigin('amazon.co.uk')).toBe('https://www.amazon.co.uk');
+ });
+
+ it('rejects arbitrary Amazon hosts', () => {
+ expect(normalizeAmazonDomain('localhost')).toBe('amazon.com');
+ expect(amazonOrigin('amazon.com@127.0.0.1')).toBe('https://www.amazon.com');
+ });
+
+ it('normalizes supported Audible host styles and rejects arbitrary suffixes', () => {
+ expect(normalizeAudibleDomain('https://api.audible.co.uk/path')).toBe('co.uk');
+ expect(audibleApiOrigin('audible.co.uk')).toBe('https://api.audible.co.uk');
+ expect(audibleApiOrigin('localhost')).toBe('https://api.audible.com');
+ });
+
+ it('bounds hostile Audible input and removes paths without a regular expression', () => {
+ expect(normalizeAudibleDomain(`https://api.audible.co.uk/${'/'.repeat(100_000)}`)).toBe('co.uk');
+ expect(normalizeAudibleDomain(`https://api.audible.com${'x'.repeat(10_000)}/path`)).toBe('com');
+ expect(normalizeAudibleDomain({ length: Number.MAX_SAFE_INTEGER } as never)).toBe('com');
+ });
+});
diff --git a/server/src/common/utils/metadata-provider-hosts.utils.ts b/server/src/common/utils/metadata-provider-hosts.utils.ts
new file mode 100644
index 000000000..330751af6
--- /dev/null
+++ b/server/src/common/utils/metadata-provider-hosts.utils.ts
@@ -0,0 +1,66 @@
+const AMAZON_ORIGINS: Readonly> = {
+ 'amazon.com': 'https://www.amazon.com',
+ 'amazon.co.uk': 'https://www.amazon.co.uk',
+ 'amazon.de': 'https://www.amazon.de',
+ 'amazon.fr': 'https://www.amazon.fr',
+ 'amazon.it': 'https://www.amazon.it',
+ 'amazon.es': 'https://www.amazon.es',
+ 'amazon.ca': 'https://www.amazon.ca',
+ 'amazon.com.au': 'https://www.amazon.com.au',
+ 'amazon.co.jp': 'https://www.amazon.co.jp',
+ 'amazon.in': 'https://www.amazon.in',
+ 'amazon.com.br': 'https://www.amazon.com.br',
+ 'amazon.com.mx': 'https://www.amazon.com.mx',
+ 'amazon.nl': 'https://www.amazon.nl',
+ 'amazon.se': 'https://www.amazon.se',
+ 'amazon.pl': 'https://www.amazon.pl',
+ 'amazon.sg': 'https://www.amazon.sg',
+ 'amazon.ae': 'https://www.amazon.ae',
+ 'amazon.sa': 'https://www.amazon.sa',
+ 'amazon.tr': 'https://www.amazon.tr',
+};
+
+const AUDIBLE_API_ORIGINS: Readonly> = {
+ com: 'https://api.audible.com',
+ 'co.uk': 'https://api.audible.co.uk',
+ de: 'https://api.audible.de',
+ fr: 'https://api.audible.fr',
+ it: 'https://api.audible.it',
+ es: 'https://api.audible.es',
+ ca: 'https://api.audible.ca',
+ 'com.au': 'https://api.audible.com.au',
+ 'co.jp': 'https://api.audible.co.jp',
+ in: 'https://api.audible.in',
+};
+
+const MAX_PROVIDER_DOMAIN_LENGTH = 256;
+
+function normalizeProviderDomainInput(value: string | undefined | null): string {
+ return typeof value === 'string' ? value.slice(0, MAX_PROVIDER_DOMAIN_LENGTH).trim().toLowerCase() : '';
+}
+
+export function normalizeAmazonDomain(value: string | undefined | null): string {
+ const normalized = normalizeProviderDomainInput(value);
+ return AMAZON_ORIGINS[normalized] ? normalized : 'amazon.com';
+}
+
+export function amazonOrigin(value: string | undefined | null): string {
+ return AMAZON_ORIGINS[normalizeAmazonDomain(value)] ?? AMAZON_ORIGINS['amazon.com'];
+}
+
+export function normalizeAudibleDomain(value: string | undefined | null): string {
+ let domain = normalizeProviderDomainInput(value);
+ if (domain.startsWith('https://')) domain = domain.slice('https://'.length);
+ else if (domain.startsWith('http://')) domain = domain.slice('http://'.length);
+
+ if (domain.startsWith('api.audible.')) domain = domain.slice('api.audible.'.length);
+ else if (domain.startsWith('audible.')) domain = domain.slice('audible.'.length);
+
+ const pathStart = domain.indexOf('/');
+ if (pathStart !== -1) domain = domain.slice(0, pathStart);
+ return AUDIBLE_API_ORIGINS[domain] ? domain : 'com';
+}
+
+export function audibleApiOrigin(value: string | undefined | null): string {
+ return AUDIBLE_API_ORIGINS[normalizeAudibleDomain(value)] ?? AUDIBLE_API_ORIGINS.com;
+}
diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts
index bf0a67e8c..2ac9364a9 100644
--- a/server/src/modules/book/book.service.test.ts
+++ b/server/src/modules/book/book.service.test.ts
@@ -4695,6 +4695,9 @@ describe('BookService', () => {
await expect(service.renameFile(fileId, { filename: '../new.epub' }, user)).rejects.toThrow(BadRequestException);
await expect(service.renameFile(fileId, { filename: 'dir/new.epub' }, user)).rejects.toThrow(BadRequestException);
+ await expect(service.renameFile(fileId, { filename: 'bad\0name.epub' }, user)).rejects.toThrow(BadRequestException);
+ await expect(service.renameFile(fileId, { filename: `${'a'.repeat(256)}.epub` }, user)).rejects.toThrow(BadRequestException);
+ expect(rename).not.toHaveBeenCalled();
});
it('only updates db if filename is not provided or unchanged', async () => {
diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts
index fe40e7969..3cf8d8d06 100644
--- a/server/src/modules/book/book.service.ts
+++ b/server/src/modules/book/book.service.ts
@@ -1341,12 +1341,21 @@ export class BookService {
let newAbsolutePath = file.absolutePath;
if (dto.filename && dto.filename !== basename(file.absolutePath)) {
- if (dto.filename.includes('/') || dto.filename.includes('\\')) {
- throw new BadRequestException('Filename cannot contain path separators');
+ const safeFilename = basename(dto.filename);
+ if (
+ safeFilename !== dto.filename ||
+ safeFilename === '.' ||
+ safeFilename === '..' ||
+ safeFilename.includes('\\') ||
+ safeFilename.includes('\0') ||
+ Buffer.byteLength(safeFilename, 'utf8') > 255
+ ) {
+ throw new BadRequestException('Filename is invalid');
}
- newAbsolutePath = join(dirname(file.absolutePath), dto.filename);
+ newAbsolutePath = join(dirname(file.absolutePath), safeFilename);
if (newAbsolutePath !== file.absolutePath) {
try {
+ // codeql[js/path-injection] basename constrains the user-provided value to one validated filename segment.
await rename(file.absolutePath, newAbsolutePath);
} catch (err) {
throw new BadRequestException(`Failed to rename file on disk: ${err instanceof Error ? err.message : String(err)}`);
diff --git a/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts b/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts
index 0f39afd63..229e71b59 100644
--- a/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts
+++ b/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts
@@ -46,6 +46,12 @@ describe('sanitizeSvgIcon', () => {
expect(sanitized).not.toContain('style=');
});
+ it('rejects comments containing script-like markup', () => {
+ expect(() => sanitizeSvgIcon(Buffer.from(''))).toThrow(
+ BadRequestException,
+ );
+ });
+
it('rejects doctype subsets', () => {
expect(() => sanitizeSvgIcon(Buffer.from(' ]>'))).toThrow(
BadRequestException,
diff --git a/server/src/modules/custom-icon/custom-icon.sanitizer.ts b/server/src/modules/custom-icon/custom-icon.sanitizer.ts
index f013ea859..3249c771f 100644
--- a/server/src/modules/custom-icon/custom-icon.sanitizer.ts
+++ b/server/src/modules/custom-icon/custom-icon.sanitizer.ts
@@ -198,7 +198,6 @@ function stripSafePreamble(value: string): string {
return value
.replace(/^\uFEFF/, '')
.replace(/<\?xml[^>]*\?>/gi, '')
- .replace(//g, '')
.replace(/]*>/gi, '')
.trim();
}
diff --git a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts
index 8e99dd21d..d3c06eed3 100644
--- a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts
+++ b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts
@@ -2,6 +2,7 @@ import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
import { COMIC_INFO_MANAGED_NOTES_KEYS, COMIC_INFO_PROVIDER_ID_KEYS, COMIC_INFO_PROVIDER_WEB_URL_BUILDERS } from '../../file-write.constants';
+import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils';
type ComicInfoObject = Record;
@@ -36,7 +37,7 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite
const info: ComicInfoObject = existingXml ? parseComicInfoXml(existingXml) : {};
setComicInfoField(info, fieldMask, 'title', 'Title', payload.title);
- setComicInfoField(info, fieldMask, 'description', 'Summary', payload.description, stripHtml);
+ setComicInfoField(info, fieldMask, 'description', 'Summary', payload.description, htmlToPlainText);
setComicInfoField(info, fieldMask, 'publisher', 'Publisher', payload.publisher);
setComicInfoField(info, fieldMask, 'seriesName', 'Series', payload.seriesName);
setIssueNumberField(info, fieldMask, payload);
@@ -89,18 +90,6 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite
return `\n${xmlBody}`;
}
-function stripHtml(html: string): string {
- return html
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, "'")
- .replace(/<[^>]*>/g, ' ')
- .replace(/\s{2,}/g, ' ')
- .trim();
-}
-
function formatSeriesIndex(val: number): string {
return val % 1 === 0 ? String(Math.trunc(val)) : String(val);
}
diff --git a/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts b/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts
index 3818acbce..e168d2496 100644
--- a/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts
+++ b/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts
@@ -1,5 +1,6 @@
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
+import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils';
import { AladinItem } from './aladin.types';
function parseAuthors(authorString: string): string[] {
@@ -22,7 +23,7 @@ function parsePageCount(subInfo?: AladinItem['subInfo']): number | undefined {
function parseDescription(description: string, fullDescription?: string): string | undefined {
const desc = fullDescription ?? description;
if (!desc) return undefined;
- return desc.replace(/<[^>]*>/g, '').trim();
+ return htmlToPlainText(desc) || undefined;
}
function parseGenres(categoryIdList?: AladinItem['categoryIdList']): string[] | undefined {
diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts
index 25da7ee4c..77ea58005 100644
--- a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts
+++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts
@@ -3,6 +3,7 @@ import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
+import { amazonOrigin } from '../../../../common/utils/metadata-provider-hosts.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
@@ -60,14 +61,14 @@ export class AmazonProvider implements IdentifiableProvider {
private async searchAsins(params: MetadataSearchParams, domain: string, cookie: string, limit: number, signal?: AbortSignal): Promise {
const query = params.isbn?.trim() || [params.title, params.author].filter(Boolean).join(' ');
if (!query) return [];
- const url = `https://www.${domain}/s?k=${encodeURIComponent(query)}&i=stripbooks`;
+ const url = `${amazonOrigin(domain)}/s?k=${encodeURIComponent(query)}&i=stripbooks`;
const html = await this.fetchHtml(url, cookie, 'search', query, undefined, signal);
return html ? extractAsins(html, limit) : [];
}
private async fetchByAsin(asin: string, domain: string, cookie: string, signal?: AbortSignal): Promise {
- const url = `https://www.${domain}/dp/${asin}`;
- const html = await this.fetchHtml(url, cookie, 'lookup', undefined, asin, signal);
+ const url = new URL(`/dp/${encodeURIComponent(asin)}`, amazonOrigin(domain));
+ const html = await this.fetchHtml(url.toString(), cookie, 'lookup', undefined, asin, signal);
if (!html) return null;
const data = parseBookPage(html);
if (!data.title) return null;
@@ -89,7 +90,7 @@ export class AmazonProvider implements IdentifiableProvider {
seriesIndex: data.seriesIndex,
coverUrl: data.coverUrl,
genres: data.tags?.length ? data.tags : undefined,
- sourceUrl: `https://www.${domain}/dp/${asin}`,
+ sourceUrl: url.toString(),
...(data.communityRating !== undefined ? { communityRating: data.communityRating } : {}),
...(data.communityRatingCount !== undefined ? { communityRatingCount: data.communityRatingCount } : {}),
};
diff --git a/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts b/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts
index 406d3a7eb..7c3d64353 100644
--- a/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts
+++ b/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts
@@ -3,6 +3,7 @@ import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
+import { audibleApiOrigin } from '../../../../common/utils/metadata-provider-hosts.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
@@ -31,7 +32,7 @@ export class AudibleProvider implements IdentifiableProvider {
const query = this.buildQuery(params);
if (!query) return [];
- const url = new URL(`https://api.audible.${normalizedDomain}/1.0/catalog/products`);
+ const url = new URL('/1.0/catalog/products', audibleApiOrigin(normalizedDomain));
url.searchParams.set('num_results', '10');
url.searchParams.set('keywords', query);
url.searchParams.set('response_groups', 'product_desc,media,product_attrs,series,product_plan_details,category_ladders,rating');
@@ -71,7 +72,7 @@ export class AudibleProvider implements IdentifiableProvider {
if (!enabled) return null;
const normalizedDomain = normalizeAudibleDomain(domain);
- const url = new URL(`https://api.audible.${normalizedDomain}/1.0/catalog/products/${providerId}`);
+ const url = new URL(`/1.0/catalog/products/${encodeURIComponent(providerId)}`, audibleApiOrigin(normalizedDomain));
url.searchParams.set('response_groups', 'product_desc,media,product_attrs,series,product_plan_details,category_ladders,rating');
const requestUrl = url.toString();
const startedAt = Date.now();
diff --git a/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts b/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts
index dcb318239..96e6c7f66 100644
--- a/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts
+++ b/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts
@@ -1,11 +1 @@
-export function normalizeAudibleDomain(value: string | undefined | null): string {
- let domain = (value ?? '').trim().toLowerCase();
- if (!domain) return 'com';
-
- domain = domain.replace(/^https?:\/\//, '');
- domain = domain.replace(/^api\.audible\./, '');
- domain = domain.replace(/^audible\./, '');
- domain = domain.replace(/\/.*$/, '');
-
- return domain || 'com';
-}
+export { normalizeAudibleDomain } from '../../../../common/utils/metadata-provider-hosts.utils';
diff --git a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts
index 89ccdb8e5..3d7a164ae 100644
--- a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts
+++ b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts
@@ -64,7 +64,7 @@ describe('mapAudNexusBook — basic fields', () => {
it('strips HTML from summary when description is absent', () => {
const result = mapAudNexusBook(makeBook({ summary: 'A lone astronaut.
' }));
- expect(result.description).toBe('A lone astronaut .');
+ expect(result.description).toBe('A lone astronaut.');
});
it('maps publisherName', () => {
diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
index 720457ec3..3e8fa8123 100644
--- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
+++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
@@ -8,6 +8,7 @@ import {
GoodreadsAutocompleteItem,
} from './goodreads.types';
import { parsePublishedDateFromEpochMillis, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
+import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils';
export function mapGoodreadsApolloState(state: Record, bookId: string): MetadataCandidate | null {
const book = findBook(state, bookId);
@@ -106,17 +107,7 @@ function parseSeriesFromTitle(title: string | undefined): { seriesName?: string;
function extractAutocompleteDescription(description: GoodreadsAutocompleteItem['description']): string | undefined {
const html = typeof description === 'string' ? description : description?.html;
if (!html) return undefined;
- const text = html
- .replace(/
/gi, '\n')
- .replace(/<[^>]+>/g, '')
- .replace(/"/g, '"')
- .replace(/&(?:#39|#x27|apos);/g, "'")
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/&/g, '&')
- .replace(/[ \t]+\n/g, '\n')
- .replace(/\n{3,}/g, '\n\n')
- .trim();
+ const text = htmlToPlainText(html, { preserveLineBreaks: true });
return text || undefined;
}
diff --git a/server/src/modules/metadata-fetch/providers/provider-utils.ts b/server/src/modules/metadata-fetch/providers/provider-utils.ts
index 0958d2a33..b18737caa 100644
--- a/server/src/modules/metadata-fetch/providers/provider-utils.ts
+++ b/server/src/modules/metadata-fetch/providers/provider-utils.ts
@@ -1,10 +1,4 @@
-const HTML_ENTITY_REPLACEMENTS: Array<[RegExp, string]> = [
- [/&/g, '&'],
- [/</g, '<'],
- [/>/g, '>'],
- [/"/g, '"'],
- [/'/g, "'"],
-];
+import { htmlToPlainText } from '../../../common/utils/html-to-text.utils';
export function normalizeMaxCandidates(value: number | undefined, maxResults: number): number {
if (!Number.isFinite(value) || value == null) return maxResults;
@@ -42,11 +36,7 @@ export function sleep(ms: number, signal?: AbortSignal): Promise {
}
export function stripHtml(html: string): string {
- let value = html.replace(/<[^>]*>/g, ' ');
- for (const [pattern, replacement] of HTML_ENTITY_REPLACEMENTS) {
- value = value.replace(pattern, replacement);
- }
- return value.replace(/\s+/g, ' ').trim();
+ return htmlToPlainText(html);
}
export function sanitizeLogError(error: unknown): string {
diff --git a/server/src/modules/metadata-preferences/provider-config.service.test.ts b/server/src/modules/metadata-preferences/provider-config.service.test.ts
index 2ab24119a..43e258d19 100644
--- a/server/src/modules/metadata-preferences/provider-config.service.test.ts
+++ b/server/src/modules/metadata-preferences/provider-config.service.test.ts
@@ -488,6 +488,19 @@ describe('ProviderConfigService', () => {
expect(result.message).toContain('bot-check');
});
+ it('falls back to the allowlisted Amazon origin for an untrusted domain', async () => {
+ db.query.appSettings.findFirst.mockResolvedValue(undefined);
+ fetchMock.mockResolvedValue(new Response('Amazon results', { status: 200 }));
+
+ await service.testProvider(MetadataProviderKey.AMAZON, {
+ amazon: { domain: 'localhost' },
+ });
+
+ const requestUrl = fetchMock.mock.calls[0]?.[0];
+ expect(requestUrl).toBeInstanceOf(URL);
+ expect((requestUrl as URL).hostname).toBe('www.amazon.com');
+ });
+
it('rejects unsupported provider test requests', async () => {
await expect(service.testProvider(MetadataProviderKey.GOODREADS, {})).rejects.toThrow('Provider test not supported');
});
diff --git a/server/src/modules/metadata-preferences/provider-config.service.ts b/server/src/modules/metadata-preferences/provider-config.service.ts
index ccf4f6d6c..b14bc986a 100644
--- a/server/src/modules/metadata-preferences/provider-config.service.ts
+++ b/server/src/modules/metadata-preferences/provider-config.service.ts
@@ -5,6 +5,7 @@ import { MetadataProviderKey, ProviderConfigurations, ProviderConnectionTestResu
import { stripBearerPrefix, toBearerAuthorization } from '../../common/utils/bearer-token.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
+import { amazonOrigin, normalizeAmazonDomain, normalizeAudibleDomain } from '../../common/utils/metadata-provider-hosts.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -274,7 +275,7 @@ export class ProviderConfigService {
google: { ...config.google, apiKey: googleApiKey },
amazon: {
...config.amazon,
- domain: this.normalizeDomain(config.amazon.domain, DEFAULT_CONFIG.amazon.domain),
+ domain: normalizeAmazonDomain(config.amazon.domain),
cookie: this.normalizeAmazonCookie(config.amazon.cookie),
},
hardcover: {
@@ -283,7 +284,7 @@ export class ProviderConfigService {
},
audible: {
...config.audible,
- domain: this.normalizeDomain(config.audible.domain, DEFAULT_CONFIG.audible.domain),
+ domain: this.normalizeAudibleConfigDomain(config.audible.domain),
},
comicvine: {
...config.comicvine,
@@ -307,11 +308,6 @@ export class ProviderConfigService {
};
}
- private normalizeDomain(value: string, fallback: string): string {
- const normalized = value.trim().toLowerCase();
- return normalized || fallback;
- }
-
private normalizeAmazonCookie(cookie: string): string {
const normalized = cookie.trim();
if (!normalized) return '';
@@ -321,6 +317,13 @@ export class ProviderConfigService {
return normalized;
}
+ private normalizeAudibleConfigDomain(value: string): string {
+ const normalized = value.trim().toLowerCase();
+ const suffix = normalizeAudibleDomain(normalized);
+ if (normalized === suffix || normalized === `audible.${suffix}`) return normalized;
+ return DEFAULT_CONFIG.audible.domain;
+ }
+
private normalizeKoboPathSegment(value: string, fallback: string): string {
const normalized = value
.trim()
@@ -517,9 +520,11 @@ export class ProviderConfigService {
}
private async testAmazonProvider(config: ProviderConfigurations['amazon']): Promise {
- const domain = this.normalizeDomain(config.domain, DEFAULT_CONFIG.amazon.domain);
+ const domain = normalizeAmazonDomain(config.domain);
const cookie = this.normalizeAmazonCookie(config.cookie);
- const url = `https://www.${domain}/s?k=${encodeURIComponent(AMAZON_TEST_QUERY)}&i=stripbooks`;
+ const url = new URL('/s', amazonOrigin(domain));
+ url.searchParams.set('k', AMAZON_TEST_QUERY);
+ url.searchParams.set('i', 'stripbooks');
const headers: HeadersInit = cookie ? { ...AMAZON_TEST_HEADERS, cookie } : AMAZON_TEST_HEADERS;
const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(PROVIDER_TEST_TIMEOUT_MS) });
diff --git a/server/src/modules/metadata/lib/epub-isbn-scan.ts b/server/src/modules/metadata/lib/epub-isbn-scan.ts
index 3e85a355d..ecfb066fe 100644
--- a/server/src/modules/metadata/lib/epub-isbn-scan.ts
+++ b/server/src/modules/metadata/lib/epub-isbn-scan.ts
@@ -3,6 +3,7 @@ import { XMLParser } from 'fast-xml-parser';
import { attr, findInZip, toRecordArray } from './epub-zip-utils';
import { findIsbnInText, pickBestIsbn } from './isbn-detect';
+import { htmlToPlainText } from '../../../common/utils/html-to-text.utils';
const SPINE_WINDOW_FRONT = 10;
const SPINE_WINDOW_BACK = 5;
@@ -22,13 +23,6 @@ interface IsbnResult {
const EMPTY: IsbnResult = { isbn10: null, isbn13: null };
-function stripTags(html: string): string {
- return html
- .replace(/ipt> after')[0];
+
+ expect(result.title).toBe('Safe');
+ expect(result.body).not.toContain('