diff --git a/app/controllers/account_logo_controller.rb b/app/controllers/account_logo_controller.rb new file mode 100644 index 00000000..e3b69bd0 --- /dev/null +++ b/app/controllers/account_logo_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AccountLogoController < ApplicationController + before_action :authorize_change + + def create + file = params[:logo] + + return reject('Choose a file to upload.') if file.blank? || !file.respond_to?(:content_type) + return reject('Logo must be a PNG, JPEG, or SVG image.') unless Account::LOGO_CONTENT_TYPES.include?(file.content_type) + return reject("Logo must be under #{Account::LOGO_MAX_BYTES / 1.megabyte} MB.") if file.size > Account::LOGO_MAX_BYTES + + safe = AccountLogo.sanitize_upload(file) + current_account.logo.attach(io: safe.io, filename: safe.filename, content_type: safe.content_type) + + redirect_to settings_personalization_path, notice: 'Logo updated.' + rescue StandardError => e + Rails.logger.warn("[AccountLogo] upload failed: #{e.class}: #{e.message}") + reject("Couldn't save the logo: #{e.message}") + end + + def destroy + current_account.logo.purge if current_account.logo.attached? + redirect_to settings_personalization_path, notice: 'Logo removed.' + end + + private + + def authorize_change + authorize!(:manage, current_account) + end + + def reject(message) + redirect_back(fallback_location: settings_personalization_path, alert: message) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index d3d53d0c..aab90683 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -18,8 +18,13 @@ # index_accounts_on_uuid (uuid) UNIQUE # class Account < ApplicationRecord + LOGO_CONTENT_TYPES = %w[image/png image/jpeg image/svg+xml].freeze + LOGO_MAX_BYTES = 2.megabytes + attribute :uuid, :string, default: -> { SecureRandom.uuid } + has_one_attached :logo + has_many :users, dependent: :destroy has_many :encrypted_configs, dependent: :destroy has_many :account_configs, dependent: :destroy diff --git a/app/views/personalization_settings/_logo_form.html.erb b/app/views/personalization_settings/_logo_form.html.erb index fc6f3ac7..1740e7e8 100644 --- a/app/views/personalization_settings/_logo_form.html.erb +++ b/app/views/personalization_settings/_logo_form.html.erb @@ -1 +1,32 @@ -<%= render 'logo_placeholder' %> +
+ <% if current_account.logo.attached? %> +
+
+ <%= image_tag rails_blob_path(current_account.logo, disposition: 'inline'), + class: 'w-12 h-12 object-contain bg-white rounded', + alt: current_account.name %> + <%= current_account.logo.filename %> +
+ <%= button_to 'Remove', settings_account_logo_path, method: :delete, + class: 'btn btn-sm btn-outline btn-error', + data: { turbo_confirm: 'Remove the uploaded logo?' } %> +
+ <% end %> + + <%= form_with url: settings_account_logo_path, method: :post, + multipart: true, html: { class: 'space-y-3', autocomplete: 'off' } do %> +
+ + +
+ + <% end %> + +

+ Replaces the default WaboSign mark on the sign-in page, signing flow, dashboard navbar, share-link QR page, and audit-trail PDFs. Browser favicons and the PWA manifest icon stay on the default brand. +

+
diff --git a/app/views/personalization_settings/_logo_placeholder.html.erb b/app/views/personalization_settings/_logo_placeholder.html.erb deleted file mode 100644 index 2a54d497..00000000 --- a/app/views/personalization_settings/_logo_placeholder.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -
- <%= svg_icon('info_circle', class: 'w-6 h-6') %> -
-

- <%= t('display_your_company_name_and_logo_when_signing_documents') %> -

-

- Logo upload UI is not bundled with this open-source edition. Drop your custom logo into public/logo.svg and edit app/views/shared/_logo.html.erb to white-label the signing UI. -

-
-
diff --git a/app/views/shared/_account_logo.html.erb b/app/views/shared/_account_logo.html.erb new file mode 100644 index 00000000..dcc039d9 --- /dev/null +++ b/app/views/shared/_account_logo.html.erb @@ -0,0 +1,23 @@ +<%# Render the account's uploaded logo if present; otherwise fall back to the + default inline SVG mark. Locals: + account: (optional) an Account record + class: CSS class string forwarded to / + width: pixel width (default 37) + height: pixel height (default 37) +%> +<% + acc = local_assigns[:account] + klass = local_assigns[:class] + w = local_assigns.fetch(:width, '37') + h = local_assigns.fetch(:height, '37') +%> +<% if acc&.logo&.attached? %> + <%= image_tag rails_blob_path(acc.logo, disposition: 'inline'), + class: klass, + width: w, + height: h, + alt: acc.name, + style: 'object-fit: contain;' %> +<% else %> + <%= render 'shared/logo', class: klass, width: w, height: h %> +<% end %> diff --git a/app/views/shared/_title.html.erb b/app/views/shared/_title.html.erb index 1abba732..c5e7b611 100644 --- a/app/views/shared/_title.html.erb +++ b/app/views/shared/_title.html.erb @@ -1,2 +1,2 @@ -<%= render 'shared/logo' %> +<%= render 'shared/account_logo', account: current_account %> <%= Wabosign.product_name %> diff --git a/app/views/start_form/_brand_logo.html.erb b/app/views/start_form/_brand_logo.html.erb index 4a3f3f9a..04dbb467 100644 --- a/app/views/start_form/_brand_logo.html.erb +++ b/app/views/start_form/_brand_logo.html.erb @@ -1,6 +1,6 @@ - <%= render 'shared/logo', width: '50px', height: '50px' %> + <%= render 'shared/account_logo', account: @template&.account, width: '50px', height: '50px' %>

<%= Wabosign.product_name %>

diff --git a/app/views/submissions/_logo.html.erb b/app/views/submissions/_logo.html.erb index f6b67f5c..cb0685af 100644 --- a/app/views/submissions/_logo.html.erb +++ b/app/views/submissions/_logo.html.erb @@ -1 +1 @@ -<%= render 'shared/logo', width: 40, height: 40 %> +<%= render 'shared/account_logo', account: local_assigns[:account] || @submission&.account || @submitter&.account, width: 40, height: 40 %> diff --git a/app/views/submit_form/_brand_logo.html.erb b/app/views/submit_form/_brand_logo.html.erb index c633f860..ce58f268 100644 --- a/app/views/submit_form/_brand_logo.html.erb +++ b/app/views/submit_form/_brand_logo.html.erb @@ -1,4 +1,4 @@ - <%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %> + <%= render 'shared/account_logo', account: @submitter&.submission&.account, class: 'w-9 h-9 md:w-12 md:h-12' %> <%= Wabosign.product_name %> diff --git a/app/views/templates_share_link_qr/_logo.html.erb b/app/views/templates_share_link_qr/_logo.html.erb index 1abba732..6ad9ee61 100644 --- a/app/views/templates_share_link_qr/_logo.html.erb +++ b/app/views/templates_share_link_qr/_logo.html.erb @@ -1,2 +1,2 @@ -<%= render 'shared/logo' %> +<%= render 'shared/account_logo', account: @template&.account %> <%= Wabosign.product_name %> diff --git a/app/views/templates_uploads/show.html.erb b/app/views/templates_uploads/show.html.erb index 16ca71ab..0c419aa8 100644 --- a/app/views/templates_uploads/show.html.erb +++ b/app/views/templates_uploads/show.html.erb @@ -2,7 +2,7 @@
- <%= render 'shared/logo', width: 50, height: 50, class: 'mx-auto animate-bounce' %> + <%= render 'shared/account_logo', account: current_account, width: 50, height: 50, class: 'mx-auto animate-bounce' %> <%= t('processing') %>... diff --git a/config/routes.rb b/config/routes.rb index 5bdad31d..0cf74373 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -206,6 +206,7 @@ Rails.application.routes.draw do resources :integration_users, only: %i[index], path: 'users/:status', controller: 'users', defaults: { status: :integration } resource :personalization, only: %i[show create], controller: 'personalization_settings' + resource :account_logo, only: %i[create destroy], controller: 'account_logo' resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do post :resend diff --git a/lib/account_logo.rb b/lib/account_logo.rb new file mode 100644 index 00000000..e8bbf67c --- /dev/null +++ b/lib/account_logo.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# AccountLogo handles validation/sanitization of uploads before they hit +# ActiveStorage. Raster images pass through unchanged. SVGs are scrubbed of +# scripts, event-handler attributes, foreign-object elements, and external +# resource references — the standard XSS surface for inline-embedded SVG. +module AccountLogo + Sanitized = Struct.new(:io, :filename, :content_type) + + # Allowed top-level/nested SVG-related element + attribute names that + # carry external resource URIs. We don't enumerate every safe attribute — + # we instead drop the known-dangerous ones by name pattern. + EVENT_HANDLER_PREFIX = 'on' + EXTERNAL_REF_ATTRS = %w[href xlink:href].freeze + + module_function + + def sanitize_upload(uploaded_file) + content_type = uploaded_file.content_type.to_s + + if content_type == 'image/svg+xml' + bytes = uploaded_file.read + uploaded_file.rewind if uploaded_file.respond_to?(:rewind) + cleaned = sanitize_svg(bytes) + Sanitized.new(StringIO.new(cleaned), uploaded_file.original_filename.to_s, 'image/svg+xml') + else + io = uploaded_file.respond_to?(:tempfile) ? uploaded_file.tempfile : uploaded_file + Sanitized.new(io, uploaded_file.original_filename.to_s, content_type) + end + end + + # Public for spec testing. + def sanitize_svg(svg_string) + doc = Nokogiri::XML(svg_string) { |c| c.nonet.noblanks } + + doc.traverse do |node| + next unless node.element? + + local = node.name.to_s.downcase.sub(/.*:/, '') + if local == 'script' || local == 'foreignobject' + node.remove + next + end + + node.attributes.each_value do |attr| + name = attr.name.to_s + downcased = name.downcase + + if downcased.start_with?(EVENT_HANDLER_PREFIX) + node.remove_attribute(name) + next + end + + if EXTERNAL_REF_ATTRS.include?(downcased) || downcased.end_with?(':href') + value = attr.value.to_s.strip + unless value.start_with?('#') || value.start_with?('data:') + node.remove_attribute(name) + end + end + end + end + + doc.to_xml + end +end diff --git a/lib/pdf_icons.rb b/lib/pdf_icons.rb index d9beab6e..34a39b72 100644 --- a/lib/pdf_icons.rb +++ b/lib/pdf_icons.rb @@ -20,6 +20,26 @@ module PdfIcons StringIO.new(logo_data) end + # Returns binary IO for the account's uploaded logo when attached, + # otherwise the default WaboSign mark. SVG uploads are rasterised via + # ActiveStorage variants (libvips + librsvg ship in the production image + # via the `vips` Alpine package). On any failure path we fall back to the + # default mark so audit-trail generation never crashes on a bad logo. + def account_logo_io(account) + return logo_io if account.nil? || !account.logo.attached? + + blob = account.logo + if blob.content_type == 'image/svg+xml' + variant = blob.variant(resize_to_limit: [WIDTH, HEIGHT], format: :png).processed + StringIO.new(variant.download) + else + StringIO.new(blob.download) + end + rescue StandardError => e + Rails.logger.warn("[PdfIcons] account_logo_io fallback for account=#{account&.id}: #{e.class}: #{e.message}") + logo_io + end + def stamp_logo_io StringIO.new(stamp_logo_data) end diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index 45d04048..1671ccec 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -530,8 +530,9 @@ module Submissions !submission.source.in?(%w[embed api]) end - def add_logo(column, _submission = nil) - column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) + def add_logo(column, submission = nil) + column.image(PdfIcons.account_logo_io(submission&.account), + width: 40, height: 40, position: :float) column.formatted_text([{ text: Wabosign.product_name, link: Wabosign::PRODUCT_EMAIL_URL }], diff --git a/spec/lib/account_logo_spec.rb b/spec/lib/account_logo_spec.rb new file mode 100644 index 00000000..e9ce08e2 --- /dev/null +++ b/spec/lib/account_logo_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountLogo do + describe '.sanitize_svg' do + it 'removes + + + SVG + + cleaned = described_class.sanitize_svg(svg) + + expect(cleaned).not_to include(' elements' do + svg = 'malicious' + + cleaned = described_class.sanitize_svg(svg) + + expect(cleaned).not_to match(/foreignObject/i) + end + + it 'drops external href / xlink:href but keeps in-doc fragments and data: URIs' do + svg = <<~SVG + + + + + + SVG + + cleaned = described_class.sanitize_svg(svg) + + expect(cleaned).not_to include('https://attacker.example') + expect(cleaned).to include('#circle') + expect(cleaned).to include('data:image/png') + end + end + + describe '.sanitize_upload' do + it 'returns the original tempfile for PNG uploads' do + png_bytes = File.binread(Rails.root.join('public/favicon-32x32.png')) + file = ActionDispatch::Http::UploadedFile.new( + tempfile: Tempfile.new(['logo', '.png']).tap { |t| t.binmode; t.write(png_bytes); t.rewind }, + filename: 'logo.png', type: 'image/png' + ) + + result = described_class.sanitize_upload(file) + + expect(result.content_type).to eq('image/png') + expect(result.filename).to eq('logo.png') + end + + it 'sanitises SVG content and returns a StringIO with cleaned bytes' do + svg = '' + file = ActionDispatch::Http::UploadedFile.new( + tempfile: Tempfile.new(['logo', '.svg']).tap { |t| t.write(svg); t.rewind }, + filename: 'logo.svg', type: 'image/svg+xml' + ) + + result = described_class.sanitize_upload(file) + + expect(result.content_type).to eq('image/svg+xml') + body = result.io.read + expect(body).not_to include('