patx/micropie
Release 0.31 route lookup hardening
Commit a15f9e2 · patx · 2026-06-22T22:08:19-04:00
Comments
No comments yet.
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 @@
[](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."""