From b64a84a362cd58288dd8c5bd2fee1ac76d7b9c90 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 24 Sep 2025 12:07:54 +0300 Subject: [PATCH] add csp --- app/controllers/application_controller.rb | 22 ++ app/controllers/csp_controller.rb | 15 + app/javascript/application.js | 16 + app/javascript/application.scss | 4 +- app/javascript/elements/app_tour_start.js | 7 + app/javascript/elements/custom_validation.js | 14 + app/javascript/elements/remove_on_event.js | 15 + app/javascript/elements/review_form.js | 19 + app/javascript/elements/scroll_to.js | 10 + app/javascript/elements/search_input.js | 14 + app/javascript/elements/set_value.js | 11 + app/javascript/elements/show_on_value.js | 17 + app/javascript/elements/submit_form.js | 20 +- app/javascript/elements/toggle_classes.js | 11 + app/javascript/elements/toggle_visible.js | 12 +- app/javascript/form.js | 2 + app/javascript/form.scss | 4 +- app/javascript/submission_form/form.vue | 4 +- app/views/devise/shared/_links.html.erb | 4 +- app/views/esign_settings/show.html.erb | 14 +- .../notifications_settings/index.html.erb | 4 +- .../_form_toggle_options.html.erb | 4 +- app/views/scripts/_autosize_field.html.erb | 2 +- app/views/scripts/_server_selector.html.erb | 2 +- app/views/shared/_flash.html.erb | 2 +- app/views/shared/_navbar.html.erb | 4 +- app/views/shared/_search_input.html.erb | 18 +- app/views/shared/_settings_nav.html.erb | 4 +- app/views/shared/_test_mode_toggle.html.erb | 4 +- app/views/start_form/show.html.erb | 4 +- app/views/submissions/_detailed_form.html.erb | 24 +- app/views/submissions/_phone_form.html.erb | 12 +- app/views/submissions/_send_email.html.erb | 10 +- app/views/submissions/show.html.erb | 28 +- app/views/submitters/edit.html.erb | 8 +- app/views/templates/_upload_button.html.erb | 15 +- app/views/templates/new.html.erb | 16 +- app/views/templates_code_modal/show.html.erb | 4 +- app/views/templates_dashboard/index.html.erb | 10 +- .../templates_form_preview/show.html.erb | 14 +- .../_recipients.html.erb | 8 +- app/views/templates_preferences/show.html.erb | 46 ++- app/views/templates_share_link/show.html.erb | 8 +- .../webhook_events/_drawer_events.html.erb | 4 +- app/views/webhook_events/_event_row.html.erb | 4 +- app/views/webhook_settings/show.html.erb | 4 +- config/application.rb | 3 + config/routes.rb | 2 + package.json | 2 +- spec/system/template_spec.rb | 4 +- yarn.lock | 363 ++++++++++++++++-- 51 files changed, 725 insertions(+), 147 deletions(-) create mode 100644 app/controllers/csp_controller.rb create mode 100644 app/javascript/elements/app_tour_start.js create mode 100644 app/javascript/elements/custom_validation.js create mode 100644 app/javascript/elements/remove_on_event.js create mode 100644 app/javascript/elements/review_form.js create mode 100644 app/javascript/elements/scroll_to.js create mode 100644 app/javascript/elements/set_value.js create mode 100644 app/javascript/elements/show_on_value.js create mode 100644 app/javascript/elements/toggle_classes.js diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7500acdb..843ef605 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,6 +13,8 @@ class ApplicationController < ActionController::Base before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :authenticate_user!, unless: :devise_controller? + before_action :set_csp, if: -> { request.get? && !turbo_frame_request? && !request.headers['HTTP_VND.PREFETCH'] } + helper_method :button_title, :current_account, :form_link_host, @@ -123,4 +125,24 @@ class ApplicationController < ActionController::Base redirect_to request.url.gsub('.co/', '.com/'), allow_other_host: true, status: :moved_permanently end + + def set_csp + request.content_security_policy_report_only = Rails.env.production? + + request.content_security_policy = current_content_security_policy.tap do |policy| + policy.default_src :self + policy.script_src :self + policy.style_src :self, :unsafe_inline + policy.img_src :self, :https, :http, :blob, :data + policy.font_src :self, :https, :http, :blob, :data + policy.manifest_src :self + policy.media_src :self + policy.frame_src :self + policy.worker_src :self, :blob + policy.connect_src :self + policy.report_uri '/csp' + + policy.directives['connect-src'] << 'ws:' if Rails.env.development? + end + end end diff --git a/app/controllers/csp_controller.rb b/app/controllers/csp_controller.rb new file mode 100644 index 00000000..587ee306 --- /dev/null +++ b/app/controllers/csp_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CspController < ActionController::API + FILTER_REPORT_REGEXP = /extension|sandbox/i + + SANITIZE_REGEXP = %r{(/[sdep]/)(\w{5})[^/"]+} + + def create + data = request.raw_post.gsub(SANITIZE_REGEXP, '\1\2') + + Rails.logger.warn(data) if Rails.env.development? + + Rollbar.warning('CSP', data:) if defined?(Rollbar) && !data.match?(FILTER_REPORT_REGEXP) + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 7b51186d..b9467861 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -34,11 +34,19 @@ import MaskedInput from './elements/masked_input' import SetDateButton from './elements/set_date_button' import IndeterminateCheckbox from './elements/indeterminate_checkbox' import AppTour from './elements/app_tour' +import AppTourStart from './elements/app_tour_start' import DashboardDropzone from './elements/dashboard_dropzone' import RequiredCheckboxGroup from './elements/required_checkbox_group' import PageContainer from './elements/page_container' import EmailEditor from './elements/email_editor' import MountOnClick from './elements/mount_on_click' +import RemoveOnEvent from './elements/remove_on_event' +import ScrollTo from './elements/scroll_to' +import SetValue from './elements/set_value' +import ReviewForm from './elements/review_form' +import ShowOnValue from './elements/show_on_value' +import CustomValidation from './elements/custom_validation' +import ToggleClasses from './elements/toggle_classes' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -107,12 +115,20 @@ safeRegisterElement('masked-input', MaskedInput) safeRegisterElement('set-date-button', SetDateButton) safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox) safeRegisterElement('app-tour', AppTour) +safeRegisterElement('app-tour-start', AppTourStart) safeRegisterElement('dashboard-dropzone', DashboardDropzone) safeRegisterElement('check-on-click', CheckOnClick) safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) safeRegisterElement('page-container', PageContainer) safeRegisterElement('email-editor', EmailEditor) safeRegisterElement('mount-on-click', MountOnClick) +safeRegisterElement('remove-on-event', RemoveOnEvent) +safeRegisterElement('scroll-to', ScrollTo) +safeRegisterElement('set-value', SetValue) +safeRegisterElement('review-form', ReviewForm) +safeRegisterElement('show-on-value', ShowOnValue) +safeRegisterElement('custom-validation', CustomValidation) +safeRegisterElement('toggle-classes', ToggleClasses) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 1ab92498..47eeb4f6 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -19,7 +19,7 @@ button .disabled { display: none; } -button[disabled] .disabled { +button[disabled] .disabled, button.btn-disabled .disabled { display: initial; } @@ -27,7 +27,7 @@ button .enabled { display: initial; } -button[disabled] .enabled { +button[disabled] .enabled, button.btn-disabled .enabled { display: none; } diff --git a/app/javascript/elements/app_tour_start.js b/app/javascript/elements/app_tour_start.js new file mode 100644 index 00000000..1b06becb --- /dev/null +++ b/app/javascript/elements/app_tour_start.js @@ -0,0 +1,7 @@ +export default class extends HTMLElement { + connectedCallback () { + this.querySelector('form').addEventListener('submit', () => { + window.app_tour.start() + }) + } +} diff --git a/app/javascript/elements/custom_validation.js b/app/javascript/elements/custom_validation.js new file mode 100644 index 00000000..ac738679 --- /dev/null +++ b/app/javascript/elements/custom_validation.js @@ -0,0 +1,14 @@ +export default class extends HTMLElement { + connectedCallback () { + const input = this.querySelector('input') + const invalidMessage = this.dataset.invalidMessage || '' + + input.addEventListener('invalid', () => { + input.setCustomValidity(input.value ? invalidMessage : '') + }) + + input.addEventListener('input', () => { + input.setCustomValidity('') + }) + } +} diff --git a/app/javascript/elements/remove_on_event.js b/app/javascript/elements/remove_on_event.js new file mode 100644 index 00000000..e912e4cd --- /dev/null +++ b/app/javascript/elements/remove_on_event.js @@ -0,0 +1,15 @@ +export default class extends HTMLElement { + connectedCallback () { + const eventType = this.dataset.on || 'click' + const selector = document.getElementById(this.dataset.selectorId) || this + const eventElement = eventType === 'submit' ? this.querySelector('form') : this + + eventElement.addEventListener(eventType, (event) => { + if (eventType === 'click') { + event.preventDefault() + } + + selector.remove() + }) + } +} diff --git a/app/javascript/elements/review_form.js b/app/javascript/elements/review_form.js new file mode 100644 index 00000000..537fe602 --- /dev/null +++ b/app/javascript/elements/review_form.js @@ -0,0 +1,19 @@ +export default class extends HTMLElement { + connectedCallback () { + this.querySelectorAll('input[type="radio"]').forEach(radio => { + radio.addEventListener('change', (event) => { + const rating = parseInt(event.target.value) + + if (rating === 10) { + window.review_comment.value = '' + window.review_comment.classList.add('hidden') + window.review_submit.classList.add('hidden') + event.target.form.submit() + } else { + window.review_comment.classList.remove('hidden') + window.review_submit.classList.remove('hidden') + } + }) + }) + } +} diff --git a/app/javascript/elements/scroll_to.js b/app/javascript/elements/scroll_to.js new file mode 100644 index 00000000..ded724b5 --- /dev/null +++ b/app/javascript/elements/scroll_to.js @@ -0,0 +1,10 @@ +export default class extends HTMLElement { + connectedCallback () { + this.selector = document.getElementById(this.dataset.selectorId) + + this.addEventListener('click', () => { + this.selector.scrollIntoView({ behavior: 'smooth', block: 'start' }) + history.replaceState(null, null, `#${this.dataset.selectorId}`) + }) + } +} diff --git a/app/javascript/elements/search_input.js b/app/javascript/elements/search_input.js index 9b536ab3..502e2b3a 100644 --- a/app/javascript/elements/search_input.js +++ b/app/javascript/elements/search_input.js @@ -13,6 +13,16 @@ export default class extends HTMLElement { this.input.classList.remove('w-60') } }) + + this.button.addEventListener('click', (event) => { + event.preventDefault() + + if (this.input.value || document.activeElement === this.input) { + return + } + + this.input.focus() + }) } get input () { @@ -22,4 +32,8 @@ export default class extends HTMLElement { get title () { return document.querySelector(this.dataset.title) } + + get button () { + return this.querySelector('button') + } } diff --git a/app/javascript/elements/set_value.js b/app/javascript/elements/set_value.js new file mode 100644 index 00000000..49e05c9c --- /dev/null +++ b/app/javascript/elements/set_value.js @@ -0,0 +1,11 @@ +export default class extends HTMLElement { + connectedCallback () { + const input = this.dataset.inputId ? document.getElementById(this.dataset.inputId) : this.querySelector('input') + + this.firstElementChild.addEventListener(this.dataset.on || 'click', () => { + if (this.dataset.emptyOnly !== 'true' || !input.value) { + input.value = this.dataset.value + } + }) + } +} diff --git a/app/javascript/elements/show_on_value.js b/app/javascript/elements/show_on_value.js new file mode 100644 index 00000000..c969d823 --- /dev/null +++ b/app/javascript/elements/show_on_value.js @@ -0,0 +1,17 @@ +export default class extends HTMLElement { + connectedCallback () { + this.addEventListener('change', (event) => { + const targetValue = this.dataset.value + const selectorId = this.dataset.selectorId + const targetElement = document.getElementById(selectorId) + + if (event.target.value === targetValue) { + targetElement.classList.remove('hidden') + } else { + targetElement.classList.add('hidden') + targetElement.value = '' + event.target.form.requestSubmit() + } + }) + } +} diff --git a/app/javascript/elements/submit_form.js b/app/javascript/elements/submit_form.js index 164a069e..e936c0bd 100644 --- a/app/javascript/elements/submit_form.js +++ b/app/javascript/elements/submit_form.js @@ -1,15 +1,27 @@ export default class extends HTMLElement { connectedCallback () { + const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form + if (this.dataset.interval) { this.interval = setInterval(() => { - this.querySelector('form').requestSubmit() + form.requestSubmit() }, parseInt(this.dataset.interval)) } else if (this.dataset.on) { - this.lastElementChild.addEventListener(this.dataset.on, () => { - this.lastElementChild.form.requestSubmit() + this.lastElementChild.addEventListener(this.dataset.on, (event) => { + if (this.dataset.disable === 'true') { + form.querySelector('[type="submit"]')?.setAttribute('disabled', true) + } + + if (this.dataset.submitIfValue === 'true') { + if (event.target.value) { + form.requestSubmit() + } + } else { + form.requestSubmit() + } }) } else { - this.querySelector('form').requestSubmit() + form.requestSubmit() } } diff --git a/app/javascript/elements/toggle_classes.js b/app/javascript/elements/toggle_classes.js new file mode 100644 index 00000000..ab96f293 --- /dev/null +++ b/app/javascript/elements/toggle_classes.js @@ -0,0 +1,11 @@ +export default class extends HTMLElement { + connectedCallback () { + const button = this.querySelector('a, button') + + button.addEventListener('click', () => { + this.dataset.classes.split(' ').forEach((cls) => { + button.classList.toggle(cls) + }) + }) + } +} diff --git a/app/javascript/elements/toggle_visible.js b/app/javascript/elements/toggle_visible.js index a796b9c6..7fa968f2 100644 --- a/app/javascript/elements/toggle_visible.js +++ b/app/javascript/elements/toggle_visible.js @@ -4,9 +4,15 @@ export default actionable(class extends HTMLElement { trigger (event) { const elementIds = JSON.parse(this.dataset.elementIds) - elementIds.forEach((elementId) => { - document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId) - }) + if (event.target.type === 'checkbox') { + elementIds.forEach((elementId) => { + document.getElementById(elementId)?.classList.toggle('hidden') + }) + } else { + elementIds.forEach((elementId) => { + document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId) + }) + } if (this.dataset.focusId) { document.getElementById(this.dataset.focusId)?.focus() diff --git a/app/javascript/form.js b/app/javascript/form.js index ac49174d..b8253f86 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -6,6 +6,7 @@ import ToggleSubmit from './elements/toggle_submit' import FetchForm from './elements/fetch_form' import ScrollButtons from './elements/scroll_buttons' import PageContainer from './elements/page_container' +import SubmitForm from './elements/submit_form' const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) @@ -14,6 +15,7 @@ safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('fetch-form', FetchForm) safeRegisterElement('scroll-buttons', ScrollButtons) safeRegisterElement('page-container', PageContainer) +safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submission-form', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') diff --git a/app/javascript/form.scss b/app/javascript/form.scss index f0d26baf..92fc6f6a 100644 --- a/app/javascript/form.scss +++ b/app/javascript/form.scss @@ -19,7 +19,7 @@ button .disabled { display: none; } -button[disabled] .disabled { +button[disabled] .disabled, button.btn-disabled .disabled { display: initial; } @@ -27,7 +27,7 @@ button .enabled { display: initial; } -button[disabled] .enabled { +button[disabled] .enabled, button.btn-disabled .enabled { display: none; } diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index d58c04d8..761ad0f9 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -349,10 +349,10 @@ :id="field.uuid" type="checkbox" class="base-checkbox !h-7 !w-7" - :oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`" - :onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`" :required="field.required" :checked="!!values[field.uuid]" + @invalid="$event.target.setCustomValidity(t('please_check_the_box_to_continue'))" + @change="$event.target.setCustomValidity($event.target.validity.valueMissing ? t('please_check_the_box_to_continue') : '')" @click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]" > <%= hidden_field_tag :redir, params[:redir] %> <% end %> - <%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR it-IT es-ES]).map { |code| [t("language_#{code}"), code] }, I18n.locale), onchange: 'this.form.requestSubmit();', class: 'select select-sm border-base-content/30 text-base' %> + + <%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR it-IT es-ES]).map { |code| [t("language_#{code}"), code] }, I18n.locale), class: 'select select-sm border-base-content/30 text-base' %> + <% end %> diff --git a/app/views/esign_settings/show.html.erb b/app/views/esign_settings/show.html.erb index 6961c016..f22700b8 100644 --- a/app/views/esign_settings/show.html.erb +++ b/app/views/esign_settings/show.html.erb @@ -148,7 +148,9 @@ <%= t('apply_multiple_pdf_digital_signatures_in_the_document_per_each_signer') %> - <%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %> + + <%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple' }, 'multiple', 'single' %> + <% end %> <% end %> @@ -160,7 +162,9 @@ <%= t('remove_pdf_form_fillable_fields_from_the_signed_pdf_flatten_form') %> - <%= f.check_box :value, { class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' } %> + + <%= f.check_box :value, { class: 'toggle', checked: account_config.value != false } %> + <% end %> <% end %> @@ -172,9 +176,9 @@ <%= t('document_download_filename_format') %> -
- <%= f.select :value, [["#{I18n.t('document_name')}.pdf", '{document.name}'], ["#{I18n.t('document_name')} - #{I18n.t(:signed)}.pdf", '{document.name} - {submission.status}'], ["#{I18n.t('document_name')} - name@domain.com.pdf", '{document.name} - {submission.submitters}'], ["#{I18n.t('document_name')} - name@domain.com - #{I18n.l(Time.current.beginning_of_year.in_time_zone(current_account.timezone), format: :short)}.pdf", '{document.name} - {submission.submitters} - {submission.completed_at}']], {}, class: 'base-select', onchange: 'this.form.requestSubmit()' %> -
+ + <%= f.select :value, [["#{I18n.t('document_name')}.pdf", '{document.name}'], ["#{I18n.t('document_name')} - #{I18n.t(:signed)}.pdf", '{document.name} - {submission.status}'], ["#{I18n.t('document_name')} - name@domain.com.pdf", '{document.name} - {submission.submitters}'], ["#{I18n.t('document_name')} - name@domain.com - #{I18n.l(Time.current.beginning_of_year.in_time_zone(current_account.timezone), format: :short)}.pdf", '{document.name} - {submission.submitters} - {submission.completed_at}']], {}, class: 'base-select' %> + <% end %> <% end %> diff --git a/app/views/notifications_settings/index.html.erb b/app/views/notifications_settings/index.html.erb index b3c0c253..b163e8e8 100644 --- a/app/views/notifications_settings/index.html.erb +++ b/app/views/notifications_settings/index.html.erb @@ -13,7 +13,9 @@ <%= t('receive_notification_emails_on_completed_submission') %> - <%= f.check_box :value, class: 'toggle', checked: user_config.value != false, onchange: 'this.form.requestSubmit()' %> + + <%= f.check_box :value, class: 'toggle', checked: user_config.value != false %> + <% end %> <% end %> diff --git a/app/views/personalization_settings/_form_toggle_options.html.erb b/app/views/personalization_settings/_form_toggle_options.html.erb index 78c45067..f57dbee8 100644 --- a/app/views/personalization_settings/_form_toggle_options.html.erb +++ b/app/views/personalization_settings/_form_toggle_options.html.erb @@ -7,7 +7,9 @@ <%= t('show_confetti_on_successful_completion') %> - <%= f.check_box :value, { class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' }, '1', '0' %> + + <%= f.check_box :value, { class: 'toggle', checked: account_config.value != false }, '1', '0' %> + <% end %> diff --git a/app/views/scripts/_autosize_field.html.erb b/app/views/scripts/_autosize_field.html.erb index 096a7f1c..0d34e314 100644 --- a/app/views/scripts/_autosize_field.html.erb +++ b/app/views/scripts/_autosize_field.html.erb @@ -1,4 +1,4 @@ -