Skip to content

Commit ce71536

Browse files
committed
feat(appsec): enable request blocking
1 parent d2a195e commit ce71536

File tree

4 files changed

+246
-9
lines changed

4 files changed

+246
-9
lines changed

datadog_lambda/asm.py

Lines changed: 47 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, set_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+
except Exception:
138+
pass
139+
raw_uri += "?" + encoded_query # type: ignore
140+
129141
core.dispatch(
130142
# The matching listener is registered in ddtrace.appsec._handlers
131143
"aws_lambda.start_request",
@@ -182,3 +194,37 @@ 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+
set_blocked(blocked)
210+
211+
desired_type = blocked.get("type", "auto")
212+
if desired_type == "none":
213+
content_type = "text/plain; charset=utf-8"
214+
content = ""
215+
else:
216+
content_type = blocked.get("content-type", "application/json")
217+
content = http_utils._get_blocked_template(content_type)
218+
219+
response_headers = {
220+
"content-type": content_type,
221+
}
222+
if "location" in blocked:
223+
response_headers["location"] = blocked["location"]
224+
225+
return {
226+
"statusCode": blocked.get("status_code", 403),
227+
"headers": response_headers,
228+
"body": content,
229+
"isBase64Encoded": False,
230+
}

datadog_lambda/wrapper.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
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
13+
from datadog_lambda.asm import (
14+
asm_set_context,
15+
asm_start_response,
16+
asm_start_request,
17+
get_asm_blocked_response,
18+
)
1319
from datadog_lambda.extension import should_use_extension, flush_extension
1420
from datadog_lambda.cold_start import (
1521
set_cold_start,
@@ -120,6 +126,7 @@ def __init__(self, func):
120126
self.span = None
121127
self.inferred_span = None
122128
self.response = None
129+
self.blocking_response = None
123130

124131
if config.profiling_enabled:
125132
self.prof = profiler.Profiler(env=config.env, service=config.service)
@@ -155,12 +162,21 @@ def __init__(self, func):
155162
except Exception as e:
156163
logger.error(format_err_with_traceback(e))
157164

165+
def _get_blocking_response(self):
166+
if not config.appsec_enabled:
167+
return None
168+
return get_asm_blocked_response(self.event_source)
169+
158170
def __call__(self, event, context, **kwargs):
159171
"""Executes when the wrapped function gets called"""
160172
self._before(event, context)
161173
try:
174+
if self.blocking_response:
175+
return self.blocking_response
162176
self.response = self.func(event, context, **kwargs)
163177
return self.response
178+
except BlockingException:
179+
self.blocking_response = self._get_blocking_response()
164180
except Exception:
165181
from datadog_lambda.metric import submit_errors_metric
166182

@@ -171,6 +187,8 @@ def __call__(self, event, context, **kwargs):
171187
raise
172188
finally:
173189
self._after(event, context)
190+
if self.blocking_response:
191+
return self.blocking_response
174192

175193
def _inject_authorizer_span_headers(self, request_id):
176194
reference_span = self.inferred_span if self.inferred_span else self.span
@@ -203,6 +221,7 @@ def _inject_authorizer_span_headers(self, request_id):
203221
def _before(self, event, context):
204222
try:
205223
self.response = None
224+
self.blocking_response = None
206225
set_cold_start(init_timestamp_ns)
207226

208227
if not should_use_extension:
@@ -253,6 +272,7 @@ def _before(self, event, context):
253272
)
254273
if config.appsec_enabled:
255274
asm_start_request(self.span, event, event_source, self.trigger_tags)
275+
self.blocking_response = self._get_blocking_response()
256276
else:
257277
set_correlation_ids()
258278
if config.profiling_enabled and is_new_sandbox():
@@ -286,13 +306,14 @@ def _after(self, event, context):
286306
if status_code:
287307
self.span.set_tag("http.status_code", status_code)
288308

289-
if config.appsec_enabled:
309+
if config.appsec_enabled and not self.blocking_response:
290310
asm_start_response(
291311
self.span,
292312
status_code,
293313
self.event_source,
294314
response=self.response,
295315
)
316+
self.blocking_response = self._get_blocking_response()
296317

297318
self.span.finish()
298319

tests/test_asm.py

Lines changed: 66 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,30 @@
199208
),
200209
]
201210

211+
ASM_BLOCKED_RESPONSE_TEST_CASES = [
212+
(
213+
{"status_code": 403, "type": "auto"},
214+
403,
215+
{"content-type": "application/json"},
216+
),
217+
(
218+
{"status_code": 401, "content-type": "text/html", "location": "/login"},
219+
401,
220+
{"content-type": "text/html", "location": "/login"},
221+
),
222+
(
223+
{"status_code": 301, "type": "none", "location": "/redirect"},
224+
301,
225+
{"content-type": "text/plain; charset=utf-8", "location": "/redirect"},
226+
),
227+
(
228+
{"status_code": 302, "location": "https://datadoghq.com"},
229+
302,
230+
{"content-type": "application/json", "location": "https://datadoghq.com"},
231+
),
232+
({"type": "auto"}, 403, {"content-type": "application/json"}),
233+
]
234+
202235

203236
@pytest.mark.parametrize(
204237
"name,file,expected_ip,expected_uri,expected_method,expected_body,expected_base64,expected_query,expected_path_params,expected_route",
@@ -327,3 +360,31 @@ def test_asm_start_response_parametrized(
327360
else:
328361
# Verify core.dispatch was not called for non-HTTP events
329362
mock_core.dispatch.assert_not_called()
363+
364+
365+
@pytest.mark.parametrize(
366+
"blocked_config, expected_status, expected_headers",
367+
ASM_BLOCKED_RESPONSE_TEST_CASES,
368+
)
369+
@patch("datadog_lambda.asm.get_blocked")
370+
def test_get_asm_blocked_response_blocked(
371+
mock_get_blocked,
372+
blocked_config,
373+
expected_status,
374+
expected_headers,
375+
):
376+
mock_get_blocked.return_value = blocked_config
377+
event_source = _EventSource(event_type=EventTypes.API_GATEWAY)
378+
response = get_asm_blocked_response(event_source)
379+
assert response["statusCode"] == expected_status
380+
assert response["headers"] == expected_headers
381+
382+
383+
@patch("datadog_lambda.asm.get_blocked")
384+
def test_get_asm_blocked_response_not_blocked(
385+
mock_get_blocked,
386+
):
387+
mock_get_blocked.return_value = None
388+
event_source = _EventSource(event_type=EventTypes.API_GATEWAY)
389+
response = get_asm_blocked_response(event_source)
390+
assert response is None

tests/test_wrapper.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
import unittest
55

6-
from unittest.mock import patch, call, ANY
6+
from unittest.mock import MagicMock, patch, call, ANY
77
from datadog_lambda.constants import TraceHeader
88

99
import datadog_lambda.wrapper as wrapper
@@ -660,3 +660,112 @@ def lambda_handler(event, context):
660660
lambda_handler(lambda_event, lambda_context)
661661

662662
self.assertEqual(len(flushes), 0)
663+
664+
665+
class TestLambdaWrapperAppsecBlocking(unittest.TestCase):
666+
def setUp(self):
667+
os.environ["DD_APPSEC_ENABLED"] = "true"
668+
os.environ["DD_TRACE_ENABLED"] = "true"
669+
670+
self.addCleanup(os.environ.pop, "DD_APPSEC_ENABLED", None)
671+
self.addCleanup(os.environ.pop, "DD_TRACE_ENABLED", None)
672+
673+
patcher = patch("datadog_lambda.wrapper.asm_set_context")
674+
self.mock_asm_set_context = patcher.start()
675+
self.addCleanup(patcher.stop)
676+
677+
patcher = patch("datadog_lambda.wrapper.asm_start_request")
678+
self.mock_asm_start_request = patcher.start()
679+
self.addCleanup(patcher.stop)
680+
681+
patcher = patch("datadog_lambda.wrapper.asm_start_response")
682+
self.mock_asm_start_response = patcher.start()
683+
self.addCleanup(patcher.stop)
684+
685+
patcher = patch("datadog_lambda.wrapper.get_asm_blocked_response")
686+
self.mock_get_asm_blocking_response = patcher.start()
687+
self.addCleanup(patcher.stop)
688+
689+
self.fake_blocking_response = {
690+
"statusCode": "403",
691+
"headers": {
692+
"Content-Type": "application/json",
693+
},
694+
"body": '{"message": "Blocked by AppSec"}',
695+
"isBase64Encoded": False,
696+
}
697+
698+
def test_blocking_before(self):
699+
self.mock_get_asm_blocking_response.return_value = self.fake_blocking_response
700+
701+
mock_handler = MagicMock()
702+
703+
lambda_handler = wrapper.datadog_lambda_wrapper(mock_handler)
704+
705+
response = lambda_handler({}, get_mock_context())
706+
self.assertEqual(response, self.fake_blocking_response)
707+
708+
mock_handler.assert_not_called()
709+
710+
self.mock_asm_set_context.assert_called_once()
711+
self.mock_asm_start_request.assert_called_once()
712+
self.mock_asm_start_response.assert_not_called()
713+
714+
def test_blocking_during(self):
715+
self.mock_get_asm_blocking_response.return_value = None
716+
717+
@wrapper.datadog_lambda_wrapper
718+
def lambda_handler(event, context):
719+
self.mock_get_asm_blocking_response.return_value = (
720+
self.fake_blocking_response
721+
)
722+
raise wrapper.BlockingException()
723+
724+
response = lambda_handler({}, get_mock_context())
725+
self.assertEqual(response, self.fake_blocking_response)
726+
727+
self.mock_asm_set_context.assert_called_once()
728+
self.mock_asm_start_request.assert_called_once()
729+
self.mock_asm_start_response.assert_not_called()
730+
731+
def test_blocking_after(self):
732+
self.mock_get_asm_blocking_response.return_value = None
733+
734+
@wrapper.datadog_lambda_wrapper
735+
def lambda_handler(event, context):
736+
self.mock_get_asm_blocking_response.return_value = (
737+
self.fake_blocking_response
738+
)
739+
return {
740+
"statusCode": 200,
741+
"body": "This should not be returned",
742+
}
743+
744+
response = lambda_handler({}, get_mock_context())
745+
self.assertEqual(response, self.fake_blocking_response)
746+
747+
self.mock_asm_set_context.assert_called_once()
748+
self.mock_asm_start_request.assert_called_once()
749+
self.mock_asm_start_response.assert_called_once()
750+
751+
def test_no_blocking_appsec_disabled(self):
752+
os.environ["DD_APPSEC_ENABLED"] = "false"
753+
754+
self.mock_get_asm_blocking_response.return_value = self.fake_blocking_response
755+
756+
expected_response = {
757+
"statusCode": 200,
758+
"body": "This should be returned",
759+
}
760+
761+
@wrapper.datadog_lambda_wrapper
762+
def lambda_handler(event, context):
763+
return expected_response
764+
765+
response = lambda_handler({}, get_mock_context())
766+
self.assertEqual(response, expected_response)
767+
768+
self.mock_get_asm_blocking_response.assert_not_called()
769+
self.mock_asm_set_context.assert_not_called()
770+
self.mock_asm_start_request.assert_not_called()
771+
self.mock_asm_start_response.assert_not_called()

0 commit comments

Comments
 (0)