You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by di...@apache.org on 2026/09/18 15:28:06 UTC

(allura) branch master updated (281c0ba2c -> ebb6c7c87)

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git


    from 281c0ba2c [#8618] optimze the way to read the raw stream and a small change since now the content is bytes
     new bd7668079 security fixes: turn on autoescaping for widgets, fix tracker select XSS
     new c30ee2a8c [#8623] keep inline script/style bodies raw under easywidgets autoescaping
     new ebb6c7c87 [#8623] bound the easywidgets resource cache and cap _slim requests

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 Allura/allura/config/middleware.py               | 54 +++++++++++++++++++++++-
 Allura/allura/lib/custom_middleware.py           | 31 ++++++++++++++
 Allura/allura/lib/helpers.py                     | 12 +++---
 Allura/allura/lib/widgets/auth_widgets.py        |  5 ++-
 Allura/allura/lib/widgets/forms.py               |  2 +-
 Allura/allura/tests/test_helpers.py              |  5 +++
 Allura/allura/tests/test_middlewares.py          | 48 +++++++++++++++++++++
 ForgeTracker/forgetracker/widgets/ticket_form.py |  4 +-
 8 files changed, 150 insertions(+), 11 deletions(-)


(allura) 02/03: [#8623] keep inline script/style bodies raw under easywidgets autoescaping

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit c30ee2a8c03c5ddd701db2a2d609fcd3a1323b2a
Author: Guillermo Cruz <gu...@slashdotmedia.com>
AuthorDate: Fri Sep 11 00:17:10 2026 +0000

    [#8623] keep inline script/style bodies raw under easywidgets autoescaping
---
 Allura/allura/config/middleware.py      | 17 ++++++++++++
 Allura/allura/tests/test_middlewares.py | 48 +++++++++++++++++++++++++++++++++
 2 files changed, 65 insertions(+)

diff --git a/Allura/allura/config/middleware.py b/Allura/allura/config/middleware.py
index 008a5338f..9914e8856 100644
--- a/Allura/allura/config/middleware.py
+++ b/Allura/allura/config/middleware.py
@@ -32,6 +32,7 @@
 
 import activitystream
 import ew
+import ew.jinja2_ew
 import formencode
 from ming.odm.middleware import MingMiddleware
 from beaker_session_jwt import JWTCookieSession
@@ -68,6 +69,21 @@
 __all__ = ['make_app']
 
 
+def _mark_ew_script_bodies_safe():
+    """Keep inline <script>/<style> bodies out of easywidgets' autoescaping.
+
+    easywidgets 0.4.x renders JSScript/CSSScript through a bare ``{{widget.text}}``.  That was
+    harmless while its jinja env had autoescaping off, but _make_core_app turns it on, which would
+    escape every inline script and style body in the site (``&&`` -> ``&amp;&amp;`` and so on).
+    Script bodies are developer-supplied markup by definition, so mark them safe.  0.5 does this
+    itself; drop this once requirements pin easywidgets >= 0.5.
+    """
+    ew.jinja2_ew.JSScript.WidgetClass.template = ew.jinja2_ew.Snippet(
+        '<script type="text/javascript">{{widget.text|safe}}</script>', 'jinja2')
+    ew.jinja2_ew.CSSScript.WidgetClass.template = ew.jinja2_ew.Snippet(
+        '<style>{{widget.text|safe}}</style>', 'jinja2')
+
+
 # this is webapp entry point from setup.py
 def make_app(global_conf: dict, **app_conf):
     override_root_module_name = app_conf.get('override_root', None)
@@ -156,6 +172,7 @@ def _make_core_app(root, global_conf: dict, **app_conf):
     if config.get('override_root') != 'task':
         app = SSLMiddleware(app, app_conf.get('no_redirect.pattern'),
                             app_conf.get('force_ssl.pattern'))
+    _mark_ew_script_bodies_safe()
     # Setup resource manager, widget context SOP
     app = ew.WidgetMiddleware(
         app,
diff --git a/Allura/allura/tests/test_middlewares.py b/Allura/allura/tests/test_middlewares.py
index 191e00377..b2e734f64 100644
--- a/Allura/allura/tests/test_middlewares.py
+++ b/Allura/allura/tests/test_middlewares.py
@@ -16,6 +16,15 @@
 #       under the License.
 
 from mock import MagicMock, patch
+from paste.registry import Registry
+
+import ew.jinja2_ew
+from ew import widget_context
+from ew.core import WidgetContext
+from ew.render import TemplateEngine
+from ew.resource import ResourceManager
+
+from allura.config.middleware import _mark_ew_script_bodies_safe
 from allura.lib.custom_middleware import CORSMiddleware
 
 
@@ -103,3 +112,42 @@ def test_get_access_control_request_headers(self):
         assert f({key: ''}) == set()
         assert (f({key: 'Authorization, Accept'}) ==
                 {'authorization', 'accept'})
+
+
+class TestEwScriptBodiesStayRaw:
+    """_make_core_app turns easywidgets' autoescaping on, which would otherwise escape every
+    inline script and style body rendered through JSScript/CSSScript."""
+
+    def setup_method(self):
+        self.saved_engines = TemplateEngine._engines
+        self.saved_js = ew.jinja2_ew.JSScript.WidgetClass.template
+        self.saved_css = ew.jinja2_ew.CSSScript.WidgetClass.template
+        TemplateEngine.initialize({'jinja2.autoescape': True})
+        self.registry = Registry()
+        self.registry.prepare()
+        self.manager = ResourceManager()
+        self.registry.register(
+            widget_context, WidgetContext(scheme='http', resource_manager=self.manager))
+        _mark_ew_script_bodies_safe()
+
+    def teardown_method(self):
+        self.registry.cleanup()
+        TemplateEngine._engines = self.saved_engines
+        ew.jinja2_ew.JSScript.WidgetClass.template = self.saved_js
+        ew.jinja2_ew.CSSScript.WidgetClass.template = self.saved_css
+
+    def _render(self, widget):
+        widget.manager = self.manager
+        return widget.display()
+
+    def test_js_script_body_is_not_escaped(self):
+        body = '''$(function () {$('.x').go({"a": 1}); if (a && b) { c(); }});'''
+        assert body in self._render(ew.jinja2_ew.JSScript(body))
+
+    def test_css_script_body_is_not_escaped(self):
+        body = 'div.x table{ width: 700px; } a > b { color: red }'
+        assert body in self._render(ew.jinja2_ew.CSSScript(body))
+
+    def test_widget_values_are_still_escaped(self):
+        rendered = ew.jinja2_ew.TextField(name='f').display(value='a" onmouseover=alert(1)')
+        assert '&#34;' in rendered


(allura) 03/03: [#8623] bound the easywidgets resource cache and cap _slim requests

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit ebb6c7c87af22db90671b2eefa206a66a55d0991
Author: Guillermo Cruz <gu...@slashdotmedia.com>
AuthorDate: Fri Sep 11 16:08:03 2026 +0000

    [#8623] bound the easywidgets resource cache and cap _slim requests
---
 Allura/allura/config/middleware.py               | 54 ++++++++++++++++++------
 Allura/allura/lib/custom_middleware.py           | 31 ++++++++++++++
 Allura/allura/lib/widgets/auth_widgets.py        | 12 ++----
 Allura/allura/lib/widgets/forms.py               |  2 -
 ForgeTracker/forgetracker/widgets/ticket_form.py |  4 +-
 5 files changed, 78 insertions(+), 25 deletions(-)

diff --git a/Allura/allura/config/middleware.py b/Allura/allura/config/middleware.py
index 9914e8856..3e969e43d 100644
--- a/Allura/allura/config/middleware.py
+++ b/Allura/allura/config/middleware.py
@@ -21,6 +21,7 @@
 import importlib
 import mimetypes
 import re
+import threading
 import warnings
 
 from tg import config
@@ -33,6 +34,7 @@
 import activitystream
 import ew
 import ew.jinja2_ew
+import ew.resource
 import formencode
 from ming.odm.middleware import MingMiddleware
 from beaker_session_jwt import JWTCookieSession
@@ -55,6 +57,7 @@
 from allura.lib.custom_middleware import SSLMiddleware
 from allura.lib.custom_middleware import StaticFilesMiddleware
 from allura.lib.custom_middleware import CSRFMiddleware
+from allura.lib.custom_middleware import EwSlimRequestLimitMiddleware
 from allura.lib.custom_middleware import CORSMiddleware
 from allura.lib.custom_middleware import LoginRedirectMiddleware
 from allura.lib.custom_middleware import RememberLoginMiddleware
@@ -69,14 +72,39 @@
 __all__ = ['make_app']
 
 
+class _BoundedResourceCache(dict):
+    """A byte-budgeted replacement for easywidgets' unbounded resource cache.
+    drop this once requirements pin easywidgets >= 0.5.
+    """
+
+    def __init__(self, max_bytes):
+        super().__init__()
+        self.max_bytes = max_bytes
+        self._bytes = 0
+        self._lock = threading.Lock()
+
+    def __setitem__(self, key, value):
+        if len(value) > self.max_bytes:
+            return
+        with self._lock:
+            if key in self:
+                self._bytes -= len(super().__getitem__(key))
+                super().__delitem__(key)
+            super().__setitem__(key, value)
+            self._bytes += len(value)
+            while self._bytes > self.max_bytes and len(self) > 1:
+                oldest = next(iter(self))
+                self._bytes -= len(super().__getitem__(oldest))
+                super().__delitem__(oldest)
+
+
+def _bound_ew_resource_cache(max_bytes):
+    ew.resource.ResourceManager.resource_cache = _BoundedResourceCache(max_bytes)
+
+
 def _mark_ew_script_bodies_safe():
     """Keep inline <script>/<style> bodies out of easywidgets' autoescaping.
-
-    easywidgets 0.4.x renders JSScript/CSSScript through a bare ``{{widget.text}}``.  That was
-    harmless while its jinja env had autoescaping off, but _make_core_app turns it on, which would
-    escape every inline script and style body in the site (``&&`` -> ``&amp;&amp;`` and so on).
-    Script bodies are developer-supplied markup by definition, so mark them safe.  0.5 does this
-    itself; drop this once requirements pin easywidgets >= 0.5.
+    drop this once requirements pin easywidgets >= 0.5.
     """
     ew.jinja2_ew.JSScript.WidgetClass.template = ew.jinja2_ew.Snippet(
         '<script type="text/javascript">{{widget.text|safe}}</script>', 'jinja2')
@@ -173,6 +201,7 @@ def _make_core_app(root, global_conf: dict, **app_conf):
         app = SSLMiddleware(app, app_conf.get('no_redirect.pattern'),
                             app_conf.get('force_ssl.pattern'))
     _mark_ew_script_bodies_safe()
+    _bound_ew_resource_cache(asint(app_conf.get('ew.max_cache_bytes', 64 * 1024 * 1024)))
     # Setup resource manager, widget context SOP
     app = ew.WidgetMiddleware(
         app,
@@ -183,16 +212,10 @@ def _make_core_app(root, global_conf: dict, **app_conf):
         extra_headers=ast.literal_eval(app_conf.get('ew.extra_headers', '[]')),
         cache_max_age=asint(app_conf.get('ew.cache_header_seconds', 60*60*24*365)),
 
-        # settings to pass through to jinja Environment for EW core widgets
+        # settings to pass through to a second jinja Environment for EW core widgets
         # these are for the easywidgets' own [easy_widgets.engines] entry point
         # (the Allura [easy_widgets.engines] entry point is named "jinja" (not jinja2) but it doesn't need
         #  any settings since it is a class that uses the same jinja env as the rest of allura)
-        # NB: this is a *second* jinja environment, separate from the one in
-        # AlluraJinjaRenderer.  Widgets render through this one and their output
-        # is spliced into our own templates as Markup, so it does not inherit
-        # their autoescaping -- it has to be turned on here too.  Recent
-        # easywidgets defaults it on; set it explicitly so an older pin can't
-        # silently render widgets unescaped.
         **{
             'jinja2.auto_reload': asbool(config['auto_reload_templates']),
             'jinja2.autoescape': True,
@@ -200,6 +223,11 @@ def _make_core_app(root, global_conf: dict, **app_conf):
             'jinja2.cache_size': asint(config.get('jinja_cache_size', -1)),
         }
     )
+    # Refuse oversized _slim requests before WidgetMiddleware can concatenate them
+    app = EwSlimRequestLimitMiddleware(
+        app,
+        script_name=app_conf.get('ew.script_name', '/_ew_resources/'),
+        max_hrefs=asint(app_conf.get('ew.max_slim_hrefs', 100)))
     # Handle static files (by tool)
     app = StaticFilesMiddleware(app, app_conf.get('static.script_name'))
     # Handle setup and flushing of Ming ORM sessions
diff --git a/Allura/allura/lib/custom_middleware.py b/Allura/allura/lib/custom_middleware.py
index 183356f35..5c8f427cc 100644
--- a/Allura/allura/lib/custom_middleware.py
+++ b/Allura/allura/lib/custom_middleware.py
@@ -18,6 +18,7 @@
 import os
 import re
 import logging
+from urllib.parse import parse_qs
 
 import tg
 from paste import fileapp
@@ -657,3 +658,33 @@ def __call__(self, environ, start_response):
             newstatus, headers, app_iter, exc_info = _call_wsgi_application(self.app, new_environ)
         start_response(status, headers, exc_info)
         return app_iter
+
+
+class EwSlimRequestLimitMiddleware:
+    """Cap how many files one /_ew_resources/_slim/ request may concatenate.
+
+    easywidgets 0.4.x joins as many files as the query string names, with no bound, and its
+    WidgetMiddleware answers that path before the wrapped app sees it -- so an unauthenticated
+    request can name one file hundreds of times and get back a response hundreds of times its own
+    size, which is then cached for the life of the process.  Naming Allura's largest registered
+    resource 500 times turns a 9 KB query string into a 155 MB response.
+
+    This has to sit *outside* WidgetMiddleware so oversized requests never reach it.  easywidgets
+    0.5 enforces the same limit itself via max_slim_hrefs; drop this once the pin moves.
+    """
+
+    def __init__(self, app, script_name='/_ew_resources/', max_hrefs=100):
+        self.app = app
+        self.slim_prefix = script_name + '_slim/'
+        self.max_hrefs = max_hrefs
+
+    def __call__(self, environ, start_response):
+        if environ.get('PATH_INFO', '').startswith(self.slim_prefix):
+            href = parse_qs(environ.get('QUERY_STRING', '')).get('href', [''])[0]
+            count = len([h for h in href.split(';') if h])
+            if count > self.max_hrefs:
+                log.warning('Refusing _slim request for %s files (max %s) from %s',
+                            count, self.max_hrefs, environ.get('REMOTE_ADDR'))
+                resp = exc.HTTPBadRequest('Too many resources requested')
+                return resp(environ, start_response)
+        return self.app(environ, start_response)
diff --git a/Allura/allura/lib/widgets/auth_widgets.py b/Allura/allura/lib/widgets/auth_widgets.py
index 38efc5df5..ef863229f 100644
--- a/Allura/allura/lib/widgets/auth_widgets.py
+++ b/Allura/allura/lib/widgets/auth_widgets.py
@@ -29,16 +29,12 @@
 from allura.lib import plugin
 
 
-# Emits already-rendered markup as-is.  The antispam honeypot fields arrive as
-# Markup, so passing them as *template source* is both pointless and risky --
-# a '$' in the content would be read as a core-ew substitution.  Render them as
-# data instead.
-_raw_html = ew.Snippet('{{widget.text|safe}}', 'jinja2')
-
-
 class LoginForm(ForgeForm):
     submit_text = 'Login'
     style = 'wide'
+    # The antispam honeypot fields arrive as Markup but it's better to
+    # render them as data.
+    _raw_html = ew.Snippet('{{widget.text|safe}}', 'jinja2')
 
     @property
     def fields(self):
@@ -69,7 +65,7 @@ def fields(self):
 
         for fld in g.antispam.extra_fields():
             fields.append(
-                ew_core.Widget(template=_raw_html, text=fld))
+                ew_core.Widget(template=self._raw_html, text=fld))
 
         return fields
 
diff --git a/Allura/allura/lib/widgets/forms.py b/Allura/allura/lib/widgets/forms.py
index de6eba00f..a3a3fce50 100644
--- a/Allura/allura/lib/widgets/forms.py
+++ b/Allura/allura/lib/widgets/forms.py
@@ -44,8 +44,6 @@
 
 
 class _HTMLExplanation(ew.InputField):
-    # text is developer-supplied markup (never user input); |safe keeps
-    # easywidgets' autoescaping from mangling it
     template = ew.Snippet(
         '''<label class="grid-4">&nbsp;</label>
            <div class="grid-14" style="margin:2px;">{{widget.text|safe}}</div>
diff --git a/ForgeTracker/forgetracker/widgets/ticket_form.py b/ForgeTracker/forgetracker/widgets/ticket_form.py
index 6a3e59368..30b6f8fea 100644
--- a/ForgeTracker/forgetracker/widgets/ticket_form.py
+++ b/ForgeTracker/forgetracker/widgets/ticket_form.py
@@ -240,12 +240,12 @@ class MilestoneField(ew.SingleSelectField):
                'class':css_class},
                attrs)}}>
             {% for o in open_milestones %}
-            <option{% if o.selected%} selected{% endif %} value="{{o.html_value|e}}">{{o.label|e}}</option>
+            <option{% if o.selected%} selected{% endif %} value="{{o.html_value|e}}">{{o.label}}</option>
             {% endfor %}
             {% if closed_milestones %}
             <optgroup label="Closed">
                 {% for o in closed_milestones %}
-                <option{% if o.selected%} selected{% endif %} value="{{o.html_value|e}}">{{o.label|e}}</option>
+                <option{% if o.selected%} selected{% endif %} value="{{o.html_value|e}}">{{o.label}}</option>
                 {% endfor %}
             </optgroup>
             {% endif %}


(allura) 01/03: security fixes: turn on autoescaping for widgets, fix tracker select XSS

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit bd7668079f193e1a46aba97c2a37078cb66fa4d4
Author: Guillermo Cruz <gu...@slashdotmedia.com>
AuthorDate: Thu Sep 10 12:39:33 2026 -0600

    security fixes: turn on autoescaping for widgets, fix tracker select XSS
---
 Allura/allura/config/middleware.py               |  7 +++++++
 Allura/allura/lib/helpers.py                     | 12 ++++++------
 Allura/allura/lib/widgets/auth_widgets.py        |  9 ++++++++-
 Allura/allura/lib/widgets/forms.py               |  4 +++-
 Allura/allura/tests/test_helpers.py              |  5 +++++
 ForgeTracker/forgetracker/widgets/ticket_form.py |  4 ++--
 6 files changed, 31 insertions(+), 10 deletions(-)

diff --git a/Allura/allura/config/middleware.py b/Allura/allura/config/middleware.py
index 57a027f96..008a5338f 100644
--- a/Allura/allura/config/middleware.py
+++ b/Allura/allura/config/middleware.py
@@ -170,8 +170,15 @@ def _make_core_app(root, global_conf: dict, **app_conf):
         # these are for the easywidgets' own [easy_widgets.engines] entry point
         # (the Allura [easy_widgets.engines] entry point is named "jinja" (not jinja2) but it doesn't need
         #  any settings since it is a class that uses the same jinja env as the rest of allura)
+        # NB: this is a *second* jinja environment, separate from the one in
+        # AlluraJinjaRenderer.  Widgets render through this one and their output
+        # is spliced into our own templates as Markup, so it does not inherit
+        # their autoescaping -- it has to be turned on here too.  Recent
+        # easywidgets defaults it on; set it explicitly so an older pin can't
+        # silently render widgets unescaped.
         **{
             'jinja2.auto_reload': asbool(config['auto_reload_templates']),
+            'jinja2.autoescape': True,
             'jinja2.bytecode_cache': AlluraJinjaRenderer._setup_bytecode_cache(),
             'jinja2.cache_size': asint(config.get('jinja_cache_size', -1)),
         }
diff --git a/Allura/allura/lib/helpers.py b/Allura/allura/lib/helpers.py
index d51057b7f..21c256ec1 100644
--- a/Allura/allura/lib/helpers.py
+++ b/Allura/allura/lib/helpers.py
@@ -1029,12 +1029,12 @@ def split_select_field_options(field_options):
         field_options = shlex_split(field_options)
     except ValueError:
         field_options = field_options.split()
-        # After regular split field_options might contain a " characters,
-        # which would break html when rendered inside tag's value attr.
-        # Escaping doesn't help here, 'cause it breaks EasyWidgets' validation,
-        # so we're getting rid of those.
-        field_options = [o.replace('"', '') for o in field_options]
-    return field_options
+    # An option containing a " breaks out of the value attr it gets rendered
+    # into.  shlex_split can hand one through even on the success path -- it
+    # unescapes \" inside a quoted word -- so strip them on *both* branches,
+    # not just after the plain split.  Escaping instead of stripping isn't an
+    # option here, it breaks EasyWidgets' validation of the submitted value.
+    return [o.replace('"', '') for o in field_options]
 
 
 @contextmanager
diff --git a/Allura/allura/lib/widgets/auth_widgets.py b/Allura/allura/lib/widgets/auth_widgets.py
index 1382545cb..38efc5df5 100644
--- a/Allura/allura/lib/widgets/auth_widgets.py
+++ b/Allura/allura/lib/widgets/auth_widgets.py
@@ -29,6 +29,13 @@
 from allura.lib import plugin
 
 
+# Emits already-rendered markup as-is.  The antispam honeypot fields arrive as
+# Markup, so passing them as *template source* is both pointless and risky --
+# a '$' in the content would be read as a core-ew substitution.  Render them as
+# data instead.
+_raw_html = ew.Snippet('{{widget.text|safe}}', 'jinja2')
+
+
 class LoginForm(ForgeForm):
     submit_text = 'Login'
     style = 'wide'
@@ -62,7 +69,7 @@ def fields(self):
 
         for fld in g.antispam.extra_fields():
             fields.append(
-                ew_core.Widget(template=ew.Snippet(fld)))
+                ew_core.Widget(template=_raw_html, text=fld))
 
         return fields
 
diff --git a/Allura/allura/lib/widgets/forms.py b/Allura/allura/lib/widgets/forms.py
index cb48c535e..de6eba00f 100644
--- a/Allura/allura/lib/widgets/forms.py
+++ b/Allura/allura/lib/widgets/forms.py
@@ -44,9 +44,11 @@
 
 
 class _HTMLExplanation(ew.InputField):
+    # text is developer-supplied markup (never user input); |safe keeps
+    # easywidgets' autoescaping from mangling it
     template = ew.Snippet(
         '''<label class="grid-4">&nbsp;</label>
-           <div class="grid-14" style="margin:2px;">{{widget.text}}</div>
+           <div class="grid-14" style="margin:2px;">{{widget.text|safe}}</div>
         ''',
         'jinja2')
 
diff --git a/Allura/allura/tests/test_helpers.py b/Allura/allura/tests/test_helpers.py
index fbee314fe..0b4a5c96d 100644
--- a/Allura/allura/tests/test_helpers.py
+++ b/Allura/allura/tests/test_helpers.py
@@ -414,6 +414,11 @@ def test_split_select_field_options():
             ['test', 'message', 'test2'])
     assert (h.split_select_field_options('abc ƒå∂ ººº') ==
             ['abc', 'ƒå∂', 'ººº'])
+    # shlex unescapes \" inside a quoted word, so a double quote can reach the
+    # option even when parsing succeeds.  It has to be stripped there too, or
+    # it breaks out of the value attr the option is rendered into.
+    assert (h.split_select_field_options(r'"x\" onmouseover=alert(1) y" normal') ==
+            ['x onmouseover=alert(1) y', 'normal'])
 
 
 def test_notifications_disabled():
diff --git a/ForgeTracker/forgetracker/widgets/ticket_form.py b/ForgeTracker/forgetracker/widgets/ticket_form.py
index b53bfdafc..6a3e59368 100644
--- a/ForgeTracker/forgetracker/widgets/ticket_form.py
+++ b/ForgeTracker/forgetracker/widgets/ticket_form.py
@@ -240,12 +240,12 @@ class MilestoneField(ew.SingleSelectField):
                'class':css_class},
                attrs)}}>
             {% for o in open_milestones %}
-            <option{% if o.selected%} selected{% endif %} value="{{o.html_value}}">{{o.label|e}}</option>
+            <option{% if o.selected%} selected{% endif %} value="{{o.html_value|e}}">{{o.label|e}}</option>
             {% endfor %}
             {% if closed_milestones %}
             <optgroup label="Closed">
                 {% for o in closed_milestones %}
-                <option{% if o.selected%} selected{% endif %} value="{{o.html_value}}">{{o.label|e}}</option>
+                <option{% if o.selected%} selected{% endif %} value="{{o.html_value|e}}">{{o.label|e}}</option>
                 {% endfor %}
             </optgroup>
             {% endif %}