Skip to content

Commit 42c288b

Browse files
authored
fix(appsec): flask hangs on empty body requests [backport 1.18] (#7295)
Backport cc35faf from #7266 to 1.18. This PR fix #7265 Local and debug Flask applications hang when Appsec `_on_request_span_modifier` handler calls to `wsgi_input.read()` and the body is empty ### Example file1.py: ```python @app.route("/test-body-hang", methods=["POST"]) def apsec_body_hang(): return "OK", 200 if __name__ == "__main__": app.run(port=8000) ``` then: ```bash DD_APPSEC_ENABLED=true poetry run ddtrace-run python -m file1:app # or DD_APPSEC_ENABLED=true python -m ddtrace-run python file1.py ``` If you make an empty POST request `curl -X POST '127.0.0.1:8000/'` Flask hangs when the ASM handler tries to read the body ## 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/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## 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 has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) - [x] If this PR touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`. - [x] This PR doesn't touch any of that.
1 parent 3b495c5 commit 42c288b

File tree

6 files changed

+155
-71
lines changed

6 files changed

+155
-71
lines changed

ddtrace/appsec/handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def _on_request_span_modifier(request, environ, _HAS_JSON_MIXIN, exception_type)
5555
seekable = False
5656
if not seekable:
5757
content_length = int(environ.get("CONTENT_LENGTH", 0))
58-
body = wsgi_input.read(content_length) if content_length else wsgi_input.read()
58+
body = wsgi_input.read(content_length) if content_length else b""
5959
environ["wsgi.input"] = BytesIO(body)
6060

6161
try:
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
issues:
3+
- |
4+
ASM: fix a body read problem on some corner case where passing empty content length makes wsgi.input.read() blocks.

tests/appsec/app.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
""" This Flask application is imported on tests.appsec.appsec_utils.gunicorn_server
2+
"""
13
from flask import Flask
24

35

@@ -9,4 +11,13 @@
911

1012
@app.route("/")
1113
def index():
12-
return "OK", 200
14+
return "OK_index", 200
15+
16+
17+
@app.route("/test-body-hang", methods=["POST"])
18+
def apsec_body_hang():
19+
return "OK_test-body-hang", 200
20+
21+
22+
if __name__ == "__main__":
23+
app.run(debug=True, port=8000)

tests/appsec/appsec_utils.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from contextlib import contextmanager
2+
import os
3+
import signal
4+
import subprocess
5+
import sys
6+
7+
from ddtrace.internal.utils.retry import RetryError
8+
from ddtrace.vendor import psutil
9+
from tests.webclient import Client
10+
11+
12+
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
13+
ROOT_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
14+
15+
16+
def _build_env():
17+
environ = dict(PATH="%s:%s" % (ROOT_PROJECT_DIR, ROOT_DIR), PYTHONPATH="%s:%s" % (ROOT_PROJECT_DIR, ROOT_DIR))
18+
if os.environ.get("PATH"):
19+
environ["PATH"] = "%s:%s" % (os.environ.get("PATH"), environ["PATH"])
20+
if os.environ.get("PYTHONPATH"):
21+
environ["PYTHONPATH"] = "%s:%s" % (os.environ.get("PYTHONPATH"), environ["PYTHONPATH"])
22+
return environ
23+
24+
25+
@contextmanager
26+
def gunicorn_server(appsec_enabled="true", remote_configuration_enabled="true", token=None):
27+
cmd = ["gunicorn", "-w", "3", "-b", "0.0.0.0:8000", "tests.appsec.app:app"]
28+
for res in appsec_application_server(
29+
cmd, appsec_enabled=appsec_enabled, remote_configuration_enabled=remote_configuration_enabled, token=token
30+
):
31+
yield res
32+
33+
34+
@contextmanager
35+
def flask_server(appsec_enabled="true", remote_configuration_enabled="true", token=None):
36+
cmd = ["python", "tests/appsec/app.py", "--no-reload"]
37+
for res in appsec_application_server(
38+
cmd, appsec_enabled=appsec_enabled, remote_configuration_enabled=remote_configuration_enabled, token=token
39+
):
40+
yield res
41+
42+
43+
def appsec_application_server(cmd, appsec_enabled="true", remote_configuration_enabled="true", token=None):
44+
env = _build_env()
45+
env["DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS"] = "0.5"
46+
env["DD_REMOTE_CONFIGURATION_ENABLED"] = remote_configuration_enabled
47+
if token:
48+
env["_DD_REMOTE_CONFIGURATION_ADDITIONAL_HEADERS"] = "X-Datadog-Test-Session-Token:%s," % (token,)
49+
if appsec_enabled:
50+
env["DD_APPSEC_ENABLED"] = appsec_enabled
51+
env["DD_TRACE_AGENT_URL"] = os.environ.get("DD_TRACE_AGENT_URL", "")
52+
53+
server_process = subprocess.Popen(
54+
cmd,
55+
env=env,
56+
stdout=sys.stdout,
57+
stderr=sys.stderr,
58+
preexec_fn=os.setsid,
59+
)
60+
try:
61+
client = Client("http://0.0.0.0:8000")
62+
63+
try:
64+
print("Waiting for server to start")
65+
client.wait(max_tries=100, delay=0.1)
66+
print("Server started")
67+
except RetryError:
68+
raise AssertionError(
69+
"Server failed to start, see stdout and stderr logs"
70+
"\n=== Captured STDOUT ===\n%s=== End of captured STDOUT ==="
71+
"\n=== Captured STDERR ===\n%s=== End of captured STDERR ==="
72+
% (server_process.stdout, server_process.stderr)
73+
)
74+
# If we run a Gunicorn application, we want to get the child's pid, see test_remoteconfiguration_e2e.py
75+
parent = psutil.Process(server_process.pid)
76+
children = parent.children(recursive=True)
77+
78+
yield server_process, client, (children[1].pid if len(children) > 1 else None)
79+
try:
80+
client.get_ignored("/shutdown")
81+
except Exception:
82+
raise AssertionError(
83+
"\n=== Captured STDOUT ===\n%s=== End of captured STDOUT ==="
84+
"\n=== Captured STDERR ===\n%s=== End of captured STDERR ==="
85+
% (server_process.stdout, server_process.stderr)
86+
)
87+
finally:
88+
os.killpg(os.getpgid(server_process.pid), signal.SIGTERM)
89+
server_process.wait()

tests/appsec/test_handlers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from tests.appsec.appsec_utils import flask_server
2+
from tests.appsec.appsec_utils import gunicorn_server
3+
4+
5+
def test_flask_when_appsec_reads_empty_body_hang():
6+
"""A bug was detected when running a Flask application locally
7+
8+
file1.py:
9+
app.run(debug=True, port=8000)
10+
11+
then:
12+
DD_APPSEC_ENABLED=true poetry run ddtrace-run python -m file1:app
13+
DD_APPSEC_ENABLED=true python -m ddtrace-run python file1.py
14+
15+
If you make an empty POST request (curl -X POST '127.0.0.1:8000/'), Flask hangs when the ASM handler tries to read
16+
an empty body
17+
"""
18+
with flask_server(remote_configuration_enabled="false", token=None) as context:
19+
_, flask_client, pid = context
20+
21+
response = flask_client.get("/")
22+
23+
assert response.status_code == 200
24+
assert response.content == b"OK_index"
25+
26+
response = flask_client.post("/test-body-hang")
27+
28+
assert response.status_code == 200
29+
assert response.content == b"OK_test-body-hang"
30+
31+
32+
def test_gunicorn_when_appsec_reads_empty_body_no_hang():
33+
"""In relation to the previous test, Gunicorn works correctly, but we added a regression test to ensure that
34+
no similar bug arises in a production application.
35+
"""
36+
with gunicorn_server(remote_configuration_enabled="false", token=None) as context:
37+
_, gunicorn_client, pid = context
38+
39+
response = gunicorn_client.get("/")
40+
41+
assert response.status_code == 200
42+
assert response.content == b"OK_index"
43+
44+
response = gunicorn_client.post("/test-body-hang")
45+
46+
assert response.status_code == 200
47+
assert response.content == b"OK_test-body-hang"

tests/appsec/test_remoteconfiguration_e2e.py

Lines changed: 2 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import base64
2-
from contextlib import contextmanager
32
import datetime
43
import hashlib
54
import json
65
from multiprocessing.pool import ThreadPool
76
import os
87
import signal
9-
import subprocess
108
import sys
119
import time
1210
import uuid
@@ -16,72 +14,7 @@
1614
from ddtrace import tracer
1715
from ddtrace.internal.compat import httplib
1816
from ddtrace.internal.compat import parse
19-
from ddtrace.internal.utils.retry import RetryError
20-
from ddtrace.vendor import psutil
21-
from tests.webclient import Client
22-
23-
24-
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
25-
ROOT_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26-
27-
28-
def _build_env():
29-
environ = dict(PATH="%s:%s" % (ROOT_PROJECT_DIR, ROOT_DIR), PYTHONPATH="%s:%s" % (ROOT_PROJECT_DIR, ROOT_DIR))
30-
if os.environ.get("PATH"):
31-
environ["PATH"] = "%s:%s" % (os.environ.get("PATH"), environ["PATH"])
32-
if os.environ.get("PYTHONPATH"):
33-
environ["PYTHONPATH"] = "%s:%s" % (os.environ.get("PYTHONPATH"), environ["PYTHONPATH"])
34-
return environ
35-
36-
37-
@contextmanager
38-
def gunicorn_server(appsec_enabled="true", remote_configuration_enabled="true", token=None):
39-
cmd = ["gunicorn", "-w", "3", "-b", "0.0.0.0:8000", "tests.appsec.app:app"]
40-
env = _build_env()
41-
env["DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS"] = "0.5"
42-
env["DD_REMOTE_CONFIGURATION_ENABLED"] = remote_configuration_enabled
43-
if token:
44-
env["_DD_REMOTE_CONFIGURATION_ADDITIONAL_HEADERS"] = "X-Datadog-Test-Session-Token:%s," % (token,)
45-
if appsec_enabled:
46-
env["DD_APPSEC_ENABLED"] = appsec_enabled
47-
env["DD_TRACE_AGENT_URL"] = os.environ.get("DD_TRACE_AGENT_URL", "")
48-
server_process = subprocess.Popen(
49-
cmd,
50-
env=env,
51-
stdout=sys.stdout,
52-
stderr=sys.stderr,
53-
close_fds=True,
54-
preexec_fn=os.setsid,
55-
)
56-
try:
57-
client = Client("http://0.0.0.0:8000")
58-
try:
59-
print("Waiting for server to start")
60-
client.wait(max_tries=100, delay=0.1)
61-
print("Server started")
62-
except RetryError:
63-
raise AssertionError(
64-
"Server failed to start, see stdout and stderr logs"
65-
"\n=== Captured STDOUT ===\n%s=== End of captured STDOUT ==="
66-
"\n=== Captured STDERR ===\n%s=== End of captured STDERR ==="
67-
% (server_process.stdout, server_process.stderr)
68-
)
69-
time.sleep(1)
70-
parent = psutil.Process(server_process.pid)
71-
children = parent.children(recursive=True)
72-
73-
yield server_process, client, children[1].pid
74-
try:
75-
client.get_ignored("/shutdown")
76-
except Exception:
77-
raise AssertionError(
78-
"\n=== Captured STDOUT ===\n%s=== End of captured STDOUT ==="
79-
"\n=== Captured STDERR ===\n%s=== End of captured STDERR ==="
80-
% (server_process.stdout, server_process.stderr)
81-
)
82-
finally:
83-
server_process.terminate()
84-
server_process.wait()
17+
from tests.appsec.appsec_utils import gunicorn_server
8518

8619

8720
def _get_agent_client():
@@ -261,7 +194,7 @@ def _request_200(client, debug_mode=False, max_retries=7, sleep_time=1):
261194
previous = False
262195
for _ in range(max_retries):
263196
results = _multi_requests(client, debug_mode)
264-
check = all(response.status_code == 200 and response.content == b"OK" for response in results)
197+
check = all(response.status_code == 200 and response.content == b"OK_index" for response in results)
265198
if check:
266199
if previous:
267200
return

0 commit comments

Comments
 (0)