diff --git a/app/controllers/lead_submissions_controller.rb b/app/controllers/lead_submissions_controller.rb index 64c25dcced6a7..859582c90ab43 100644 --- a/app/controllers/lead_submissions_controller.rb +++ b/app/controllers/lead_submissions_controller.rb @@ -6,7 +6,7 @@ def check submissions = current_user.lead_submissions.where(organization_lead_form_id: form_ids) .pluck(:organization_lead_form_id, :created_at) result = submissions.to_h { |form_id, created_at| [form_id.to_s, created_at.iso8601] } - render json: result + render json: result.merge("csrf_token" => form_authenticity_token) end def create diff --git a/app/liquid_tags/org_lead_gate_tag.rb b/app/liquid_tags/org_lead_gate_tag.rb new file mode 100644 index 0000000000000..bc12de7b43b9a --- /dev/null +++ b/app/liquid_tags/org_lead_gate_tag.rb @@ -0,0 +1,54 @@ +# A lead-generation gate for organization pages. The enclosed HTML remains in +# the public page source, so this must not be used as an authorization boundary. +class OrgLeadGateTag < Liquid::Block + PARTIAL = "liquids/org_lead_gate".freeze + VALID_CONTEXTS = %w[Organization].freeze + + def initialize(_tag_name, input, parse_context) + super + source = parse_context.partial_options[:source] + validate_source(source) + @form = find_form(input) + + return if @form.organization_id == source.id + + raise StandardError, I18n.t("liquid_tags.org_lead_form_tag.wrong_organization") + end + + def render(context) + ApplicationController.render( + partial: PARTIAL, + locals: { form: @form, gated_content: super }, + ) + end + + private + + def find_form(input) + form_id = Integer(input.strip, exception: false) + unless form_id&.positive? + raise StandardError, I18n.t("liquid_tags.org_lead_gate_tag.invalid_id") + end + + form = OrganizationLeadForm.find_by(id: form_id) + raise StandardError, I18n.t("liquid_tags.org_lead_form_tag.not_found") unless form + raise StandardError, I18n.t("liquid_tags.org_lead_form_tag.inactive") unless form.active? + + form + end + + def validate_source(source) + unless source + raise LiquidTags::Errors::InvalidParseContext, + I18n.t("liquid_tags.liquid_tag_base.no_source_found") + end + + return if VALID_CONTEXTS.include?(source.class.name) + + valid_contexts = VALID_CONTEXTS.map(&:pluralize).join(", ") + error_message = I18n.t("liquid_tags.liquid_tag_base.invalid_context", valid: valid_contexts) + raise LiquidTags::Errors::InvalidParseContext, error_message + end +end + +Liquid::Template.register_tag("org_lead_gate", OrgLeadGateTag) diff --git a/app/services/html/parser.rb b/app/services/html/parser.rb index 1b2138d9c1835..c6db15bca1e2a 100644 --- a/app/services/html/parser.rb +++ b/app/services/html/parser.rb @@ -67,7 +67,11 @@ def wrap_all_images_in_links doc.search("p img").each do |image| next if image.parent.name == "a" - image.swap("#{image}") + link = doc.document.create_element("a") + link["href"] = image.attr("src").to_s + link["class"] = "article-body-image-wrapper" + image.replace(link) + link.add_child(image) end @html = doc.to_html diff --git a/app/views/liquids/_org_lead_gate.html.erb b/app/views/liquids/_org_lead_gate.html.erb new file mode 100644 index 0000000000000..0fca592e601be --- /dev/null +++ b/app/views/liquids/_org_lead_gate.html.erb @@ -0,0 +1,101 @@ +
+
+

<%= form.title %>

+ <% if form.description.present? %> +

<%= form.description %>

+ <% end %> + + + + +
+ + + +
+ + diff --git a/config/fastly/snippets/safe_params_list.vcl b/config/fastly/snippets/safe_params_list.vcl index 8f1f3de0d1bdb..768afc7b17ee9 100644 --- a/config/fastly/snippets/safe_params_list.vcl +++ b/config/fastly/snippets/safe_params_list.vcl @@ -2,6 +2,6 @@ import querystring; sub vcl_recv { # return this URL with only the parameters that match this regular expression if (req.url !~ "/ahoy/" && req.url !~ "/admin/" && req.url !~ "/search/" && req.url !~ "/bulk_show" && !(req.url ~ "^/api" && req.http.api-key)) { - set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|email|filter|followable_id|followable_type|forem_owner_secret|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|onboarding|org_id|organization_id|p|page|per_page|p_id|placement_area|prefill|preview|purchaser|q|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|source_id|source_type|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug|period|comments_sort|billboard|controller_action|bb_test_placement_area|cookies_allowed|members|bb_test_id|item|mode|month|page_id|token|passed_domain)$"); + set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|email|filter|followable_id|followable_type|forem_owner_secret|fork_id|form_ids|i|key|message_offset|name|oauth_token|oauth_verifier|offset|onboarding|org_id|organization_id|p|page|per_page|p_id|placement_area|prefill|preview|purchaser|q|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|source_id|source_type|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug|period|comments_sort|billboard|controller_action|bb_test_placement_area|cookies_allowed|members|bb_test_id|item|mode|month|page_id|token|passed_domain)$"); } } diff --git a/config/locales/liquid_tags/en.yml b/config/locales/liquid_tags/en.yml index 72b8824bd89e9..2999b4bbeec45 100644 --- a/config/locales/liquid_tags/en.yml +++ b/config/locales/liquid_tags/en.yml @@ -199,5 +199,10 @@ en: field_job_title: Job Title name_email_required: Name and email are required. wrong_organization: This lead form does not belong to this organization. + org_lead_gate_tag: + invalid_id: "Invalid lead form ID. Use: {% org_lead_gate ID %}...{% endorg_lead_gate %}" + sign_in_required: Sign in with your %{community} account to submit this form and unlock the content. + sign_in: Sign in to unlock + data_shared: "Shares your name, email, username, company, and title with this organization." youtube_tag: invalid_youtube_id: Invalid YouTube ID or URL diff --git a/config/locales/liquid_tags/fr.yml b/config/locales/liquid_tags/fr.yml index 50d7c4ae26467..d33c8d80025df 100644 --- a/config/locales/liquid_tags/fr.yml +++ b/config/locales/liquid_tags/fr.yml @@ -199,5 +199,10 @@ fr: field_job_title: Poste name_email_required: Le nom et l'e-mail sont obligatoires. wrong_organization: Ce formulaire de leads n'appartient pas à cette organisation. + org_lead_gate_tag: + invalid_id: "ID de formulaire de leads non valide. Utilisez : {% org_lead_gate ID %}...{% endorg_lead_gate %}" + sign_in_required: Connectez-vous avec votre compte %{community} pour envoyer ce formulaire et déverrouiller le contenu. + sign_in: Se connecter pour déverrouiller + data_shared: "Partage votre nom, e-mail, nom d’utilisateur, entreprise et poste avec cette organisation." youtube_tag: invalid_youtube_id: ID ou URL YouTube non valide diff --git a/config/locales/liquid_tags/pt.yml b/config/locales/liquid_tags/pt.yml index b39a9217a55cb..9195a5bdf855b 100644 --- a/config/locales/liquid_tags/pt.yml +++ b/config/locales/liquid_tags/pt.yml @@ -199,5 +199,10 @@ pt: field_job_title: Cargo name_email_required: Nome e e-mail são obrigatórios. wrong_organization: Este formulário de leads não pertence a esta organização. + org_lead_gate_tag: + invalid_id: "ID de formulário de leads inválido. Use: {% org_lead_gate ID %}...{% endorg_lead_gate %}" + sign_in_required: Entre com sua conta %{community} para enviar este formulário e desbloquear o conteúdo. + sign_in: Entrar para desbloquear + data_shared: "Compartilha seu nome, e-mail, nome de usuário, empresa e cargo com esta organização." youtube_tag: invalid_youtube_id: ID do YouTube inválido diff --git a/spec/liquid_tags/org_lead_gate_tag_spec.rb b/spec/liquid_tags/org_lead_gate_tag_spec.rb new file mode 100644 index 0000000000000..63fea26ed4020 --- /dev/null +++ b/spec/liquid_tags/org_lead_gate_tag_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe OrgLeadGateTag, type: :liquid_tag do + let(:organization) { create(:organization) } + let(:lead_form) { create(:organization_lead_form, organization: organization, title: "Watch the recording") } + let(:liquid_tag_options) { { source: organization, user: nil } } + + def parse_tag(input = lead_form.id.to_s, content: "

Gated recording

", options: liquid_tag_options) + Liquid::Template.parse( + "{% org_lead_gate #{input} %}#{content}{% endorg_lead_gate %}", + options, + ) + end + + before do + Liquid::Template.register_tag("org_lead_gate", described_class) + end + + it "renders a signed-in lead gate with deferred content" do + rendered = parse_tag.render + + expect(rendered).to include("ltag-org-lead-gate") + expect(rendered).to include("Watch the recording") + expect(rendered).to include("data-org-lead-gate-submit") + expect(rendered).to include("name, email, username, company, and title") + expect(rendered).to include('role="status" aria-live="polite"') + expect(rendered).to include("") + expect(rendered).not_to include('input name="email"') + end + + it "checks for an existing authenticated submission before showing the form" do + rendered = parse_tag.render + + expect(rendered).to include("/lead_submissions/check?form_ids=") + expect(rendered).to include("csrfToken = data.csrf_token") + expect(rendered).to include("if (data[formId])") + expect(rendered).to include("document.body.getAttribute('data-user-status') !== 'logged-in'") + end + + it "does not enable submission when the authenticated check fails" do + rendered = parse_tag.render + + expect(rendered).to include("submitButton.disabled = !retryable") + expect(rendered).to include("showError('Something went wrong. Please try again.', false)") + end + + it "does not expose raw browser errors when submission fails" do + rendered = parse_tag.render + + expect(rendered).to include("showError('Something went wrong. Please try again.', true)") + expect(rendered).not_to include("showError(error.message") + end + + it "preserves the deferred content through the Markdown renderer" do + markdown = "{% org_lead_gate #{lead_form.id} %}**Gated recording**{% endorg_lead_gate %}" + rendered = MarkdownProcessor::Parser.new(markdown, source: organization).finalize + + expect(rendered).to include("") + end + + it "rejects a non-numeric form ID" do + expect { parse_tag("abc") } + .to raise_error(StandardError, I18n.t("liquid_tags.org_lead_gate_tag.invalid_id")) + end + + it "rejects an inactive form" do + lead_form.update!(active: false) + + expect { parse_tag } + .to raise_error(StandardError, I18n.t("liquid_tags.org_lead_form_tag.inactive")) + end + + it "rejects a form owned by another organization" do + other_form = create(:organization_lead_form) + + expect { parse_tag(other_form.id.to_s) } + .to raise_error(StandardError, I18n.t("liquid_tags.org_lead_form_tag.wrong_organization")) + end + + it "rejects use outside an organization page" do + options = { source: build(:billboard), user: nil } + + expect { parse_tag(options: options) } + .to raise_error(LiquidTags::Errors::InvalidParseContext) + end +end diff --git a/spec/requests/lead_submissions_spec.rb b/spec/requests/lead_submissions_spec.rb index 7ca26ed1fb8ef..838b5e8b776c4 100644 --- a/spec/requests/lead_submissions_spec.rb +++ b/spec/requests/lead_submissions_spec.rb @@ -5,6 +5,29 @@ let(:lead_form) { create(:organization_lead_form, organization: organization) } let(:user) { create(:user) } + describe "GET /lead_submissions/check" do + before { sign_in user } + + it "requires a signed-in user" do + sign_out user + + get "/lead_submissions/check", params: { form_ids: lead_form.id }, as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it "returns the current user's submissions in the existing shape and a fresh CSRF token" do + submission = create(:lead_submission, organization_lead_form: lead_form, user: user) + + get "/lead_submissions/check", params: { form_ids: lead_form.id }, as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body[lead_form.id.to_s]).to eq(submission.created_at.iso8601) + expect(response.parsed_body["csrf_token"]).to be_present + expect(response.parsed_body).not_to have_key("submissions") + end + end + describe "POST /lead_submissions" do context "when signed in" do before { sign_in user } diff --git a/spec/services/html/parser_spec.rb b/spec/services/html/parser_spec.rb index dde7baed07464..a9691ebd68020 100644 --- a/spec/services/html/parser_spec.rb +++ b/spec/services/html/parser_spec.rb @@ -99,9 +99,31 @@ end it "wraps image in link" do - html = "

" \ + "" + expect(parsed_html).to include(expected) + end + + it "does not wrap images already inside a link" do + html = "

" + parsed_html = described_class.new(html).wrap_all_images_in_links.html + doc = Nokogiri::HTML.fragment(parsed_html) + expect(doc.css("a").size).to eq(1) + expect(doc.at_css("a")["href"]).to eq("https://example.com") + end + + it "safely escapes src attributes containing quotes without injecting event handlers" do + html = "

\"B\"

" + parsed_html = described_class.new(html).wrap_all_images_in_links.html + doc = Nokogiri::HTML.fragment(parsed_html) + + link = doc.at_css("a") + expect(link).to be_present + expect(link["onmouseover"]).to be_nil + expect(link["class"]).to eq("article-body-image-wrapper") + expect(doc.xpath("//*[@onmouseover]")).to be_empty end end