Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa35e2d30c
|
||
|
|
850d5dda35
|
||
|
|
270a0d87fc
|
||
|
|
59f1a8227f
|
||
|
|
9a68d10868
|
||
|
|
87d1b0acb1
|
||
|
|
dfc5d70eec
|
||
|
|
08370c4963
|
||
|
|
01a54e3fb5
|
@@ -52,6 +52,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
.venv/bin/python -m mypy -p kaya.openapi
|
.venv/bin/python -m mypy -p kaya.openapi
|
||||||
.venv/bin/python -m unittest discover -s packages/kaya-openapi/tests
|
.venv/bin/python -m unittest discover -s packages/kaya-openapi/tests
|
||||||
|
- name: Check kaya-cors
|
||||||
|
run: |
|
||||||
|
.venv/bin/python -m mypy -p kaya.cors
|
||||||
|
.venv/bin/python -m unittest discover -s packages/kaya-cors/tests
|
||||||
|
- name: Check kaya-forwarded
|
||||||
|
run: |
|
||||||
|
.venv/bin/python -m mypy -p kaya.forwarded
|
||||||
|
.venv/bin/python -m unittest discover -s packages/kaya-forwarded/tests
|
||||||
- name: Publish kaya-core artifacts
|
- name: Publish kaya-core artifacts
|
||||||
env:
|
env:
|
||||||
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
@@ -108,3 +116,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
.venv/bin/pyproject-build packages/kaya-openapi
|
.venv/bin/pyproject-build packages/kaya-openapi
|
||||||
.venv/bin/twine upload --repository gitea packages/kaya-openapi/dist/*.whl packages/kaya-openapi/dist/*.tar.gz
|
.venv/bin/twine upload --repository gitea packages/kaya-openapi/dist/*.whl packages/kaya-openapi/dist/*.tar.gz
|
||||||
|
- name: Publish kaya-cors artifacts
|
||||||
|
env:
|
||||||
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
|
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
|
||||||
|
run: |
|
||||||
|
.venv/bin/pyproject-build packages/kaya-cors
|
||||||
|
.venv/bin/twine upload --repository gitea packages/kaya-cors/dist/*.whl packages/kaya-cors/dist/*.tar.gz
|
||||||
|
- name: Publish kaya-forwarded artifacts
|
||||||
|
env:
|
||||||
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
|
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
|
||||||
|
run: |
|
||||||
|
.venv/bin/pyproject-build packages/kaya-forwarded
|
||||||
|
.venv/bin/twine upload --repository gitea packages/kaya-forwarded/dist/*.whl packages/kaya-forwarded/dist/*.tar.gz
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ This repository is a monorepo for the Kaya framework. The code is split into ind
|
|||||||
- **kaya-session-memcache** — memcached-backed session storage (`packages/kaya-session-memcache/`)
|
- **kaya-session-memcache** — memcached-backed session storage (`packages/kaya-session-memcache/`)
|
||||||
- **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`)
|
- **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`)
|
||||||
- **kaya-openapi** — automatic OpenAPI specification generation (`packages/kaya-openapi/`)
|
- **kaya-openapi** — automatic OpenAPI specification generation (`packages/kaya-openapi/`)
|
||||||
|
- **kaya-cors** — CORS (Cross-Origin Resource Sharing) support (`packages/kaya-cors/`)
|
||||||
|
- **kaya-forwarded** — trusted-proxy `Forwarded`/`X-Forwarded-*` client address resolution (`packages/kaya-forwarded/`)
|
||||||
|
|
||||||
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si
|
|||||||
Install the packages in development mode:
|
Install the packages in development mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc -e packages/kaya-openapi
|
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc -e packages/kaya-openapi -e packages/kaya-cors -e packages/kaya-forwarded
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the example:
|
Run the example:
|
||||||
@@ -46,6 +48,8 @@ python -m unittest discover -s packages/kaya-session-redis/tests
|
|||||||
python -m unittest discover -s packages/kaya-session-memcache/tests
|
python -m unittest discover -s packages/kaya-session-memcache/tests
|
||||||
python -m unittest discover -s packages/kaya-oidc/tests
|
python -m unittest discover -s packages/kaya-oidc/tests
|
||||||
python -m unittest discover -s packages/kaya-openapi/tests
|
python -m unittest discover -s packages/kaya-openapi/tests
|
||||||
|
python -m unittest discover -s packages/kaya-cors/tests
|
||||||
|
python -m unittest discover -s packages/kaya-forwarded/tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Static analysis
|
## Static analysis
|
||||||
@@ -58,6 +62,8 @@ mypy -p kaya.session.redis
|
|||||||
mypy -p kaya.session.memcache
|
mypy -p kaya.session.memcache
|
||||||
mypy -p kaya.oidc
|
mypy -p kaya.oidc
|
||||||
mypy -p kaya.openapi
|
mypy -p kaya.openapi
|
||||||
|
mypy -p kaya.cors
|
||||||
|
mypy -p kaya.forwarded
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building packages
|
## Building packages
|
||||||
@@ -70,4 +76,6 @@ python -m build packages/kaya-session-redis
|
|||||||
python -m build packages/kaya-session-memcache
|
python -m build packages/kaya-session-memcache
|
||||||
python -m build packages/kaya-oidc
|
python -m build packages/kaya-oidc
|
||||||
python -m build packages/kaya-openapi
|
python -m build packages/kaya-openapi
|
||||||
|
python -m build packages/kaya-cors
|
||||||
|
python -m build packages/kaya-forwarded
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# kaya-cors
|
||||||
|
|
||||||
|
CORS (Cross-Origin Resource Sharing) support for the Kaya web framework.
|
||||||
|
|
||||||
|
Provides `CorsMixin`, a `KayaMixin` that adds CORS response headers to outgoing
|
||||||
|
responses and answers CORS preflight (`OPTIONS`) requests, with the same
|
||||||
|
configuration parameters and semantics as FastAPI/Starlette's `CORSMiddleware`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kaya.core import KayaApp, HttpContext
|
||||||
|
from kaya.cors import CorsMixin
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[
|
||||||
|
CorsMixin(
|
||||||
|
allow_origins=['https://example.com'],
|
||||||
|
allow_methods=('GET', 'POST'),
|
||||||
|
allow_headers=('X-Custom-Header',),
|
||||||
|
allow_credentials=True,
|
||||||
|
max_age=600,
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
@app.GET('/')
|
||||||
|
async def home(ctx: HttpContext):
|
||||||
|
await ctx.send_str(200, 'Hello World!')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `allow_origins`: list of origins allowed to make cross-origin requests.
|
||||||
|
Use `['*']` to allow any origin.
|
||||||
|
- `allow_origin_regex`: optional regex string matched (fullmatch) against the
|
||||||
|
request origin.
|
||||||
|
- `allow_methods`: HTTP methods allowed for cross-origin requests
|
||||||
|
(default `('GET',)`); use `'*'` to allow all standard methods.
|
||||||
|
- `allow_headers`: request headers allowed in cross-origin requests
|
||||||
|
(default `()`); use `'*'` to mirror back any requested headers.
|
||||||
|
- `allow_credentials`: allow cookies/credentials in cross-origin requests
|
||||||
|
(default `False`). When enabled, the allowed origin is always echoed
|
||||||
|
explicitly instead of `'*'`.
|
||||||
|
- `expose_headers`: response headers made accessible to the browser.
|
||||||
|
- `max_age`: seconds browsers may cache the preflight response
|
||||||
|
(default `600`).
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Requests without an `Origin` header pass through untouched.
|
||||||
|
- Simple cross-origin requests with an allowed origin get
|
||||||
|
`Access-Control-Allow-Origin` (plus `Access-Control-Allow-Credentials` and
|
||||||
|
`Access-Control-Expose-Headers` when configured) added to the response.
|
||||||
|
Headers already set by the handler are never overwritten.
|
||||||
|
- Preflight requests (`OPTIONS` with `Origin` and
|
||||||
|
`Access-Control-Request-Method` headers) are answered directly by the mixin
|
||||||
|
with `200 OK` (or `400` with a `Disallowed CORS ...` body when the origin,
|
||||||
|
method or headers are not allowed). The preflight response is the only one
|
||||||
|
delivered to the client: if the routing tree matches the request anyway
|
||||||
|
(including user-registered `OPTIONS` handlers or the 404 fallback), its
|
||||||
|
output is discarded.
|
||||||
|
|
||||||
|
`CorsMixin` is a `KayaMixin`, so the app stays a `KayaApp` and both ASGI and
|
||||||
|
RSGI keep working.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "kaya-cors"
|
||||||
|
dynamic = ["version"]
|
||||||
|
authors = [
|
||||||
|
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||||
|
]
|
||||||
|
description = "CORS support for the Kaya lightweight ASGI web framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
'Intended Audience :: System Administrators',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Environment :: Console',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"build", "mypy", "ipdb", "twine", "httpx", "httpx-ws", "kaya-rsgi"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/woggioni/kaya"
|
||||||
|
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
show_error_codes = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
exclude = ["scripts", "docs", "test"]
|
||||||
|
strict = true
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
root = "../.."
|
||||||
|
version_file = "src/kaya/cors/_version.py"
|
||||||
|
|
||||||
|
[tool.setuptools_scm.tag]
|
||||||
|
prefix = "release/"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pkgutil import extend_path
|
||||||
|
|
||||||
|
__path__ = extend_path(__path__, __name__)
|
||||||
|
|
||||||
|
from ._mixin import CorsMixin
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'CorsMixin',
|
||||||
|
]
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
AsyncGenerator,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, HttpMethod, KayaApp, KayaMixin
|
||||||
|
from kaya.core._types import StrOrStrings
|
||||||
|
|
||||||
|
ALL_METHODS: Tuple[str, ...] = ("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY")
|
||||||
|
SAFELISTED_HEADERS = frozenset({"Accept", "Accept-Language", "Content-Language", "Content-Type"})
|
||||||
|
|
||||||
|
|
||||||
|
def _first_header(headers: Mapping[str, Sequence[str]], name: str) -> Optional[str]:
|
||||||
|
values = headers.get(name)
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
return values[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_headers(headers: Optional[Mapping[str, StrOrStrings]],
|
||||||
|
cors_headers: Mapping[str, str]) -> Mapping[str, StrOrStrings]:
|
||||||
|
"""Merge CORS headers into the response headers.
|
||||||
|
|
||||||
|
Header names are matched case-insensitively; headers already set by the
|
||||||
|
handler are never overwritten. A CORS ``Vary`` value is appended to an
|
||||||
|
existing ``Vary`` header when not already present.
|
||||||
|
"""
|
||||||
|
result: Dict[str, StrOrStrings] = dict(headers) if headers else {}
|
||||||
|
key_by_lower: Dict[str, str] = {k.lower(): k for k in result}
|
||||||
|
for key, value in cors_headers.items():
|
||||||
|
existing_key = key_by_lower.get(key.lower())
|
||||||
|
if existing_key is None:
|
||||||
|
result[key] = value
|
||||||
|
key_by_lower[key.lower()] = key
|
||||||
|
elif key.lower() == 'vary':
|
||||||
|
previous = result[existing_key]
|
||||||
|
previous_values = [previous] if isinstance(previous, str) else list(previous)
|
||||||
|
present = {v.strip().lower() for part in previous_values for v in part.split(',')}
|
||||||
|
if value.lower() not in present:
|
||||||
|
if isinstance(previous, str):
|
||||||
|
result[existing_key] = f"{previous}, {value}"
|
||||||
|
else:
|
||||||
|
result[existing_key] = (*previous_values, value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CorsHttpContext(HttpContext):
|
||||||
|
"""HttpContext wrapper that injects CORS headers into response headers.
|
||||||
|
|
||||||
|
Works with any concrete ``HttpContext`` (ASGI or RSGI) because it only
|
||||||
|
relies on the abstract send methods, which all implementations share.
|
||||||
|
Attributes not explicitly overridden are delegated to the wrapped context
|
||||||
|
via ``__getattr__``, so protocol-specific fields (``pathsend``,
|
||||||
|
``receive``/``send`` for ASGI, ``protocol`` for RSGI, etc.) are passed
|
||||||
|
through transparently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ctx: HttpContext, cors_headers: Mapping[str, str]) -> None:
|
||||||
|
object.__setattr__(self, '_ctx', ctx)
|
||||||
|
object.__setattr__(self, 'session', ctx.session)
|
||||||
|
object.__setattr__(self, '_cors_headers', cors_headers)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name == '_ctx':
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self._ctx, name)
|
||||||
|
|
||||||
|
def _merge(self, headers: Optional[Mapping[str, StrOrStrings]]) -> Mapping[str, StrOrStrings]:
|
||||||
|
return _merge_headers(headers, self._cors_headers)
|
||||||
|
|
||||||
|
async def stream_body(self,
|
||||||
|
status: int,
|
||||||
|
body_generator: AsyncGenerator[bytes, None],
|
||||||
|
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.stream_body(status, body_generator, self._merge(headers))
|
||||||
|
|
||||||
|
async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_bytes(status, body, self._merge(headers))
|
||||||
|
|
||||||
|
async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_str(status, body, self._merge(headers))
|
||||||
|
|
||||||
|
async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_file(status, path, self._merge(headers))
|
||||||
|
|
||||||
|
async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_empty(status, self._merge(headers))
|
||||||
|
|
||||||
|
|
||||||
|
class _SwallowedHttpContext(HttpContext):
|
||||||
|
"""HttpContext wrapper whose send methods are no-ops.
|
||||||
|
|
||||||
|
Returned by the CORS hook after a preflight response has already been sent,
|
||||||
|
so that routing (or the 404 fallback) does not attempt to send a second
|
||||||
|
response for the same request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ctx: HttpContext) -> None:
|
||||||
|
object.__setattr__(self, '_ctx', ctx)
|
||||||
|
object.__setattr__(self, 'session', ctx.session)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name == '_ctx':
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self._ctx, name)
|
||||||
|
|
||||||
|
async def stream_body(self,
|
||||||
|
status: int,
|
||||||
|
body_generator: AsyncGenerator[bytes, None],
|
||||||
|
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CorsMixin(KayaMixin):
|
||||||
|
"""Kaya mixin adding CORS headers to responses, modeled after
|
||||||
|
FastAPI/Starlette's ``CORSMiddleware``.
|
||||||
|
|
||||||
|
Registers a before-request hook that:
|
||||||
|
|
||||||
|
- answers CORS preflight requests (``OPTIONS`` requests carrying ``Origin``
|
||||||
|
and ``Access-Control-Request-Method`` headers) directly: ``200 OK`` when
|
||||||
|
the origin, method and headers are allowed, ``400`` with a
|
||||||
|
``Disallowed CORS ...`` body otherwise. The preflight response is the
|
||||||
|
only one delivered to the client; if the routing tree matches the
|
||||||
|
request anyway, its output is discarded;
|
||||||
|
- wraps the request context in a :class:`CorsHttpContext` for simple
|
||||||
|
cross-origin requests, injecting ``Access-Control-Allow-Origin`` (and
|
||||||
|
the configured credentials/expose headers) into the response.
|
||||||
|
|
||||||
|
Requests without an ``Origin`` header pass through untouched. Because the
|
||||||
|
app stays a ``KayaApp``, both ASGI and RSGI keep working.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[
|
||||||
|
CorsMixin(
|
||||||
|
allow_origins=['https://example.com'],
|
||||||
|
allow_methods=('GET', 'POST'),
|
||||||
|
allow_headers=('X-Custom-Header',),
|
||||||
|
allow_credentials=True,
|
||||||
|
)
|
||||||
|
])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
allow_origins: Sequence[str] = (),
|
||||||
|
allow_methods: Sequence[str] = ('GET',),
|
||||||
|
allow_headers: Sequence[str] = (),
|
||||||
|
allow_credentials: bool = False,
|
||||||
|
allow_origin_regex: Optional[str] = None,
|
||||||
|
expose_headers: Sequence[str] = (),
|
||||||
|
max_age: int = 600) -> None:
|
||||||
|
methods: Sequence[str] = ALL_METHODS if '*' in allow_methods else allow_methods
|
||||||
|
self._allow_origin_regex = re.compile(allow_origin_regex) if allow_origin_regex is not None else None
|
||||||
|
self._allow_all_origins = '*' in allow_origins
|
||||||
|
self._allow_all_headers = '*' in allow_headers
|
||||||
|
self._allow_credentials = allow_credentials
|
||||||
|
self._allow_origins = tuple(allow_origins)
|
||||||
|
self._allow_methods = tuple(methods)
|
||||||
|
sorted_allow_headers = sorted(SAFELISTED_HEADERS | set(allow_headers))
|
||||||
|
self._allow_headers = [h.lower() for h in sorted_allow_headers]
|
||||||
|
self._preflight_explicit_allow_origin = not self._allow_all_origins or allow_credentials
|
||||||
|
|
||||||
|
simple_headers: Dict[str, str] = {}
|
||||||
|
if self._allow_all_origins:
|
||||||
|
simple_headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
if allow_credentials:
|
||||||
|
simple_headers['Access-Control-Allow-Credentials'] = 'true'
|
||||||
|
if expose_headers:
|
||||||
|
simple_headers['Access-Control-Expose-Headers'] = ', '.join(expose_headers)
|
||||||
|
self._simple_headers = simple_headers
|
||||||
|
|
||||||
|
preflight_headers: Dict[str, str] = {}
|
||||||
|
if self._preflight_explicit_allow_origin:
|
||||||
|
# the origin value is set dynamically in _preflight_response()
|
||||||
|
preflight_headers['Vary'] = 'Origin'
|
||||||
|
else:
|
||||||
|
preflight_headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
preflight_headers['Access-Control-Allow-Methods'] = ', '.join(self._allow_methods)
|
||||||
|
preflight_headers['Access-Control-Max-Age'] = str(max_age)
|
||||||
|
if sorted_allow_headers and not self._allow_all_headers:
|
||||||
|
preflight_headers['Access-Control-Allow-Headers'] = ', '.join(sorted_allow_headers)
|
||||||
|
if allow_credentials:
|
||||||
|
preflight_headers['Access-Control-Allow-Credentials'] = 'true'
|
||||||
|
self._preflight_headers = preflight_headers
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
app.add_before_request_hook(self._before_request)
|
||||||
|
|
||||||
|
def _is_allowed_origin(self, origin: str) -> bool:
|
||||||
|
if self._allow_all_origins:
|
||||||
|
return True
|
||||||
|
if self._allow_origin_regex is not None and self._allow_origin_regex.fullmatch(origin):
|
||||||
|
return True
|
||||||
|
return origin in self._allow_origins
|
||||||
|
|
||||||
|
def _simple_response_headers(self, origin: str) -> Mapping[str, str]:
|
||||||
|
headers = dict(self._simple_headers)
|
||||||
|
if self._allow_all_origins and self._allow_credentials:
|
||||||
|
# credentials require the specific origin instead of '*'
|
||||||
|
headers['Access-Control-Allow-Origin'] = origin
|
||||||
|
headers['Vary'] = 'Origin'
|
||||||
|
elif not self._allow_all_origins and self._is_allowed_origin(origin):
|
||||||
|
# specific origins must be mirrored back in the response
|
||||||
|
headers['Access-Control-Allow-Origin'] = origin
|
||||||
|
headers['Vary'] = 'Origin'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _preflight_response(self,
|
||||||
|
request_headers: Mapping[str, Sequence[str]],
|
||||||
|
origin: str) -> Tuple[int, str, Mapping[str, str]]:
|
||||||
|
requested_method = _first_header(request_headers, 'access-control-request-method')
|
||||||
|
requested_headers = _first_header(request_headers, 'access-control-request-headers')
|
||||||
|
|
||||||
|
headers = dict(self._preflight_headers)
|
||||||
|
failures: List[str] = []
|
||||||
|
|
||||||
|
if self._is_allowed_origin(origin):
|
||||||
|
if self._preflight_explicit_allow_origin:
|
||||||
|
# the "else" case is already accounted for in self._preflight_headers
|
||||||
|
# and the value would be '*'
|
||||||
|
headers['Access-Control-Allow-Origin'] = origin
|
||||||
|
else:
|
||||||
|
failures.append('origin')
|
||||||
|
|
||||||
|
if requested_method not in self._allow_methods:
|
||||||
|
failures.append('method')
|
||||||
|
|
||||||
|
# if we allow all headers, then we have to mirror back any requested
|
||||||
|
# headers in the response
|
||||||
|
if self._allow_all_headers and requested_headers is not None:
|
||||||
|
headers['Access-Control-Allow-Headers'] = requested_headers
|
||||||
|
elif requested_headers is not None:
|
||||||
|
for header in [h.lower() for h in requested_headers.split(',')]:
|
||||||
|
if header.strip() not in self._allow_headers:
|
||||||
|
failures.append('headers')
|
||||||
|
break
|
||||||
|
|
||||||
|
# we don't strictly need to use 400 responses here, since it's up to
|
||||||
|
# the browser to enforce the CORS policy, but it's more informative
|
||||||
|
# if we do
|
||||||
|
if failures:
|
||||||
|
return 400, 'Disallowed CORS ' + ', '.join(failures), headers
|
||||||
|
return 200, 'OK', headers
|
||||||
|
|
||||||
|
async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]:
|
||||||
|
origin = _first_header(ctx.headers, 'origin')
|
||||||
|
if origin is None:
|
||||||
|
return None
|
||||||
|
if ctx.method == HttpMethod.OPTIONS \
|
||||||
|
and _first_header(ctx.headers, 'access-control-request-method') is not None:
|
||||||
|
status, body, headers = self._preflight_response(ctx.headers, origin)
|
||||||
|
response_headers: Dict[str, StrOrStrings] = {'Content-Type': 'text/plain; charset=utf-8'}
|
||||||
|
response_headers.update(headers)
|
||||||
|
await ctx.send_str(status, body, response_headers)
|
||||||
|
return _SwallowedHttpContext(ctx)
|
||||||
|
return CorsHttpContext(ctx, self._simple_response_headers(origin))
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pwo import async_test
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.cors import CorsMixin
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(**cors_kwargs: Any) -> KayaApp:
|
||||||
|
app = KayaApp(mixins=[CorsMixin(**cors_kwargs)])
|
||||||
|
|
||||||
|
@app.GET('/hello')
|
||||||
|
async def hello(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, 'Hello World!')
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def request(app: KayaApp,
|
||||||
|
method: str,
|
||||||
|
path: str = '/hello',
|
||||||
|
headers: Optional[dict[str, str]] = None) -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
return await client.request(method, path, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class CorsSimpleRequestTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_allowed_origin(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'])
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('Hello World!', r.text)
|
||||||
|
self.assertEqual('https://example.com', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
self.assertEqual('Origin', r.headers.get('Vary'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_disallowed_origin(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'])
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://evil.com'})
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertNotIn('Access-Control-Allow-Origin', r.headers)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_wildcard_origin(self):
|
||||||
|
app = make_app(allow_origins=['*'])
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://anything.example.com'})
|
||||||
|
self.assertEqual('*', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_wildcard_origin_with_credentials_echoes_origin(self):
|
||||||
|
app = make_app(allow_origins=['*'], allow_credentials=True)
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual('https://example.com', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
self.assertEqual('true', r.headers.get('Access-Control-Allow-Credentials'))
|
||||||
|
self.assertEqual('Origin', r.headers.get('Vary'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_origin_regex(self):
|
||||||
|
app = make_app(allow_origin_regex=r'https://.*\.example\.com')
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://api.example.com'})
|
||||||
|
self.assertEqual('https://api.example.com', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://example.com.evil.org'})
|
||||||
|
self.assertNotIn('Access-Control-Allow-Origin', r.headers)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_no_origin_header(self):
|
||||||
|
app = make_app(allow_origins=['*'])
|
||||||
|
r = await request(app, 'GET')
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertNotIn('Access-Control-Allow-Origin', r.headers)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_expose_headers(self):
|
||||||
|
app = make_app(allow_origins=['*'], expose_headers=['X-Total-Count'])
|
||||||
|
r = await request(app, 'GET', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual('X-Total-Count', r.headers.get('Access-Control-Expose-Headers'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_handler_set_cors_header_not_overwritten(self):
|
||||||
|
app = KayaApp(mixins=[CorsMixin(allow_origins=['*'])])
|
||||||
|
|
||||||
|
@app.GET('/custom')
|
||||||
|
async def custom(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, 'custom', headers={'Access-Control-Allow-Origin': 'https://custom.example.com'})
|
||||||
|
|
||||||
|
r = await request(app, 'GET', '/custom', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual('https://custom.example.com', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
|
||||||
|
|
||||||
|
class CorsPreflightTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def preflight_headers(origin: str = 'https://example.com',
|
||||||
|
method: str = 'POST',
|
||||||
|
headers: Optional[str] = None) -> dict[str, str]:
|
||||||
|
result = {
|
||||||
|
'Origin': origin,
|
||||||
|
'Access-Control-Request-Method': method,
|
||||||
|
}
|
||||||
|
if headers is not None:
|
||||||
|
result['Access-Control-Request-Headers'] = headers
|
||||||
|
return result
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_allowed(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'],
|
||||||
|
allow_methods=('GET', 'POST'),
|
||||||
|
allow_credentials=True)
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers())
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('OK', r.text)
|
||||||
|
self.assertEqual('https://example.com', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
self.assertEqual('GET, POST', r.headers.get('Access-Control-Allow-Methods'))
|
||||||
|
self.assertEqual('600', r.headers.get('Access-Control-Max-Age'))
|
||||||
|
self.assertEqual('true', r.headers.get('Access-Control-Allow-Credentials'))
|
||||||
|
self.assertEqual('Origin', r.headers.get('Vary'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_wildcard_origin(self):
|
||||||
|
app = make_app(allow_origins=['*'], allow_methods=('GET', 'POST'))
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers())
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('*', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_disallowed_origin(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'], allow_methods=('GET', 'POST'))
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers(origin='https://evil.com'))
|
||||||
|
self.assertEqual(400, r.status_code)
|
||||||
|
self.assertEqual('Disallowed CORS origin', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_disallowed_method(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'], allow_methods=('GET',))
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers(method='DELETE'))
|
||||||
|
self.assertEqual(400, r.status_code)
|
||||||
|
self.assertEqual('Disallowed CORS method', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_disallowed_headers(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'], allow_methods=('GET', 'POST'))
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers(headers='X-Custom'))
|
||||||
|
self.assertEqual(400, r.status_code)
|
||||||
|
self.assertEqual('Disallowed CORS headers', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_safelisted_headers_allowed(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'], allow_methods=('GET', 'POST'))
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers(headers='Content-Type'))
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_allow_all_headers_mirrors_request(self):
|
||||||
|
app = make_app(allow_origins=['*'], allow_methods=('GET', 'POST'), allow_headers=['*'])
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers(headers='X-Custom, X-Other'))
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('X-Custom, X-Other', r.headers.get('Access-Control-Allow-Headers'))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_configured_allow_headers(self):
|
||||||
|
app = make_app(allow_origins=['https://example.com'],
|
||||||
|
allow_methods=('GET', 'POST'),
|
||||||
|
allow_headers=('X-Custom',))
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers(headers='X-Custom'))
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
allow_headers = r.headers.get('Access-Control-Allow-Headers')
|
||||||
|
self.assertIsNotNone(allow_headers)
|
||||||
|
assert allow_headers is not None
|
||||||
|
self.assertIn('X-Custom', allow_headers)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preflight_response_not_overwritten_by_handler(self):
|
||||||
|
# even when a user-registered OPTIONS handler matches, the preflight
|
||||||
|
# response sent by the mixin is the only one delivered to the client
|
||||||
|
app = KayaApp(mixins=[CorsMixin(allow_origins=['https://example.com'], allow_methods=('GET', 'POST'))])
|
||||||
|
|
||||||
|
@app.GET('/hello')
|
||||||
|
async def hello(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, 'Hello World!')
|
||||||
|
|
||||||
|
@app.OPTIONS('/hello')
|
||||||
|
async def options(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, 'custom OPTIONS handler')
|
||||||
|
|
||||||
|
r = await request(app, 'OPTIONS', headers=self.preflight_headers())
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('OK', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_options_without_preflight_headers_routes_normally(self):
|
||||||
|
app = KayaApp(mixins=[CorsMixin(allow_origins=['https://example.com'])])
|
||||||
|
|
||||||
|
@app.OPTIONS('/hello')
|
||||||
|
async def options(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_str(200, 'custom OPTIONS handler')
|
||||||
|
|
||||||
|
# an OPTIONS request without Access-Control-Request-Method is not a
|
||||||
|
# preflight request and is routed normally
|
||||||
|
r = await request(app, 'OPTIONS', headers={'Origin': 'https://example.com'})
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual('custom OPTIONS handler', r.text)
|
||||||
|
self.assertEqual('https://example.com', r.headers.get('Access-Control-Allow-Origin'))
|
||||||
|
|
||||||
|
|
||||||
|
class CorsRsgiTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_rsgi_context_header_injection(self):
|
||||||
|
from kaya.rsgi import RsgiContext
|
||||||
|
|
||||||
|
class FakeScope:
|
||||||
|
scheme = 'http'
|
||||||
|
method = 'GET'
|
||||||
|
path = '/'
|
||||||
|
query_string = ''
|
||||||
|
headers = {'origin': 'https://example.com'}
|
||||||
|
client = '127.0.0.1:12345'
|
||||||
|
server = '127.0.0.1:80'
|
||||||
|
|
||||||
|
class FakeProtocol:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.responses = []
|
||||||
|
|
||||||
|
def response_str(self, status: int, headers: list, body: str) -> None:
|
||||||
|
self.responses.append((status, dict(headers), body))
|
||||||
|
|
||||||
|
mixin = CorsMixin(allow_origins=['https://example.com'])
|
||||||
|
protocol = FakeProtocol()
|
||||||
|
ctx = RsgiContext(FakeScope(), protocol) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
wrapped = await mixin._before_request(ctx)
|
||||||
|
assert wrapped is not None
|
||||||
|
await wrapped.send_str(200, 'hi')
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
self.assertEqual(1, len(protocol.responses))
|
||||||
|
status, headers, body = protocol.responses[0]
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual('https://example.com', headers.get('Access-Control-Allow-Origin'))
|
||||||
|
self.assertEqual('Origin', headers.get('Vary'))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# kaya-forwarded
|
||||||
|
|
||||||
|
Trusted-proxy forwarded header handling for the Kaya web framework.
|
||||||
|
|
||||||
|
Without this package, Kaya exposes the raw socket peer address as
|
||||||
|
`ctx.client` / `ws.client` and ignores `Forwarded` / `X-Forwarded-*` headers
|
||||||
|
entirely (they are client-controllable and trivially spoofable when the app is
|
||||||
|
directly exposed).
|
||||||
|
|
||||||
|
`ForwardedHeadersMixin` opts the application into honoring those headers, but
|
||||||
|
only when the direct socket peer is a trusted proxy, identified by a list of
|
||||||
|
trusted CIDRs/IPs.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kaya.core import KayaApp, HttpContext
|
||||||
|
from kaya.forwarded import ForwardedHeadersMixin
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[
|
||||||
|
ForwardedHeadersMixin(trusted_proxies=['127.0.0.1', '::1', '10.0.0.0/8'])
|
||||||
|
])
|
||||||
|
|
||||||
|
@app.GET('/whoami')
|
||||||
|
async def whoami(ctx: HttpContext):
|
||||||
|
host, port = ctx.client
|
||||||
|
await ctx.send_str(200, f'{host}:{port}')
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
When a request arrives:
|
||||||
|
|
||||||
|
1. If the socket peer IP does not belong to any trusted CIDR (or there is no
|
||||||
|
peer address), the mixin leaves the context untouched — `client` remains
|
||||||
|
the socket peer and all proxy headers are ignored.
|
||||||
|
2. Otherwise the client address is resolved from the headers, in order:
|
||||||
|
- `Forwarded` (RFC 7239): the `for=` entries are walked **from right to
|
||||||
|
left**, skipping entries that are themselves trusted proxies (and
|
||||||
|
`unknown`); the first untrusted entry is the client. This defeats
|
||||||
|
spoofing when the edge proxy *appends* to the header (e.g. nginx with
|
||||||
|
`$proxy_add_x_forwarded_for`), because attacker-supplied leftmost entries
|
||||||
|
are never selected. A `:port` in the selected `for=` value also
|
||||||
|
populates the port.
|
||||||
|
- `X-Forwarded-For`: same right-to-left trusted-proxy walk; the port comes
|
||||||
|
from `X-Forwarded-Port` when present and valid.
|
||||||
|
- `X-Forwarded-Host`: first entry; port from `X-Forwarded-Port` as above.
|
||||||
|
3. If none of the headers are present or usable, the socket peer is kept.
|
||||||
|
|
||||||
|
If every entry in the chain is a trusted proxy, the leftmost entry is used
|
||||||
|
(the whole chain is trusted, so the leftmost is the original client).
|
||||||
|
|
||||||
|
The resolved address is exposed by wrapping the request context /
|
||||||
|
websocket (the same pattern as `kaya-session`), so both ASGI and RSGI keep
|
||||||
|
working and `ctx.session` from other mixins is preserved.
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Even with this mixin, the edge proxy should still strip or overwrite inbound
|
||||||
|
`Forwarded` / `X-Forwarded-*` headers from clients — the mixin protects the
|
||||||
|
application, the proxy protects the chain.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "kaya-forwarded"
|
||||||
|
dynamic = ["version"]
|
||||||
|
authors = [
|
||||||
|
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||||
|
]
|
||||||
|
description = "Trusted-proxy forwarded header handling for the Kaya lightweight ASGI web framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
'Intended Audience :: System Administrators',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Environment :: Console',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"build", "mypy", "ipdb", "twine", "httpx", "httpx-ws", "kaya-rsgi"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/woggioni/kaya"
|
||||||
|
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
show_error_codes = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
exclude = ["scripts", "docs", "test"]
|
||||||
|
strict = true
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
root = "../.."
|
||||||
|
version_file = "src/kaya/forwarded/_version.py"
|
||||||
|
|
||||||
|
[tool.setuptools_scm.tag]
|
||||||
|
prefix = "release/"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pkgutil import extend_path
|
||||||
|
|
||||||
|
__path__ = extend_path(__path__, __name__)
|
||||||
|
|
||||||
|
from ._mixin import ForwardedHeadersMixin
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ForwardedHeadersMixin',
|
||||||
|
]
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
from ipaddress import ip_address, ip_network, IPv4Address, IPv4Network, IPv6Address, IPv6Network
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
AsyncGenerator,
|
||||||
|
List,
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket, WebSocketMessage
|
||||||
|
from kaya.core._types import StrOrStrings
|
||||||
|
|
||||||
|
_IPAddress = Union[IPv4Address, IPv6Address]
|
||||||
|
_IPNetwork = Union[IPv4Network, IPv6Network]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_host_port(value: str) -> Tuple[str, Optional[int]]:
|
||||||
|
if value.startswith('['):
|
||||||
|
# bracketed IPv6 address, optionally followed by :port
|
||||||
|
closing = value.find(']')
|
||||||
|
if closing == -1:
|
||||||
|
return value, None
|
||||||
|
host = value[1:closing]
|
||||||
|
rest = value[closing + 1:]
|
||||||
|
if rest.startswith(':'):
|
||||||
|
try:
|
||||||
|
return host, int(rest[1:])
|
||||||
|
except ValueError:
|
||||||
|
return host, None
|
||||||
|
return host, None
|
||||||
|
if value.count(':') == 1:
|
||||||
|
host, _, port_str = value.rpartition(':')
|
||||||
|
try:
|
||||||
|
return host, int(port_str)
|
||||||
|
except ValueError:
|
||||||
|
return value, None
|
||||||
|
return value, None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_forwarded_entries(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, Optional[int]]]:
|
||||||
|
"""Extract the (host, port) `for=` entries of the RFC 7239 `Forwarded` header,
|
||||||
|
flattened across all header values, in chain order (leftmost = original client).
|
||||||
|
"""
|
||||||
|
entries: List[Tuple[str, Optional[int]]] = []
|
||||||
|
for raw_value in headers.get('forwarded', ()):
|
||||||
|
for element in raw_value.split(','):
|
||||||
|
for param in element.split(';'):
|
||||||
|
key, sep, value = param.partition('=')
|
||||||
|
if sep and key.strip().lower() == 'for':
|
||||||
|
for_value = value.strip().strip('"')
|
||||||
|
if for_value and for_value.lower() != 'unknown':
|
||||||
|
entries.append(_split_host_port(for_value))
|
||||||
|
break
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _first_header_value(headers: Mapping[str, Sequence[str]], name: str) -> Optional[str]:
|
||||||
|
values = headers.get(name)
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
first = values[0].split(',')[0].strip()
|
||||||
|
return first or None
|
||||||
|
|
||||||
|
|
||||||
|
class _ForwardedHttpContext(HttpContext):
|
||||||
|
"""HttpContext wrapper that exposes the forwarded client address.
|
||||||
|
|
||||||
|
Everything except ``client`` is delegated to the wrapped context via
|
||||||
|
``__getattr__``, so it works with any concrete ``HttpContext`` (ASGI or
|
||||||
|
RSGI) and preserves attributes set by other mixins (e.g. ``session``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ctx: HttpContext, client: Tuple[str, int]) -> None:
|
||||||
|
object.__setattr__(self, '_ctx', ctx)
|
||||||
|
object.__setattr__(self, 'session', ctx.session)
|
||||||
|
object.__setattr__(self, 'client', client)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name == '_ctx':
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self._ctx, name)
|
||||||
|
|
||||||
|
async def stream_body(self,
|
||||||
|
status: int,
|
||||||
|
body_generator: AsyncGenerator[bytes, None],
|
||||||
|
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.stream_body(status, body_generator, headers)
|
||||||
|
|
||||||
|
async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_bytes(status, body, headers)
|
||||||
|
|
||||||
|
async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_str(status, body, headers)
|
||||||
|
|
||||||
|
async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_file(status, path, headers)
|
||||||
|
|
||||||
|
async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ctx.send_empty(status, headers)
|
||||||
|
|
||||||
|
|
||||||
|
class _ForwardedWebSocket(WebSocket):
|
||||||
|
"""WebSocket wrapper that exposes the forwarded client address.
|
||||||
|
|
||||||
|
Everything except ``client`` is delegated to the wrapped socket via
|
||||||
|
``__getattr__``, so it works with any concrete ``WebSocket`` (ASGI or
|
||||||
|
RSGI) and preserves attributes set by other mixins (e.g. ``session``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ws: WebSocket, client: Tuple[str, int]) -> None:
|
||||||
|
object.__setattr__(self, '_ws', ws)
|
||||||
|
object.__setattr__(self, 'session', ws.session)
|
||||||
|
object.__setattr__(self, 'client', client)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name == '_ws':
|
||||||
|
raise AttributeError(name)
|
||||||
|
return getattr(self._ws, name)
|
||||||
|
|
||||||
|
async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
|
await self._ws.accept(headers)
|
||||||
|
|
||||||
|
async def receive(self) -> WebSocketMessage:
|
||||||
|
return await self._ws.receive()
|
||||||
|
|
||||||
|
async def send_text(self, data: str) -> None:
|
||||||
|
await self._ws.send_text(data)
|
||||||
|
|
||||||
|
async def send_bytes(self, data: bytes) -> None:
|
||||||
|
await self._ws.send_bytes(data)
|
||||||
|
|
||||||
|
async def close(self, code: int = 1000) -> None:
|
||||||
|
await self._ws.close(code)
|
||||||
|
|
||||||
|
async def __anext__(self) -> WebSocketMessage:
|
||||||
|
return await self._ws.__anext__()
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardedHeadersMixin(KayaMixin):
|
||||||
|
"""Kaya mixin that resolves the client address from proxy headers
|
||||||
|
(``Forwarded``, ``X-Forwarded-For``, ``X-Forwarded-Host``), but only when
|
||||||
|
the direct socket peer is a trusted proxy.
|
||||||
|
|
||||||
|
Without this mixin, Kaya exposes the raw socket peer as ``ctx.client`` /
|
||||||
|
``ws.client`` and ignores forwarded headers entirely. With the mixin
|
||||||
|
applied, forwarded headers are honored only if the socket peer IP belongs
|
||||||
|
to one of the ``trusted_proxies`` CIDRs; otherwise the context is left
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
When the peer is trusted, the address chain is walked from right to left
|
||||||
|
and entries that are themselves trusted proxies are skipped, so a client
|
||||||
|
that prepends a spoofed entry cannot fool the resolution when the edge
|
||||||
|
proxy appends to the header (e.g. nginx with ``$proxy_add_x_forwarded_for``).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[
|
||||||
|
ForwardedHeadersMixin(trusted_proxies=['127.0.0.1', '::1', '10.0.0.0/8'])
|
||||||
|
])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, trusted_proxies: Sequence[str] = ()) -> None:
|
||||||
|
self._trusted_networks: Tuple[_IPNetwork, ...] = tuple(
|
||||||
|
ip_network(cidr, strict=False) for cidr in trusted_proxies
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
app.add_before_request_hook(self._before_request)
|
||||||
|
app.add_before_websocket_hook(self._before_websocket)
|
||||||
|
|
||||||
|
def _is_trusted(self, host: str) -> bool:
|
||||||
|
try:
|
||||||
|
addr: _IPAddress = ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(addr.version == network.version and addr in network
|
||||||
|
for network in self._trusted_networks)
|
||||||
|
|
||||||
|
def _select_forwarded_entry(self, entries: List[Tuple[str, Optional[int]]]) -> Optional[Tuple[str, Optional[int]]]:
|
||||||
|
"""Walk the chain right-to-left skipping trusted proxies; the first
|
||||||
|
untrusted entry is the client. If every entry is trusted, the leftmost
|
||||||
|
(original client) is returned.
|
||||||
|
"""
|
||||||
|
for entry in reversed(entries):
|
||||||
|
if not self._is_trusted(entry[0]):
|
||||||
|
return entry
|
||||||
|
return entries[0] if entries else None
|
||||||
|
|
||||||
|
def _forwarded_port(self, headers: Mapping[str, Sequence[str]], socket_port: int) -> int:
|
||||||
|
forwarded_port = _first_header_value(headers, 'x-forwarded-port')
|
||||||
|
if forwarded_port is not None:
|
||||||
|
try:
|
||||||
|
return int(forwarded_port)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return socket_port
|
||||||
|
|
||||||
|
def _resolve(self,
|
||||||
|
headers: Mapping[str, Sequence[str]],
|
||||||
|
client: Optional[Tuple[str, int]]) -> Optional[Tuple[str, int]]:
|
||||||
|
if client is None or not self._is_trusted(client[0]):
|
||||||
|
return client
|
||||||
|
socket_port = client[1]
|
||||||
|
|
||||||
|
selected = self._select_forwarded_entry(_parse_forwarded_entries(headers))
|
||||||
|
if selected is not None:
|
||||||
|
host, port = selected
|
||||||
|
return host, port if port is not None else socket_port
|
||||||
|
|
||||||
|
xff_values = headers.get('x-forwarded-for')
|
||||||
|
if xff_values:
|
||||||
|
xff_entries = [entry.strip() for value in xff_values for entry in value.split(',') if entry.strip()]
|
||||||
|
selected_host = self._select_forwarded_entry([(entry, None) for entry in xff_entries])
|
||||||
|
if selected_host is not None:
|
||||||
|
return selected_host[0], self._forwarded_port(headers, socket_port)
|
||||||
|
|
||||||
|
xfh = _first_header_value(headers, 'x-forwarded-host')
|
||||||
|
if xfh is not None:
|
||||||
|
return xfh, self._forwarded_port(headers, socket_port)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]:
|
||||||
|
resolved = self._resolve(ctx.headers, ctx.client)
|
||||||
|
if resolved is None or resolved is ctx.client:
|
||||||
|
return None
|
||||||
|
return _ForwardedHttpContext(ctx, resolved)
|
||||||
|
|
||||||
|
async def _before_websocket(self, ws: WebSocket) -> Optional[WebSocket]:
|
||||||
|
resolved = self._resolve(ws.headers, ws.client)
|
||||||
|
if resolved is None or resolved is ws.client:
|
||||||
|
return None
|
||||||
|
return _ForwardedWebSocket(ws, resolved)
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pwo import async_test
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.core._asgi import AsgiWebSocket
|
||||||
|
from kaya.forwarded import ForwardedHeadersMixin
|
||||||
|
|
||||||
|
TRUSTED = ['127.0.0.1', '::1', '10.0.0.0/8']
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(trusted_proxies=TRUSTED) -> KayaApp:
|
||||||
|
mixins = [ForwardedHeadersMixin(trusted_proxies=trusted_proxies)] if trusted_proxies is not None else []
|
||||||
|
app = KayaApp(mixins=mixins)
|
||||||
|
|
||||||
|
@app.GET('/client')
|
||||||
|
async def client(ctx: HttpContext) -> None:
|
||||||
|
host, port = ctx.client if ctx.client is not None else (None, None)
|
||||||
|
await ctx.send_str(200, json.dumps({'host': host, 'port': port}))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def request(app: KayaApp,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
client: tuple[str, int] = ('127.0.0.1', 123)) -> dict:
|
||||||
|
transport = httpx.ASGITransport(app=app, client=client)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as http_client:
|
||||||
|
r = await http_client.get('/client', headers=headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
return json.loads(r.text)
|
||||||
|
|
||||||
|
|
||||||
|
class TrustedPeerTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_no_headers_returns_socket_peer(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app)
|
||||||
|
self.assertEqual({'host': '127.0.0.1', 'port': 123}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_header_with_port(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'Forwarded': 'for=203.0.113.5:1234'})
|
||||||
|
self.assertEqual({'host': '203.0.113.5', 'port': 1234}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_header_bracketed_ipv6(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'Forwarded': 'for="[2001:db8::1]:4711"'})
|
||||||
|
self.assertEqual({'host': '2001:db8::1', 'port': 4711}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_header_without_port_keeps_socket_port(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'Forwarded': 'for=203.0.113.5'})
|
||||||
|
self.assertEqual({'host': '203.0.113.5', 'port': 123}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_unknown_entry_skipped(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'Forwarded': 'for=unknown, for=203.0.113.5'})
|
||||||
|
self.assertEqual('203.0.113.5', result['host'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_rightmost_untrusted_wins(self):
|
||||||
|
# attacker-controlled leftmost entry is skipped: the rightmost
|
||||||
|
# untrusted entry (appended by the trusted edge proxy) is the client
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'Forwarded': 'for=1.2.3.4, for=5.6.7.8, for=10.0.0.2'})
|
||||||
|
self.assertEqual('5.6.7.8', result['host'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_x_forwarded_for_spoofed_leftmost_entry_skipped(self):
|
||||||
|
# XFF = "<attacker-supplied>, <real client>" as appended by the proxy
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'X-Forwarded-For': '1.2.3.4, 5.6.7.8'})
|
||||||
|
self.assertEqual('5.6.7.8', result['host'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_x_forwarded_for_all_trusted_chain_uses_leftmost(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'X-Forwarded-For': '10.0.0.5, 10.0.0.2'})
|
||||||
|
self.assertEqual('10.0.0.5', result['host'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_x_forwarded_port(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={
|
||||||
|
'X-Forwarded-For': '203.0.113.5',
|
||||||
|
'X-Forwarded-Port': '8443',
|
||||||
|
})
|
||||||
|
self.assertEqual({'host': '203.0.113.5', 'port': 8443}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_invalid_x_forwarded_port_ignored(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={
|
||||||
|
'X-Forwarded-For': '203.0.113.5',
|
||||||
|
'X-Forwarded-Port': 'not-a-port',
|
||||||
|
})
|
||||||
|
self.assertEqual({'host': '203.0.113.5', 'port': 123}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_takes_precedence_over_x_forwarded_for(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={
|
||||||
|
'Forwarded': 'for=203.0.113.5',
|
||||||
|
'X-Forwarded-For': '198.51.100.7',
|
||||||
|
})
|
||||||
|
self.assertEqual('203.0.113.5', result['host'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_x_forwarded_host_fallback(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app, headers={'X-Forwarded-Host': '198.51.100.7'})
|
||||||
|
self.assertEqual('198.51.100.7', result['host'])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ipv6_cidr_trust(self):
|
||||||
|
app = make_app(trusted_proxies=['2001:db8::/32'])
|
||||||
|
result = await request(app,
|
||||||
|
headers={'X-Forwarded-For': '203.0.113.5'},
|
||||||
|
client=('2001:db8::10', 9999))
|
||||||
|
self.assertEqual({'host': '203.0.113.5', 'port': 9999}, result)
|
||||||
|
|
||||||
|
|
||||||
|
class UntrustedPeerTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_untrusted_peer_ignores_forwarded_headers(self):
|
||||||
|
app = make_app()
|
||||||
|
result = await request(app,
|
||||||
|
headers={'Forwarded': 'for=1.2.3.4', 'X-Forwarded-For': '1.2.3.4'},
|
||||||
|
client=('203.0.113.99', 4567))
|
||||||
|
self.assertEqual({'host': '203.0.113.99', 'port': 4567}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_empty_trusted_proxies_ignores_everything(self):
|
||||||
|
app = make_app(trusted_proxies=[])
|
||||||
|
result = await request(app, headers={'X-Forwarded-For': '1.2.3.4'})
|
||||||
|
self.assertEqual({'host': '127.0.0.1', 'port': 123}, result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_invalid_cidr_fails_fast(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
ForwardedHeadersMixin(trusted_proxies=['not-a-cidr'])
|
||||||
|
|
||||||
|
|
||||||
|
class OptOutTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_without_mixin_headers_are_ignored(self):
|
||||||
|
app = KayaApp()
|
||||||
|
|
||||||
|
@app.GET('/client')
|
||||||
|
async def client(ctx: HttpContext) -> None:
|
||||||
|
host, port = ctx.client if ctx.client is not None else (None, None)
|
||||||
|
await ctx.send_str(200, json.dumps({'host': host, 'port': port}))
|
||||||
|
|
||||||
|
result = await request(app, headers={'Forwarded': 'for=1.2.3.4', 'X-Forwarded-For': '1.2.3.4'})
|
||||||
|
self.assertEqual({'host': '127.0.0.1', 'port': 123}, result)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketTest(unittest.TestCase):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_ws(headers):
|
||||||
|
async def send(message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {'type': 'websocket.connect'}
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
'type': 'websocket',
|
||||||
|
'path': '/ws',
|
||||||
|
'query_string': b'',
|
||||||
|
'scheme': 'ws',
|
||||||
|
'client': ('127.0.0.1', 12345),
|
||||||
|
'server': ('127.0.0.1', 80),
|
||||||
|
'headers': headers,
|
||||||
|
}
|
||||||
|
return AsgiWebSocket(scope, receive, send)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_websocket_trusted_peer(self):
|
||||||
|
mixin = ForwardedHeadersMixin(trusted_proxies=TRUSTED)
|
||||||
|
ws = self._make_ws([(b'x-forwarded-for', b'1.2.3.4, 5.6.7.8')])
|
||||||
|
wrapped = await mixin._before_websocket(ws)
|
||||||
|
assert wrapped is not None
|
||||||
|
self.assertEqual(('5.6.7.8', 12345), wrapped.client)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_websocket_untrusted_peer(self):
|
||||||
|
mixin = ForwardedHeadersMixin(trusted_proxies=['10.0.0.0/8'])
|
||||||
|
ws = self._make_ws([(b'x-forwarded-for', b'1.2.3.4')])
|
||||||
|
wrapped = await mixin._before_websocket(ws)
|
||||||
|
self.assertIsNone(wrapped)
|
||||||
|
self.assertEqual(('127.0.0.1', 12345), ws.client)
|
||||||
|
|
||||||
|
|
||||||
|
class RsgiTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_rsgi_context(self):
|
||||||
|
from kaya.rsgi import RsgiContext
|
||||||
|
|
||||||
|
class FakeScope:
|
||||||
|
scheme = 'http'
|
||||||
|
method = 'GET'
|
||||||
|
path = '/'
|
||||||
|
query_string = ''
|
||||||
|
headers = {'x-forwarded-for': '1.2.3.4, 5.6.7.8'}
|
||||||
|
client = '127.0.0.1:12345'
|
||||||
|
server = '127.0.0.1:80'
|
||||||
|
|
||||||
|
mixin = ForwardedHeadersMixin(trusted_proxies=TRUSTED)
|
||||||
|
ctx = RsgiContext(FakeScope(), object()) # type: ignore[arg-type]
|
||||||
|
wrapped = asyncio.run(mixin._before_request(ctx))
|
||||||
|
assert wrapped is not None
|
||||||
|
self.assertEqual(('5.6.7.8', 12345), wrapped.client)
|
||||||
|
|
||||||
|
def test_rsgi_context_untrusted_peer(self):
|
||||||
|
from kaya.rsgi import RsgiContext
|
||||||
|
|
||||||
|
class FakeScope:
|
||||||
|
scheme = 'http'
|
||||||
|
method = 'GET'
|
||||||
|
path = '/'
|
||||||
|
query_string = ''
|
||||||
|
headers = {'x-forwarded-for': '1.2.3.4'}
|
||||||
|
client = '192.0.2.1:12345'
|
||||||
|
server = '127.0.0.1:80'
|
||||||
|
|
||||||
|
mixin = ForwardedHeadersMixin(trusted_proxies=TRUSTED)
|
||||||
|
ctx = RsgiContext(FakeScope(), object()) # type: ignore[arg-type]
|
||||||
|
wrapped = asyncio.run(mixin._before_request(ctx))
|
||||||
|
self.assertIsNone(wrapped)
|
||||||
|
self.assertEqual(('192.0.2.1', 12345), ctx.client)
|
||||||
@@ -5,6 +5,8 @@ kaya-session-redis @ file:./packages/kaya-session-redis
|
|||||||
kaya-session-memcache @ file:./packages/kaya-session-memcache
|
kaya-session-memcache @ file:./packages/kaya-session-memcache
|
||||||
kaya-oidc @ file:./packages/kaya-oidc
|
kaya-oidc @ file:./packages/kaya-oidc
|
||||||
kaya-openapi @ file:./packages/kaya-openapi
|
kaya-openapi @ file:./packages/kaya-openapi
|
||||||
|
kaya-cors @ file:./packages/kaya-cors
|
||||||
|
kaya-forwarded @ file:./packages/kaya-forwarded
|
||||||
build
|
build
|
||||||
fakeredis
|
fakeredis
|
||||||
mypy
|
mypy
|
||||||
|
|||||||
@@ -89,10 +89,16 @@ jeepney==0.9.0
|
|||||||
file:./packages/kaya-core
|
file:./packages/kaya-core
|
||||||
# via
|
# via
|
||||||
# -r requirements-dev.in
|
# -r requirements-dev.in
|
||||||
|
# kaya-cors
|
||||||
|
# kaya-forwarded
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# kaya-openapi
|
# kaya-openapi
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
|
file:./packages/kaya-cors
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
file:./packages/kaya-forwarded
|
||||||
|
# via -r requirements-dev.in
|
||||||
file:./packages/kaya-oidc
|
file:./packages/kaya-oidc
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
file:./packages/kaya-openapi
|
file:./packages/kaya-openapi
|
||||||
|
|||||||
Reference in New Issue
Block a user