From: Kovid Goyal <kovid@kovidgoyal.net>
Date: Thu, 5 Feb 2026 14:21:25 +0530
Subject: CVE-2026-25731: ZIP Output: Change the template engine used for HTML
 templating from templite to Mustache,
 for greater safety and performance. Note that this is a breaking change if
 you use custom templates with ZIP output.

Forwarded: not-needed
Bug: https://github.com/kovidgoyal/calibre/security/advisories/GHSA-xrh9-w7qx-3gcc
Origin: backport, https://github.com/kovidgoyal/calibre/commit/f0649b27512e987b95fcab2e1e0a3bcdafc23379

Signed-off-by: YOKOTA Hiroshi <yokota.hgml@gmail.com>
---
 COPYRIGHT                                          |  6 --
 resources/templates/html_export_default.mustache   | 70 +++++++++++++++++
 resources/templates/html_export_default.tmpl       | 74 ------------------
 .../templates/html_export_default_index.mustache   | 55 +++++++++++++
 resources/templates/html_export_default_index.tmpl | 61 ---------------
 .../ebooks/conversion/plugins/html_output.py       | 60 +++++++++------
 src/templite/__init__.py                           | 89 ----------------------
 7 files changed, 163 insertions(+), 252 deletions(-)
 create mode 100644 resources/templates/html_export_default.mustache
 delete mode 100644 resources/templates/html_export_default.tmpl
 create mode 100644 resources/templates/html_export_default_index.mustache
 delete mode 100644 resources/templates/html_export_default_index.tmpl
 delete mode 100644 src/templite/__init__.py

diff --git a/COPYRIGHT b/COPYRIGHT
index 3425d82..702ced2 100644
--- a/COPYRIGHT
+++ b/COPYRIGHT
@@ -12,12 +12,6 @@ Files: resources/rapydscript/*
 Copyright: Various
 License: BSD
 
-Files: src/templite/*
-Copyright: Copyright (c) 2009 joonis new media, Thimo Kraemer
-License: GPL-2+
- The full text of the GPL is distributed as in
- /usr/share/common-licenses/GPL-2 on Debian systems.
-
 Files: src/calibre/devices/bambook/*
 Copyright: 2010, Li Fanxi
 License: GPL-3
diff --git a/resources/templates/html_export_default.mustache b/resources/templates/html_export_default.mustache
new file mode 100644
index 0000000..1c8691a
--- /dev/null
+++ b/resources/templates/html_export_default.mustache
@@ -0,0 +1,70 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+{{{head_content}}}
+
+<link href="{{css_link}}" type="text/css" rel="stylesheet" />
+
+</head>
+<body>
+
+<div class="calibreMeta">
+  <div class="calibreMetaTitle">
+  {{#meta.titles}}
+	{{#is_first}}
+		<h1><a href="{{toc_url}}">{{title}}</a> </h1>
+	{{/is_first}}
+	{{^is_first}}
+		<div class="calibreMetaSubtitle">{{title}}</div>
+	{{/is_first}}
+  {{/meta.titles}}
+  </div>
+  <div class="calibreMetaAuthor">{{meta.creators}}</div>
+</div>
+
+<div class="calibreMain">
+
+  <div class="calibreEbookContent">
+    {{#has_link}}
+      <div class="calibreEbNavTop">
+	    {{#prev_link}}
+          <a href="{{prev_link}}" class="calibreAPrev">{{prev_page}}</a>
+		{{/prev_link}}
+        {{^prev_link}}
+          <a href="{{toc_url}}" class="calibreAPrev">{{prev_page}}</a>
+		{{/prev_link}}
+		{{#next_link}}
+          <a href="{{next_link}}" class="calibreANext">{{next_page}}</a>
+		{{/next_link}}
+      </div>
+	{{/has_link}}
+
+	{{{ebook_content}}}
+  </div>
+
+  {{#has_toc}}
+  <div class="calibreToc">
+    <h2><a href="{{toc_url}}">{{table_of_contents}}</a></h2>
+	{{{toc}}}
+  </div>
+  {{/has_toc}}
+
+  <div class="calibreEbNav">
+	{{#prev_link}}
+		<a href="{{prev_link}}" class="calibreAPrev">{{prev_page}}</a>
+	{{/prev_link}}
+	{{^prev_link}}
+		<a href="{{toc_url}}" class="calibreAPrev">{{prev_page}}</a>
+	{{/prev_link}}
+
+    <a href="{{toc_url}}" class="calibreAHome">{{start}}</a>
+
+	{{#next_link}}
+		<a href="{{next_link}}" class="calibreANext">{{next_page}}</a>
+	{{/next_link}}
+  </div>
+
+</div>
+
+</body>
+</html>
diff --git a/resources/templates/html_export_default.tmpl b/resources/templates/html_export_default.tmpl
deleted file mode 100644
index 7aac247..0000000
--- a/resources/templates/html_export_default.tmpl
+++ /dev/null
@@ -1,74 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml">
-<head>
-${head_content}$
-
-<link href="${cssLink}$" type="text/css" rel="stylesheet" />
-
-</head>
-<body>
-
-<div class="calibreMeta">
-  <div class="calibreMetaTitle">
-  ${pos1=1}$
-  ${for title in meta.titles():}$
-    ${if pos1:}$
-    <h1>
-      <a href="${tocUrl}$">${print(title)}$</a>
-    </h1>
-    ${:else:}$
-    <div class="calibreMetaSubtitle">${print(title)}$</div>
-    ${:endif}$
-    ${pos1=0}$
-  ${:endfor}$
-  </div>
-  <div class="calibreMetaAuthor">
-    ${print(', '.join(meta.creators()))}$
-  </div>
-</div>
-
-<div class="calibreMain">
-
-  <div class="calibreEbookContent">
-    ${if prevLink or nextLink:}$
-      <div class="calibreEbNavTop">
-        ${if prevLink:}$
-          <a href="${prevLink}$" class="calibreAPrev">${print(_('previous page'))}$</a>
-        ${:else:}$
-          <a href="${tocUrl}$" class="calibreAPrev">${print(_('previous page'))}$</a>
-        ${:endif}$
-
-        ${if nextLink:}$
-          <a href="${nextLink}$" class="calibreANext">${print(_('next page'))}$</a>
-        ${:endif}$
-      </div>
-    ${:endif}$
-
-    ${ebookContent}$
-  </div>
-
-  ${if has_toc:}$
-  <div class="calibreToc">
-    <h2><a href="${tocUrl}$">${print( _('Table of contents'))}$</a></h2>
-    ${print(toc())}$
-  </div>
-  ${:endif}$
-
-  <div class="calibreEbNav">
-    ${if prevLink:}$
-      <a href="${prevLink}$" class="calibreAPrev">${print(_('previous page'))}$</a>
-    ${:else:}$
-      <a href="${tocUrl}$" class="calibreAPrev">${print(_('previous page'))}$</a>
-    ${:endif}$
-
-    <a href="${tocUrl}$" class="calibreAHome">${print(_('start'))}$</a>
-
-    ${if nextLink:}$
-      <a href="${nextLink}$" class="calibreANext">${print(_('next page'))}$</a>
-    ${:endif}$
-  </div>
-
-</div>
-
-</body>
-</html>
diff --git a/resources/templates/html_export_default_index.mustache b/resources/templates/html_export_default_index.mustache
new file mode 100644
index 0000000..aa1bc4d
--- /dev/null
+++ b/resources/templates/html_export_default_index.mustache
@@ -0,0 +1,55 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+
+<link rel="schema.DC" href="http://purl.org/dc/elements/1.1/" />
+<link rel="schema.DCTERMS" href="http://purl.org/dc/terms/" />
+
+<title>{{meta.creators}} - {{meta.first_title}}</title>
+
+{{#meta.items}}
+  <meta name="DC.{{name}}" content="{{value}}" />
+{{/meta.items}}
+
+<link href="{{css_link}}" type="text/css" rel="stylesheet" />
+</head>
+<body>
+
+<div class="calibreMeta">
+  <div class="calibreMetaTitle">
+  {{#meta.titles}}
+	{{#is_first}}
+		<h1><a href="{{toc_url}}">{{title}}</a> </h1>
+	{{/is_first}}
+	{{^is_first}}
+		<div class="calibreMetaSubtitle">{{title}}</div>
+	{{/is_first}}
+  {{/meta.titles}}
+  </div>
+  <div class="calibreMetaAuthor">{{meta.creators}}</div>
+</div>
+
+<div class="calibreMain">
+  <div class="calibreEbookContent">
+    {{#has_toc}}
+      <div class="calibreTocIndex">
+        <h2>{{table_of_contents}}</h2>
+		{{{toc}}}
+      </div>
+    {{/has_toc}}
+    {{^has_toc}}
+        <h2>{{no_toc}}</h2>
+        <div><strong><a href="{{next_link}}">{{begin_to_read}}</a></strong></div>
+    {{/has_toc}}
+  </div>
+
+  <div class="calibreEbNav">
+    {{#next_link}}
+      <a href="{{next_link}}" class="calibreANext">{{next_page}}</a>
+    {{/next_link}}
+  </div>
+</div>
+
+</body>
+</html>
diff --git a/resources/templates/html_export_default_index.tmpl b/resources/templates/html_export_default_index.tmpl
deleted file mode 100644
index f0665ad..0000000
--- a/resources/templates/html_export_default_index.tmpl
+++ /dev/null
@@ -1,61 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml">
-<head>
-<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
-
-<link rel="schema.DC" href="http://purl.org/dc/elements/1.1/" />
-<link rel="schema.DCTERMS" href="http://purl.org/dc/terms/" />
-
-<title>${print(', '.join(meta.creators()))}$ - ${print(next(meta.titles())); print(meta.titles().close())}$</title>
-
-${for item in meta:}$
-  <meta ${print('name="DC.'+item['name']+'"')}$ ${print('content="'+item['value']+'"')}$ />
-${:endfor}$
-
-<link href="${cssLink}$" type="text/css" rel="stylesheet" />
-</head>
-<body>
-
-<div class="calibreMeta">
-  <div class="calibreMetaTitle">
-  ${pos1=1}$
-  ${for title in meta.titles():}$
-    ${if pos1:}$
-    <h1>
-      <a href="${tocUrl}$">${print(title)}$</a>
-    </h1>
-    ${:else:}$
-    <div class="calibreMetaSubtitle">${print(title)}$</div>
-    ${:endif}$
-    ${pos1=0}$
-  ${:endfor}$
-  </div>
-  <div class="calibreMetaAuthor">
-    ${print(', '.join(meta.creators()))}$
-  </div>
-</div>
-
-<div class="calibreMain">
-  <div class="calibreEbookContent">
-
-    ${if has_toc:}$
-      <div class="calibreTocIndex">
-        <h2>${print(_('Table of contents'))}$</h2>
-        ${toc}$
-      </div>
-    ${:else:}$
-        <h2>${print(_('No table of contents present'))}$</h2>
-        <div><strong><a href="${nextLink}$">${print(_('begin to read'))}$</a></strong></div>
-    ${:endif}$
-
-  </div>
-
-  <div class="calibreEbNav">
-    ${if nextLink:}$
-      <a href="${nextLink}$" class="calibreANext">${print(_('next page'))}$</a>
-    ${:endif}$
-  </div>
-</div>
-
-</body>
-</html>
diff --git a/src/calibre/ebooks/conversion/plugins/html_output.py b/src/calibre/ebooks/conversion/plugins/html_output.py
index d8e2f18..2d311d1 100644
--- a/src/calibre/ebooks/conversion/plugins/html_output.py
+++ b/src/calibre/ebooks/conversion/plugins/html_output.py
@@ -26,13 +26,13 @@ class HTMLOutput(OutputFormatPlugin):
 
     options = {
         OptionRecommendation(name='template_css',
-            help=_('CSS file used for the output instead of the default file')),
+            help=_('CSS file used for the output instead of the default CSS.')),
 
         OptionRecommendation(name='template_html_index',
-            help=_('Template used for generation of the HTML index file instead of the default file')),
+            help=_('Template used for generation of the HTML index file instead of the default template. In Mustache format.')),
 
         OptionRecommendation(name='template_html',
-            help=_('Template used for the generation of the HTML contents of the book instead of the default file')),
+            help=_('Template used for the generation of the HTML contents of the book instead of the default template. In Mustache format.')),
 
         OptionRecommendation(name='extract_to',
             help=_('Extract the contents of the generated ZIP file to the '
@@ -84,6 +84,7 @@ class HTMLOutput(OutputFormatPlugin):
                 xml_declaration=False)
 
     def convert(self, oeb_book, output_path, input_plugin, opts, log):
+        import pystache
         from lxml import etree
 
         from calibre.ebooks.html.meta import EasyMeta
@@ -96,7 +97,7 @@ class HTMLOutput(OutputFormatPlugin):
             with open(opts.template_html_index, 'rb') as f:
                 template_html_index_data = f.read()
         else:
-            template_html_index_data = P('templates/html_export_default_index.tmpl', data=True)
+            template_html_data = P('templates/html_export_default.mustache', data=True)
 
         if opts.template_html is not None:
             with open(opts.template_html, 'rb') as f:
@@ -110,9 +111,10 @@ class HTMLOutput(OutputFormatPlugin):
         else:
             template_css_data = P('templates/html_export_default.css', data=True)
 
-        template_html_index_data = template_html_index_data.decode('utf-8')
-        template_html_data = template_html_data.decode('utf-8')
+        template_html_index = pystache.parse(template_html_index_data.decode('utf-8'))
+        template_html = pystache.parse(template_html_data.decode('utf-8'))
         template_css_data = template_css_data.decode('utf-8')
+        has_toc = bool(oeb_book.toc.count())
 
         self.log  = log
         self.opts = opts
@@ -129,18 +131,31 @@ class HTMLOutput(OutputFormatPlugin):
         css_path = output_dir+os.sep+'calibreHtmlOutBasicCss.css'
         with open(css_path, 'wb') as f:
             f.write(template_css_data.encode('utf-8'))
+        meta_dict = {
+            'titles': [{'title': x, 'is_first': i == 0} for i, x in enumerate(meta.titles())],
+            'creators': authors_to_string(tuple(meta.creators())),
+            'items': list(meta),
+        }
+        meta_dict['first_title'] = meta_dict['titles'][0]['title'] if meta_dict['titles'] else ''
+        basic_template_vars = {
+                'meta': meta_dict, 'has_toc': has_toc,
+                'table_of_contents':  _('Table of contents'), 'no_toc': _('No table of contents present'),
+                'begin_to_read': _('begin to read'), 'start': _('start'),
+                'prev_page': _('previous page'), 'next_page': _('next page'),
+        }
 
         with open(output_file, 'wb') as f:
-            html_toc = self.generate_html_toc(oeb_book, output_file, output_dir)
-            templite = Templite(template_html_index_data)
             nextLink = oeb_book.spine[0].href
             nextLink = relpath(output_dir+os.sep+nextLink, dirname(output_file))
             cssLink = relpath(abspath(css_path), dirname(output_file))
             tocUrl = relpath(output_file, dirname(output_file))
-            t = templite.render(has_toc=bool(oeb_book.toc.count()),
-                    toc=html_toc, meta=meta, nextLink=nextLink,
-                    tocUrl=tocUrl, cssLink=cssLink,
-                    firstContentPageLink=nextLink)
+            toc_as_html = self.generate_html_toc(oeb_book, output_file, output_dir) if has_toc else ''
+            v = basic_template_vars.copy()
+            v.update({
+                'toc': toc_as_html, 'css_link': cssLink, 'toc_url': tocUrl, 'next_link': nextLink,
+                'first_content_page_link': nextLink,
+            })
+            t = pystache.render(template_html_index, v)
             if isinstance(t, str):
                 t = t.encode('utf-8')
             f.write(t)
@@ -196,17 +211,18 @@ class HTMLOutput(OutputFormatPlugin):
                 firstContentPageLink = oeb_book.spine[0].href
 
                 # render template
-                templite = Templite(template_html_data)
-
                 def toc():
-                    return self.generate_html_toc(oeb_book, path, output_dir)
-                t = templite.render(ebookContent=ebook_content,
-                        prevLink=prevLink, nextLink=nextLink,
-                        has_toc=bool(oeb_book.toc.count()), toc=toc,
-                        tocUrl=tocUrl, head_content=head_content,
-                        meta=meta, cssLink=cssLink,
-                        firstContentPageLink=firstContentPageLink)
-
+                    return
+                toc_as_html = self.generate_html_toc(oeb_book, path, output_dir) if has_toc else ''
+                v = basic_template_vars.copy()
+                v.update({
+                    'has_link': prevLink or nextLink,
+                    'prev_link': prevLink, 'next_link': nextLink, 'toc_url': tocUrl,
+                    'head_content': head_content, 'ebook_content': ebook_content,
+                    'css_link': cssLink, 'toc': toc_as_html,
+                    'first_content_page_link': firstContentPageLink,
+                })
+                t = pystache.render(template_html, v)
                 # write html to file
                 with open(path, 'wb') as f:
                     f.write(t.encode('utf-8'))
diff --git a/src/templite/__init__.py b/src/templite/__init__.py
deleted file mode 100644
index f46e777..0000000
--- a/src/templite/__init__.py
+++ /dev/null
@@ -1,89 +0,0 @@
-#!/usr/bin/env python
-#
-#       Templite+
-#       A light-weight, fully functional, general purpose templating engine
-#
-#       Copyright (c) 2009 joonis new media
-#       Author: Thimo Kraemer <thimo.kraemer@joonis.de>
-#
-#       Based on Templite - Tomer Filiba
-#       http://code.activestate.com/recipes/496702/
-#
-#       This program is free software; you can redistribute it and/or modify
-#       it under the terms of the GNU General Public License as published by
-#       the Free Software Foundation; either version 2 of the License, or
-#       (at your option) any later version.
-#
-#       This program is distributed in the hope that it will be useful,
-#       but WITHOUT ANY WARRANTY; without even the implied warranty of
-#       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-#       GNU General Public License for more details.
-#
-#       You should have received a copy of the GNU General Public License
-#       along with this program; if not, write to the Free Software
-#       Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
-#       MA 02110-1301, USA.
-#
-
-import sys, re
-
-from polyglot.builtins import unicode_type
-
-class Templite:
-    auto_emit = re.compile('(^[\'\"])|(^[a-zA-Z0-9_\[\]\'\"]+$)')
-
-    def __init__(self, template, start='${', end='}$'):
-        if len(start) != 2 or len(end) != 2:
-            raise ValueError('each delimiter must be two characters long')
-        delimiter = re.compile('%s(.*?)%s' % (re.escape(start), re.escape(end)), re.DOTALL)
-        offset = 0
-        tokens = []
-        for i, part in enumerate(delimiter.split(template)):
-            part = part.replace('\\'.join(list(start)), start)
-            part = part.replace('\\'.join(list(end)), end)
-            if i % 2 == 0:
-                if not part: continue
-                part = part.replace('\\', '\\\\').replace('"', '\\"')
-                part = '\t' * offset + 'emit("""%s""")' % part
-            else:
-                part = part.rstrip()
-                if not part: continue
-                if part.lstrip().startswith(':'):
-                    if not offset:
-                        raise SyntaxError('no block statement to terminate: ${%s}$' % part)
-                    offset -= 1
-                    part = part.lstrip()[1:]
-                    if not part.endswith(':'): continue
-                elif self.auto_emit.match(part.lstrip()):
-                    part = 'emit(%s)' % part.lstrip()
-                lines = part.splitlines()
-                margin = min(len(l) - len(l.lstrip()) for l in lines if l.strip())
-                part = '\n'.join('\t' * offset + l[margin:] for l in lines)
-                if part.endswith(':'):
-                    offset += 1
-            tokens.append(part)
-        if offset:
-            raise SyntaxError('%i block statement(s) not terminated' % offset)
-        self.__code = compile('\n'.join(tokens), '<templite %r>' % template[:20], 'exec')
-
-    def render(self, __namespace=None, **kw):
-        """
-        renders the template according to the given namespace.
-        __namespace - a dictionary serving as a namespace for evaluation
-        **kw - keyword arguments which are added to the namespace
-        """
-        namespace = {}
-        if __namespace: namespace.update(__namespace)
-        if kw: namespace.update(kw)
-        namespace['emit'] = self.write
-
-        __stdout = sys.stdout
-        sys.stdout = self
-        self.__output = []
-        eval(self.__code, namespace)
-        sys.stdout = __stdout
-        return ''.join(self.__output)
-
-    def write(self, *args):
-        for a in args:
-            self.__output.append(unicode_type(a))
