diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40dc470d..77d912e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Cache gems uses: actions/cache@v4 with: @@ -37,7 +37,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Cache gems uses: actions/cache@v4 with: @@ -89,7 +89,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Cache gems uses: actions/cache@v4 with: @@ -132,7 +132,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Set up Node uses: actions/setup-node@v1 with: @@ -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 diff --git a/Dockerfile b/Dockerfile index f8c4398f..3f6bb3c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:4.0.1-alpine AS download +FROM ruby:4.0.5-alpine AS download WORKDIR /fonts @@ -13,7 +13,7 @@ RUN apk --no-cache add wget && \ mkdir -p /pdfium-linux && \ tar -xzf pdfium-linux.tgz -C /pdfium-linux -FROM ruby:4.0.1-alpine AS webpack +FROM ruby:4.0.5-alpine AS webpack ENV RAILS_ENV=production ENV NODE_ENV=production @@ -40,7 +40,7 @@ COPY ./app/views ./app/views RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker -FROM ruby:4.0.1-alpine AS app +FROM ruby:4.0.5-alpine AS app ENV RAILS_ENV=production ENV BUNDLE_WITHOUT="development:test" @@ -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 @@ -94,6 +94,7 @@ WORKDIR /data/docuseal ENV HOME=/home/docuseal ENV WORKDIR=/data/docuseal ENV VIPS_MAX_COORD=17000 +ENV VIPS_BLOCK_UNTRUSTED=1 EXPOSE 3000 CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] diff --git a/Gemfile b/Gemfile index 4e2fd78e..7ed8fdeb 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -ruby '4.0.1' +ruby '4.0.5' gem 'addressable' gem 'arabic-letter-connector', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 22585096..8d15dd29 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 @@ -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) @@ -662,7 +662,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 4.0.1 + ruby 4.0.5 BUNDLED WITH 4.0.3 diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index 8ade86c6..4198f380 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -9,7 +9,9 @@ module Api before_action :set_cors_headers 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]) @@ -21,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 @@ -45,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 77ad2c6a..8bac4ce9 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 @@ -18,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/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/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index 2f615878..1d24f8ca 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -16,8 +16,10 @@ module Api return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content end + file = params[:file] + if params[:type].in?(%w[initials signature]) - image = Vips::Image.new_from_file(params[:file].path) + image = ImageUtils.load_vips(file.read, content_type: file.content_type) if ImageUtils.blank?(image) Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) @@ -33,7 +35,7 @@ module Api end end - attachment = Submitters.create_attachment!(@submitter, params) + attachment = Submitters.create_attachment!(@submitter, file) if params[:remember_signature] == 'true' && @submitter.email.present? cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) 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/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..a00cd666 --- /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', 'image/gif'].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', 'image/webp'].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 9a273f40..95b287b6 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -51,6 +51,36 @@