From a651a05168c61a9a59878855d79bddd122688a44 Mon Sep 17 00:00:00 2001 From: RevDev Date: Wed, 21 Oct 2026 15:12:39 +0300 Subject: [PATCH 1/3] fix(english/lightnovelto): sanitize chapter HTML and handle protocol-relative URLs in resolveUrl --- plugins/english/lightnovelto.ts | 104 ++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/plugins/english/lightnovelto.ts b/plugins/english/lightnovelto.ts index f92a6636b..35e35eafc 100644 --- a/plugins/english/lightnovelto.ts +++ b/plugins/english/lightnovelto.ts @@ -275,6 +275,99 @@ function parseChapterList(text: string): ChapterEntry[] { * then matched to its closing tag by depth scan so nested markup * can never truncate the content. */ +/** + * Strip executable content from chapter HTML. Chapter bodies are + * untrusted markup: scripts, frames, form elements, inline event + * handlers and javascript:/vbscript: URLs must not reach the reader. + * URL schemes are tested after stripping control characters and + * lowercasing, so javascript: and mixed-case smuggling can't pass. + */ +/** + * Strip executable content from chapter HTML before it reaches the + * reader: scripts, frames, form elements, inline event handlers and + * javascript:/vbscript:/data:text/html URLs. URL values are checked + * regardless of quoting (double, single, or unquoted) and AFTER + * decoding HTML entities, so 'javascript:...' and 'javascript:...' + * are caught equally; schemes are then tested after stripping control + * characters and lowercasing, so mixed-case and control-character + * smuggling fail too. + */ +function sanitizeChapterHtml(html: string): string { + return html + .replace( + /<(script|style|noscript|iframe|object|embed|form)\b[\s\S]*?<\/\1\s*>/gi, + '', + ) + .replace( + /<(script|style|noscript|iframe|object|embed|form|link|meta|base)\b[^>]*>/gi, + '', + ) + .replace(/\son\w+\s*=\s*"[^"]*"/gi, '') + .replace(/\son\w+\s*=\s*'[^']*'/gi, '') + .replace(/\son\w+\s*=\s*[^\s>]+/gi, '') + .replace(/\sstyle\s*=\s*"[^"]*"/gi, '') + .replace(/\sstyle\s*=\s*'[^']*'/gi, '') + .replace( + /\s(?:href|src)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, + function (m: string) { + return isSafeUrlValue(m) ? m : ''; + }, + ); +} + +// Decode HTML character references the way a browser decodes an +// attribute value: numeric (decimal and hex) plus the common named +// references, repeated so double-encoding can't smuggle a scheme past +// the check. Decoding only ever makes the check stricter, never looser. +function decodeEntities(value: string): string { + let out = value; + for (let i = 0; i < 3; i++) { + const next = out + .replace(/&#(\d+);/g, function (_m: string, code: string) { + const n = parseInt(code, 10); + return !isNaN(n) && n >= 0 && n <= 0x10ffff + ? String.fromCharCode(n) + : _m; + }) + .replace(/&#x([0-9a-fA-F]+);/g, function (_m: string, code: string) { + const n = parseInt(code, 16); + return !isNaN(n) && n >= 0 && n <= 0x10ffff + ? String.fromCharCode(n) + : _m; + }) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + if (next === out) break; + out = next; + } + return out; +} + +function isSafeUrlValue(attr: string): boolean { + let raw = ''; + const quoted = /"([^"]*)"|'([^']*)'/.exec(attr); + if (quoted) { + raw = quoted[1] !== undefined ? quoted[1] : quoted[2]; + } else { + const unquoted = /=\s*([^\s>]+)/.exec(attr); + if (unquoted) raw = unquoted[1]; + } + const clean = decodeEntities(raw) + .split('') + .filter(function (ch: string) { + const code = ch.charCodeAt(0); + return code > 32 && code !== 127; + }) + .join('') + .toLowerCase(); + if (/^(javascript|vbscript)\s*:/.test(clean)) return false; + if (/^data\s*:\s*text\/html/.test(clean)) return false; + return true; +} + function parseChapterContent(html: string): string { checkSize(html); const clean = html @@ -313,7 +406,7 @@ function parseChapterContent(html: string): string { } } if (contentEnd < 0) return ''; - return clean.slice(contentStart, contentEnd).trim(); + return sanitizeChapterHtml(clean.slice(contentStart, contentEnd).trim()); } class LightNovelTo implements Plugin.PluginBase { @@ -321,7 +414,7 @@ class LightNovelTo implements Plugin.PluginBase { name = 'LightNovel.to'; icon = 'src/en/lightnovelto/icon.png'; site = 'https://lightnovel.to'; - version = '1.0.0'; + version = '1.0.1'; async popularNovels( pageNo: number, @@ -400,8 +493,11 @@ class LightNovelTo implements Plugin.PluginBase { resolveUrl(path: string): string { // Absolute URLs (e.g. the cover image, which EPUB export passes - // through here) must go out unchanged. - return /^https?:\/\//i.test(path) ? path : this.site + path; + // through here) must go out unchanged; protocol-relative URLs + // upgrade to https. + if (/^https?:\/\//i.test(path)) return path; + if (path.indexOf('//') === 0) return 'https:' + path; + return this.site + path; } } From e632329d6350d1379afac373fc7638ebf1eaf2b6 Mon Sep 17 00:00:00 2001 From: RevDev Date: Wed, 21 Oct 2026 16:19:41 +0300 Subject: [PATCH 2/3] fix(english/lightnovelto): harden chapter sanitizer against slash-separated handlers, encoded schemes, and quote-hidden destinations --- plugins/english/lightnovelto.ts | 91 +++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/plugins/english/lightnovelto.ts b/plugins/english/lightnovelto.ts index 35e35eafc..3e4af591a 100644 --- a/plugins/english/lightnovelto.ts +++ b/plugins/english/lightnovelto.ts @@ -292,8 +292,21 @@ function parseChapterList(text: string): ChapterEntry[] { * characters and lowercasing, so mixed-case and control-character * smuggling fail too. */ +/** + * Strip executable content from chapter HTML before it reaches the + * reader. Attribute cleaning runs TAG BY TAG (not over the whole + * string), so story text is never touched: `

Set online=true

` + * keeps its words, while a real `on*` attribute inside a tag dies. + * Attribute separators are whitespace OR slash, so + * `` is caught too. URL values are checked + * regardless of quoting (double, single, or unquoted) and AFTER + * decoding HTML entities, so `javascript:...`, `j...`, + * semicolon-less numerics, and mixed-case / control-character + * smuggling are all caught; story markup (paragraphs, images, links, + * tables) is kept. + */ function sanitizeChapterHtml(html: string): string { - return html + const noExecutable = html .replace( /<(script|style|noscript|iframe|object|embed|form)\b[\s\S]*?<\/\1\s*>/gi, '', @@ -301,61 +314,85 @@ function sanitizeChapterHtml(html: string): string { .replace( /<(script|style|noscript|iframe|object|embed|form|link|meta|base)\b[^>]*>/gi, '', - ) - .replace(/\son\w+\s*=\s*"[^"]*"/gi, '') - .replace(/\son\w+\s*=\s*'[^']*'/gi, '') - .replace(/\son\w+\s*=\s*[^\s>]+/gi, '') - .replace(/\sstyle\s*=\s*"[^"]*"/gi, '') - .replace(/\sstyle\s*=\s*'[^']*'/gi, '') - .replace( - /\s(?:href|src)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, - function (m: string) { - return isSafeUrlValue(m) ? m : ''; - }, ); + return noExecutable.replace(/<[^>]*>/g, function (tag: string) { + return tag + .replace(/[\s/](on\w+)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/[\s/]style\s*=\s*(?:"[^"]*"|'[^']*')/gi, '') + .replace( + /[\s/](?:href|src)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, + function (m: string) { + return isSafeUrlValue(m) ? m : ''; + }, + ); + }); } // Decode HTML character references the way a browser decodes an -// attribute value: numeric (decimal and hex) plus the common named -// references, repeated so double-encoding can't smuggle a scheme past -// the check. Decoding only ever makes the check stricter, never looser. +// attribute value, then test the scheme. Numeric references (decimal +// and hex, either x case, with or without the semicolon) plus the +// scheme-relevant named references (`:`, ` `, ` `, +// `/`) and the legacy semicolon-less named references; repeated so +// double-encoding can't smuggle a scheme past the check. Decoding only +// ever makes the check stricter, never looser. function decodeEntities(value: string): string { let out = value; for (let i = 0; i < 3; i++) { const next = out - .replace(/&#(\d+);/g, function (_m: string, code: string) { + .replace(/&#(\d+);?/g, function (_m: string, code: string) { const n = parseInt(code, 10); return !isNaN(n) && n >= 0 && n <= 0x10ffff ? String.fromCharCode(n) : _m; }) - .replace(/&#x([0-9a-fA-F]+);/g, function (_m: string, code: string) { + .replace(/&#[xX]([0-9a-fA-F]+);?/g, function (_m: string, code: string) { const n = parseInt(code, 16); return !isNaN(n) && n >= 0 && n <= 0x10ffff ? String.fromCharCode(n) : _m; }) + .replace(/:/g, ':') + .replace(/ /g, '\t') + .replace(/ /g, '\n') + .replace(///g, '/') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/'/g, "'"); + .replace(/'/g, "'") + .replace(/&(?![a-zA-Z0-9=])/g, '&') + .replace(/<(?![a-zA-Z0-9=])/g, '<') + .replace(/>(?![a-zA-Z0-9=])/g, '>') + .replace(/"(?![a-zA-Z0-9=])/g, '"'); if (next === out) break; out = next; } return out; } -function isSafeUrlValue(attr: string): boolean { - let raw = ''; - const quoted = /"([^"]*)"|'([^']*)'/.exec(attr); - if (quoted) { - raw = quoted[1] !== undefined ? quoted[1] : quoted[2]; - } else { - const unquoted = /=\s*([^\s>]+)/.exec(attr); - if (unquoted) raw = unquoted[1]; +// Read the attribute value from immediately after `=`: a quoted value +// is everything up to its matching quote, an unquoted value runs to the +// next whitespace or `>`. Never search for quotes anywhere in the +// match — for `href=javascript:alert('x')` the value is the whole +// `javascript:alert('x')`, not `'x'`. +function attrValue(attr: string): string { + const eq = attr.indexOf('='); + if (eq < 0) return ''; + const rest = attr.slice(eq + 1).replace(/^\s+/, ''); + if (rest.charAt(0) === '"') { + const end = rest.indexOf('"', 1); + return end < 0 ? rest.slice(1) : rest.slice(1, end); + } + if (rest.charAt(0) === "'") { + const end = rest.indexOf("'", 1); + return end < 0 ? rest.slice(1) : rest.slice(1, end); } - const clean = decodeEntities(raw) + const m = /^[^\s>]+/.exec(rest); + return m ? m[0] : ''; +} + +function isSafeUrlValue(attr: string): boolean { + const clean = decodeEntities(attrValue(attr)) .split('') .filter(function (ch: string) { const code = ch.charCodeAt(0); From 2c7f5ae4c9907a05418ad79ffd1aeb65fffc6902 Mon Sep 17 00:00:00 2001 From: RevDev Date: Thu, 22 Oct 2026 13:16:58 +0300 Subject: [PATCH 3/3] fix(english/lightnovelto): harden chapter sanitizer against slash-separated handlers, encoded schemes, and quote-hidden destinations