fastapi-redline 0.2.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_redline/__init__.py +38 -0
- fastapi_redline/_httpx.py +5 -0
- fastapi_redline/_middleware.py +264 -0
- fastapi_redline/_security.py +228 -0
- fastapi_redline/_serialize.py +108 -0
- fastapi_redline/_sqlalchemy.py +19 -0
- fastapi_redline/integration.py +236 -0
- fastapi_redline/py.typed +0 -0
- fastapi_redline/router.py +165 -0
- fastapi_redline/suggest.py +5 -0
- fastapi_redline-0.2.0a1.dist-info/METADATA +267 -0
- fastapi_redline-0.2.0a1.dist-info/RECORD +14 -0
- fastapi_redline-0.2.0a1.dist-info/WHEEL +4 -0
- fastapi_redline-0.2.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""fastapi-redline: the FastAPI + SQLAlchemy adapter for Redline.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
7
|
+
from fastapi_redline import install
|
|
8
|
+
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
engine = create_async_engine("sqlite+aiosqlite:///app.db")
|
|
11
|
+
install(app, engine=engine) # UI at /_redline/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._httpx import instrument_httpx, uninstrument_httpx
|
|
17
|
+
from ._middleware import RedlineMiddleware
|
|
18
|
+
from ._security import RedlineHTTPError, csrf_guard, local_only, loopback_guard
|
|
19
|
+
from ._sqlalchemy import instrument_engine
|
|
20
|
+
from .integration import Redline, install
|
|
21
|
+
from .suggest import SQLAlchemyFixSuggester
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0a1"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Redline",
|
|
27
|
+
"RedlineHTTPError",
|
|
28
|
+
"RedlineMiddleware",
|
|
29
|
+
"SQLAlchemyFixSuggester",
|
|
30
|
+
"__version__",
|
|
31
|
+
"csrf_guard",
|
|
32
|
+
"install",
|
|
33
|
+
"instrument_engine",
|
|
34
|
+
"instrument_httpx",
|
|
35
|
+
"local_only",
|
|
36
|
+
"loopback_guard",
|
|
37
|
+
"uninstrument_httpx",
|
|
38
|
+
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""The pure-ASGI recording middleware.
|
|
2
|
+
|
|
3
|
+
Not ``BaseHTTPMiddleware``: that wraps the app in extra tasks, breaks contextvars
|
|
4
|
+
propagation for sync endpoints and buffers streaming responses. This one:
|
|
5
|
+
|
|
6
|
+
* *tees* the request body as the app reads it (the app sees the untouched ``receive``);
|
|
7
|
+
* wraps ``send`` to capture status, headers and a size-capped copy of the response body –
|
|
8
|
+
a stream is never buffered beyond ``max_body_bytes``;
|
|
9
|
+
* finishes the trace right after the last body chunk is sent, so background tasks do not
|
|
10
|
+
count towards latency;
|
|
11
|
+
* resolves the route template (``/orders/{id}``) from ``scope["route"]`` once routing ran;
|
|
12
|
+
* adds ``Server-Timing`` to the response start (``server_timing`` setting) and injects the
|
|
13
|
+
floating badge into small, single-message HTML pages (``badge`` setting): the start
|
|
14
|
+
message is held back only when it announces a 2xx ``text/html`` body that is not
|
|
15
|
+
compressed, allowed by the CSP and at most ``BADGE_MAX_BYTES`` long; a body that turns
|
|
16
|
+
out to be streamed (``more_body``) is passed through untouched.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from redline_core import Recorder, get_recorder, ui
|
|
26
|
+
from redline_core.contrib.asgi import track_request
|
|
27
|
+
from redline_core.sampling import profile_requested
|
|
28
|
+
|
|
29
|
+
__all__ = ["RedlineMiddleware"]
|
|
30
|
+
|
|
31
|
+
_JSON_PAIR = re.compile(r'("(?:[^"\\]|\\.)*")(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\]\s]+)')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _wants_profile(headers: list[tuple[bytes, bytes]], query: bytes) -> bool:
|
|
35
|
+
header = next((v for k, v in headers if k == b"x-redline-profile"), None)
|
|
36
|
+
return profile_requested(query, header)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _header(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None:
|
|
40
|
+
for key, value in headers:
|
|
41
|
+
if key.lower() == name:
|
|
42
|
+
return value.decode("latin-1")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _endpoint_name(endpoint: Any) -> str | None:
|
|
47
|
+
if endpoint is None:
|
|
48
|
+
return None
|
|
49
|
+
target = getattr(endpoint, "func", endpoint) # functools.partial
|
|
50
|
+
module = getattr(target, "__module__", None)
|
|
51
|
+
name = getattr(target, "__qualname__", None) or getattr(target, "__name__", None)
|
|
52
|
+
if name is None:
|
|
53
|
+
name = type(target).__qualname__
|
|
54
|
+
module = type(target).__module__
|
|
55
|
+
return f"{module}.{name}" if module else name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Capture:
|
|
59
|
+
"""A byte buffer that keeps at most ``limit + 1`` bytes (the +1 lets core flag truncation)."""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("buf", "limit", "total")
|
|
62
|
+
|
|
63
|
+
def __init__(self, limit: int) -> None:
|
|
64
|
+
self.buf = bytearray()
|
|
65
|
+
self.limit = limit
|
|
66
|
+
self.total = 0
|
|
67
|
+
|
|
68
|
+
def add(self, chunk: bytes) -> None:
|
|
69
|
+
self.total += len(chunk)
|
|
70
|
+
room = self.limit + 1 - len(self.buf)
|
|
71
|
+
if room > 0 and chunk:
|
|
72
|
+
self.buf += chunk[:room]
|
|
73
|
+
|
|
74
|
+
def value(self, recorder: Recorder, content_type: str | None) -> bytes:
|
|
75
|
+
data = bytes(self.buf)
|
|
76
|
+
if len(data) > self.limit and content_type and "json" in content_type:
|
|
77
|
+
# A cut JSON document cannot be parsed by core's key-based masking; mask the
|
|
78
|
+
# visible "key": value pairs so a secret in the first bytes never leaks.
|
|
79
|
+
redactor = recorder.redactor
|
|
80
|
+
text = data.decode("utf-8", errors="ignore")
|
|
81
|
+
text = _JSON_PAIR.sub(
|
|
82
|
+
lambda m: (
|
|
83
|
+
f'{m.group(1)}{m.group(2)}"[redacted]"'
|
|
84
|
+
if redactor.is_secret_key(m.group(1)[1:-1])
|
|
85
|
+
else m.group(0)
|
|
86
|
+
),
|
|
87
|
+
text,
|
|
88
|
+
)
|
|
89
|
+
data = text.encode("utf-8")
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _with_header(headers: list[tuple[bytes, bytes]], name: bytes, value: str) -> None:
|
|
94
|
+
"""Append ``value`` to header ``name`` (comma-joined) or add it, in place."""
|
|
95
|
+
if not value:
|
|
96
|
+
return
|
|
97
|
+
for i, (key, old) in enumerate(headers):
|
|
98
|
+
if key.lower() == name:
|
|
99
|
+
headers[i] = (key, old + b", " + value.encode("latin-1"))
|
|
100
|
+
return
|
|
101
|
+
headers.append((name, value.encode("latin-1")))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _badge_candidate(status: int, headers: list[tuple[bytes, bytes]]) -> bool:
|
|
105
|
+
if not ui.badge_allowed(
|
|
106
|
+
status,
|
|
107
|
+
_header(headers, b"content-type"),
|
|
108
|
+
content_encoding=_header(headers, b"content-encoding"),
|
|
109
|
+
csp=", ".join(
|
|
110
|
+
v.decode("latin-1") for k, v in headers if k.lower() == b"content-security-policy"
|
|
111
|
+
),
|
|
112
|
+
):
|
|
113
|
+
return False
|
|
114
|
+
if _header(headers, b"content-disposition") or _header(headers, b"last-modified"):
|
|
115
|
+
return False # a file (FileResponse / StaticFiles / download), not a rendered page
|
|
116
|
+
length = _header(headers, b"content-length")
|
|
117
|
+
if length is None:
|
|
118
|
+
return True # decided on the first body message (``more_body``)
|
|
119
|
+
try:
|
|
120
|
+
return int(length) <= ui.BADGE_MAX_BYTES
|
|
121
|
+
except ValueError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RedlineMiddleware:
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
app: Any,
|
|
129
|
+
recorder: Recorder | None = None,
|
|
130
|
+
prefix: str = "/_redline",
|
|
131
|
+
debug: Callable[[], bool] | None = None,
|
|
132
|
+
force_ok: Callable[[Any], bool] | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.app = app
|
|
135
|
+
self.recorder = recorder
|
|
136
|
+
self.prefix = prefix
|
|
137
|
+
self.debug = debug or (lambda: False)
|
|
138
|
+
#: ``force_ok(scope)``: may this request force profiling (``X-Redline-Profile``,
|
|
139
|
+
#: ``?_redline=profile``)? Called only when such a flag is present.
|
|
140
|
+
self.force_ok = force_ok or (lambda scope: self.debug())
|
|
141
|
+
|
|
142
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
143
|
+
if scope["type"] != "http":
|
|
144
|
+
await self.app(scope, receive, send)
|
|
145
|
+
return
|
|
146
|
+
recorder = self.recorder or get_recorder()
|
|
147
|
+
path: str = scope["path"]
|
|
148
|
+
if not recorder.should_record(path):
|
|
149
|
+
await self.app(scope, receive, send)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
settings = recorder.settings
|
|
153
|
+
headers = list(scope.get("headers") or [])
|
|
154
|
+
query = scope.get("query_string", b"")
|
|
155
|
+
client = scope.get("client")
|
|
156
|
+
builder = recorder.start_trace(
|
|
157
|
+
framework="fastapi",
|
|
158
|
+
method=scope["method"],
|
|
159
|
+
path=path,
|
|
160
|
+
headers=headers,
|
|
161
|
+
query=query,
|
|
162
|
+
client=client[0] if client else None,
|
|
163
|
+
profile_requested=_wants_profile(headers, query) and self.force_ok(scope),
|
|
164
|
+
)
|
|
165
|
+
root_path = scope.get("root_path", "")
|
|
166
|
+
limit = settings.max_body_bytes
|
|
167
|
+
req = _Capture(limit) if settings.capture_request_body else None
|
|
168
|
+
res = _Capture(limit)
|
|
169
|
+
capture_res = settings.capture_response_body
|
|
170
|
+
state: dict[str, Any] = {"status": None, "headers": [], "done": False}
|
|
171
|
+
server_timing = builder.active and ui.server_timing_enabled(settings, self.debug())
|
|
172
|
+
badge = builder.active and ui.badge_enabled(settings, self.debug())
|
|
173
|
+
held: list[Any] = [] # a response start held back for badge injection
|
|
174
|
+
|
|
175
|
+
def finalize(status: int | None = None, error: BaseException | None = None) -> None:
|
|
176
|
+
if state["done"]:
|
|
177
|
+
return
|
|
178
|
+
state["done"] = True
|
|
179
|
+
if req is not None:
|
|
180
|
+
builder.req_body = req.value(recorder, _header(headers, b"content-type"))
|
|
181
|
+
route = scope.get("route")
|
|
182
|
+
route_path = getattr(route, "path", None)
|
|
183
|
+
if route_path is not None:
|
|
184
|
+
mounted = scope.get("root_path", "")
|
|
185
|
+
if mounted != root_path and mounted.startswith(root_path):
|
|
186
|
+
route_path = mounted[len(root_path) :] + route_path
|
|
187
|
+
resp_headers = state["headers"]
|
|
188
|
+
body = None
|
|
189
|
+
if capture_res and _header(resp_headers, b"content-encoding") is None:
|
|
190
|
+
body = res.value(recorder, _header(resp_headers, b"content-type"))
|
|
191
|
+
try:
|
|
192
|
+
recorder.finish_trace(
|
|
193
|
+
builder,
|
|
194
|
+
status=status or state["status"] or 500,
|
|
195
|
+
headers=resp_headers,
|
|
196
|
+
body=body,
|
|
197
|
+
size=res.total,
|
|
198
|
+
route=route_path,
|
|
199
|
+
view=_endpoint_name(scope.get("endpoint")),
|
|
200
|
+
error=error,
|
|
201
|
+
path_params=scope.get("path_params") or None,
|
|
202
|
+
)
|
|
203
|
+
except Exception: # pragma: no cover - recording must never break a request
|
|
204
|
+
import logging
|
|
205
|
+
|
|
206
|
+
logging.getLogger("redline").exception("Redline failed to finish a trace")
|
|
207
|
+
|
|
208
|
+
async def recv() -> Any:
|
|
209
|
+
message = await receive()
|
|
210
|
+
if req is not None and message["type"] == "http.request":
|
|
211
|
+
chunk = message.get("body", b"")
|
|
212
|
+
if chunk:
|
|
213
|
+
req.add(chunk)
|
|
214
|
+
return message
|
|
215
|
+
|
|
216
|
+
def inject(start: Any, message: Any, chunk: bytes) -> tuple[Any, Any]:
|
|
217
|
+
summary = ui.badge_summary(builder, start["status"])
|
|
218
|
+
snippet = ui.badge_snippet(builder.id, root_path + self.prefix, summary)
|
|
219
|
+
body = ui.inject_badge(chunk, snippet)
|
|
220
|
+
if body is None:
|
|
221
|
+
return start, message
|
|
222
|
+
out = [(k, v) for k, v in start["headers"] if k.lower() != b"content-length"]
|
|
223
|
+
if len(out) != len(start["headers"]):
|
|
224
|
+
out.append((b"content-length", str(len(body)).encode("latin-1")))
|
|
225
|
+
return {**start, "headers": out}, {**message, "body": body}
|
|
226
|
+
|
|
227
|
+
async def send_wrapper(message: Any) -> None:
|
|
228
|
+
kind = message["type"]
|
|
229
|
+
if kind == "http.response.start":
|
|
230
|
+
state["status"] = message["status"]
|
|
231
|
+
state["headers"] = list(message.get("headers") or [])
|
|
232
|
+
if server_timing:
|
|
233
|
+
out = list(state["headers"])
|
|
234
|
+
_with_header(out, b"server-timing", builder.server_timing())
|
|
235
|
+
message = {**message, "headers": out}
|
|
236
|
+
if badge and _badge_candidate(message["status"], state["headers"]):
|
|
237
|
+
held.append(message)
|
|
238
|
+
return
|
|
239
|
+
elif kind == "http.response.body":
|
|
240
|
+
chunk = message.get("body", b"")
|
|
241
|
+
if capture_res:
|
|
242
|
+
res.add(chunk)
|
|
243
|
+
else:
|
|
244
|
+
res.total += len(chunk)
|
|
245
|
+
if held:
|
|
246
|
+
start = held.pop()
|
|
247
|
+
if not message.get("more_body", False) and len(chunk) <= ui.BADGE_MAX_BYTES:
|
|
248
|
+
start, message = inject(start, message, chunk)
|
|
249
|
+
await send(start)
|
|
250
|
+
await send(message)
|
|
251
|
+
if kind == "http.response.body" and not message.get("more_body", False):
|
|
252
|
+
finalize()
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
async with track_request(builder): # event-loop lag monitor (blocking spans)
|
|
256
|
+
await self.app(scope, recv, send_wrapper)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
finalize(error=exc)
|
|
259
|
+
raise
|
|
260
|
+
except BaseException: # cancellation: the client went away
|
|
261
|
+
finalize(status=state["status"] or 499)
|
|
262
|
+
raise
|
|
263
|
+
else:
|
|
264
|
+
finalize()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Access control for the Redline UI and API.
|
|
2
|
+
|
|
3
|
+
Every Redline request goes through :func:`redline_guard`, which asks the core access gate
|
|
4
|
+
(:class:`redline_core.access.Access`, ``CONTRACT.md`` §18) – the ``access`` setting picks
|
|
5
|
+
the policy (``local`` by default; ``token``, ``basic``, ``framework``, ``custom``,
|
|
6
|
+
``open``). Passing ``install(dependencies=[...])`` implies ``access="framework"``: your
|
|
7
|
+
dependencies decide, and FastAPI runs them as usual.
|
|
8
|
+
|
|
9
|
+
The helpers below are kept for explicit use as dependencies:
|
|
10
|
+
|
|
11
|
+
* :func:`local_only` / :func:`loopback_guard` – loopback clients addressing a loopback
|
|
12
|
+
``Host`` (against DNS rebinding);
|
|
13
|
+
* :func:`csrf_guard` – the Origin / ``Sec-Fetch-Site`` check (the gate applies it anyway);
|
|
14
|
+
* :class:`RedlineHTTPError` – what the guards raise; the Redline router renders it (and
|
|
15
|
+
any ``HTTPException`` from your own auth dependencies) in the API's error shape,
|
|
16
|
+
``{"error": {"code": ..., "message": ...}}``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
from fastapi import Depends, HTTPException, Request
|
|
27
|
+
from fastapi.params import Depends as DependsParam
|
|
28
|
+
|
|
29
|
+
from redline_core.access import (
|
|
30
|
+
LOCAL_HOST_NAMES,
|
|
31
|
+
Access,
|
|
32
|
+
Decision,
|
|
33
|
+
RequestInfo,
|
|
34
|
+
is_local_host,
|
|
35
|
+
is_loopback,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"LOCAL_HOST_NAMES",
|
|
40
|
+
"RedlineHTTPError",
|
|
41
|
+
"csrf_guard",
|
|
42
|
+
"is_local_host_header",
|
|
43
|
+
"local_only",
|
|
44
|
+
"loopback_guard",
|
|
45
|
+
"redline_guard",
|
|
46
|
+
"refuse_false",
|
|
47
|
+
"request_info",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
_SAFE = {"GET", "HEAD", "OPTIONS"}
|
|
51
|
+
_TESTCLIENT = "testclient" # Starlette's TestClient reports this as the client address
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RedlineHTTPError(HTTPException):
|
|
55
|
+
"""An ``HTTPException`` with a machine-readable ``code`` for the error shape."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
status_code: int,
|
|
60
|
+
code: str,
|
|
61
|
+
message: str,
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
extra: dict | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__(status_code=status_code, detail=message, headers=headers)
|
|
66
|
+
self.code = code
|
|
67
|
+
self.message = message
|
|
68
|
+
self.extra = dict(extra or {})
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_decision(cls, decision: Decision) -> RedlineHTTPError:
|
|
72
|
+
return cls(
|
|
73
|
+
decision.status,
|
|
74
|
+
decision.code,
|
|
75
|
+
decision.message,
|
|
76
|
+
dict(decision.headers) or None,
|
|
77
|
+
dict(decision.extra),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _client_ip(request: Request) -> str | None:
|
|
82
|
+
host = request.client.host if request.client else None
|
|
83
|
+
return "127.0.0.1" if host == _TESTCLIENT else host
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_loopback(host: str | None) -> bool:
|
|
87
|
+
"""Whether a *client address* is loopback (``None`` is a unix socket)."""
|
|
88
|
+
return host == _TESTCLIENT or is_loopback(host)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_local_host_header(header: str | None, extra: Iterable[str] = ()) -> bool:
|
|
92
|
+
"""Whether a ``Host`` header names this machine's loopback interface (see
|
|
93
|
+
:func:`redline_core.access.is_local_host`)."""
|
|
94
|
+
return is_local_host(header, extra)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def scope_info(scope: Any) -> RequestInfo:
|
|
98
|
+
"""A :class:`RequestInfo` from a raw ASGI scope (for the profile-flag check)."""
|
|
99
|
+
client = scope.get("client")
|
|
100
|
+
host = client[0] if client else None
|
|
101
|
+
return RequestInfo.build(
|
|
102
|
+
method=scope.get("method", "GET"),
|
|
103
|
+
path="",
|
|
104
|
+
client_ip="127.0.0.1" if host == _TESTCLIENT else host,
|
|
105
|
+
headers=scope.get("headers") or [],
|
|
106
|
+
scheme=scope.get("scheme", "http"),
|
|
107
|
+
mount=scope.get("root_path", "") or "/",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def request_info(request: Request, mount: str) -> RequestInfo:
|
|
112
|
+
"""The framework-neutral description of a request under ``mount`` (e.g. ``/_redline``)."""
|
|
113
|
+
root = request.scope.get("root_path", "") or ""
|
|
114
|
+
path = request.scope.get("path", "")
|
|
115
|
+
if root and path.startswith(root + "/"):
|
|
116
|
+
path = path[len(root) :]
|
|
117
|
+
rel = path[len(mount) :] if path.startswith(mount) else path
|
|
118
|
+
return RequestInfo.build(
|
|
119
|
+
method=request.method,
|
|
120
|
+
path=rel.lstrip("/"),
|
|
121
|
+
client_ip=_client_ip(request),
|
|
122
|
+
headers=request.headers.items(),
|
|
123
|
+
scheme=request.url.scheme,
|
|
124
|
+
mount=f"{root.rstrip('/')}{mount}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def redline_guard(access: Access, mount: str) -> Callable[[Request], Awaitable[None]]:
|
|
129
|
+
"""The dependency every Redline route runs first: the core access check."""
|
|
130
|
+
|
|
131
|
+
async def guard(request: Request) -> None:
|
|
132
|
+
decision = access.check(request_info(request, mount), framework_ok=lambda: True)
|
|
133
|
+
if not decision.allowed:
|
|
134
|
+
raise RedlineHTTPError.from_decision(decision)
|
|
135
|
+
|
|
136
|
+
guard.__name__ = "redline_guard"
|
|
137
|
+
return guard
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def refuse_false(dependencies: Sequence[Any]) -> Any:
|
|
141
|
+
"""One dependency that runs the user's ``dependencies`` and refuses with 403 when any
|
|
142
|
+
of them *returns* ``False``.
|
|
143
|
+
|
|
144
|
+
Raising ``HTTPException`` is the normal way for a FastAPI dependency to refuse; this
|
|
145
|
+
catches the easy mistake of a check that returns a bool (``return user.is_admin``),
|
|
146
|
+
which FastAPI would otherwise ignore – letting everyone in. Only ``False`` itself
|
|
147
|
+
refuses: ``None`` (no return value) and other values pass. The dependencies are
|
|
148
|
+
resolved by FastAPI as usual (sub-dependencies, ``yield``, ``Security`` scopes).
|
|
149
|
+
"""
|
|
150
|
+
params = [
|
|
151
|
+
inspect.Parameter(
|
|
152
|
+
f"_redline_dep_{i}",
|
|
153
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
154
|
+
default=d if isinstance(d, DependsParam) else Depends(d),
|
|
155
|
+
)
|
|
156
|
+
for i, d in enumerate(dependencies)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
async def check(**results: Any) -> None:
|
|
160
|
+
if any(value is False for value in results.values()):
|
|
161
|
+
raise RedlineHTTPError(
|
|
162
|
+
403, "forbidden", "Redline refused this request (a dependency returned False)."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
check.__signature__ = inspect.Signature(params) # type: ignore[attr-defined]
|
|
166
|
+
check.__name__ = "redline_dependencies"
|
|
167
|
+
return Depends(check)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def loopback_guard(*, allowed_hosts: Iterable[str] = ()) -> Callable[[Request], Awaitable[None]]:
|
|
171
|
+
"""A dependency allowing only loopback clients that address a loopback ``Host``.
|
|
172
|
+
|
|
173
|
+
Checking the ``Host`` header defeats **DNS rebinding**: a web page on
|
|
174
|
+
``evil.example`` that re-points its own DNS name at ``127.0.0.1`` can make your
|
|
175
|
+
browser send requests to the local server, but they carry ``Host: evil.example``.
|
|
176
|
+
``allowed_hosts`` adds names (e.g. a ``/etc/hosts`` alias such as ``"devbox"``);
|
|
177
|
+
ports are ignored. (The ``access="local"`` policy does the same with the
|
|
178
|
+
``allowed_hosts`` setting.)
|
|
179
|
+
"""
|
|
180
|
+
extra = frozenset(h.strip().lower() for h in allowed_hosts)
|
|
181
|
+
|
|
182
|
+
async def guard(request: Request) -> None:
|
|
183
|
+
client = request.client.host if request.client else None
|
|
184
|
+
if not _is_loopback(client):
|
|
185
|
+
raise RedlineHTTPError(
|
|
186
|
+
403,
|
|
187
|
+
"forbidden",
|
|
188
|
+
"Redline is only reachable from localhost. Pass "
|
|
189
|
+
"install(app, dependencies=[Depends(your_auth)]) to expose it.",
|
|
190
|
+
)
|
|
191
|
+
if not is_local_host(request.headers.get("host"), extra):
|
|
192
|
+
raise RedlineHTTPError(
|
|
193
|
+
403,
|
|
194
|
+
"forbidden",
|
|
195
|
+
"Redline only answers requests addressed to localhost, 127.0.0.1 or [::1] "
|
|
196
|
+
"(this request's Host header names another site, as in a DNS-rebinding "
|
|
197
|
+
"attack). Use loopback_guard(allowed_hosts=[...]) to allow another name.",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return guard
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#: Loopback clients, loopback ``Host`` names (what ``access="local"`` enforces).
|
|
204
|
+
local_only = loopback_guard()
|
|
205
|
+
local_only.__name__ = "local_only"
|
|
206
|
+
local_only.__doc__ = "Allow only loopback clients addressing a loopback Host."
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def csrf_guard(request: Request) -> None:
|
|
210
|
+
"""Reject cross-site requests to the mutating endpoints.
|
|
211
|
+
|
|
212
|
+
Browsers attach an ``Origin`` header to every cross-origin and every same-origin
|
|
213
|
+
non-GET request, so it must match the host the request was addressed to. Requests
|
|
214
|
+
without ``Origin`` (curl, scripts) cannot be forged by a web page and are allowed.
|
|
215
|
+
``Sec-Fetch-Site: cross-site`` is rejected too. The access gate applies the same rule
|
|
216
|
+
to every Redline request.
|
|
217
|
+
"""
|
|
218
|
+
if request.method in _SAFE:
|
|
219
|
+
return
|
|
220
|
+
if request.headers.get("sec-fetch-site") in ("cross-site", "same-site"):
|
|
221
|
+
raise RedlineHTTPError(403, "csrf_failed", "Cross-site request blocked.")
|
|
222
|
+
origin = request.headers.get("origin")
|
|
223
|
+
if origin is None:
|
|
224
|
+
return
|
|
225
|
+
allowed = {h.strip().lower() for h in (request.headers.get("host"),) if h}
|
|
226
|
+
netloc = urlsplit(origin).netloc.lower() if origin != "null" else ""
|
|
227
|
+
if netloc not in allowed:
|
|
228
|
+
raise RedlineHTTPError(403, "csrf_failed", "Cross-origin request blocked.")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Response serialization as ``serializer`` spans.
|
|
2
|
+
|
|
3
|
+
FastAPI validates and serializes the endpoint's return value in
|
|
4
|
+
``fastapi.routing.serialize_response`` (response-model validation + Pydantic dump, or
|
|
5
|
+
``jsonable_encoder`` without a response model). That one coroutine is wrapped: the span
|
|
6
|
+
covers all of it, nested models included. The wrapper is looked up as a module global by
|
|
7
|
+
FastAPI's request handler, so patching the attribute is enough; it is defensive about the
|
|
8
|
+
signature (keyword-only today, but only ``field``/``response_content`` are read, by name)
|
|
9
|
+
and costs one ``ContextVar.get()`` outside a traced request.
|
|
10
|
+
|
|
11
|
+
The span's callsite (a stack walk) is captured only when the serialization is over
|
|
12
|
+
``slow_serializer_ms``, i.e. when a ``slow_serializer`` issue can point at it: the endpoint
|
|
13
|
+
has already returned when FastAPI serializes, so a fast span's stack says nothing useful.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, get_args, get_origin
|
|
21
|
+
|
|
22
|
+
from redline_core import record_span
|
|
23
|
+
from redline_core.recorder import _trace
|
|
24
|
+
|
|
25
|
+
__all__ = ["instrument_serialization", "uninstrument_serialization"]
|
|
26
|
+
|
|
27
|
+
_perf = time.perf_counter
|
|
28
|
+
_originals: dict[str, Any] = {}
|
|
29
|
+
_SEQUENCES = (list, tuple, set, frozenset)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _type_name(annotation: Any) -> str | None:
|
|
33
|
+
"""``Order`` for ``Order``, ``list[Order]``, ``Optional[Order]``…"""
|
|
34
|
+
if annotation is None:
|
|
35
|
+
return None
|
|
36
|
+
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
37
|
+
if get_origin(annotation) is not None and args:
|
|
38
|
+
return _type_name(args[0])
|
|
39
|
+
return getattr(annotation, "__name__", None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _describe(field: Any, content: Any) -> tuple[str, int | None]:
|
|
43
|
+
"""``(model name, item count or None)`` for one serialization."""
|
|
44
|
+
items = len(content) if isinstance(content, _SEQUENCES) else None
|
|
45
|
+
annotation = getattr(field, "type_", None)
|
|
46
|
+
if annotation is None:
|
|
47
|
+
info = getattr(field, "field_info", None)
|
|
48
|
+
annotation = getattr(info, "annotation", None)
|
|
49
|
+
name = _type_name(annotation)
|
|
50
|
+
if name is None:
|
|
51
|
+
sample = content[0] if items else content
|
|
52
|
+
name = type(sample).__name__ if items != 0 else "list"
|
|
53
|
+
return name, items
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def instrument_serialization() -> bool:
|
|
57
|
+
"""Patch ``fastapi.routing.serialize_response``; ``False`` if it is not there. Idempotent."""
|
|
58
|
+
try:
|
|
59
|
+
from fastapi import routing
|
|
60
|
+
except ImportError: # pragma: no cover - fastapi is this package's dependency
|
|
61
|
+
return False
|
|
62
|
+
original = getattr(routing, "serialize_response", None)
|
|
63
|
+
if original is None:
|
|
64
|
+
return False
|
|
65
|
+
if _originals:
|
|
66
|
+
return True
|
|
67
|
+
_originals["serialize_response"] = original
|
|
68
|
+
|
|
69
|
+
@functools.wraps(original)
|
|
70
|
+
async def serialize_response(*args: Any, **kwargs: Any) -> Any:
|
|
71
|
+
builder = _trace.get()
|
|
72
|
+
if builder is None:
|
|
73
|
+
return await original(*args, **kwargs)
|
|
74
|
+
start = _perf()
|
|
75
|
+
try:
|
|
76
|
+
return await original(*args, **kwargs)
|
|
77
|
+
finally:
|
|
78
|
+
duration = _perf() - start
|
|
79
|
+
try:
|
|
80
|
+
name, items = _describe(kwargs.get("field"), kwargs.get("response_content"))
|
|
81
|
+
except Exception:
|
|
82
|
+
name, items = "response", None
|
|
83
|
+
slow = duration * 1000.0 >= builder.recorder.settings.slow_serializer_ms
|
|
84
|
+
record_span(
|
|
85
|
+
"serializer",
|
|
86
|
+
f"serialize {name}",
|
|
87
|
+
start,
|
|
88
|
+
duration,
|
|
89
|
+
{
|
|
90
|
+
"library": "pydantic",
|
|
91
|
+
"serializer": name,
|
|
92
|
+
"items": items,
|
|
93
|
+
"many": items is not None,
|
|
94
|
+
},
|
|
95
|
+
slow,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
routing.serialize_response = serialize_response
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def uninstrument_serialization() -> None:
|
|
103
|
+
"""Undo :func:`instrument_serialization`."""
|
|
104
|
+
if not _originals:
|
|
105
|
+
return
|
|
106
|
+
from fastapi import routing
|
|
107
|
+
|
|
108
|
+
routing.serialize_response = _originals.pop("serialize_response")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Compatibility shim: the implementation lives in :mod:`redline_core.contrib.sqlalchemy`."""
|
|
2
|
+
|
|
3
|
+
from redline_core.contrib.sqlalchemy import (
|
|
4
|
+
EngineLike,
|
|
5
|
+
alias_for,
|
|
6
|
+
build_executor,
|
|
7
|
+
instrument_engine,
|
|
8
|
+
name_engines,
|
|
9
|
+
vendor_of,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"EngineLike",
|
|
14
|
+
"alias_for",
|
|
15
|
+
"build_executor",
|
|
16
|
+
"instrument_engine",
|
|
17
|
+
"name_engines",
|
|
18
|
+
"vendor_of",
|
|
19
|
+
]
|