Skip to content

Commit c7739eb

Browse files
authored
feat(appsec): enable request blocking (#630)
1 parent 664124e commit c7739eb

File tree

4 files changed

+258
-9
lines changed

4 files changed

+258
-9
lines changed

datadog_lambda/asm.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
from copy import deepcopy
21
import logging
2+
import urllib.parse
3+
from copy import deepcopy
34
from typing import Any, Dict, List, Optional, Union
45

56
from ddtrace.contrib.internal.trace_utils import _get_request_header_client_ip
67
from ddtrace.internal import core
8+
from ddtrace.internal.utils import get_blocked
9+
from ddtrace.internal.utils import http as http_utils
710
from ddtrace.trace import Span
811

912
from datadog_lambda.trigger import (
@@ -50,6 +53,7 @@ def asm_set_context(event_source: _EventSource):
5053
This allows the AppSecSpanProcessor to know information about the event
5154
at the moment the span is created and skip it when not relevant.
5255
"""
56+
5357
if event_source.event_type not in _http_event_types:
5458
core.set_item("appsec_skip_next_lambda_event", True)
5559

@@ -126,6 +130,14 @@ def asm_start_request(
126130
span.set_tag_str("http.client_ip", request_ip)
127131
span.set_tag_str("network.client.ip", request_ip)
128132

133+
# Encode the parsed query and append it to reconstruct the original raw URI expected by AppSec.
134+
if parsed_query:
135+
try:
136+
encoded_query = urllib.parse.urlencode(parsed_query, doseq=True)
137+
raw_uri += "?" + encoded_query # type: ignore
138+
except Exception:
139+
pass
140+
129141
core.dispatch(
130142
# The matching listener is registered in ddtrace.appsec._handlers
131143
"aws_lambda.start_request",
@@ -182,3 +194,36 @@ def asm_start_response(
182194
response_headers,
183195
),
184196
)
197+
198+
199+
def get_asm_blocked_response(
200+
event_source: _EventSource,
201+
) -> Optional[Dict[str, Any]]:
202+
"""Get the blocked response for the given event source."""
203+
if event_source.event_type not in _http_event_types:
204+
return None
205+
206+
blocked = get_blocked()
207+
if not blocked:
208+
return None
209+
210+
desired_type = blocked.get("type", "auto")
211+
if desired_type == "none":
212+
content_type = "text/plain; charset=utf-8"
213+
content = ""
214+
else:
215+
content_type = blocked.get("content-type", "application/json")
216+
content = http_utils._get_blocked_template(content_type)
217+
218+
response_headers = {
219+
"content-type": content_type,
220+
}
221+
if "location" in blocked:
222+
response_headers["location"] = blocked["location"]
223+
224+
return {
225+
"statusCode": blocked.get("status_code", 403),
226+
"headers": response_headers,
227+
"body": content,
228+
"isBase64Encoded": False,
229+
}

datadog_lambda/wrapper.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from importlib import import_module
1010
from time import time_ns
1111

12-
from datadog_lambda.asm import asm_set_context, asm_start_response, asm_start_request
12+
from ddtrace.internal._exceptions import BlockingException
1313
from datadog_lambda.extension import should_use_extension, flush_extension
1414
from datadog_lambda.cold_start import (
1515
set_cold_start,
@@ -46,6 +46,14 @@
4646
extract_http_status_code_tag,
4747
)
4848

49+
if config.appsec_enabled:
50+
from datadog_lambda.asm import (
51+
asm_set_context,
52+
asm_start_response,
53+
asm_start_request,
54+
get_asm_blocked_response,
55+
)
56+
4957
if config.profiling_enabled:
5058
from ddtrace.profiling import profiler
5159

@@ -120,6 +128,7 @@ def __init__(self, func):
120128
self.span = None
121129
self.inferred_span = None
122130
self.response = None
131+
self.blocking_response = None
123132

124133
if config.profiling_enabled:
125134
self.prof = profiler.Profiler(env=config.env, service=config.service)
@@ -159,8 +168,12 @@ def __call__(self, event, context, **kwargs):
159168
"""Executes when the wrapped function gets called"""
160169
self._before(event, context)
161170
try:
171+
if self.blocking_response:
172+
return self.blocking_response
162173
self.response = self.func(event, context, **kwargs)
163174
return self.response
175+
except BlockingException:
176+
self.blocking_response = get_asm_blocked_response(self.event_source)
164177
except Exception:
165178
from datadog_lambda.metric import submit_errors_metric
166179

@@ -171,6 +184,8 @@ def __call__(self, event, context, **kwargs):
171184
raise
172185
finally:
173186
self._after(event, context)
187+
if self.blocking_response:
188+
return self.blocking_response
174189

175190
def _inject_authorizer_span_headers(self, request_id):
176191
reference_span = self.inferred_span if self.inferred_span else self.span
@@ -203,6 +218,7 @@ def _inject_authorizer_span_headers(self, request_id):
203218
def _before(self, event, context):
204219
try:
205220
self.response = None
221+
self.blocking_response = None
206222
set_cold_start(init_timestamp_ns)
207223

208224
if not should_use_extension:
@@ -253,6 +269,7 @@ def _before(self, event, context):
253269
)
254270
if config.appsec_enabled:
255271
asm_start_request(self.span, event, event_source, self.trigger_tags)
272+
self.blocking_response = get_asm_blocked_response(self.event_source)
256273
else:
257274
set_correlation_ids()
258275
if config.profiling_enabled and is_new_sandbox():
@@ -286,13 +303,14 @@ def _after(self, event, context):
286303
if status_code:
287304
self.span.set_tag("http.status_code", status_code)
288305

289-
if config.appsec_enabled:
306+
if config.appsec_enabled and not self.blocking_response:
290307
asm_start_response(
291308
self.span,
292309
status_code,
293310
self.event_source,
294311
response=self.response,
295312
)
313+
self.blocking_response = get_asm_blocked_response(self.event_source)
296314

297315
self.span.finish()
298316

tests/test_asm.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22
import pytest
33
from unittest.mock import MagicMock, patch
44

5-
from datadog_lambda.asm import asm_start_request, asm_start_response
6-
from datadog_lambda.trigger import parse_event_source, extract_trigger_tags
5+
from datadog_lambda.asm import (
6+
asm_start_request,
7+
asm_start_response,
8+
get_asm_blocked_response,
9+
)
10+
from datadog_lambda.trigger import (
11+
EventTypes,
12+
_EventSource,
13+
extract_trigger_tags,
14+
parse_event_source,
15+
)
716
from tests.utils import get_mock_context
817

918
event_samples = "tests/event_samples/"
@@ -15,7 +24,7 @@
1524
"application_load_balancer",
1625
"application-load-balancer.json",
1726
"72.12.164.125",
18-
"/lambda",
27+
"/lambda?query=1234ABCD",
1928
"GET",
2029
"",
2130
False,
@@ -27,7 +36,7 @@
2736
"application_load_balancer_multivalue_headers",
2837
"application-load-balancer-mutivalue-headers.json",
2938
"72.12.164.125",
30-
"/lambda",
39+
"/lambda?query=1234ABCD",
3140
"GET",
3241
"",
3342
False,
@@ -51,7 +60,7 @@
5160
"api_gateway",
5261
"api-gateway.json",
5362
"127.0.0.1",
54-
"/path/to/resource",
63+
"/path/to/resource?foo=bar",
5564
"POST",
5665
"eyJ0ZXN0IjoiYm9keSJ9",
5766
True,
@@ -199,6 +208,40 @@
199208
),
200209
]
201210

211+
ASM_BLOCKED_RESPONSE_TEST_CASES = [
212+
# JSON blocking response
213+
(
214+
{"status_code": 403, "type": "auto", "content-type": "application/json"},
215+
403,
216+
{"content-type": "application/json"},
217+
),
218+
# HTML blocking response
219+
(
220+
{
221+
"status_code": 401,
222+
"type": "html",
223+
"content-type": "text/html",
224+
},
225+
401,
226+
{"content-type": "text/html"},
227+
),
228+
# Plain text redirect response
229+
(
230+
{"status_code": 301, "type": "none", "location": "https://example.com/blocked"},
231+
301,
232+
{
233+
"content-type": "text/plain; charset=utf-8",
234+
"location": "https://example.com/blocked",
235+
},
236+
),
237+
# Default to content-type application/json and status code 403 when not provided
238+
(
239+
{"type": "auto"},
240+
403,
241+
{"content-type": "application/json"},
242+
),
243+
]
244+
202245

203246
@pytest.mark.parametrize(
204247
"name,file,expected_ip,expected_uri,expected_method,expected_body,expected_base64,expected_query,expected_path_params,expected_route",
@@ -327,3 +370,31 @@ def test_asm_start_response_parametrized(
327370
else:
328371
# Verify core.dispatch was not called for non-HTTP events
329372
mock_core.dispatch.assert_not_called()
373+
374+
375+
@pytest.mark.parametrize(
376+
"blocked_config, expected_status, expected_headers",
377+
ASM_BLOCKED_RESPONSE_TEST_CASES,
378+
)
379+
@patch("datadog_lambda.asm.get_blocked")
380+
def test_get_asm_blocked_response_blocked(
381+
mock_get_blocked,
382+
blocked_config,
383+
expected_status,
384+
expected_headers,
385+
):
386+
mock_get_blocked.return_value = blocked_config
387+
event_source = _EventSource(event_type=EventTypes.API_GATEWAY)
388+
response = get_asm_blocked_response(event_source)
389+
assert response["statusCode"] == expected_status
390+
assert response["headers"] == expected_headers
391+
392+
393+
@patch("datadog_lambda.asm.get_blocked")
394+
def test_get_asm_blocked_response_not_blocked(
395+
mock_get_blocked,
396+
):
397+
mock_get_blocked.return_value = None
398+
event_source = _EventSource(event_type=EventTypes.API_GATEWAY)
399+
response = get_asm_blocked_response(event_source)
400+
assert response is None

0 commit comments

Comments
 (0)