Release 0.31 route lookup hardening

Commit a15f9e2 · patx · 2026-06-22T22:08:19-04:00

Changeset
a15f9e26739d42b254372ae8b7e8c0c1e28645d7
Parents
c2c11013ab714c01a78f9e1e5ac126b972e4c9a6

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/docs/apidocs/conf.py b/docs/apidocs/conf.py
index d0e1923..2b3f752 100644
--- a/docs/apidocs/conf.py
+++ b/docs/apidocs/conf.py
@@ -2,7 +2,7 @@
 
 project = "MicroPie"
 author = "Harrison Erd"
-release = "0.30"
+release = "0.31"
 
 extensions = [
     "sphinx.ext.autodoc",
diff --git a/docs/apidocs/whats_new.rst b/docs/apidocs/whats_new.rst
index c0bc2f0..5a60bca 100644
--- a/docs/apidocs/whats_new.rst
+++ b/docs/apidocs/whats_new.rst
@@ -11,6 +11,10 @@ releases, consult the `GitHub releases page <https://github.com/patx/micropie/re
 Version highlights
 ------------------
 
+* **0.31** – Hardens route lookup so non-callable public attributes and
+  property-like descriptors are not treated as handlers. Descriptor-backed
+  route methods such as ``functools.partialmethod`` continue to work after
+  binding.
 * **0.30** – Removes the public ``Request.get_json`` attribute. Use
   ``Request.json()`` for the full parsed JSON payload or
   ``Request.json(name, default)`` for top-level object lookups.
diff --git a/docs/release_notes.md b/docs/release_notes.md
index 89fb853..dd8a7bb 100644
--- a/docs/release_notes.md
+++ b/docs/release_notes.md
@@ -1,6 +1,7 @@
 [![Logo](https://patx.github.io/micropie/logo.png)](https://patx.github.io/micropie)
 
 ## Releases Notes
+- **[0.31](https://github.com/patx/micropie/releases/tag/v0.31)** - Security fix: route lookup now ignores non-callable public attributes and property-like descriptors, preventing internal App attributes from being treated as handlers while preserving descriptor-backed route methods.
 - **[0.30](https://github.com/patx/micropie/releases/tag/v0.30)** - Remove the public `Request.get_json` attribute. Use `Request.json()` or `Request.json(name, default)`.
 - **[0.29](https://github.com/patx/micropie/releases/tag/v0.29)** - Performance upgrades, no more per request signature inspections for routing. 24%-54% increase in req/sec.
 - **[0.28](https://github.com/patx/micropie/releases/tag/v0.28)** - Add `Request.json` helper
diff --git a/micropie.py b/micropie.py
index df9da6a..813ba3f 100644
--- a/micropie.py
+++ b/micropie.py
@@ -54,6 +54,7 @@ _DEFAULT_HEADER_BYTES = (b"Content-Type", b"text/html; charset=utf-8")
 _JSON_HEADER_BYTES = (b"Content-Type", b"application/json")
 _DEFAULT_HEADERS_BYTES = [_DEFAULT_HEADER_BYTES]
 _JSON_HEADERS_BYTES = [_JSON_HEADER_BYTES]
+_ROUTE_ATTR_MISSING = object()
 
 
 class _HandlerParam(NamedTuple):
@@ -504,6 +505,35 @@ class App:
         self._handler_cache[cache_key] = handler_info
         return handler_info
 
+    def _resolve_route_handler(
+        self, handler_name: str
+    ) -> Tuple[Optional[Callable[..., Any]], bool]:
+        """
+        Return a callable route handler and whether the route attribute exists.
+        """
+        raw_handler = inspect.getattr_static(
+            self, handler_name, _ROUTE_ATTR_MISSING
+        )
+        if raw_handler is _ROUTE_ATTR_MISSING:
+            return None, False
+        if isinstance(raw_handler, property):
+            return None, True
+        if not callable(raw_handler):
+            if inspect.isdatadescriptor(raw_handler) or not hasattr(
+                raw_handler, "__get__"
+            ):
+                return None, True
+
+        try:
+            handler = getattr(self, handler_name, _ROUTE_ATTR_MISSING)
+        except Exception:
+            return None, True
+        if handler is _ROUTE_ATTR_MISSING:
+            return None, False
+        if not callable(handler):
+            return None, True
+        return handler, True
+
     async def _load_session_from_scope(
         self, scope: Dict[str, Any], cookies: Dict[str, str]
     ) -> Dict[str, Any]:
@@ -746,8 +776,14 @@ class App:
 
             if not request.path_params:
                 request.path_params = parts[1:] if len(parts) > 1 else []
-            handler = getattr(self, func_name, None) or getattr(self, "index", None)
-            if not handler:
+            handler, route_exists = self._resolve_route_handler(func_name)
+            index_handler, _ = self._resolve_route_handler("index")
+            if handler is None:
+                if route_exists:
+                    await _early_exit(404, "404 Not Found")
+                    return
+                handler = index_handler
+            if handler is None:
                 await _early_exit(404, "404 Not Found")
                 return
             handler_info = self._get_handler_info(handler)
@@ -756,7 +792,7 @@ class App:
             func_args: List[Any] = []
 
             # Check if index handler accepts parameters (for non-root paths)
-            if handler == getattr(self, "index", None) and path and path != "index":
+            if handler == index_handler and path and path != "index":
                 if not handler_info.accepts_params:
                     await _early_exit(404, "404 Not Found")
                     return
@@ -993,8 +1029,8 @@ class App:
             if hasattr(request, "_ws_route_handler"):
                 handler_name = request._ws_route_handler
             request.path_params = parts[1:] if len(parts) > 1 else []
-            handler = getattr(self, handler_name, None)
-            if not handler:
+            handler, _ = self._resolve_route_handler(handler_name)
+            if handler is None:
                 await self._send_websocket_close(
                     send, 1008, "No matching WebSocket route"
                 )
diff --git a/pyproject.toml b/pyproject.toml
index c8a468b..a696b28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
 
 [project]
 name = "micropie"
-version = "0.30"
+version = "0.31"
 description = "An ultra micro ASGI web framework"
 keywords = ["micropie", "asgi", "microframework", "http"]
 readme = "docs/README.md"
diff --git a/tests.py b/tests.py
index 02c7e4c..0edd055 100644
--- a/tests.py
+++ b/tests.py
@@ -1,4 +1,5 @@
 import asyncio
+import functools
 import json as std_json
 import unittest
 import uuid
@@ -440,6 +441,122 @@ class TestRouting(MicroPieTestCase):
             {"type": "http.response.body", "body": b"404 Not Found", "more_body": False}
         )
 
+    async def test_public_internal_attributes_are_not_routable(self):
+        """Public App attributes should not be treated as route handlers."""
+        for path in ("/session_backend", "/env", "/middlewares"):
+            with self.subTest(path=path):
+                scope = self.create_mock_scope(path=path)
+                receive = AsyncMock(
+                    return_value={
+                        "type": "http.request",
+                        "body": b"",
+                        "more_body": False,
+                    }
+                )
+                send = AsyncMock()
+
+                await self.app(scope, receive, send)
+
+                send.assert_any_call(
+                    {
+                        "type": "http.response.start",
+                        "status": 404,
+                        "headers": [(b"Content-Type", b"text/html; charset=utf-8")],
+                    }
+                )
+                send.assert_any_call(
+                    {
+                        "type": "http.response.body",
+                        "body": b"404 Not Found",
+                        "more_body": False,
+                    }
+                )
+
+    async def test_route_lookup_does_not_execute_properties(self):
+        """Non-callable descriptors should be rejected before normal getattr."""
+
+        class PropertyApp(App):
+            @property
+            def dangerous(self):
+                raise AssertionError("route lookup executed a property")
+
+        self.app = PropertyApp()
+        scope = self.create_mock_scope(path="/dangerous")
+        receive = AsyncMock(
+            return_value={"type": "http.request", "body": b"", "more_body": False}
+        )
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "http.response.start",
+                "status": 404,
+                "headers": [(b"Content-Type", b"text/html; charset=utf-8")],
+            }
+        )
+
+    async def test_descriptor_backed_handlers_are_routable_after_binding(self):
+        """Method descriptors that bind to callables should remain routable."""
+
+        class DescriptorApp(App):
+            def partial_base(self, prefix, suffix=""):
+                return f"{prefix}:{suffix}"
+
+            partial = functools.partialmethod(partial_base, "partial")
+
+            @functools.singledispatchmethod
+            def dispatch(self, value):
+                return f"default:{value}"
+
+        self.app = DescriptorApp()
+
+        for path, body in (
+            ("/partial/value", b"partial:value"),
+            ("/dispatch/value", b"default:value"),
+        ):
+            with self.subTest(path=path):
+                scope = self.create_mock_scope(path=path)
+                receive = AsyncMock(
+                    return_value={
+                        "type": "http.request",
+                        "body": b"",
+                        "more_body": False,
+                    }
+                )
+                send = AsyncMock()
+
+                await self.app(scope, receive, send)
+
+                send.assert_any_call(
+                    {"type": "http.response.body", "body": body, "more_body": False}
+                )
+
+    async def test_missing_route_still_falls_back_to_index_with_path_params(self):
+        """Unknown routes should still use index when it accepts path params."""
+
+        async def index(self, *parts):
+            return "/".join(parts)
+
+        setattr(self.app, "index", index.__get__(self.app, App))
+
+        scope = self.create_mock_scope(path="/missing/route")
+        receive = AsyncMock(
+            return_value={"type": "http.request", "body": b"", "more_body": False}
+        )
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "http.response.body",
+                "body": b"missing/route",
+                "more_body": False,
+            }
+        )
+
     async def test_missing_parameter(self):
         """Test handler with missing required parameter."""
 
@@ -522,6 +639,23 @@ class TestWebSocket(MicroPieTestCase):
             }
         )
 
+    async def test_websocket_non_callable_attribute_is_not_routable(self):
+        """WebSocket routing should ignore non-callable ws_* attributes."""
+        self.app.ws_status = {"connected": False}
+        scope = self.create_mock_scope(path="/status", scope_type="websocket")
+        receive = AsyncMock(return_value={"type": "websocket.connect"})
+        send = AsyncMock()
+
+        await self.app(scope, receive, send)
+
+        send.assert_any_call(
+            {
+                "type": "websocket.close",
+                "code": 1008,
+                "reason": "No matching WebSocket route",
+            }
+        )
+
 
 class TestMiddleware(MicroPieTestCase):
     """Tests for HTTP and WebSocket middleware."""