Skip to content

Commit d0f09b3

Browse files
Fixed #1682 -- alert user when using file field without proper encoding (#1933)
* Fixed issue #1682 -- alert user when using file field without proper encoding * Changed issues to alerts, added docstring to check_invalid_... function, changed call in nav_subtitle to get_stats * Update AlertsPanel documentation to list pre-defined alert cases * added check for file inputs that directly reference a form, including tests; also added tests for setting encoding in submit input type * Update the alert messages to be on the panel as a map. This also explicitly mentions what attribute the form needs in the message. * Expose a page in the example app that triggers the alerts panel. --------- Co-authored-by: Tim Schilling <schillingt@better-simple.com>
1 parent 325ea19 commit d0f09b3

File tree

11 files changed

+297
-0
lines changed

11 files changed

+297
-0
lines changed

debug_toolbar/panels/alerts.py

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from html.parser import HTMLParser
2+
3+
from django.utils.translation import gettext_lazy as _
4+
5+
from debug_toolbar.panels import Panel
6+
7+
8+
class FormParser(HTMLParser):
9+
"""
10+
HTML form parser, used to check for invalid configurations of forms that
11+
take file inputs.
12+
"""
13+
14+
def __init__(self):
15+
super().__init__()
16+
self.in_form = False
17+
self.current_form = {}
18+
self.forms = []
19+
self.form_ids = []
20+
self.referenced_file_inputs = []
21+
22+
def handle_starttag(self, tag, attrs):
23+
attrs = dict(attrs)
24+
if tag == "form":
25+
self.in_form = True
26+
form_id = attrs.get("id")
27+
if form_id:
28+
self.form_ids.append(form_id)
29+
self.current_form = {
30+
"file_form": False,
31+
"form_attrs": attrs,
32+
"submit_element_attrs": [],
33+
}
34+
elif (
35+
self.in_form
36+
and tag == "input"
37+
and attrs.get("type") == "file"
38+
and (not attrs.get("form") or attrs.get("form") == "")
39+
):
40+
self.current_form["file_form"] = True
41+
elif (
42+
self.in_form
43+
and (
44+
(tag == "input" and attrs.get("type") in {"submit", "image"})
45+
or tag == "button"
46+
)
47+
and (not attrs.get("form") or attrs.get("form") == "")
48+
):
49+
self.current_form["submit_element_attrs"].append(attrs)
50+
elif tag == "input" and attrs.get("form"):
51+
self.referenced_file_inputs.append(attrs)
52+
53+
def handle_endtag(self, tag):
54+
if tag == "form" and self.in_form:
55+
self.forms.append(self.current_form)
56+
self.in_form = False
57+
58+
59+
class AlertsPanel(Panel):
60+
"""
61+
A panel to alert users to issues.
62+
"""
63+
64+
messages = {
65+
"form_id_missing_enctype": _(
66+
'Form with id "{form_id}" contains file input, but does not have the attribute enctype="multipart/form-data".'
67+
),
68+
"form_missing_enctype": _(
69+
'Form contains file input, but does not have the attribute enctype="multipart/form-data".'
70+
),
71+
"input_refs_form_missing_enctype": _(
72+
'Input element references form with id "{form_id}", but the form does not have the attribute enctype="multipart/form-data".'
73+
),
74+
}
75+
76+
title = _("Alerts")
77+
78+
template = "debug_toolbar/panels/alerts.html"
79+
80+
def __init__(self, *args, **kwargs):
81+
super().__init__(*args, **kwargs)
82+
self.alerts = []
83+
84+
@property
85+
def nav_subtitle(self):
86+
alerts = self.get_stats()["alerts"]
87+
if alerts:
88+
alert_text = "alert" if len(alerts) == 1 else "alerts"
89+
return f"{len(alerts)} {alert_text}"
90+
else:
91+
return ""
92+
93+
def add_alert(self, alert):
94+
self.alerts.append(alert)
95+
96+
def check_invalid_file_form_configuration(self, html_content):
97+
"""
98+
Inspects HTML content for a form that includes a file input but does
99+
not have the encoding type set to multipart/form-data, and warns the
100+
user if so.
101+
"""
102+
parser = FormParser()
103+
parser.feed(html_content)
104+
105+
# Check for file inputs directly inside a form that do not reference
106+
# any form through the form attribute
107+
for form in parser.forms:
108+
if (
109+
form["file_form"]
110+
and form["form_attrs"].get("enctype") != "multipart/form-data"
111+
and not any(
112+
elem.get("formenctype") == "multipart/form-data"
113+
for elem in form["submit_element_attrs"]
114+
)
115+
):
116+
if form_id := form["form_attrs"].get("id"):
117+
alert = self.messages["form_id_missing_enctype"].format(
118+
form_id=form_id
119+
)
120+
else:
121+
alert = self.messages["form_missing_enctype"]
122+
self.add_alert({"alert": alert})
123+
124+
# Check for file inputs that reference a form
125+
form_attrs_by_id = {
126+
form["form_attrs"].get("id"): form["form_attrs"] for form in parser.forms
127+
}
128+
129+
for attrs in parser.referenced_file_inputs:
130+
form_id = attrs.get("form")
131+
if form_id and attrs.get("type") == "file":
132+
form_attrs = form_attrs_by_id.get(form_id)
133+
if form_attrs and form_attrs.get("enctype") != "multipart/form-data":
134+
alert = self.messages["input_refs_form_missing_enctype"].format(
135+
form_id=form_id
136+
)
137+
self.add_alert({"alert": alert})
138+
139+
return self.alerts
140+
141+
def generate_stats(self, request, response):
142+
html_content = response.content.decode(response.charset)
143+
self.check_invalid_file_form_configuration(html_content)
144+
145+
# Further alert checks can go here
146+
147+
# Write all alerts to record_stats
148+
self.record_stats({"alerts": self.alerts})

debug_toolbar/settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def get_config():
6767
"debug_toolbar.panels.sql.SQLPanel",
6868
"debug_toolbar.panels.staticfiles.StaticFilesPanel",
6969
"debug_toolbar.panels.templates.TemplatesPanel",
70+
"debug_toolbar.panels.alerts.AlertsPanel",
7071
"debug_toolbar.panels.cache.CachePanel",
7172
"debug_toolbar.panels.signals.SignalsPanel",
7273
"debug_toolbar.panels.redirects.RedirectsPanel",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{% load i18n %}
2+
3+
{% if alerts %}
4+
<h4>{% trans "Alerts found" %}</h4>
5+
{% for alert in alerts %}
6+
<ul>
7+
<li>{{ alert.alert }}</li>
8+
</ul>
9+
{% endfor %}
10+
{% else %}
11+
<h4>{% trans "No alerts found" %}</h4>
12+
{% endif %}

docs/changes.rst

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Change log
44
Pending
55
-------
66

7+
* Added alert panel with warning when form is using file fields
8+
without proper encoding type.
79
* Fixed overriding font-family for both light and dark themes.
810
* Restored compatibility with ``iptools.IpRangeList``.
911
* Limit ``E001`` check to likely error cases when the

docs/configuration.rst

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ default value is::
2929
'debug_toolbar.panels.sql.SQLPanel',
3030
'debug_toolbar.panels.staticfiles.StaticFilesPanel',
3131
'debug_toolbar.panels.templates.TemplatesPanel',
32+
'debug_toolbar.panels.alerts.AlertsPanel',
3233
'debug_toolbar.panels.cache.CachePanel',
3334
'debug_toolbar.panels.signals.SignalsPanel',
3435
'debug_toolbar.panels.redirects.RedirectsPanel',

docs/panels.rst

+11
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ Default built-in panels
99

1010
The following panels are enabled by default.
1111

12+
Alerts
13+
~~~~~~~
14+
15+
.. class:: debug_toolbar.panels.alerts.AlertsPanel
16+
17+
This panel shows alerts for a set of pre-defined cases:
18+
19+
- Alerts when the response has a form without the
20+
``enctype="multipart/form-data"`` attribute and the form contains
21+
a file input.
22+
1223
History
1324
~~~~~~~
1425

example/templates/bad_form.html

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{% load cache %}
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta http-equiv="content-type" content="text/html; charset=utf-8">
6+
<title>Bad form</title>
7+
</head>
8+
<body>
9+
<h1>Bad form test</h1>
10+
<form>
11+
<input type="file" name="file" />
12+
</form>
13+
</body>
14+
</html>

example/templates/index.html

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ <h1>Index of Tests</h1>
1414
<li><a href="/prototype/">Prototype 1.7.3.0</a></li>
1515
<li><a href="{% url 'turbo' %}">Hotwire Turbo</a></li>
1616
<li><a href="{% url 'htmx' %}">htmx</a></li>
17+
<li><a href="{% url 'bad_form' %}">Bad form</a></li>
1718
</ul>
1819
<p><a href="/admin/">Django Admin</a></p>
1920
{% endcache %}

example/urls.py

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88
urlpatterns = [
99
path("", TemplateView.as_view(template_name="index.html"), name="home"),
10+
path(
11+
"bad-form/",
12+
TemplateView.as_view(template_name="bad_form.html"),
13+
name="bad_form",
14+
),
1015
path("jquery/", TemplateView.as_view(template_name="jquery/index.html")),
1116
path("mootools/", TemplateView.as_view(template_name="mootools/index.html")),
1217
path("prototype/", TemplateView.as_view(template_name="prototype/index.html")),

tests/panels/test_alerts.py

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from django.http import HttpResponse
2+
from django.template import Context, Template
3+
4+
from ..base import BaseTestCase
5+
6+
7+
class AlertsPanelTestCase(BaseTestCase):
8+
panel_id = "AlertsPanel"
9+
10+
def test_alert_warning_display(self):
11+
"""
12+
Test that the panel (does not) display[s] an alert when there are
13+
(no) problems.
14+
"""
15+
self.panel.record_stats({"alerts": []})
16+
self.assertNotIn("alerts", self.panel.nav_subtitle)
17+
18+
self.panel.record_stats({"alerts": ["Alert 1", "Alert 2"]})
19+
self.assertIn("2 alerts", self.panel.nav_subtitle)
20+
21+
def test_file_form_without_enctype_multipart_form_data(self):
22+
"""
23+
Test that the panel displays a form invalid message when there is
24+
a file input but encoding not set to multipart/form-data.
25+
"""
26+
test_form = '<form id="test-form"><input type="file"></form>'
27+
result = self.panel.check_invalid_file_form_configuration(test_form)
28+
expected_error = (
29+
'Form with id "test-form" contains file input, '
30+
'but does not have the attribute enctype="multipart/form-data".'
31+
)
32+
self.assertEqual(result[0]["alert"], expected_error)
33+
self.assertEqual(len(result), 1)
34+
35+
def test_file_form_no_id_without_enctype_multipart_form_data(self):
36+
"""
37+
Test that the panel displays a form invalid message when there is
38+
a file input but encoding not set to multipart/form-data.
39+
40+
This should use the message when the form has no id.
41+
"""
42+
test_form = '<form><input type="file"></form>'
43+
result = self.panel.check_invalid_file_form_configuration(test_form)
44+
expected_error = (
45+
"Form contains file input, but does not have "
46+
'the attribute enctype="multipart/form-data".'
47+
)
48+
self.assertEqual(result[0]["alert"], expected_error)
49+
self.assertEqual(len(result), 1)
50+
51+
def test_file_form_with_enctype_multipart_form_data(self):
52+
test_form = """<form id="test-form" enctype="multipart/form-data">
53+
<input type="file">
54+
</form>"""
55+
result = self.panel.check_invalid_file_form_configuration(test_form)
56+
57+
self.assertEqual(len(result), 0)
58+
59+
def test_file_form_with_enctype_multipart_form_data_in_button(self):
60+
test_form = """<form id="test-form">
61+
<input type="file">
62+
<input type="submit" formenctype="multipart/form-data">
63+
</form>"""
64+
result = self.panel.check_invalid_file_form_configuration(test_form)
65+
66+
self.assertEqual(len(result), 0)
67+
68+
def test_referenced_file_input_without_enctype_multipart_form_data(self):
69+
test_file_input = """<form id="test-form"></form>
70+
<input type="file" form = "test-form">"""
71+
result = self.panel.check_invalid_file_form_configuration(test_file_input)
72+
73+
expected_error = (
74+
'Input element references form with id "test-form", '
75+
'but the form does not have the attribute enctype="multipart/form-data".'
76+
)
77+
self.assertEqual(result[0]["alert"], expected_error)
78+
self.assertEqual(len(result), 1)
79+
80+
def test_referenced_file_input_with_enctype_multipart_form_data(self):
81+
test_file_input = """<form id="test-form" enctype="multipart/form-data">
82+
</form>
83+
<input type="file" form = "test-form">"""
84+
result = self.panel.check_invalid_file_form_configuration(test_file_input)
85+
86+
self.assertEqual(len(result), 0)
87+
88+
def test_integration_file_form_without_enctype_multipart_form_data(self):
89+
t = Template('<form id="test-form"><input type="file"></form>')
90+
c = Context({})
91+
rendered_template = t.render(c)
92+
response = HttpResponse(content=rendered_template)
93+
94+
self.panel.generate_stats(self.request, response)
95+
96+
self.assertIn("1 alert", self.panel.nav_subtitle)
97+
self.assertIn(
98+
"Form with id &quot;test-form&quot; contains file input, "
99+
"but does not have the attribute enctype=&quot;multipart/form-data&quot;.",
100+
self.panel.content,
101+
)

tests/panels/test_history.py

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class HistoryViewsTestCase(IntegrationTestCase):
7575
"SQLPanel",
7676
"StaticFilesPanel",
7777
"TemplatesPanel",
78+
"AlertsPanel",
7879
"CachePanel",
7980
"SignalsPanel",
8081
"ProfilingPanel",

0 commit comments

Comments
 (0)