You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by gc...@apache.org on 2026/09/11 16:10:42 UTC

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

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

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

commit f6b0f4f65cfec1a7a87a7c1dae92ee41ef5a5056
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     | 47 ++++++++++++++++++++++++++++++++++
 Allura/allura/lib/custom_middleware.py | 31 ++++++++++++++++++++++
 2 files changed, 78 insertions(+)

diff --git a/Allura/allura/config/middleware.py b/Allura/allura/config/middleware.py
index 9914e8856..1bdd9d1b5 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,6 +72,44 @@
 __all__ = ['make_app']
 
 
+class _BoundedResourceCache(dict):
+    """A byte-budgeted replacement for easywidgets' unbounded resource cache.
+
+    ew 0.4.x keeps ResourceManager.resource_cache -- a plain class-level dict shared by every
+    instance and every request -- forever, keyed on the raw href query string and with no eviction.
+    WidgetMiddleware answers /_ew_resources/ before the wrapped app, so anyone at all can grow the
+    worker's heap until it dies.  It only ever does ``cache[href]`` and ``cache[href] = content``,
+    so evicting on insert is enough to bound it without touching the library's code.
+
+    Oldest-first rather than true LRU: eviction only happens under abuse, where the recency of what
+    gets dropped does not matter.  easywidgets 0.5 bounds this itself; drop once the pin moves.
+    """
+
+    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.
 
@@ -173,6 +214,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,
@@ -200,6 +242,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)