Skip to content

Commit 80d6ce3

Browse files
feat(AAP): add support for sub applications in FastAPI (#17570)
APPSEC-62352 ## Description Add Application & API Protection (AAP) support for FastAPI and Starlette applications using mounted sub-applications (`app.mount()`). Previously, when endpoints were defined on sub-applications, several issues occurred: - **Duplicate WAF invocations**: The WAF ran on both the intermediate `Mount` handler and the final `Route` handler, producing duplicate triggers. - **Duplicate tracing**: Each sub-app's `TraceMiddleware` created redundant processing (body parsing, distributed tracing activation, WAF dispatch). - **Incomplete path parameters**: `scope["path_params"]` was only partially populated when ASM processed the `Mount` handler. - **Incorrect endpoint discovery**: Routes in sub-apps were registered with their local paths (e.g., `/get`) instead of the full mounted path (e.g., `/api/v2/get`). ### Changes **`ddtrace/contrib/internal/asgi/middleware.py`** - Detect sub-app middleware via `is_subapp = "datadog" in scope`. Sub-app middlewares still create spans (for trace visibility), but skip body parsing, distributed tracing activation, and WAF dispatch to avoid duplicates. - Trigger endpoint discovery via route tree walk on first request (root middleware only). **`ddtrace/contrib/internal/starlette/patch.py`** - Gate ASM/WAF processing behind `isinstance(instance, starlette.routing.Route)` so it only runs on the final `Route` handler, not intermediate `Mount` handlers. - Replace init-time endpoint registration with `_collect_routes_from_app()`, a recursive route tree walker that accumulates mount prefixes for correct full-path registration. - Handle `Host`-based routing in the tree walker. - Per-route error handling in the walker to prevent one bad route from blocking discovery. **`ddtrace/appsec/_asm_request_context.py`** - Guard `start_context()` to skip creating a new ASM environment when `is_subapp=True` and an ASM context already exists from the parent. This preserves the parent's request data (body, headers, etc.) for the WAF instead of creating an empty child context that would shadow it. **Tests** - Parametrize the FastAPI appsec test suite with both flat (`get_app`) and sub-app (`get_app_with_subapps`) app variants via the `interface` fixture. - Add `app_subapps.py` with endpoints grouped into mounted sub-applications. - Reset `endpoint_collection` singleton between parametrized variants to prevent state leakage. ## Testing All test suites pass with both flat and sub-app variants: - `appsec_threats_fastapi_no_iast`: 2006 passed - `appsec_threats_fastapi_iast`: 2006 passed - `appsec_threats_fastapi_rc`: 10 passed - `contrib::fastapi`: 72 passed (snapshot tests unchanged) - `contrib::starlette`: 52 passed (snapshot tests unchanged) - `contrib::asgi`: 183 passed ## Risks - **Endpoint discovery timing**: Endpoint registration is now deferred to first request (was at `Route.__init__` time). This is necessary because mount prefixes are unknown at init time. The app tree is guaranteed complete at first request for all standard ASGI server setups. - **Trace shape**: Sub-app spans are preserved (child `starlette.request`/`fastapi.request` spans still appear), but they no longer carry body/query data or trigger WAF independently. Existing snapshot tests pass without changes. - **`is_subapp` propagation**: The flag is passed via `core.context_with_data` and checked in `start_context()`. Only affects mounted sub-apps where `scope["datadog"]` is already set by the parent middleware. ## Additional Notes - The `is_subapp` approach was chosen over a full middleware skip to preserve sub-app child spans in traces, maintaining backward compatibility with existing snapshot tests and regular monitoring behavior. Co-authored-by: christophe.papazian <christophe.papazian@datadoghq.com>
1 parent d02755c commit 80d6ce3

6 files changed

Lines changed: 683 additions & 61 deletions

File tree

ddtrace/appsec/_asm_request_context.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,12 @@ def store_waf_results_data(data: "list[WafEvent]") -> None:
576576

577577
def start_context(waf_callable: Optional[WafCallable], span: Span, rc_products: str) -> None:
578578
if asm_config._asm_enabled:
579-
# it should only be called at start of a core context, when ASM_Env is not set yet
579+
# Skip creating a new ASM context if one already exists in a parent context
580+
# AND this is a sub-app span (e.g., mounted FastAPI/Starlette sub-application).
581+
# The parent's ASM context already has the request data (body, headers, etc.)
582+
# and is accessible via core.find_item thanks to context tree traversal.
583+
if in_asm_context() and core.find_item("is_subapp"):
584+
return
580585
core.set_item(
581586
_ASM_CONTEXT,
582587
ASM_Environment(

ddtrace/contrib/internal/asgi/middleware.py

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,28 @@ async def __call__(self, scope: Mapping[str, Any], receive: Callable, send: Call
195195
- HTTP: asgi.request spans with HTTP metadata
196196
- websocket: websocket.receive, websocket.send, websocket.close spans
197197
"""
198+
# Track whether this is a sub-app middleware (parent already set up tracing).
199+
# Sub-app middlewares still create spans for visibility, but skip body parsing,
200+
# distributed tracing activation, HTTP meta, and WAF dispatch to avoid duplicates.
201+
is_subapp = "datadog" in scope
202+
203+
# On the first request to the root app, walk the route tree to register all
204+
# endpoints (including those in mounted sub-apps) for API endpoint discovery.
205+
# Only do this for the root app's middleware, not sub-app middlewares.
206+
# scope["app"] is set by Starlette before its middleware stack runs.
207+
if not is_subapp:
208+
root_app = scope.get("app")
209+
if root_app is not None and not getattr(root_app, "_datadog_endpoints_collected", False):
210+
# Set the flag before attempting collection to avoid retrying on every request
211+
# if the import or walk fails (e.g., starlette not patched, pure ASGI app).
212+
root_app._datadog_endpoints_collected = True
213+
try:
214+
from ddtrace.contrib.internal.starlette.patch import _collect_routes_from_app
215+
216+
_collect_routes_from_app(root_app)
217+
except Exception:
218+
log.debug("failed to collect routes from app for endpoint discovery", exc_info=True)
219+
198220
if scope["type"] == "http":
199221
method = scope["method"]
200222
elif scope["type"] == "websocket" and self.integration_config.trace_asgi_websocket_messages:
@@ -207,9 +229,10 @@ async def __call__(self, scope: Mapping[str, Any], receive: Callable, send: Call
207229
log.warning("failed to decode headers for distributed tracing", exc_info=True)
208230
headers = {}
209231
else:
210-
trace_utils.activate_distributed_headers(
211-
tracer, int_config=self.integration_config, request_headers=headers
212-
)
232+
if not is_subapp:
233+
trace_utils.activate_distributed_headers(
234+
tracer, int_config=self.integration_config, request_headers=headers
235+
)
213236
resource = " ".join([method, scope["path"]])
214237

215238
# in the case of websockets we don't currently schematize the operation names
@@ -233,6 +256,7 @@ async def __call__(self, scope: Mapping[str, Any], receive: Callable, send: Call
233256
activate_distributed_headers=True,
234257
scope=scope,
235258
integration_config=self.integration_config,
259+
is_subapp=is_subapp,
236260
) as ctx,
237261
ctx.span as span,
238262
):
@@ -281,30 +305,34 @@ async def __call__(self, scope: Mapping[str, Any], receive: Callable, send: Call
281305
raw_url = f"{raw_url}?{query_string}"
282306
if not self.integration_config.trace_query_string:
283307
query_string = None
308+
# Sub-app middlewares skip body parsing since it's already handled
309+
# by the parent app's middleware. HTTP meta and version tags are still
310+
# set on the child span for visibility.
284311
body = None
285-
result = core.dispatch_with_results( # ast-grep-ignore: core-dispatch-with-results
286-
"asgi.request.parse.body", (receive, headers)
287-
).await_receive_and_body
288-
if result:
289-
receive, body = await result.value
290-
291-
client = scope.get("client")
292-
# Both list and tuple must be supported for scope["client"].
293-
# In Startlette's ASGI implementation, it is a 2-item tuple (host, port). Other implementations
294-
# may use a list, and Starlette's own testing code often uses a 2-item list here.
295-
if isinstance(client, (list, tuple)) and len(client) and is_valid_ip(client[0]):
296-
peer_ip = client[0]
297-
else:
298-
peer_ip = None
312+
peer_ip = None
313+
if not is_subapp:
314+
result = core.dispatch_with_results( # ast-grep-ignore: core-dispatch-with-results
315+
"asgi.request.parse.body", (receive, headers)
316+
).await_receive_and_body
317+
if result:
318+
receive, body = await result.value
319+
320+
client = scope.get("client")
321+
# Both list and tuple must be supported for scope["client"].
322+
# In Startlette's ASGI implementation, it is a 2-item tuple (host, port). Other implementations
323+
# may use a list, and Starlette's own testing code often uses a 2-item list here.
324+
if isinstance(client, (list, tuple)) and len(client) and is_valid_ip(client[0]):
325+
peer_ip = client[0]
326+
299327
trace_utils.set_http_meta(
300328
span,
301329
self.integration_config,
302330
method=method,
303331
url=url,
304332
query=query_string,
305333
request_headers=headers,
306-
raw_uri=raw_url,
307-
parsed_query=parsed_query,
334+
raw_uri=raw_url if not is_subapp else None,
335+
parsed_query=parsed_query if not is_subapp else None,
308336
request_body=body,
309337
peer_ip=peer_ip,
310338
headers_are_case_sensitive=True,
@@ -474,7 +502,8 @@ async def wrapped_blocked_send(message: Mapping[str, Any]):
474502

475503
wrapped_recv = wrapped_receive if scope["type"] == "websocket" else receive
476504
try:
477-
core.dispatch("asgi.start_request", ("asgi",))
505+
if not is_subapp:
506+
core.dispatch("asgi.start_request", ("asgi",))
478507
# Do not block right here. Wait for route to be resolved in starlette/patch.py
479508
return await self.app(scope, wrapped_recv, wrapped_send)
480509
except BlockingException as e:

ddtrace/contrib/internal/starlette/patch.py

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,53 @@ def traced_init(wrapped, instance, args, kwargs):
7777

7878

7979
def traced_route_init(wrapped, _instance, args, kwargs):
80-
route = args[0] if args else None
81-
if route is not None:
82-
response_body_type = getattr(kwargs.get("response_class", None), "media_type", None)
83-
response_body_type = [response_body_type] if isinstance(response_body_type, str) else []
84-
response_code = kwargs.get("status_code", None)
85-
response_code = [response_code] if isinstance(response_code, int) else []
86-
for m in kwargs.get("methods", None) or []:
87-
endpoint_collection.add_endpoint(
88-
m,
89-
route,
90-
operation_name="fastapi.request",
91-
response_body_type=response_body_type,
92-
response_code=response_code,
93-
)
80+
# Endpoint registration for the endpoint_collection is NOT done here because at
81+
# Route.__init__ time, we don't know the mount prefix for sub-app routes.
82+
# Instead, _collect_routes_from_app walks the full route tree on first request
83+
# and registers endpoints with their complete paths (mount prefix + local route).
9484
handler = get_argument_value(args, kwargs, 1, "endpoint")
9585
core.dispatch("service_entrypoint.patch", (inspect.unwrap(handler),))
9686
return wrapped(*args, **kwargs)
9787

9888

89+
def _collect_routes_from_app(app, prefix=""):
90+
"""Walk an ASGI app's route tree and register all endpoints with their full paths.
91+
92+
Called once on first request via the ASGI TraceMiddleware. At that point the app
93+
is fully constructed (all mounts done). Endpoint registration cannot happen at
94+
Route.__init__ time because the mount prefix is unknown then (sub-apps are
95+
created before being mounted).
96+
"""
97+
routes = getattr(app, "routes", None)
98+
if not routes:
99+
return
100+
for route in routes:
101+
try:
102+
if isinstance(route, starlette.routing.Mount):
103+
mount_path = prefix + route.path
104+
_collect_routes_from_app(route, prefix=mount_path)
105+
elif hasattr(starlette.routing, "Host") and isinstance(route, starlette.routing.Host):
106+
# Host-based routing: recurse into the host's app without adding a path prefix
107+
_collect_routes_from_app(route, prefix=prefix)
108+
elif isinstance(route, starlette.routing.Route):
109+
full_path = prefix + route.path
110+
response_class = getattr(route, "response_class", None)
111+
media_type = getattr(response_class, "media_type", None)
112+
response_body_type = [media_type] if isinstance(media_type, str) else []
113+
response_code = getattr(route, "status_code", None)
114+
response_code = [response_code] if isinstance(response_code, int) else []
115+
for m in getattr(route, "methods", None) or []:
116+
endpoint_collection.add_endpoint(
117+
m,
118+
full_path,
119+
operation_name="fastapi.request",
120+
response_body_type=response_body_type,
121+
response_code=response_code,
122+
)
123+
except Exception:
124+
log.debug("failed to collect endpoint for route %r", route, exc_info=True)
125+
126+
99127
def patch():
100128
if getattr(starlette, "_datadog_patch", False):
101129
return
@@ -189,29 +217,33 @@ def traced_handler(wrapped, instance, args, kwargs):
189217
request_spans,
190218
resource_paths,
191219
)
192-
request_cookies = ""
193-
for name, value in scope.get("headers", []):
194-
if name == b"cookie":
195-
request_cookies = value.decode("utf-8", errors="ignore")
196-
break
197-
198-
if request_spans:
199-
if asm_config._iast_enabled:
200-
from ddtrace.appsec._iast._handlers import _iast_instrument_starlette_scope
201-
202-
_iast_instrument_starlette_scope(scope, request_spans[0].get_tag(http.ROUTE))
203-
204-
trace_utils.set_http_meta(
205-
request_spans[0],
206-
"starlette",
207-
request_path_params=scope.get("path_params"),
208-
request_cookies=starlette_requests.cookie_parser(request_cookies),
209-
route=request_spans[0].get_tag(http.ROUTE),
210-
)
211-
core.dispatch("asgi.start_request", ("starlette",))
212-
blocked = get_blocked()
213-
if blocked:
214-
raise BlockingException(blocked)
220+
# Only run ASM/WAF processing on the final Route handler, not on intermediate Mount handlers.
221+
# With sub-applications, traced_handler is called for each routing layer (Mount then Route).
222+
# Running ASM on Mount would cause duplicate WAF triggers and incomplete path_params.
223+
if isinstance(instance, starlette.routing.Route):
224+
request_cookies = ""
225+
for name, value in scope.get("headers", []):
226+
if name == b"cookie":
227+
request_cookies = value.decode("utf-8", errors="ignore")
228+
break
229+
230+
if request_spans:
231+
if asm_config._iast_enabled:
232+
from ddtrace.appsec._iast._handlers import _iast_instrument_starlette_scope
233+
234+
_iast_instrument_starlette_scope(scope, request_spans[0].get_tag(http.ROUTE))
235+
236+
trace_utils.set_http_meta(
237+
request_spans[0],
238+
"starlette",
239+
request_path_params=scope.get("path_params"),
240+
request_cookies=starlette_requests.cookie_parser(request_cookies),
241+
route=request_spans[0].get_tag(http.ROUTE),
242+
)
243+
core.dispatch("asgi.start_request", ("starlette",))
244+
blocked = get_blocked()
245+
if blocked:
246+
raise BlockingException(blocked)
215247

216248
# https://github.com/encode/starlette/issues/1336
217249
if _STARLETTE_VERSION_LTE_0_33_0 and len(request_spans) > 1:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
features:
3+
- |
4+
AAP: This adds Application Security support for FastAPI and Starlette applications using
5+
mounted sub-applications (via ``app.mount()``). WAF evaluation, path parameter extraction,
6+
API endpoint discovery, and ``http.route`` reporting now correctly account for mount prefixes
7+
in sub-application routing.

0 commit comments

Comments
 (0)