diff --git a/src/strass/strass_app/forms.py b/src/strass/strass_app/forms.py index e61e6d7a3a8f7655605483e75dc801c5c9435b83..b0817194768c570e5a7a1f7811f413fdb1f59df1 100644 --- a/src/strass/strass_app/forms.py +++ b/src/strass/strass_app/forms.py @@ -27,7 +27,7 @@ from django.utils.translation import gettext_lazy as _, gettext, ngettext from language_override.translation import gettext_lazy as ogettext from live_settings import live_settings -from strass_app import models, business_logic, misc, data_io +from strass_app import models, business_logic, misc, data_io, utils from strass_app.custom_layout_object import Formset from strass_app.templatetags.strass_tags import markdown from strass_app.utils import get_email_backend, validate_multiple_email, safe_pdf @@ -350,6 +350,8 @@ class CandidateForm(ModelFormWithReadOnly): del self.fields['cv'] if not live_settings.motivation_enabled__bool: del self.fields['motivation'] + else: + self.fields['motivation'].help_text = utils.use_markdown_or_plain_text_message def clean(self): cleaned_data = super().clean() @@ -551,6 +553,11 @@ class AppSettingsForm(forms.Form): help_text=_("AppSettingsForm.motivation_enabled.help_text"), required=False, ) + markdown_enabled = forms.BooleanField( + label=_("AppSettingsForm.markdown_enabled.label"), + help_text=_("AppSettingsForm.markdown_enabled.help_text"), + required=False, + ) language_override_autofill_enabled = forms.BooleanField( label=_("AppSettingsForm.language_override_autofill_enabled.label"), help_text=_("AppSettingsForm.language_override_autofill_enabled.help_text"), @@ -597,6 +604,7 @@ class AppSettingsForm(forms.Form): initial["plausible_data_domain"] = live_settings.plausible_data_domain initial["cv_enabled"] = live_settings.cv_enabled__bool initial["motivation_enabled"] = live_settings.motivation_enabled__bool + initial["markdown_enabled"] = live_settings.markdown_enabled__bool initial["language_override_autofill_enabled"] = live_settings.language_override_autofill_enabled__bool super().__init__(initial=initial, *args, **kwargs) if self.settings: @@ -718,6 +726,10 @@ class AppSettingsForm(forms.Form): live_settings.cv_enabled = self.cleaned_data["cv_enabled"] except KeyError: pass + try: + live_settings.markdown_enabled = self.cleaned_data["markdown_enabled"] + except KeyError: + pass try: live_settings.language_override_autofill_enabled = self.cleaned_data["language_override_autofill_enabled"] except KeyError: diff --git a/src/strass/strass_app/locale/en/LC_MESSAGES/django.po b/src/strass/strass_app/locale/en/LC_MESSAGES/django.po index 8776558c09f715d68b2eb4fcb74db497c6b837dc..6798b5673e49554ab2091271009e76468c52063f 100644 --- a/src/strass/strass_app/locale/en/LC_MESSAGES/django.po +++ b/src/strass/strass_app/locale/en/LC_MESSAGES/django.po @@ -10,7 +10,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-12 17:57+0100\n" +"POT-Creation-Date: 2025-02-12 18:54+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -508,6 +508,16 @@ msgid "AppSettingsForm.motivation_enabled.help_text" msgstr "" "When enabled, a motivation letter is required to submit an application." +msgid "AppSettingsForm.markdown_enabled.label" +msgstr "Markdown enabled" + +msgid "AppSettingsForm.markdown_enabled.help_text" +msgstr "" +"Applicable to the entire application. If disabled, the content will be " +"displayed as plain text. Markdown can be used for code injection attacks. " +"Although the application has been protected against this, the feature can " +"still be disabled in case of suspicion." + msgid "AppSettingsForm.language_override_autofill_enabled.label" msgstr "Debug and autofill language override module" @@ -1312,9 +1322,6 @@ msgstr "" msgid "Candidate.motivation.verbose_name" msgstr "Motivation letter" -msgid "Candidate.motivation.help_text" -msgstr "You can use markdown here." - msgid "Candidate.cv.verbose_name" msgstr "Resume" @@ -2561,6 +2568,12 @@ msgstr "" msgid "The email address \"%(mail)s\" is invalid." msgstr "" +msgid "You can use markdown here." +msgstr "" + +msgid "Write in plain text here." +msgstr "Write in plain text here (no html, Markdown, ...)." + #, python-format msgid "" "MIME type \"%(mime_type)s\" is not allowed. Allowed MIME types are: " diff --git a/src/strass/strass_app/locale/fr/LC_MESSAGES/django.po b/src/strass/strass_app/locale/fr/LC_MESSAGES/django.po index eb5bcaa4894069c1269b7f6c39402b74f58e9ed3..f88d736dd13f6f0c62ff6411f54934ba8634f163 100644 --- a/src/strass/strass_app/locale/fr/LC_MESSAGES/django.po +++ b/src/strass/strass_app/locale/fr/LC_MESSAGES/django.po @@ -10,7 +10,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-12 17:57+0100\n" +"POT-Creation-Date: 2025-02-12 18:54+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -541,6 +541,16 @@ msgstr "" "Une lettre de motivation doit-elle être écrite pour soumettre une " "candidature ?" +msgid "AppSettingsForm.markdown_enabled.label" +msgstr "Markdown activé" + +msgid "AppSettingsForm.markdown_enabled.help_text" +msgstr "" +"Valable pour l'ensemble de l'application. Si désactivé alors les contenus " +"seront affichés comme du texte simple. Le markdown peut être utilisé pour " +"des attaques par injection de code, l'application à été protégé contre, mais " +"la fonctionnalité peut tout de même être désactivée en cas de suspicion." + msgid "AppSettingsForm.language_override_autofill_enabled.label" msgstr "" "Activer le mode debug, remplir automatiquement le module de substitution " @@ -1367,9 +1377,6 @@ msgstr "" msgid "Candidate.motivation.verbose_name" msgstr "Lettre de motivation" -msgid "Candidate.motivation.help_text" -msgstr "Possibilité d'écrire en markdown." - msgid "Candidate.cv.verbose_name" msgstr "CV" @@ -2679,6 +2686,12 @@ msgstr "Aucune action sélectionnée" msgid "The email address \"%(mail)s\" is invalid." msgstr "L'adresse de courriel \"%(mail)s\" est invalide." +msgid "You can use markdown here." +msgstr "Vous pouvez écrire en markdown." + +msgid "Write in plain text here." +msgstr "Le contenu doit être du texte simple (pas de html, Markdown, ...)." + #, python-format msgid "" "MIME type \"%(mime_type)s\" is not allowed. Allowed MIME types are: " diff --git a/src/strass/strass_app/migrations/0003_auto_20201028_1803.py b/src/strass/strass_app/migrations/0003_auto_20201028_1803.py index f4677b4ec7b8608f5107f9a32ca581ff5eb0ab44..d5745444c90c285625623dd6b3f6aa3517eb6f7c 100644 --- a/src/strass/strass_app/migrations/0003_auto_20201028_1803.py +++ b/src/strass/strass_app/migrations/0003_auto_20201028_1803.py @@ -22,6 +22,7 @@ def migration_code(apps, schema_editor): set_default_live_setting("show_key_date_after_call", True) set_default_live_setting("cv_enabled", True) set_default_live_setting("motivation_enabled", True) + set_default_live_setting("markdown_enabled", True) set_default_live_setting("show_documentation_link", True) diff --git a/src/strass/strass_app/migrations/0042_alter_candidate_motivation.py b/src/strass/strass_app/migrations/0042_alter_candidate_motivation.py new file mode 100644 index 0000000000000000000000000000000000000000..136ac4d8b60952da605457b588ddb98dfc3f1bb2 --- /dev/null +++ b/src/strass/strass_app/migrations/0042_alter_candidate_motivation.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.19 on 2025-02-17 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('strass_app', '0041_alter_candidate_cv'), + ] + + operations = [ + migrations.AlterField( + model_name='candidate', + name='motivation', + field=models.TextField(verbose_name='Candidate.motivation.verbose_name'), + ), + ] diff --git a/src/strass/strass_app/models.py b/src/strass/strass_app/models.py index d7af7bcc51aad96c5b78f94b21306891229d21e9..37c551512cac1ddceee79792115462b21786e102 100644 --- a/src/strass/strass_app/models.py +++ b/src/strass/strass_app/models.py @@ -564,7 +564,6 @@ class Candidate(models.Model): ) motivation = models.TextField( verbose_name=_("Candidate.motivation.verbose_name"), - help_text=_("Candidate.motivation.help_text"), ) cv = models.FileField( verbose_name=_("Candidate.cv.verbose_name"), diff --git a/src/strass/strass_app/templates/strass_app/setup.html b/src/strass/strass_app/templates/strass_app/setup.html index 2d90d0f4ca1aff4bb25702d8aed021e2eb7bd86c..f22eae2b9626c06b1d32882abf94b6cfb02e8883 100644 --- a/src/strass/strass_app/templates/strass_app/setup.html +++ b/src/strass/strass_app/templates/strass_app/setup.html @@ -283,7 +283,7 @@ {% include "strass_app/setup_card_setting_form.html" with key='show_email_as_message-contact_us-email_prefix' title=title id="mail-settings" %} {% trans "Application configuration" as title %} -{% include "strass_app/setup_card_setting_form.html" with key='app_name-show_documentation_link-day_or_time_in_abscissa-google_tracker_id-plausible_data_domain' title=title id="misc-settings" %} +{% include "strass_app/setup_card_setting_form.html" with key='app_name-show_documentation_link-markdown_enabled-day_or_time_in_abscissa-google_tracker_id-plausible_data_domain' title=title id="misc-settings" %} <div class="col-12 col-md-6 col-lg-4 col-xl mb-4"> diff --git a/src/strass/strass_app/templatetags/strass_tags.py b/src/strass/strass_app/templatetags/strass_tags.py index 9918d180f0fc9036ffde8198c735badc9bf668b0..c70d498313aac9fcfe868800aed8cefad37b9d79 100644 --- a/src/strass/strass_app/templatetags/strass_tags.py +++ b/src/strass/strass_app/templatetags/strass_tags.py @@ -7,6 +7,7 @@ from django import template from django.contrib.auth import get_user_model from django.db.models import QuerySet, Exists, OuterRef, Q, Avg, Value, F, FloatField, Subquery, ManyToManyField from django.db.models.functions import Upper, Round +from django.template.defaultfilters import linebreaksbr from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext @@ -199,6 +200,15 @@ __MARKDOWN_WHITE_LIST = [ @register.filter def markdown(value): + if not live_settings.markdown_enabled__bool: + return mark_safe( + '<p>' + + linebreaksbr( + value.replace('<br/>', '\n'), + autoescape=True, + ) + + '</p>' + ) value = cleanup_line_starts(value) value = escape(value) for tag, escaped_tag in __MARKDOWN_WHITE_LIST: diff --git a/src/strass/strass_app/tests/test_candidate_apply.py b/src/strass/strass_app/tests/test_candidate_apply.py index 0235029122af91f22f7c29014b759f3751c76ad4..9f6a4dfa38a726e94b88d09c5e4824b18ff20abd 100644 --- a/src/strass/strass_app/tests/test_candidate_apply.py +++ b/src/strass/strass_app/tests/test_candidate_apply.py @@ -752,6 +752,10 @@ class TestCandidateApply(TooledTestCase): pref.save() self.apply_simple(smtp_issue=True) + def test_apply_with_markdown_disabled(self): + live_settings.markdown_enabled = False + self.apply_simple(smtp_issue=False) + def apply_simple( self, *, diff --git a/src/strass/strass_app/tests/test_views_candidate.py b/src/strass/strass_app/tests/test_views_candidate.py index 0878e97ccc359514fccc3fb11e3118e4cb3816b9..88480e5bde98fb9ef1ae3b44c69dbab845f3ff8f 100644 --- a/src/strass/strass_app/tests/test_views_candidate.py +++ b/src/strass/strass_app/tests/test_views_candidate.py @@ -395,6 +395,25 @@ class ViewsTestCase(BaseTestCase): self.assertIn("<script", content_str, "check page will still work") self.assertIn(expected_html, content_str, "check markdown still work") + def test_candidate_html_injection_with_markdown_killed(self): + live_settings.markdown_enabled = False + injection_script = '<script>window.alter("HTML INJECTION!")</script>' + str_part = 'hello world' + ok_md = '\n\n## ' + str_part + expected_html = '<h2>' + str_part + + candidate = self.candidate_with_account.get_associated_candidate() + candidate.motivation = f"foobar {injection_script} zoorrr {ok_md} tt" + candidate.save() + url = reverse('strass:candidate-detail-me') + self.client.force_login(self.candidate_with_account) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + content_str = str(response.content) + self.assertNotIn(injection_script, content_str, "script injection should be prevented") + self.assertIn("<script", content_str, "check page will still work") + self.assertNotIn(expected_html, content_str, "check markdown is killed") + class ViewsTooledTestCase(TooledTestCase): def test_delete_user(self): diff --git a/src/strass/strass_app/tests/test_views_others.py b/src/strass/strass_app/tests/test_views_others.py index cafbd4a33b35814b1cd02e6d9bbf16800db0e510..5b02197b2a0f64b3f4fbedcecc3ee45b70f4eded 100644 --- a/src/strass/strass_app/tests/test_views_others.py +++ b/src/strass/strass_app/tests/test_views_others.py @@ -557,6 +557,7 @@ class OtherWithDataTestCase(BaseTestCase): def test_call_html_allowed_and_injection_prevented(self): h2_str = "hello world" + h2_html = '<h2>' + h2_str img_tag = '<img src="foo.bar"/>' img_tag_md = '' img_tag_from_md = markdown(img_tag_md) @@ -567,11 +568,30 @@ class OtherWithDataTestCase(BaseTestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) content_str = str(response.content) - self.assertIn(h2_str, content_str, "an h2 should be rendered, is markdown working?") + self.assertIn(h2_html, content_str, "an h2 should be rendered, is markdown working?") self.assertNotIn(img_tag, content_str, "<img is not allowed yet") self.assertIn(img_tag_from_md, content_str, "<img should produced after ![image]") self.assertNotIn(script_tag, content_str, "<script should still be prevented") + def test_call_markdown_killed(self): + live_settings.markdown_enabled = False + h2_str = "hello world" + h2_html = '<h2>' + h2_str + img_tag = '<img src="foo.bar"/>' + img_tag_md = '' + img_tag_from_md = markdown(img_tag_md) + script_tag = '<script foo="bar>' + models.CallContent.objects.update(content=f'## {h2_str}\n\n{img_tag}\n\n{script_tag}\n\n{img_tag_md}') + url = reverse('home') + ####################################################################### + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + content_str = str(response.content) + self.assertNotIn(h2_html, content_str, "an h2 should not be rendered as markdown is killed") + self.assertNotIn(img_tag, content_str, "<img is not allowed yet") + self.assertNotIn(img_tag_from_md, content_str, "<img should NOT be produced as markdown is killed") + self.assertNotIn(script_tag, content_str, "<script should still be prevented") + def test_autocomplete_email(self): u = reverse('strass:autocomplete-mail-view') urls = [u, u + '?term=ada'] diff --git a/src/strass/strass_app/utils.py b/src/strass/strass_app/utils.py index e0dfdde81bd8c80a318d924bb36722aa6474f5ab..7268d2c641263fe0830783a2732d5e4dddfbd5c1 100644 --- a/src/strass/strass_app/utils.py +++ b/src/strass/strass_app/utils.py @@ -293,3 +293,9 @@ def safe_pdf(my_stream: IO[Any]): writer.write(myio) myio.seek(0) return myio + + +def use_markdown_or_plain_text_message() -> str: + if live_settings.markdown_enabled__bool: + return _('You can use markdown here.') + return _('Write in plain text here.')