From 6806772346c4c5cfb64f4dfd388a44fb25adb2ff Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 18 May 2026 16:11:16 +0300 Subject: [PATCH 01/16] add security headers --- .../api/active_storage_blobs_proxy_controller.rb | 1 + .../api/active_storage_blobs_proxy_legacy_controller.rb | 1 + app/controllers/api/api_base_controller.rb | 4 ++++ config/application.rb | 8 ++++++++ 4 files changed, 14 insertions(+) diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index 8ade86c6..3fc0dd5e 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -9,6 +9,7 @@ module Api before_action :set_cors_headers before_action :set_noindex_headers + before_action :set_security_headers def show blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) diff --git a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb index 77ad2c6a..2485b9fa 100644 --- a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb @@ -9,6 +9,7 @@ module Api before_action :set_cors_headers before_action :set_noindex_headers + before_action :set_security_headers # rubocop:disable Metrics def show diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index ff01fc8f..6d9e2185 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -102,6 +102,10 @@ module Api headers['X-Robots-Tag'] = 'noindex' end + def set_security_headers + response.headers['X-Content-Type-Options'] = 'nosniff' + end + def set_cors_headers headers['Access-Control-Allow-Origin'] = '*' headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' diff --git a/config/application.rb b/config/application.rb index a2bcddaf..b9131dc3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -25,6 +25,14 @@ module DocuSeal config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true' + config.active_storage.content_types_to_serve_as_binary += %w[ + application/javascript + text/javascript + application/ecmascript + text/ecmascript + application/wasm + ] + config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT nl-NL es it de fr nl pl uk cs pt he ar ko ja] config.i18n.fallbacks = [:en] From 354bccd6e8542548cea09bfb3ef22879d743b42e Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 18 May 2026 17:51:50 +0300 Subject: [PATCH 02/16] handle dangerous extensions --- .../active_storage_blobs_proxy_controller.rb | 8 ++++ ...e_storage_blobs_proxy_legacy_controller.rb | 6 +++ app/controllers/user_initials_controller.rb | 6 +++ app/controllers/user_signatures_controller.rb | 6 +++ lib/submitters/normalize_values.rb | 38 +++++++++++++------ 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index 3fc0dd5e..4198f380 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -11,6 +11,7 @@ module Api before_action :set_noindex_headers before_action :set_security_headers + # rubocop:disable Metrics def show blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) @@ -22,6 +23,12 @@ module Api blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) + if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase) + Rollbar.error('Dangerous extension') if defined?(Rollbar) + + return head :unprocessable_content + end + attachment = blob.attachments.take @record = attachment.record @@ -46,6 +53,7 @@ module Api end end end + # rubocop:enable Metrics private diff --git a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb index 2485b9fa..8bac4ce9 100644 --- a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb @@ -19,6 +19,12 @@ module Api return head :not_found unless blob + if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase) + Rollbar.error('Dangerous extension') if defined?(Rollbar) + + return head :unprocessable_content + end + is_permitted = blob.attachments.any? do |a| (current_user && a.record.account.id == current_user.account_id) || a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } || diff --git a/app/controllers/user_initials_controller.rb b/app/controllers/user_initials_controller.rb index b2db409b..f6b87daa 100644 --- a/app/controllers/user_initials_controller.rb +++ b/app/controllers/user_initials_controller.rb @@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank? + extension = File.extname(file.original_filename).delete_prefix('.').downcase + + if Submitters::DANGEROUS_EXTENSIONS.include?(extension) + raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." + end + blob = ActiveStorage::Blob.create_and_upload!(io: file.open, filename: file.original_filename, content_type: file.content_type) diff --git a/app/controllers/user_signatures_controller.rb b/app/controllers/user_signatures_controller.rb index 1200acff..f6511d00 100644 --- a/app/controllers/user_signatures_controller.rb +++ b/app/controllers/user_signatures_controller.rb @@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank? + extension = File.extname(file.original_filename).delete_prefix('.').downcase + + if Submitters::DANGEROUS_EXTENSIONS.include?(extension) + raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." + end + blob = ActiveStorage::Blob.create_and_upload!(io: file.open, filename: file.original_filename, content_type: file.content_type) diff --git a/lib/submitters/normalize_values.rb b/lib/submitters/normalize_values.rb index 61697b5f..fe9f2d02 100644 --- a/lib/submitters/normalize_values.rb +++ b/lib/submitters/normalize_values.rb @@ -212,8 +212,8 @@ module Submitters elsif type.in?(%w[signature initials]) && value.length < 60 find_or_create_blob_from_text(account, value, type) elsif (data = Base64.decode64(value.sub(BASE64_PREFIX_REGEXP, ''))) && - Marcel::MimeType.for(data).exclude?('octet-stream') - find_or_create_blob_from_base64(account, data, type) + (mime_type = Marcel::MimeType.for(data)).exclude?('octet-stream') + find_or_create_blob_from_base64(account, data, type, mime_type:) elsif type == 'image' && (value.starts_with?('') || value.starts_with?(' Date: Mon, 18 May 2026 18:42:04 +0300 Subject: [PATCH 03/16] update gem --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 22585096..3b1b721c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,7 +195,7 @@ GEM railties (>= 6.1.0) faker (3.6.1) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger From f0fafbadd4e5d359792922d6303b876866069f84 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 19 May 2026 10:52:48 +0300 Subject: [PATCH 04/16] isolate editor nodes --- app/javascript/template_builder/dynamic_editor.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index 79698e17..d83062a4 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -94,6 +94,8 @@ img.ProseMirror-separator { } dynamic-variable { background-color: #fef3c7; + word-break: break-all; + overflow-wrap: anywhere; }`) function collectDomAttrs (dom) { @@ -136,11 +138,12 @@ function collectSpanDomAttrs (dom) { return result } -function createBlockNode (name, tag, content) { +function createBlockNode (name, tag, content, extra = {}) { return Node.create({ name, group: 'block', content: content || 'block+', + ...extra, addAttributes () { return { htmlAttrs: { default: {} } @@ -194,9 +197,9 @@ const CustomHeading = Node.create({ }) const SectionNode = createBlockNode('section', 'section') -const ArticleNode = createBlockNode('article', 'article') -const HeaderNode = createBlockNode('header', 'header') -const FooterNode = createBlockNode('footer', 'footer') +const ArticleNode = createBlockNode('article', 'article', null, { isolating: true }) +const HeaderNode = createBlockNode('header', 'header', null, { isolating: true }) +const FooterNode = createBlockNode('footer', 'footer', null, { isolating: true }) const DivNode = createBlockNode('div', 'div') const BlockquoteNode = createBlockNode('blockquote', 'blockquote') const PreNode = createBlockNode('pre', 'pre') From 5249d8d18deff450b564f1ce4863fd0aa78965cb Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 19 May 2026 11:02:15 +0300 Subject: [PATCH 05/16] update gem --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3b1b721c..ce25e900 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -275,7 +275,7 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.19.5) - jwt (3.1.2) + jwt (3.2.0) base64 language_server-protocol (3.17.0.5) launchy (3.1.1) From 7e8f045a2992a88cd72bed8cd5d32f5a35bb7e58 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 19 May 2026 11:42:14 +0300 Subject: [PATCH 06/16] cleanup --- app/jobs/reindex_search_entry_job.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/jobs/reindex_search_entry_job.rb b/app/jobs/reindex_search_entry_job.rb index c5650021..71993c83 100644 --- a/app/jobs/reindex_search_entry_job.rb +++ b/app/jobs/reindex_search_entry_job.rb @@ -3,8 +3,6 @@ class ReindexSearchEntryJob include Sidekiq::Job - InvalidFormat = Class.new(StandardError) - def perform(params = {}) entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id')) From d057fb0f67551aafe53f360e0d582c1c49b080ab Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 19 May 2026 12:37:04 +0300 Subject: [PATCH 07/16] content type priority --- config/initializers/marcel.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 config/initializers/marcel.rb diff --git a/config/initializers/marcel.rb b/config/initializers/marcel.rb new file mode 100644 index 00000000..8936c481 --- /dev/null +++ b/config/initializers/marcel.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +priority = %w[application/pdf image/jpeg image/png] + +indexes = Marcel::MAGIC.each_with_index.with_object({}) do |((type, _), i), acc| + acc[type] = i if priority.include?(type) + + break acc if acc.size == priority.size +end + +pdf_index, jpg_index, png_index = indexes.values_at(*priority) + +Marcel::MAGIC[0], Marcel::MAGIC[pdf_index] = Marcel::MAGIC[pdf_index], Marcel::MAGIC[0] +Marcel::MAGIC[1], Marcel::MAGIC[jpg_index] = Marcel::MAGIC[jpg_index], Marcel::MAGIC[1] +Marcel::MAGIC[2], Marcel::MAGIC[png_index] = Marcel::MAGIC[png_index], Marcel::MAGIC[2] From a68cc0b689a20cb30c5e714acbfaeb02521248c8 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 20 May 2026 15:23:42 +0300 Subject: [PATCH 08/16] convert images on upload --- Dockerfile | 2 +- app/javascript/submission_form/dropzone.vue | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index f8c4398f..722223b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf WORKDIR /app -RUN apk add --no-cache libpq vips redis vips-heif onnxruntime +RUN apk add --no-cache libpq vips redis onnxruntime RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal diff --git a/app/javascript/submission_form/dropzone.vue b/app/javascript/submission_form/dropzone.vue index 9a273f40..6cc96866 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -153,8 +153,14 @@ export default { } }) } else { - if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') { - file = await this.convertBmpToPng(file) + try { + if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml'].includes(file.type)) { + file = await this.convertImage(file, 'image/png') + } else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence'].includes(file.type)) { + file = await this.convertImage(file, 'image/jpeg', 0.9) + } + } catch (e) { + alert(e.message) } formData.append('file', file) @@ -182,7 +188,7 @@ export default { this.isLoading = false }) }, - convertBmpToPng (bmpFile) { + convertImage (sourceFile, targetType, quality) { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -197,15 +203,18 @@ export default { canvas.height = img.height ctx.drawImage(img, 0, 0) canvas.toBlob(function (blob) { - const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' }) + const ext = targetType === 'image/jpeg' ? '.jpg' : '.png' + const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType }) resolve(newFile) - }, 'image/png') + }, targetType, quality) } + img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`)) + img.src = event.target.result } reader.onerror = reject - reader.readAsDataURL(bmpFile) + reader.readAsDataURL(sourceFile) }) } } From dc6e4313a1188be68923b1192511a3ec008958bb Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 20 May 2026 16:15:15 +0300 Subject: [PATCH 09/16] fix ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40dc470d..b8bbc689 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: yarn install sudo apt-get update sudo apt-get install -y libvips - wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" + wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so rm -f pdfium-linux.tgz - name: Run From 9e4da5948bf3eaf140040b64d894ac23ec744263 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 21 May 2026 14:50:30 +0300 Subject: [PATCH 10/16] convert images on upload --- app/javascript/application.js | 2 + app/javascript/elements/convert_upload.js | 73 +++++++++++++++++++ app/javascript/elements/dashboard_dropzone.js | 9 ++- app/javascript/elements/file_dropzone.js | 11 ++- app/javascript/submission_form/dropzone.vue | 63 ++++++++-------- app/javascript/template_builder/upload.vue | 63 ++++++++++++++++ app/views/templates/_upload_button.html.erb | 4 +- 7 files changed, 187 insertions(+), 38 deletions(-) create mode 100644 app/javascript/elements/convert_upload.js diff --git a/app/javascript/application.js b/app/javascript/application.js index 311b1612..bec3cc0b 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete' import FolderAutocomplete from './elements/folder_autocomplete' import SignatureForm from './elements/signature_form' import SubmitForm from './elements/submit_form' +import ConvertUpload from './elements/convert_upload' import PromptPassword from './elements/prompt_password' import EmailsTextarea from './elements/emails_textarea' import ToggleSubmit from './elements/toggle_submit' @@ -111,6 +112,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete) safeRegisterElement('folder-autocomplete', FolderAutocomplete) safeRegisterElement('signature-form', SignatureForm) safeRegisterElement('submit-form', SubmitForm) +safeRegisterElement('convert-upload', ConvertUpload) safeRegisterElement('prompt-password', PromptPassword) safeRegisterElement('emails-textarea', EmailsTextarea) safeRegisterElement('toggle-cookies', ToggleCookies) diff --git a/app/javascript/elements/convert_upload.js b/app/javascript/elements/convert_upload.js new file mode 100644 index 00000000..4124ad5b --- /dev/null +++ b/app/javascript/elements/convert_upload.js @@ -0,0 +1,73 @@ +export function convertImage (sourceFile, targetType, quality) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = function (event) { + const img = new Image() + + img.onload = function () { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + canvas.toBlob(function (blob) { + const ext = targetType === 'image/jpeg' ? '.jpg' : '.png' + const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType }) + resolve(newFile) + }, targetType, quality) + } + + img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`)) + + img.src = event.target.result + } + reader.onerror = reject + reader.readAsDataURL(sourceFile) + }) +} + +export async function convertImagesInInput (input) { + if (!input.files || input.files.length === 0) return + + const dt = new DataTransfer() + let didConvert = false + + for (const file of Array.from(input.files)) { + let converted = file + + try { + if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml'].includes(file.type)) { + converted = await convertImage(file, 'image/png') + didConvert = true + } else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence'].includes(file.type)) { + converted = await convertImage(file, 'image/jpeg', 0.9) + didConvert = true + } + } catch (e) { + alert(e.message) + } + + dt.items.add(converted) + } + + if (didConvert) { + input.files = dt.files + } +} + +export default class extends HTMLElement { + connectedCallback () { + const input = this.querySelector('input[type="file"]') + const form = input.form + + input.addEventListener('change', async () => { + await convertImagesInInput(input) + + form.querySelector('[type="submit"]')?.setAttribute('disabled', true) + + form.requestSubmit() + }) + } +} diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js index 886aae37..434b33b7 100644 --- a/app/javascript/elements/dashboard_dropzone.js +++ b/app/javascript/elements/dashboard_dropzone.js @@ -1,4 +1,5 @@ import { target, targets, targetable } from '@github/catalyst/lib/targetable' +import { convertImagesInInput } from './convert_upload' const loadingIconHtml = ` @@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement { if (!this.isLoading) this.hideDraghover() } - uploadFiles (files, url) { + async uploadFiles (files, url) { this.isLoading = true this.form.action = url - this.form.querySelector('[type="file"]').files = files + const input = this.form.querySelector('[type="file"]') + + input.files = files + + await convertImagesInInput(input) this.form.querySelector('[type="submit"]').click() } diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index 12ef253d..fb8c1ae7 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -1,5 +1,6 @@ import { actionable } from '@github/catalyst/lib/actionable' import { target, targetable } from '@github/catalyst/lib/targetable' +import { convertImagesInInput } from './convert_upload' export default actionable(targetable(class extends HTMLElement { static [target.static] = [ @@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement { this.classList.add('border-base-300', 'hover:bg-base-200/30') } - onDrop (e) { + async onDrop (e) { e.preventDefault() this.input.files = e.dataTransfer.files - this.uploadFiles(e.dataTransfer.files) + await convertImagesInInput(this.input) + + this.uploadFiles(this.input.files) } - onSelectFiles (e) { + async onSelectFiles (e) { e.preventDefault() + await convertImagesInInput(this.input) + this.uploadFiles(this.input.files) } diff --git a/app/javascript/submission_form/dropzone.vue b/app/javascript/submission_form/dropzone.vue index 6cc96866..25790206 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -51,6 +51,36 @@