Skip to content

Commit 5755849

Browse files
juanjuxgnufedeavara1986
authored
fix(asm): blocking response fixes post RC1 (#5264)
## Description - Ensure a subset of tags are always set on blocked requests. - Fix Flask sending headers before the actual blocking response on Suspicious Request matches. - Ensure the content-type is correctly set on blocking response. Thanks @christophe-papazian for the help. ## Checklist - [X] Change(s) are motivated and described in the PR description. - [X] Testing strategy is described if automated tests are not included in the PR. - [X] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [X] Change is maintainable (easy to change, telemetry, documentation). - [X] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/contributing.html#Release-Note-Guidelines) are followed. - [X] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [X] Author is aware of the performance implications of this PR as reported in the benchmarks PR comment. ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer is aware of, and discussed the performance implications of this PR as reported in the benchmarks PR comment. --------- Signed-off-by: Juanjo Alvarez <[email protected]> Co-authored-by: Federico Mon <[email protected]> Co-authored-by: Alberto Vara <[email protected]>
1 parent 807c189 commit 5755849

16 files changed

+445
-214
lines changed

ddtrace/contrib/asgi/middleware.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ async def wrapped_send(message):
224224
span.finish()
225225

226226
async def wrapped_blocked_send(message):
227-
accept_header = headers.get("accept")
227+
accept_header = (
228+
"text/html"
229+
if "text/html" in _asm_request_context.get_headers().get("accept", "").lower()
230+
else "text/json"
231+
)
228232
if span and message.get("type") == "http.response.start":
229233
message["headers"] = [
230234
(

ddtrace/contrib/django/patch.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from . import utils
4343
from .. import trace_utils
4444
from ...internal.utils import get_argument_value
45+
from ..trace_utils import _get_request_header_user_agent
46+
from ..trace_utils import _set_url_tag
4547

4648

4749
log = get_logger(__name__)
@@ -354,8 +356,27 @@ def wrapped_factory(func, instance, args, kwargs):
354356
return func(*args, **kwargs)
355357

356358

357-
def _block_request_callable(span):
359+
def _set_block_tags(request, request_headers, span):
360+
try:
361+
span.set_tag_str(http.STATUS_CODE, "403")
362+
span.set_tag_str(http.METHOD, request.method)
363+
url = utils.get_request_uri(request)
364+
query = request.META.get("QUERY_STRING", "")
365+
_set_url_tag(config.django, span, url, query)
366+
if query and config.django.trace_query_string:
367+
span.set_tag_str(http.QUERY_STRING, query)
368+
user_agent = _get_request_header_user_agent(request_headers)
369+
if user_agent:
370+
span.set_tag_str(http.USER_AGENT, user_agent)
371+
except Exception as e:
372+
log.warning("Could not set some span tags on blocked request: %s", str(e)) # noqa: G200
373+
374+
375+
def _block_request_callable(request, request_headers, span):
376+
# This is used by user-id blocking to block responses. It could be called
377+
# at any point so it's a callable stored in the ASM context.
358378
_context.set_item("http.request.blocked", True, span=span)
379+
_set_block_tags(request, request_headers, span)
359380
raise PermissionDenied()
360381

361382

@@ -387,7 +408,9 @@ def traced_get_response(django, pin, func, instance, args, kwargs):
387408
service=trace_utils.int_service(pin, config.django),
388409
span_type=SpanTypes.WEB,
389410
) as span:
390-
_asm_request_context.set_block_request_callable(functools.partial(_block_request_callable, span))
411+
_asm_request_context.set_block_request_callable(
412+
functools.partial(_block_request_callable, request, request_headers, span)
413+
)
391414
span.set_tag_str(COMPONENT, config.django.integration_name)
392415

393416
# set span.kind to the type of request being performed
@@ -399,10 +422,11 @@ def traced_get_response(django, pin, func, instance, args, kwargs):
399422
response = None
400423

401424
def blocked_response():
402-
response = HttpResponseForbidden(
403-
appsec_utils._get_blocked_template(request_headers.get("Accept")), content_type="text/plain"
404-
)
405-
response.content = appsec_utils._get_blocked_template(request_headers.get("Accept"))
425+
ctype = "text/html" if "text/html" in request_headers.get("Accept", "").lower() else "text/json"
426+
content = appsec_utils._get_blocked_template(ctype)
427+
response = HttpResponseForbidden(content, content_type=ctype)
428+
response.content = content
429+
utils._after_request_tags(pin, span, request, response)
406430
return response
407431

408432
try:
@@ -460,7 +484,6 @@ def blocked_response():
460484
# [Suspicious Request Blocking on response]
461485
if _context.get_item("http.request.blocked", span=span):
462486
response = blocked_response()
463-
utils._after_request_tags(pin, span, request, response)
464487
return response
465488

466489

ddtrace/contrib/flask/patch.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@
3434
from ...constants import SPAN_MEASURED_KEY
3535
from ...contrib.wsgi.wsgi import _DDWSGIMiddlewareBase
3636
from ...ext import SpanTypes
37+
from ...ext import http
3738
from ...internal.compat import maybe_stringify
3839
from ...internal.logger import get_logger
3940
from ...internal.utils import get_argument_value
4041
from ...internal.utils.version import parse_version
42+
from ..trace_utils import _get_request_header_user_agent
43+
from ..trace_utils import _set_url_tag
4144
from ..trace_utils import unwrap as _u
4245
from .helpers import get_current_app
4346
from .helpers import simple_tracer
@@ -123,13 +126,23 @@ def _traced_start_response(self, start_response, req_span, app_span, status_code
123126
req_span, config.flask, status_code=code, response_headers=headers, route=req_span.get_tag(FLASK_URL_RULE)
124127
)
125128

126-
result = start_response(status_code, headers)
127129
if config._appsec_enabled and not _context.get_item("http.request.blocked", span=req_span):
128130
log.debug("Flask WAF call for Suspicious Request Blocking on response")
129131
_asm_request_context.call_waf_callback()
130132
if _context.get_item("http.request.blocked", span=req_span):
131-
# response code must be set here or it will be too late
132-
result = start_response("403 FORBIDDEN", [])
133+
# response code must be set here, or it will be too late
134+
ctype = (
135+
"text/html"
136+
if "text/html" in _asm_request_context.get_headers().get("Accept", "").lower()
137+
else "text/json"
138+
)
139+
response_headers = [("content-type", ctype)]
140+
result = start_response("403 FORBIDDEN", response_headers)
141+
trace_utils.set_http_meta(req_span, config.flask, status_code="403", response_headers=response_headers)
142+
else:
143+
result = start_response(status_code, headers)
144+
else:
145+
result = start_response(status_code, headers)
133146
return result
134147

135148
def _request_span_modifier(self, span, environ, parsed_headers=None):
@@ -563,9 +576,30 @@ def _wrap(code_or_exception, f):
563576
return _wrap(*args, **kwargs)
564577

565578

566-
def _block_request_callable(headers, span):
579+
def _set_block_tags(span):
580+
span.set_tag_str(http.STATUS_CODE, "403")
581+
request = flask.request
582+
try:
583+
base_url = getattr(request, "base_url", None)
584+
query_string = getattr(request, "query_string", None)
585+
if base_url and query_string:
586+
_set_url_tag(config.flask, span, base_url, query_string)
587+
if query_string and config.flask.trace_query_string:
588+
span.set_tag_str(http.QUERY_STRING, query_string)
589+
if request.method is not None:
590+
span.set_tag_str(http.METHOD, request.method)
591+
user_agent = _get_request_header_user_agent(request.headers)
592+
if user_agent:
593+
span.set_tag_str(http.USER_AGENT, user_agent)
594+
except Exception as e:
595+
log.warning("Could not set some span tags on blocked request: %s", str(e)) # noqa: G200
596+
597+
598+
def _block_request_callable(span):
599+
request = flask.request
567600
_context.set_item("http.request.blocked", True, span=span)
568-
ctype = headers.get("Accept") or "text/json"
601+
_set_block_tags(span)
602+
ctype = "text/html" if "text/html" in request.headers.get("Accept", "").lower() else "text/json"
569603
abort(flask.Response(utils._get_blocked_template(ctype), content_type=ctype, status=403))
570604

571605

@@ -585,15 +619,12 @@ def _traced_request(pin, wrapped, instance, args, kwargs):
585619
# This call may be unnecessary since we try to add the tags earlier
586620
# We just haven't been able to confirm this yet
587621
_set_request_tags(span)
588-
request = flask.request
589622

590623
with pin.tracer.trace(
591624
".".join(("flask", name)),
592625
service=trace_utils.int_service(pin, config.flask, pin),
593626
) as request_span:
594-
_asm_request_context.set_block_request_callable(
595-
functools.partial(_block_request_callable, request.headers, span)
596-
)
627+
_asm_request_context.set_block_request_callable(functools.partial(_block_request_callable, span))
597628
request_span.set_tag_str(COMPONENT, config.flask.integration_name)
598629

599630
request_span._ignore_exception(werkzeug.exceptions.NotFound)

ddtrace/contrib/trace_utils.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,18 @@ def ext_service(pin, int_config, default=None):
404404
return default
405405

406406

407+
def _set_url_tag(integration_config, span, url, query):
408+
# type: (IntegrationConfig, Span, str, str) -> None
409+
410+
if integration_config.http_tag_query_string: # Tagging query string in http.url
411+
if config.global_query_string_obfuscation_disabled: # No redacting of query strings
412+
span.set_tag_str(http.URL, url)
413+
else: # Redact query strings
414+
span.set_tag_str(http.URL, redact_url(url, config._obfuscation_query_string_pattern, query))
415+
else: # Not tagging query string in http.url
416+
span.set_tag_str(http.URL, strip_query_string(url))
417+
418+
407419
def set_http_meta(
408420
span, # type: Span
409421
integration_config, # type: IntegrationConfig
@@ -446,14 +458,7 @@ def set_http_meta(
446458

447459
if url is not None:
448460
url = _sanitized_url(url)
449-
450-
if integration_config.http_tag_query_string: # Tagging query string in http.url
451-
if config.global_query_string_obfuscation_disabled: # No redacting of query strings
452-
span.set_tag_str(http.URL, url)
453-
else: # Redact query strings
454-
span.set_tag_str(http.URL, redact_url(url, config._obfuscation_query_string_pattern, query))
455-
else: # Not tagging query string in http.url
456-
span.set_tag_str(http.URL, strip_query_string(url))
461+
_set_url_tag(integration_config, span, url, query)
457462

458463
if status_code is not None:
459464
try:

ddtrace/contrib/wsgi/wsgi.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
from ddtrace.appsec import _asm_request_context
66

7+
from ...appsec._constants import SPAN_DATA_NAMES
8+
from ..trace_utils import _get_request_header_user_agent
9+
from ..trace_utils import _set_url_tag
10+
711

812
if TYPE_CHECKING: # pragma: no cover
913
from typing import Any
@@ -119,6 +123,29 @@ def _response_span_name(self):
119123
"Returns the name of a response span. Example: `flask.response`"
120124
raise NotImplementedError
121125

126+
def _make_block_content(self, environ, headers, span):
127+
ctype = "text/html" if "text/html" in headers.get("Accept", "").lower() else "text/json"
128+
content = utils._get_blocked_template(ctype).encode("UTF-8")
129+
try:
130+
span.set_tag_str(SPAN_DATA_NAMES.RESPONSE_HEADERS_NO_COOKIES + ".content-length", str(len(content)))
131+
span.set_tag_str(SPAN_DATA_NAMES.RESPONSE_HEADERS_NO_COOKIES + ".content-type", ctype)
132+
span.set_tag_str(http.STATUS_CODE, "403")
133+
url = construct_url(environ)
134+
query_string = environ.get("QUERY_STRING")
135+
_set_url_tag(self._config, span, url, query_string)
136+
if query_string and self._config.trace_query_string:
137+
span.set_tag_str(http.QUERY_STRING, query_string)
138+
method = environ.get("REQUEST_METHOD")
139+
if method:
140+
span.set_tag_str(http.METHOD, method)
141+
user_agent = _get_request_header_user_agent(headers, headers_are_case_sensitive=True)
142+
if user_agent:
143+
span.set_tag_str(http.USER_AGENT, user_agent)
144+
except Exception as e:
145+
log.warning("Could not set some span tags on blocked request: %s", str(e)) # noqa: G200
146+
147+
return ctype, content
148+
122149
def __call__(self, environ, start_response):
123150
# type: (Iterable, Callable) -> _TracedIterable
124151
trace_utils.activate_distributed_headers(self.tracer, int_config=self._config, request_headers=environ)
@@ -138,8 +165,9 @@ def __call__(self, environ, start_response):
138165
if self.tracer._appsec_enabled:
139166
# [IP Blocking]
140167
if _context.get_item("http.request.blocked", span=req_span):
141-
start_response("403 FORBIDDEN", [("content-type", headers.get("Accept"))])
142-
closing_iterator = [utils._get_blocked_template(headers.get("Accept")).encode("UTF-8")]
168+
ctype, content = self._make_block_content(environ, headers, req_span)
169+
start_response("403 FORBIDDEN", [("content-type", ctype)])
170+
closing_iterator = [content]
143171
not_blocked = False
144172

145173
if not_blocked:
@@ -150,8 +178,9 @@ def __call__(self, environ, start_response):
150178
if self.tracer._appsec_enabled:
151179
# [Suspicious Request Blocking on request]
152180
if _context.get_item("http.request.blocked", span=req_span):
153-
start_response("403 FORBIDDEN", [("content-type", headers.get("Accept"))])
154-
closing_iterator = [utils._get_blocked_template(headers.get("Accept")).encode("UTF-8")]
181+
ctype, content = self._make_block_content(environ, headers, req_span)
182+
start_response("403 FORBIDDEN", [("content-type", ctype)])
183+
closing_iterator = [content]
155184
not_blocked = False
156185

157186
if not_blocked:
@@ -174,7 +203,8 @@ def __call__(self, environ, start_response):
174203
raise
175204
if self.tracer._appsec_enabled and _context.get_item("http.request.blocked", span=req_span):
176205
# [Suspicious Request Blocking on response]
177-
closing_iterator = [utils._get_blocked_template(headers.get("Accept")).encode("UTF-8")]
206+
_, content = self._make_block_content(environ, headers, req_span)
207+
closing_iterator = [content]
178208

179209
# start flask.response span. This span will be finished after iter(result) is closed.
180210
# start_span(child_of=...) is used to ensure correct parenting.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
fixes:
3+
- |
4+
ASM: Solve some corner cases where a Flask blocking request would fail because
5+
headers would be already sent.
6+
- |
7+
ASM: Solve the content-type not always being correct in blocking responses.
8+
- |
9+
ASM: Ensure the blocking responses have the following tags: `http.url`, `http.query_string`,
10+
`http.useragent`, `http.method`, `http.response.headers.content-type` and
11+
`http.response.headers.content-length`.

0 commit comments

Comments
 (0)