Skip to content

Commit d0268d0

Browse files
authored
fix(asm): fix reading wsgi input (backport #4114) (#4127)
This is an automatic backport of pull request #4114 done by [Mergify](https://mergify.com). Cherry-pick of 90e11db has failed: ``` On branch mergify/bp/1.4/pr-4114 Your branch is up to date with 'origin/1.4'. You are currently cherry-picking commit 90e11db. (fix conflicts and run "git cherry-pick --continue") (use "git cherry-pick --skip" to skip this patch) (use "git cherry-pick --abort" to cancel the cherry-pick operation) Changes to be committed: modified: benchmarks/flask_simple/app.py modified: benchmarks/flask_simple/config.yaml modified: benchmarks/flask_simple/gunicorn.conf.py modified: benchmarks/flask_simple/scenario.py modified: benchmarks/flask_simple/utils.py modified: benchmarks/span/utils.py new file: releasenotes/notes/asm-fix-reset-wsgi-input-035e0a7d917af2b2.yaml modified: tests/contrib/django/django1_app/urls.py modified: tests/contrib/django/django_app/urls.py modified: tests/contrib/django/views.py modified: tests/contrib/flask/app.py modified: tests/contrib/pylons/app/controllers/root.py modified: tests/contrib/pylons/app/router.py Unmerged paths: (use "git add/rm <file>..." as appropriate to mark resolution) deleted by us: benchmarks/set_http_meta/scenario.py both modified: ddtrace/contrib/flask/patch.py both modified: tests/contrib/django/test_django_appsec.py both modified: tests/contrib/flask/test_flask_appsec.py both modified: tests/contrib/pylons/test_pylons.py ``` To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/github/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally --- <details> <summary>Mergify commands and options</summary> <br /> More conditions and actions can be found in the [documentation](https://docs.mergify.com/). You can also trigger Mergify actions by commenting on this pull request: - `@Mergifyio refresh` will re-evaluate the rules - `@Mergifyio rebase` will rebase this PR on its base branch - `@Mergifyio update` will merge the base branch into this PR - `@Mergifyio backport <destination>` will backport this PR on `<destination>` branch Additionally, on Mergify [dashboard](https://dashboard.mergify.com/) you can: - look at your merge queues - generate the Mergify configuration with the config editor. Finally, you can contact us on https://mergify.com </details>
1 parent 522c354 commit d0268d0

File tree

17 files changed

+272
-7
lines changed

17 files changed

+272
-7
lines changed

benchmarks/flask_simple/app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from flask import Flask
44
from flask import render_template_string
5+
from flask import request
56

67

78
app = Flask(__name__)
@@ -40,3 +41,9 @@ def index():
4041
""",
4142
rand_numbers=rand_numbers,
4243
)
44+
45+
46+
@app.route("/post-view", methods=["POST"])
47+
def post_view():
48+
data = request.data
49+
return data, 200
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
baseline: &baseline
22
tracer_enabled: false
33
profiler_enabled: false
4+
appsec_enabled: false
5+
post_request: false
46
tracer:
57
<<: *baseline
68
tracer_enabled: true
79
profiler:
810
<<: *baseline
911
profiler_enabled: true
12+
appsec-get: &appsec
13+
<<: *baseline
14+
tracer_enabled: true
15+
appsec_enabled: true
16+
appsec-post:
17+
<<: *appsec
18+
tracer_enabled: true
19+
appsec_enabled: true
20+
post_request: true
1021
tracer-and-profiler:
1122
<<: *baseline
1223
tracer_enabled: true
1324
profiler_enabled: true
25+
tracer-and-profiler-and-appsec:
26+
<<: *baseline
27+
tracer_enabled: true
28+
profiler_enabled: true
29+
appsec_enabled: true
30+
post_request: true

benchmarks/flask_simple/gunicorn.conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ def post_fork(server, worker):
77
os.environ.update(
88
{"DD_PROFILING_ENABLED": "1", "DD_PROFILING_API_TIMEOUT": "0.1", "DD_PROFILING_UPLOAD_INTERVAL": "10"}
99
)
10+
if os.environ.get("PERF_APPSEC_ENABLED") == "1":
11+
os.environ.update({"DD_APPSEC_ENABLED ": "1"})
1012
# This will not work with gevent workers as the gevent hub has not been
1113
# initialized when this hook is called.
1214
if os.environ.get("PERF_TRACER_ENABLED") == "1":

benchmarks/flask_simple/scenario.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
class FlaskSimple(bm.Scenario):
66
tracer_enabled = bm.var_bool()
77
profiler_enabled = bm.var_bool()
8+
appsec_enabled = bm.var_bool()
9+
post_request = bm.var_bool()
810

911
def run(self):
1012
with utils.server(self) as get_response:

benchmarks/flask_simple/utils.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import subprocess
44

5+
import bm.utils as utils
56
import requests
67
import tenacity
78

@@ -14,6 +15,22 @@ def _get_response():
1415
r.raise_for_status()
1516

1617

18+
def _post_response():
19+
HEADERS = {
20+
"SERVER_PORT": "8000",
21+
"REMOTE_ADDR": "127.0.0.1",
22+
"CONTENT_TYPE": "application/json",
23+
"HTTP_HOST": "localhost:8000",
24+
"HTTP_ACCEPT": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,"
25+
"image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
26+
"HTTP_SEC_FETCH_DEST": "document",
27+
"HTTP_ACCEPT_ENCODING": "gzip, deflate, br",
28+
"HTTP_ACCEPT_LANGUAGE": "en-US,en;q=0.9",
29+
}
30+
r = requests.post(SERVER_URL + "post-view", data=utils.EXAMPLE_POST_DATA, headers=HEADERS)
31+
r.raise_for_status()
32+
33+
1734
@tenacity.retry(
1835
wait=tenacity.wait_fixed(1),
1936
stop=tenacity.stop_after_attempt(30),
@@ -27,6 +44,7 @@ def server(scenario):
2744
env = {
2845
"PERF_TRACER_ENABLED": str(scenario.tracer_enabled),
2946
"PERF_PROFILER_ENABLED": str(scenario.profiler_enabled),
47+
"PERF_APPSEC_ENABLED": str(scenario.appsec_enabled),
3048
}
3149
# copy over current environ
3250
env.update(os.environ)
@@ -42,7 +60,11 @@ def server(scenario):
4260
assert proc.poll() is None
4361
try:
4462
_wait()
45-
yield _get_response
63+
if scenario.post_request:
64+
response = _post_response
65+
else:
66+
response = _get_response
67+
yield response
4668
finally:
4769
proc.terminate()
4870
proc.wait()
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from collections import defaultdict
2+
import copy
3+
4+
import bm as bm
5+
import bm.utils as utils
6+
7+
from ddtrace import config as ddconfig
8+
from ddtrace.contrib.trace_utils import set_http_meta
9+
10+
11+
class Config(defaultdict):
12+
__header_tag_name = {
13+
"User-Agent": "http.user_agent",
14+
"REFERER": "http.referer",
15+
"Content-Type": "http.content_type",
16+
"Etag": "http.etag",
17+
}
18+
19+
def _header_tag_name(self, header_name):
20+
return self.__header_tag_name.get(header_name)
21+
22+
def __getattr__(self, item):
23+
return self[item]
24+
25+
26+
COOKIES = {"csrftoken": "cR8TVoVebF2afssCR16pQeqHcxAlA3867P6zkkUBYDL5Q92kjSGtqptAry1htdlL"}
27+
28+
DATA_GET = dict(
29+
method="GET",
30+
status_code=200,
31+
status_msg="OK",
32+
parsed_query={
33+
"key1": "value1",
34+
"key2": "value2",
35+
"token": "cR8TVoVebF2afssCR16pQeqHcxAlA3867P6zkkUBYDL5Q92kjSGtqptAry1htdlL",
36+
},
37+
request_headers=utils.COMMON_DJANGO_META,
38+
response_headers=utils.COMMON_DJANGO_META,
39+
retries_remain=0,
40+
raw_uri="http://localhost:8888{}?key1=value1&key2=value2&token="
41+
"cR8TVoVebF2afssCR16pQeqHcxAlA3867P6zkkUBYDL5Q92kjSGtqptAry1htdlL".format(utils.PATH),
42+
request_cookies=COOKIES,
43+
request_path_params={"id": 1},
44+
)
45+
46+
47+
class SetHttpMeta(bm.Scenario):
48+
allenabled = bm.var_bool()
49+
useragentvariant = bm.var(type=str)
50+
obfuscation_disabled = bm.var_bool()
51+
send_querystring_enabled = bm.var_bool()
52+
url = bm.var(type=str)
53+
querystring = bm.var(type=str)
54+
55+
def run(self):
56+
# run scenario to also set tags on spans
57+
if self.allenabled:
58+
config = Config(lambda: True)
59+
else:
60+
config = Config(lambda: False)
61+
62+
# querystring obfuscation config
63+
config["trace_query_string"] = self.send_querystring_enabled
64+
if self.obfuscation_disabled:
65+
ddconfig._obfuscation_query_string_pattern = None
66+
67+
data = copy.deepcopy(DATA_GET)
68+
data["url"] = self.url
69+
data["query"] = self.querystring
70+
71+
if self.useragentvariant:
72+
data["request_headers"][self.useragentvariant] = (
73+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
74+
"(KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36"
75+
)
76+
77+
span = utils.gen_span(str("test"))
78+
span._local_root = utils.gen_span(str("root"))
79+
80+
def bm(loops):
81+
for _ in range(loops):
82+
set_http_meta(span, config, **data)
83+
84+
yield bm

benchmarks/span/utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,56 @@
11
from functools import partial
2+
import json
23
import random
34
import string
45

6+
from six import BytesIO
7+
58
from ddtrace import Span
69
from ddtrace import __version__ as ddtrace_version
710

811

912
_Span = Span
1013

14+
PATH = "/test-benchmark/test/1/"
15+
16+
EXAMPLE_POST_DATA = {f"example_key_{i}": f"example_value{i}" for i in range(100)}
17+
18+
COMMON_DJANGO_META = {
19+
"SERVER_PORT": "8000",
20+
"REMOTE_HOST": "",
21+
"CONTENT_LENGTH": "",
22+
"SCRIPT_NAME": "",
23+
"SERVER_PROTOCOL": "HTTP/1.1",
24+
"SERVER_SOFTWARE": "WSGIServer/0.2",
25+
"REQUEST_METHOD": "GET",
26+
"PATH_INFO": PATH,
27+
"QUERY_STRING": "func=subprocess.run&cmd=%2Fbin%2Fecho+hello",
28+
"REMOTE_ADDR": "127.0.0.1",
29+
"CONTENT_TYPE": "application/json",
30+
"HTTP_HOST": "localhost:8000",
31+
"HTTP_CONNECTION": "keep-alive",
32+
"HTTP_CACHE_CONTROL": "max-age=0",
33+
"HTTP_SEC_CH_UA": '"Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"',
34+
"HTTP_SEC_CH_UA_MOBILE": "?0",
35+
"HTTP_UPGRADE_INSECURE_REQUESTS": "1",
36+
"HTTP_ACCEPT": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,"
37+
"image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
38+
"HTTP_SEC_FETCH_SITE": "none",
39+
"HTTP_SEC_FETCH_MODE": "navigate",
40+
"HTTP_SEC_FETCH_USER": "?1",
41+
"HTTP_SEC_FETCH_DEST": "document",
42+
"HTTP_ACCEPT_ENCODING": "gzip, deflate, br",
43+
"HTTP_ACCEPT_LANGUAGE": "en-US,en;q=0.9",
44+
"HTTP_COOKIE": "Pycharm-45729245=449f1b16-fe0a-4623-92bc-418ec418ed4b; Idea-9fdb9ed8="
45+
"448d4c93-863c-4e9b-a8e7-bbfbacd073d2; csrftoken=cR8TVoVebF2afssCR16pQeqHcxA"
46+
"lA3867P6zkkUBYDL5Q92kjSGtqptAry1htdlL; _xsrf=2|d4b85683|7e2604058ea673d12dc6604f"
47+
'96e6e06d|1635869800; username-localhost-8888="2|1:0|10:1637328584|23:username-loca'
48+
"lhost-8888|44:OWNiOTFhMjg1NDllNDQxY2I2Y2M2ODViMzRjMTg3NGU=|3bc68f938dcc081a9a02e51660"
49+
'0c0d38b14a3032053a7e16b180839298e25b42"',
50+
"wsgi.input": BytesIO(bytes(json.dumps(EXAMPLE_POST_DATA), encoding="utf-8")),
51+
"wsgi.url_scheme": "http",
52+
}
53+
1154
# DEV: 1.x dropped tracer positional argument
1255
if ddtrace_version.split(".")[0] == "0":
1356
_Span = partial(_Span, None)

ddtrace/contrib/flask/patch.py

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

33
import flask
4+
from six import BytesIO
45
import werkzeug
56
from werkzeug.exceptions import BadRequest
67

@@ -348,6 +349,17 @@ def traced_wsgi_app(pin, wrapped, instance, args, kwargs):
348349
req_body = None
349350
if config._appsec_enabled and request.method in _BODY_METHODS:
350351
content_type = request.content_type
352+
wsgi_input = environ.get("wsgi.input", "")
353+
354+
# Copy wsgi input if not seekable
355+
try:
356+
seekable = wsgi_input.seekable()
357+
except AttributeError:
358+
seekable = False
359+
if not seekable:
360+
body = wsgi_input.read()
361+
environ["wsgi.input"] = BytesIO(body)
362+
351363
try:
352364
if content_type == "application/json":
353365
if _HAS_JSON_MIXIN and hasattr(request, "json"):
@@ -360,8 +372,16 @@ def traced_wsgi_app(pin, wrapped, instance, args, kwargs):
360372
req_body = request.args.to_dict()
361373
elif hasattr(request, "form"):
362374
req_body = request.form.to_dict()
375+
else:
376+
req_body = request.get_data()
363377
except (AttributeError, RuntimeError, TypeError, BadRequest):
364378
log.warning("Failed to parse werkzeug request body", exc_info=True)
379+
finally:
380+
# Reset wsgi input to the beginning
381+
if seekable:
382+
wsgi_input.seek(0)
383+
else:
384+
environ["wsgi.input"] = BytesIO(body)
365385

366386
# DEV: We set response status code in `_wrap_start_response`
367387
# DEV: Use `request.base_url` and not `request.url` to keep from leaking any query string parameters
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
ASM: reset wsgi input after reading.

tests/contrib/django/django1_app/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@
2929
url(r"^composed-get-view/$", views.ComposedGetView.as_view(), name="composed-get-view"),
3030
url(r"^composed-view/$", views.ComposedView.as_view(), name="composed-view"),
3131
url(r"^alter-resource/$", views.alter_resource, name="alter-resource"),
32+
url(r"^body/$", views.body_view, name="body_view"),
3233
]

0 commit comments

Comments
 (0)