diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a1f664f..1008f3d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,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: @@ -51,7 +51,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: @@ -103,7 +103,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: @@ -146,7 +146,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@v4 with: diff --git a/Dockerfile b/Dockerfile index 623b0baa..7885b29f 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 ARG VERSION="dev" ARG REVISION="unknown" @@ -61,7 +61,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 wabosign && adduser -u 2000 -G wabosign -s /bin/sh -D -h /home/wabosign wabosign @@ -107,6 +107,7 @@ WORKDIR /data/wabosign ENV HOME=/home/wabosign ENV WORKDIR=/data/wabosign 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 ceba679f..bc770a36 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 1a44ee86..8d15dd29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -116,7 +116,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (4.1.0) + bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) @@ -257,8 +257,6 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) hashdiff (1.2.1) - hashie (5.1.0) - logger hexapdf (1.7.0) cmdparse (~> 3.0, >= 3.0.3) geom2d (~> 0.4, >= 0.4.1) @@ -270,13 +268,13 @@ GEM mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.19.3) + json (2.19.5) jwt (3.2.0) base64 language_server-protocol (3.17.0.5) @@ -313,13 +311,11 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.3) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) multi_json (1.19.1) - multi_xml (0.9.1) - bigdecimal (>= 3.1, < 5) net-http (0.9.1) uri (>= 0.11.1) net-imap (0.6.4) @@ -343,33 +339,9 @@ GEM nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) numo-narray-alt (0.10.3) - oauth2 (2.0.18) - faraday (>= 0.17.3, < 4.0) - jwt (>= 1.0, < 4.0) - logger (~> 1.2) - multi_xml (~> 0.5) - rack (>= 1.2, < 4) - snaky_hash (~> 2.0, >= 2.0.3) - version_gem (~> 1.1, >= 1.1.9) oj (3.16.16) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.4) - hashie (>= 3.4.6) - logger - rack (>= 2.2.3) - rack-protection - omniauth-google-oauth2 (1.2.2) - jwt (>= 2.9.2) - oauth2 (~> 2.0) - omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8) - omniauth-oauth2 (1.9.0) - oauth2 (>= 2.0.2, < 3) - omniauth (~> 2.0) - omniauth-rails_csrf_protection (1.0.2) - actionpack (>= 4.2) - omniauth (~> 2.0) onnxruntime (0.10.1-aarch64-linux) ffi onnxruntime (0.10.1-arm64-darwin) @@ -414,10 +386,6 @@ GEM nio4r (~> 2.0) racc (1.8.1) rack (3.2.6) - rack-protection (4.2.1) - base64 (>= 0.1.0) - logger (>= 1.6.0) - rack (>= 3.0.0, < 4) rack-proxy (0.7.7) rack rack-session (2.1.2) @@ -461,7 +429,7 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rdoc (7.2.0) erb psych (>= 4.0.0) @@ -566,9 +534,6 @@ GEM simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - snaky_hash (2.0.3) - hashie (>= 0.1.0, < 6) - version_gem (>= 1.1.8, < 3) sqlite3 (2.9.2-aarch64-linux-gnu) sqlite3 (2.9.2-aarch64-linux-musl) sqlite3 (2.9.2-arm64-darwin) @@ -603,7 +568,6 @@ GEM uniform_notifier (1.18.0) uri (1.1.1) useragent (0.16.11) - version_gem (1.1.9) warden (1.2.9) rack (>= 2.0.9) web-console (4.3.0) @@ -666,9 +630,6 @@ DEPENDENCIES lograge numo-narray-alt oj - omniauth (~> 2.1) - omniauth-google-oauth2 (~> 1.2) - omniauth-rails_csrf_protection (~> 1.0) onnxruntime pagy pg @@ -701,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 f85cace0..e1d39507 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -51,6 +51,36 @@