Pre-split dynamic routes and combine repeated Cookie headers

Commit 84c8364 · patx · 2026-10-17T22:39:51-04:00

Changeset
84c8364aba1075c7d7465baa8f864c76274952bb
Parents
53388e780d105a0c441843c2a6bc01fa192ed2cc

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 51e6c68..75f0f72 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,13 @@ Unsupported protocols raise `RuntimeError` without sending HTTP messages,
 following [ASGI guidance](https://asgi.readthedocs.io/en/latest/specs/main.html#protocol-specifications).
 You can also export a middleware wrapper as `app`.
 
+For public deployments, serve Wred behind a reverse proxy such as
+[Nginx](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size)
+or [Cloudflare](https://developers.cloudflare.com/waf/). Configure request-body
+size limits, HTTPS, timeouts, and rate limiting at that layer. Wred buffers request
+bodies in memory and does not impose a body-size limit. Restrict direct access to
+the ASGI server so clients cannot bypass the proxy's protections.
+
 ## Routes
 
 Handlers are async and receive a request. Methods default to `['GET']`;
@@ -95,7 +102,7 @@ with pretty much every ASGI application you can think of.
 
 * `request.path` -> Path without the query string
 * `request.method` -> HTTP method
-* `request.headers` -> Header dict with lowercase names and Latin-1 values; repeated names keep their last value
+* `request.headers` -> Header dict with lowercase names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
 * `request.scope` -> Original ASGI scope, including client, scheme, and middleware state
 * `request.query()` -> Cached dict of string keys and values. No `await`!
 * `await request.body()` -> Cached body raw bytes
diff --git a/tests/test_wred.py b/tests/test_wred.py
index d4bd142..095f822 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -368,9 +368,10 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
             return next(chunks)
 
         messages = await call_app(app, {
-            'type': 'http', 'http_version': '1.1', 'path': '/stream', 'method': 'POST',
+            'type': 'http', 'http_version': '2', 'path': '/stream', 'method': 'POST',
             'headers': [(b'X-Example', b'caf\xe9'),
-                        (b'Cookie', b'notebook_refresh=valid-token; theme=dark; lang=en')],
+                        (b'Cookie', b'notebook_refresh=valid-token'),
+                        (b'cookie', b'theme=dark'), (b'COOKIE', b'lang=en')],
         }, receive)
         self.assertEqual(reads, 3)
         self.assertEqual(messages[0]['status'], 200)
diff --git a/wred.py b/wred.py
index 3cf0353..3b044e0 100644
--- a/wred.py
+++ b/wred.py
@@ -27,7 +27,8 @@ class Request:
     def __init__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]):
         self.scope, self.receive, self.send = scope, receive, send
         self.path, self.method = scope['path'], scope['method']
-        self.headers = {k.decode().lower(): v.decode('latin-1') for k, v in scope.get('headers', [])}
+        self.headers = {}
+        for k, v in scope.get('headers', []): k = k.decode('latin-1').lower(); v = v.decode('latin-1'); self.headers[k] = self.headers[k] + '; ' + v if k == 'cookie' and k in self.headers else v
         self._body = self._query = None
 
     def query(self) -> dict[str, str]:
@@ -60,7 +61,7 @@ class App:
     """A minimal ASGI application framework."""
     def __init__(self):
         self.routes: dict[str, dict[str, Callable]] = {}
-        self.pattern_routes: dict[str, dict[str, Callable]] = {}
+        self.pattern_routes: dict[tuple[str, ...], dict[str, Callable]] = {}
 
     async def __call__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
         """Handle ASGI requests."""
@@ -80,8 +81,9 @@ class App:
         if path in self.routes and method in self.routes[path]:
             await self.dispatch_response(request, await self.routes[path][method](request))
             return
+        path_parts = path.split('/')
         for pattern, method_handlers in self.pattern_routes.items():
-            params = self.match_path(pattern, path)
+            params = self.match_path(pattern, path_parts)
             if params is not None and method in method_handlers:
                 await self.dispatch_response(request, await method_handlers[method](request, **params))
                 return
@@ -109,10 +111,8 @@ class App:
         await send({'type': 'http.response.start', 'status': status, 'headers': [(b'content-type', content_type.encode('latin-1'))] + headers})
         await send({'type': 'http.response.body', 'body': body})
 
-    def match_path(self, pattern: str, path: str) -> dict[str, str] | None:
+    def match_path(self, pattern_parts: tuple[str, ...], path_parts: list[str]) -> dict[str, str] | None:
         """Match normalized paths and extract parameters."""
-        pattern_parts = pattern.split('/')
-        path_parts = path.split('/')
         if len(pattern_parts) != len(path_parts):
             return None
         params = {}
@@ -143,7 +143,8 @@ class App:
         path = path.rstrip('/') or '/'
         def decorator(handler: Callable) -> Callable:
             routes = self.pattern_routes if '{' in path and '}' in path else self.routes
-            routes.setdefault(path, {}).update({m.upper(): handler for m in methods})
+            key = tuple(path.split('/')) if routes is self.pattern_routes else path
+            routes.setdefault(key, {}).update({m.upper(): handler for m in methods})
             return handler
         return decorator