diff --git a/app/models/attestation_template.rb b/app/models/attestation_template.rb index b81b5c43781..bbc3cbc8c09 100644 --- a/app/models/attestation_template.rb +++ b/app/models/attestation_template.rb @@ -70,8 +70,8 @@ def render_attributes_for(params = {}) if dossier.present? attributes.merge({ - title: replace_tags(title, dossier), - body: replace_tags(body, dossier), + title: replace_tags(title, dossier, escape: false), + body: replace_tags(body, dossier, escape: false), signature: signature_to_render(dossier.groupe_instructeur) }) else diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 8ded0c3e9a8..bf827a77003 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -66,6 +66,7 @@ def self.normalize(str) libelle: 'motivation', description: 'Motivation facultative associée à la décision finale d’acceptation, refus ou classement sans suite', lambda: -> (d) { simple_format(d.motivation) }, + escapable: false, # sanitized by simple_format available_for_states: Dossier::TERMINE }, { @@ -118,14 +119,16 @@ def self.normalize(str) libelle: 'lien dossier', description: '', lambda: -> (d) { external_link(dossier_url(d)) }, - available_for_states: Dossier::SOUMIS + available_for_states: Dossier::SOUMIS, + escapable: false }, { id: 'dossier_attestation_url', libelle: 'lien attestation', description: '', lambda: -> (d) { external_link(attestation_dossier_url(d)) }, - available_for_states: [Dossier.states.fetch(:accepte)] + available_for_states: [Dossier.states.fetch(:accepte)], + escapable: false }, { id: 'dossier_motivation_url', @@ -138,7 +141,8 @@ def self.normalize(str) return "[l’instructeur n’a pas joint de document supplémentaire]" end }, - available_for_states: Dossier::TERMINE + available_for_states: Dossier::TERMINE, + escapable: false } ] @@ -310,11 +314,13 @@ def types_de_champ_tags(types_de_champ, available_for_states) tags end - def replace_tags(text, dossier) + def replace_tags(text, dossier, escape: true) if text.nil? return '' end + @escape_unsafe_tags = escape + tokens = parse_tags(text) tags_and_datas = [ @@ -352,11 +358,21 @@ def replace_tags(text, dossier) end def replace_tag(tag, data) - if tag.key?(:target) + value = if tag.key?(:target) data.public_send(tag[:target]) else instance_exec(data, &tag[:lambda]) end + + if escape_unsafe_tags? && tag.fetch(:escapable, true) + escape_once(value) + else + value + end + end + + def escape_unsafe_tags? + @escape_unsafe_tags end def procedure_types_de_champ_tags diff --git a/spec/models/concern/tags_substitution_concern_spec.rb b/spec/models/concern/tags_substitution_concern_spec.rb index 405d743f496..929982b0bfd 100644 --- a/spec/models/concern/tags_substitution_concern_spec.rb +++ b/spec/models/concern/tags_substitution_concern_spec.rb @@ -411,6 +411,24 @@ def procedure end end end + + context 'when data contains malicious code' do + let(:template) { '--libelleA-- --nom--' } + context 'in individual data' do + let(:for_individual) { true } + let(:individual) { create(:individual, nom: 'name') } + + it { is_expected.to eq('--libelleA-- <a href="https://oops.com">name</a>') } + end + + context 'in a champ' do + let(:types_de_champ_public) { [{ libelle: 'libelleA' }] } + + before { dossier.champs_public.first.update(value: 'hey anchor') } + + it { is_expected.to eq('hey <a href="https://oops.com">anchor</a> --nom--') } + end + end end describe 'tags' do