Skip to content

Commit a801675

Browse files
authored
feat: detect body attacks on flask (#4015)
## Description Detect attacks on the request body when running in Flask. ## Checklist - [X] Title must conform to [conventional commit](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional). - [ ] Add additional sections for `feat` and `fix` pull requests. - [ ] Ensure tests are passing for affected code. - [ ] [Library documentation](https://github.com/DataDog/dd-trace-py/tree/1.x/docs) and/or [Datadog's documentation site](https://github.com/DataDog/documentation/) is updated. Link to doc PR in description. ## Reviewer Checklist - [ ] Title is accurate. - [ ] Description motivates each change. - [ ] No unnecessary changes were introduced in this PR. - [ ] PR cannot be broken up into smaller PRs. - [ ] Avoid breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [ ] Tests provided or description of manual testing performed is included in the code or PR. - [ ] Release note has been added for fixes and features, or else `changelog/no-changelog` label added. - [ ] All relevant GitHub issues are correctly linked. - [ ] Backports are identified and tagged with Mergifyio. - [ ] Add to milestone.
1 parent 0090ece commit a801675

File tree

3 files changed

+109
-1
lines changed

3 files changed

+109
-1
lines changed

ddtrace/contrib/flask/patch.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1+
import json
2+
13
import flask
24
import werkzeug
5+
from werkzeug.exceptions import BadRequest
6+
7+
8+
# Not all versions of flask/werkzeug have this mixin
9+
try:
10+
from werkzeug.wrappers.json import JSONMixin
11+
12+
_HAS_JSON_MIXIN = True
13+
except ImportError:
14+
_HAS_JSON_MIXIN = False
315

416
from ddtrace import Pin
517
from ddtrace import config
@@ -27,6 +39,7 @@
2739
FLASK_VIEW_ARGS = "flask.view_args"
2840
FLASK_URL_RULE = "flask.url_rule"
2941
FLASK_VERSION = "flask.version"
42+
_BODY_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
3043

3144
# Configure default configuration
3245
config._add(
@@ -42,6 +55,15 @@
4255
)
4356

4457

58+
if _HAS_JSON_MIXIN:
59+
60+
class RequestWithJson(werkzeug.Request, JSONMixin):
61+
pass
62+
63+
_RequestType = RequestWithJson
64+
else:
65+
_RequestType = werkzeug.Request
66+
4567
# Extract flask version into a tuple e.g. (0, 12, 1) or (1, 0, 2)
4668
# DEV: This makes it so we can do `if flask_version >= (0, 12, 0):`
4769
# DEV: Example tests:
@@ -297,7 +319,7 @@ def traced_wsgi_app(pin, wrapped, instance, args, kwargs):
297319

298320
# Create a werkzeug request from the `environ` to make interacting with it easier
299321
# DEV: This executes before a request context is created
300-
request = werkzeug.Request(environ)
322+
request = _RequestType(environ)
301323

302324
# Configure distributed tracing
303325
trace_utils.activate_distributed_headers(pin.tracer, int_config=config.flask, request_headers=request.headers)
@@ -323,6 +345,24 @@ def traced_wsgi_app(pin, wrapped, instance, args, kwargs):
323345

324346
start_response = _wrap_start_response(start_response, span, request)
325347

348+
req_body = None
349+
if config._appsec_enabled and request.method in _BODY_METHODS:
350+
content_type = request.content_type
351+
try:
352+
if content_type == "application/json":
353+
if _HAS_JSON_MIXIN and hasattr(request, "json"):
354+
req_body = request.json
355+
else:
356+
req_body = json.loads(request.data.decode("UTF-8"))
357+
elif hasattr(request, "values"):
358+
req_body = request.values.to_dict()
359+
elif hasattr(request, "args"):
360+
req_body = request.args.to_dict()
361+
elif hasattr(request, "form"):
362+
req_body = request.form.to_dict()
363+
except (AttributeError, RuntimeError, TypeError, BadRequest):
364+
log.warning("Failed to parse werkzeug request body", exc_info=True)
365+
326366
# DEV: We set response status code in `_wrap_start_response`
327367
# DEV: Use `request.base_url` and not `request.url` to keep from leaking any query string parameters
328368
trace_utils.set_http_meta(
@@ -335,6 +375,7 @@ def traced_wsgi_app(pin, wrapped, instance, args, kwargs):
335375
parsed_query=request.args,
336376
request_headers=request.headers,
337377
request_cookies=request.cookies,
378+
request_body=req_body,
338379
)
339380

340381
return wrapped(environ, start_response)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
ASM: Detect attacks on Flask body.
5+

tests/contrib/flask/test_flask_appsec.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
22

33
from ddtrace.internal import _context
4+
from ddtrace.internal.compat import urlencode
45
from tests.appsec.test_processor import RULES_GOOD_PATH
56
from tests.contrib.flask import BaseFlaskTestCase
67
from tests.utils import override_env
8+
from tests.utils import override_global_config
79

810

911
class FlaskAppSecTestCase(BaseFlaskTestCase):
@@ -109,3 +111,63 @@ def test_flask_cookie(self):
109111
assert query == {"testingcookie_key": "testingcookie_value"} or query == {
110112
"testingcookie_key": ["testingcookie_value"]
111113
}
114+
115+
def test_flask_body_urlencoded(self):
116+
with override_global_config(dict(_appsec_enabled=True)):
117+
self.tracer._appsec_enabled = True
118+
# Hack: need to pass an argument to configure so that the processors are recreated
119+
self.tracer.configure(api_version="v0.4")
120+
payload = urlencode({"mytestingbody_key": "mytestingbody_value"})
121+
self.client.post("/", data=payload, content_type="application/x-www-form-urlencoded")
122+
root_span = self.pop_spans()[0]
123+
query = dict(_context.get_item("http.request.body", span=root_span))
124+
125+
assert root_span.get_tag("_dd.appsec.json") is None
126+
assert query == {"mytestingbody_key": "mytestingbody_value"}
127+
128+
def test_flask_body_urlencoded_appsec_disabled_then_no_body(self):
129+
with override_global_config(dict(_appsec_enabled=False)):
130+
self.tracer._appsec_enabled = True
131+
# Hack: need to pass an argument to configure so that the processors are recreated
132+
self.tracer.configure(api_version="v0.4")
133+
payload = urlencode({"mytestingbody_key": "mytestingbody_value"})
134+
self.client.post("/", data=payload, content_type="application/x-www-form-urlencoded")
135+
root_span = self.pop_spans()[0]
136+
assert not _context.get_item("http.request.body", span=root_span)
137+
138+
def test_flask_request_body_urlencoded_attack(self):
139+
with override_global_config(dict(_appsec_enabled=True)):
140+
self.tracer._appsec_enabled = True
141+
# Hack: need to pass an argument to configure so that the processors are recreated
142+
self.tracer.configure(api_version="v0.4")
143+
payload = urlencode({"attack": "1' or '1' = '1'"})
144+
self.client.post("/", data=payload, content_type="application/x-www-form-urlencoded")
145+
root_span = self.pop_spans()[0]
146+
query = dict(_context.get_item("http.request.body", span=root_span))
147+
assert "triggers" in json.loads(root_span.get_tag("_dd.appsec.json"))
148+
assert query == {"attack": "1' or '1' = '1'"}
149+
150+
def test_flask_body_json(self):
151+
with override_global_config(dict(_appsec_enabled=True)):
152+
self.tracer._appsec_enabled = True
153+
# Hack: need to pass an argument to configure so that the processors are recreated
154+
self.tracer.configure(api_version="v0.4")
155+
payload = {"mytestingbody_key": "mytestingbody_value"}
156+
self.client.post("/", json=payload, content_type="application/json")
157+
root_span = self.pop_spans()[0]
158+
query = dict(_context.get_item("http.request.body", span=root_span))
159+
160+
assert root_span.get_tag("_dd.appsec.json") is None
161+
assert query == {"mytestingbody_key": "mytestingbody_value"}
162+
163+
def test_flask_body_json_attack(self):
164+
with override_global_config(dict(_appsec_enabled=True)):
165+
self.tracer._appsec_enabled = True
166+
# Hack: need to pass an argument to configure so that the processors are recreated
167+
self.tracer.configure(api_version="v0.4")
168+
payload = {"attack": "1' or '1' = '1'"}
169+
self.client.post("/", json=payload, content_type="application/json")
170+
root_span = self.pop_spans()[0]
171+
query = dict(_context.get_item("http.request.body", span=root_span))
172+
assert "triggers" in json.loads(root_span.get_tag("_dd.appsec.json"))
173+
assert query == {"attack": "1' or '1' = '1'"}

0 commit comments

Comments
 (0)