Skip to content

Commit d23def9

Browse files
authored
chore(iast): fix stacktrace leak in flask (#12508)
- `werkzeug.DebugTraceback.render_debugger_html` runs outside of `iast_request_context`. Because of this, when this function is called, we store the result to report it in the next request when there's context and the span hasn't been sent yet. - Support for Werkzeug <= 2.0.3 ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - 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)
1 parent 3679d55 commit d23def9

File tree

10 files changed

+212
-31
lines changed

10 files changed

+212
-31
lines changed

ddtrace/appsec/_iast/_handlers.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -438,10 +438,10 @@ def _on_django_finalize_response_pre(ctx, after_request_tags, request, response)
438438
return
439439

440440
try:
441-
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
441+
from .taint_sinks.stacktrace_leak import iast_check_stacktrace_leak
442442

443443
content = response.content.decode("utf-8", errors="ignore")
444-
asm_check_stacktrace_leak(content)
444+
iast_check_stacktrace_leak(content)
445445
except Exception:
446446
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)
447447

@@ -470,10 +470,11 @@ def _on_flask_finalize_request_post(response, _):
470470
return
471471

472472
try:
473-
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
473+
from .taint_sinks.stacktrace_leak import iast_check_stacktrace_leak
474474

475475
content = response[0].decode("utf-8", errors="ignore")
476-
asm_check_stacktrace_leak(content)
476+
477+
iast_check_stacktrace_leak(content)
477478
except Exception:
478479
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)
479480

@@ -483,22 +484,23 @@ def _on_asgi_finalize_response(body, _):
483484
return
484485

485486
try:
486-
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
487+
from .taint_sinks.stacktrace_leak import iast_check_stacktrace_leak
487488

488489
content = body.decode("utf-8", errors="ignore")
489-
asm_check_stacktrace_leak(content)
490+
iast_check_stacktrace_leak(content)
490491
except Exception:
491492
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)
492493

493494

494495
def _on_werkzeug_render_debugger_html(html):
495-
if not html or not asm_config._iast_enabled or not asm_config.is_iast_request_enabled:
496+
# we don't check asm_config.is_iast_request_enabled due to werkzeug.render_debugger_html works outside the request
497+
if not html or not asm_config._iast_enabled:
496498
return
497499

498500
try:
499-
from .taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
501+
from .taint_sinks.stacktrace_leak import iast_check_stacktrace_leak
500502

501-
asm_check_stacktrace_leak(html)
503+
iast_check_stacktrace_leak(html)
502504
set_iast_stacktrace_reported(True)
503505
except Exception:
504506
log.debug("Unexpected exception checking for stacktrace leak", exc_info=True)

ddtrace/appsec/_iast/_taint_tracking/_errors.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99

1010

1111
def iast_taint_log_error(msg):
12+
iast_error(msg, default_prefix="[IAST] Propagation error")
13+
14+
15+
def iast_error(msg, default_prefix="[IAST] "):
1216
if _is_iast_debug_enabled():
1317
stack = inspect.stack()
1418
frame_info = "\n".join("%s %s" % (frame_info.filename, frame_info.lineno) for frame_info in stack[:7])
15-
log.debug("[IAST] Propagation error. %s:\n%s", msg, frame_info)
16-
_set_iast_error_metric("[IAST] Propagation error. %s" % msg)
19+
log.debug("%s. %s:\n%s", default_prefix, msg, frame_info)
20+
_set_iast_error_metric("%s. %s" % (default_prefix, msg))

ddtrace/appsec/_iast/processor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ def __post_init__(self) -> None:
2222
def on_span_start(self, span: Span):
2323
if span.span_type != SpanTypes.WEB:
2424
return
25-
2625
_iast_start_request(span)
26+
from .taint_sinks.stacktrace_leak import check_and_report_stacktrace_leak
27+
28+
check_and_report_stacktrace_leak()
2729

2830
def on_span_finish(self, span: Span):
2931
"""Report reported vulnerabilities.

ddtrace/appsec/_iast/taint_sinks/stacktrace_leak.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import os
22
import re
33

4+
from ddtrace.settings.asm import config as asm_config
5+
46
from ..._constants import IAST_SPAN_TAGS
57
from .. import oce
68
from .._iast_request_context import set_iast_stacktrace_reported
79
from .._metrics import _set_metric_iast_executed_sink
810
from .._metrics import increment_iast_span_metric
9-
from .._taint_tracking._errors import iast_taint_log_error
11+
from .._taint_tracking._errors import iast_error
1012
from ..constants import HTML_TAGS_REMOVE
1113
from ..constants import STACKTRACE_EXCEPTION_REGEX
1214
from ..constants import STACKTRACE_FILE_LINE
@@ -28,13 +30,39 @@ def asm_report_stacktrace_leak_from_django_debug_page(exc_name, module):
2830
set_iast_stacktrace_reported(True)
2931

3032

31-
def asm_check_stacktrace_leak(content: str) -> None:
33+
# `werkzeug.DebugTraceback.render_debugger_html` runs outside of `iast_request_context`. Because of this, when
34+
# this function is called, we store the result to report it in the next request when there's context and the
35+
# span hasn't been sent yet.
36+
REPORT_STACKTRACE_LATER = None
37+
38+
39+
def check_and_report_stacktrace_leak():
40+
report = get_report_stacktrace_later()
41+
if report:
42+
StacktraceLeak.report(evidence_value=report)
43+
set_report_stacktrace_later(None)
44+
45+
46+
def set_report_stacktrace_later(evidence):
47+
global REPORT_STACKTRACE_LATER
48+
REPORT_STACKTRACE_LATER = evidence
49+
50+
51+
def get_report_stacktrace_later():
52+
return REPORT_STACKTRACE_LATER
53+
54+
55+
def iast_check_stacktrace_leak(content: str) -> None:
3256
if not content:
3357
return
3458

3559
try:
3660
# Quick check to avoid the slower operations if on stacktrace
37-
if "Traceback (most recent call last):" not in content:
61+
if (
62+
"Traceback (most recent call last):" not in content
63+
and "Traceback <em>(most recent call last)" not in content
64+
and '<div class="traceback">' not in content
65+
):
3866
return
3967

4068
text = HTML_TAGS_REMOVE.sub("", content)
@@ -94,9 +122,11 @@ def asm_check_stacktrace_leak(content: str) -> None:
94122
if not module_name:
95123
module_name = module_path # fallback: just the path
96124

97-
increment_iast_span_metric(IAST_SPAN_TAGS.TELEMETRY_EXECUTED_SINK, StacktraceLeak.vulnerability_type)
98125
_set_metric_iast_executed_sink(StacktraceLeak.vulnerability_type)
99126
evidence = "Module: %s\nException: %s" % (module_name.strip(), exception_line.strip())
100-
StacktraceLeak.report(evidence_value=evidence)
127+
if asm_config.is_iast_request_enabled:
128+
StacktraceLeak.report(evidence_value=evidence)
129+
else:
130+
set_report_stacktrace_later(evidence)
101131
except Exception as e:
102-
iast_taint_log_error("[IAST] error in check stacktrace leak. {}".format(e))
132+
iast_error("Taint Sink error. Error in check stacktrace leak. {}".format(e))

ddtrace/contrib/internal/flask/patch.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def get_version():
7777
return get_version_for_package("flask")
7878

7979

80+
def get_werkzeug_version():
81+
# type: () -> str
82+
return get_version_for_package("werkzeug")
83+
84+
8085
if _HAS_JSON_MIXIN:
8186

8287
class RequestWithJson(werkzeug.Request, JSONMixin):
@@ -101,6 +106,9 @@ class RequestWithJson(werkzeug.Request, JSONMixin):
101106
flask_version_str = get_version()
102107
flask_version = parse_version(flask_version_str)
103108

109+
werkzeug_version_str = get_werkzeug_version()
110+
werkzeug_version = parse_version(werkzeug_version_str)
111+
104112

105113
class _FlaskWSGIMiddleware(_DDWSGIMiddlewareBase):
106114
_request_call_name = schematize_url_operation("flask.request", protocol="http", direction=SpanDirection.INBOUND)
@@ -224,10 +232,16 @@ def patch():
224232
_w("flask.templating", "_render", patched_render)
225233
_w("flask", "render_template", _build_render_template_wrapper("render_template"))
226234
_w("flask", "render_template_string", _build_render_template_wrapper("render_template_string"))
227-
try:
228-
_w("werkzeug.debug.tbtools", "DebugTraceback.render_debugger_html", patched_render_debugger_html)
229-
except AttributeError:
230-
log.debug("Failed to patch DebugTraceback.render_debugger_html, not supported by this werkzeug version")
235+
if werkzeug_version >= (2, 1, 0):
236+
try:
237+
_w("werkzeug.debug.tbtools", "DebugTraceback.render_debugger_html", patched_render_debugger_html)
238+
except AttributeError:
239+
log.debug("Failed to patch DebugTraceback.render_debugger_html, not supported by this werkzeug version")
240+
else:
241+
try:
242+
_w("werkzeug.debug.tbtools", "Traceback.render_summary", patched_render_debugger_html)
243+
except AttributeError:
244+
log.debug("Failed to patch Traceback.render_summary, not supported by this werkzeug version")
231245

232246
bp_hooks = [
233247
"after_app_request",

tests/appsec/app.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
""" This Flask application is imported on tests.appsec.appsec_utils.gunicorn_server
22
"""
3-
43
import os
54
import re
65
import subprocess # nosec
@@ -11,7 +10,9 @@
1110

1211

1312
import ddtrace.auto # noqa: F401 # isort: skip
13+
from ddtrace import tracer
1414
from ddtrace.appsec._iast import ddtrace_iast_flask_patch # noqa: F401
15+
from ddtrace.internal.utils.formats import asbool
1516
from tests.appsec.iast_packages.packages.pkg_aiohttp import pkg_aiohttp
1617
from tests.appsec.iast_packages.packages.pkg_aiosignal import pkg_aiosignal
1718
from tests.appsec.iast_packages.packages.pkg_annotated_types import pkg_annotated_types
@@ -193,10 +194,21 @@ def iast_cmdi_vulnerability():
193194
subp.communicate()
194195
subp.wait()
195196
resp = Response("OK")
196-
resp.set_cookie("insecure", "cookie", secure=True, httponly=True, samesite="None")
197197
return resp
198198

199199

200+
@app.route("/shutdown", methods=["GET"])
201+
def shutdown_view():
202+
tracer._writer.flush_queue()
203+
return "OK"
204+
205+
206+
@app.route("/iast-stacktrace-leak-vulnerability", methods=["GET"])
207+
def iast_stacktrace_vulnerability():
208+
raise ValueError("Check my stacktrace!")
209+
return "OK"
210+
211+
200212
@app.route("/iast-weak-hash-vulnerability", methods=["GET"])
201213
def iast_weak_hash_vulnerability():
202214
import _md5
@@ -970,5 +982,6 @@ def iast_ast_patching_non_re_search():
970982

971983
if __name__ == "__main__":
972984
env_port = os.getenv("FLASK_RUN_PORT", 8000)
985+
debug = asbool(os.getenv("FLASK_DEBUG", "false"))
973986
ddtrace_iast_flask_patch()
974-
app.run(debug=False, port=env_port)
987+
app.run(debug=debug, port=env_port)

tests/appsec/appsec_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def appsec_application_server(
9090
env["DD_REMOTE_CONFIGURATION_ENABLED"] = remote_configuration_enabled
9191
if token:
9292
env["_DD_REMOTE_CONFIGURATION_ADDITIONAL_HEADERS"] = "X-Datadog-Test-Session-Token:%s," % (token,)
93+
env["_DD_TRACE_WRITER_ADDITIONAL_HEADERS"] = "X-Datadog-Test-Session-Token:{}".format(token)
9394
if appsec_enabled is not None:
9495
env["DD_APPSEC_ENABLED"] = appsec_enabled
9596
if apm_tracing_enabled is not None:

tests/appsec/iast/taint_sinks/test_stacktrace_leak.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import os
22

33
from ddtrace.appsec._iast.constants import VULN_STACKTRACE_LEAK
4-
from ddtrace.appsec._iast.taint_sinks.stacktrace_leak import asm_check_stacktrace_leak
4+
from ddtrace.appsec._iast.taint_sinks._base import VulnerabilityBase
5+
from ddtrace.appsec._iast.taint_sinks.stacktrace_leak import check_and_report_stacktrace_leak
6+
from ddtrace.appsec._iast.taint_sinks.stacktrace_leak import get_report_stacktrace_later
7+
from ddtrace.appsec._iast.taint_sinks.stacktrace_leak import iast_check_stacktrace_leak
8+
from tests.appsec.iast.conftest import _end_iast_context_and_oce
9+
from tests.appsec.iast.conftest import _start_iast_context_and_oce
510
from tests.appsec.iast.taint_sinks.conftest import _get_span_report
611

712

@@ -13,8 +18,8 @@ def _load_text_stacktrace():
1318
return open(os.path.join(os.path.dirname(__file__), "../fixtures/plain_stacktrace.txt")).read()
1419

1520

16-
def test_asm_check_stacktrace_leak_html(iast_context_defaults):
17-
asm_check_stacktrace_leak(_load_html_django_stacktrace())
21+
def test_check_stacktrace_leak_html(iast_context_defaults):
22+
iast_check_stacktrace_leak(_load_html_django_stacktrace())
1823
span_report = _get_span_report()
1924
vulnerabilities = list(span_report.vulnerabilities)
2025
vulnerabilities_types = [vuln.type for vuln in vulnerabilities]
@@ -26,8 +31,8 @@ def test_asm_check_stacktrace_leak_html(iast_context_defaults):
2631
)
2732

2833

29-
def test_asm_check_stacktrace_leak_text(iast_context_defaults):
30-
asm_check_stacktrace_leak(_load_text_stacktrace())
34+
def test_check_stacktrace_leak_text(iast_context_defaults):
35+
iast_check_stacktrace_leak(_load_text_stacktrace())
3136
span_report = _get_span_report()
3237
vulnerabilities = list(span_report.vulnerabilities)
3338
vulnerabilities_types = [vuln.type for vuln in vulnerabilities]
@@ -37,3 +42,47 @@ def test_asm_check_stacktrace_leak_text(iast_context_defaults):
3742
vulnerabilities[0].evidence.value
3843
== 'Module: ".usr.local.lib.python3.9.site-packages.constraints.py"\nException: ValueError'
3944
)
45+
VulnerabilityBase._prepare_report._reset_cache()
46+
47+
48+
def test_stacktrace_leak_deduplication(iast_context_deduplication_enabled):
49+
for num_vuln_expected in [1, 0, 0]:
50+
_start_iast_context_and_oce()
51+
for _ in range(0, 5):
52+
iast_check_stacktrace_leak(_load_text_stacktrace())
53+
54+
span_report = _get_span_report()
55+
56+
if num_vuln_expected == 0:
57+
assert span_report is None
58+
else:
59+
assert span_report
60+
61+
assert len(span_report.vulnerabilities) == num_vuln_expected
62+
vulnerability = list(span_report.vulnerabilities)[0]
63+
assert vulnerability.type == VULN_STACKTRACE_LEAK
64+
_end_iast_context_and_oce()
65+
VulnerabilityBase._prepare_report._reset_cache()
66+
67+
68+
def test_check_stacktrace_leak_text_outside_context(iast_context_deduplication_enabled):
69+
_end_iast_context_and_oce()
70+
71+
# Report stacktrace outside the context
72+
iast_check_stacktrace_leak(_load_text_stacktrace())
73+
assert get_report_stacktrace_later() is not None
74+
_start_iast_context_and_oce()
75+
76+
# Check the stacktrace, now with a context, like the beginning of a request
77+
check_and_report_stacktrace_leak()
78+
span_report = _get_span_report()
79+
vulnerabilities = list(span_report.vulnerabilities)
80+
vulnerabilities_types = [vuln.type for vuln in vulnerabilities]
81+
assert len(vulnerabilities) == 1
82+
assert VULN_STACKTRACE_LEAK in vulnerabilities_types
83+
assert (
84+
vulnerabilities[0].evidence.value
85+
== 'Module: ".usr.local.lib.python3.9.site-packages.constraints.py"\nException: ValueError'
86+
)
87+
assert get_report_stacktrace_later() is None
88+
_end_iast_context_and_oce()

tests/appsec/integrations/flask_tests/test_iast_flask_telemetry.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77

88
def test_iast_span_metrics():
9-
# TODO: move tests/telemetry/conftest.py::test_agent_session into a common conftest
109
with flask_server(iast_enabled="true", token=None, port=8050) as context:
1110
_, flask_client, pid = context
1211

0 commit comments

Comments
 (0)