mirror of https://github.com/docusealco/docuseal
Replaces the disabled "logo upload not bundled in OSS" placeholder
with a real upload flow. Logos attach to the Account via ActiveStorage
(`has_one_attached :logo`) and replace the default WaboSign mark at
every render site that previously rendered `shared/_logo`.
Accepted formats: PNG, JPEG, and SVG. SVGs go through
`AccountLogo.sanitize_upload` before storage:
- `<script>` and `<foreignObject>` elements removed
- every `on*` attribute stripped (onclick, onload, onerror, …)
- `href` / `xlink:href` dropped unless the value starts with `#`
(in-doc fragment) or `data:` (inert in <img> context)
Raster uploads pass through unchanged. 2 MB upload cap on all formats.
Dispatch is a new `shared/_account_logo.html.erb` partial — takes an
optional `account:` local and emits either `<img>` (logo attached) or
falls back to the existing inline `shared/_logo.html.erb` SVG. All
swapped render sites pass the right account expression:
- shared/_title.html.erb → current_account
- start_form/_brand_logo.html.erb → @template.account
- submit_form/_brand_logo.html.erb → @submitter.submission.account
- templates_uploads/show.html.erb → current_account
- submissions/_logo.html.erb → @submission || @submitter accounts
- templates_share_link_qr/_logo.html.erb → @template.account
The landing page stays on the default mark (no account context).
Static favicon/PWA manifest/preview.png stay on the default brand.
Audit-trail PDF (`lib/submissions/generate_audit_trail.rb#add_logo`)
now calls `PdfIcons.account_logo_io(submission.account)`. SVG logos
are rasterized to PNG via ActiveStorage variants (libvips + librsvg,
both present in the production image via the `vips` Alpine pkg). On
any failure path the helper logs and falls back to `PdfIcons.logo_io`
so audit-trail generation never crashes on a bad logo.
Routes: `resource :account_logo, only: %i[create destroy]` nested
under `/settings`. AccountLogoController authorizes via
`authorize!(:manage, current_account)` and routes the form back to
`/settings/personalization` on success or failure.
Specs (11/11 pass in the Ruby 4.0.1 + Postgres-14 container):
- spec/lib/account_logo_spec.rb — 6 sanitizer unit cases
- spec/requests/account_logo_controller_spec.rb — 5 request cases
Smoke-tested end-to-end in the built image:
- PNG round-trips through attach/download.
- Malicious SVG (`<script>` + onload + alert) saves with all three
payloads scrubbed from the stored bytes.
- SVG → PNG rasterization for the PDF path produces a 18 KB PNG
with valid PNG magic — confirms libvips/librsvg is actually
wired in the production Alpine image.
- After purge, `PdfIcons.account_logo_io` is byte-identical to
`PdfIcons.logo_io` (clean fallback).
- /settings/personalization renders the new form with all expected
fields.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/687/head
parent
239f24236c
commit
a9a61c7979
@ -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
|
||||
@ -1 +1,32 @@
|
||||
<%= render 'logo_placeholder' %>
|
||||
<div class="space-y-4">
|
||||
<% if current_account.logo.attached? %>
|
||||
<div class="flex items-center justify-between bg-base-200 rounded-xl p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= image_tag rails_blob_path(current_account.logo, disposition: 'inline'),
|
||||
class: 'w-12 h-12 object-contain bg-white rounded',
|
||||
alt: current_account.name %>
|
||||
<span class="text-sm opacity-70"><%= current_account.logo.filename %></span>
|
||||
</div>
|
||||
<%= button_to 'Remove', settings_account_logo_path, method: :delete,
|
||||
class: 'btn btn-sm btn-outline btn-error',
|
||||
data: { turbo_confirm: 'Remove the uploaded logo?' } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: settings_account_logo_path, method: :post,
|
||||
multipart: true, html: { class: 'space-y-3', autocomplete: 'off' } do %>
|
||||
<div class="form-control">
|
||||
<label class="label" for="logo_file">
|
||||
<span class="label-text">Upload logo (PNG, JPEG, or SVG · up to 2 MB)</span>
|
||||
</label>
|
||||
<input type="file" name="logo" id="logo_file" required
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
class="file-input file-input-bordered w-full">
|
||||
</div>
|
||||
<button type="submit" class="base-button">Upload</button>
|
||||
<% end %>
|
||||
|
||||
<p class="text-sm opacity-70">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
<div class="alert my-4">
|
||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
<%= t('display_your_company_name_and_logo_when_signing_documents') %>
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 <img>/<svg>
|
||||
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 %>
|
||||
@ -1,2 +1,2 @@
|
||||
<%= render 'shared/logo' %>
|
||||
<%= render 'shared/account_logo', account: current_account %>
|
||||
<span><%= Wabosign.product_name %></span>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<a href="/" class="flex justify-center items-center">
|
||||
<span class="mr-3">
|
||||
<%= render 'shared/logo', width: '50px', height: '50px' %>
|
||||
<%= render 'shared/account_logo', account: @template&.account, width: '50px', height: '50px' %>
|
||||
</span>
|
||||
<h1 class="text-5xl font-bold text-center"><%= Wabosign.product_name %></h1>
|
||||
</a>
|
||||
|
||||
@ -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 %>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<a href="<%= root_path %>" class="mx-auto text-2xl md:text-3xl font-bold items-center flex space-x-3">
|
||||
<%= 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' %>
|
||||
<span><%= Wabosign.product_name %></span>
|
||||
</a>
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
<%= render 'shared/logo' %>
|
||||
<%= render 'shared/account_logo', account: @template&.account %>
|
||||
<span><%= Wabosign.product_name %></span>
|
||||
|
||||
@ -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
|
||||
@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AccountLogo do
|
||||
describe '.sanitize_svg' do
|
||||
it 'removes <script> elements' do
|
||||
svg = <<~SVG
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<script>alert(1)</script>
|
||||
<rect width="10" height="10" />
|
||||
</svg>
|
||||
SVG
|
||||
|
||||
cleaned = described_class.sanitize_svg(svg)
|
||||
|
||||
expect(cleaned).not_to include('<script')
|
||||
expect(cleaned).not_to include('alert(1)')
|
||||
expect(cleaned).to include('<rect')
|
||||
end
|
||||
|
||||
it 'strips on* event-handler attributes' do
|
||||
svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect onload="bad()" onclick="hax()" width="1" height="1"/></svg>'
|
||||
|
||||
cleaned = described_class.sanitize_svg(svg)
|
||||
|
||||
expect(cleaned).not_to include('onload')
|
||||
expect(cleaned).not_to include('onclick')
|
||||
expect(cleaned).not_to include('bad()')
|
||||
expect(cleaned).not_to include('hax()')
|
||||
end
|
||||
|
||||
it 'removes <foreignObject> elements' do
|
||||
svg = '<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><body>malicious</body></foreignObject></svg>'
|
||||
|
||||
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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<a href="https://attacker.example"><rect width="1" height="1"/></a>
|
||||
<use xlink:href="#circle" />
|
||||
<image xlink:href="data:image/png;base64,iVBOR" />
|
||||
</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 = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect/></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('<script')
|
||||
expect(body).not_to include('alert(1)')
|
||||
expect(body).to include('<rect')
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Account logo', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, email: 'admin@wabo.cc') }
|
||||
|
||||
before { sign_in admin }
|
||||
|
||||
def upload(content_type:, bytes:, filename: 'logo.png')
|
||||
tempfile = Tempfile.new(['logo', File.extname(filename)])
|
||||
tempfile.binmode
|
||||
tempfile.write(bytes)
|
||||
tempfile.rewind
|
||||
Rack::Test::UploadedFile.new(tempfile.path, content_type)
|
||||
end
|
||||
|
||||
describe 'POST /settings/account_logo' do
|
||||
it 'accepts a PNG upload and attaches it to the current account' do
|
||||
png_bytes = File.binread(Rails.root.join('public/favicon-32x32.png'))
|
||||
|
||||
expect do
|
||||
post settings_account_logo_path, params: { logo: upload(content_type: 'image/png', bytes: png_bytes) }
|
||||
end.to change { account.reload.logo.attached? }.from(false).to(true)
|
||||
|
||||
expect(response).to redirect_to(settings_personalization_path)
|
||||
expect(flash[:notice]).to include('Logo updated')
|
||||
end
|
||||
|
||||
it 'rejects an unsupported content type' do
|
||||
pdf_bytes = '%PDF-1.4 dummy'
|
||||
|
||||
post settings_account_logo_path, params: { logo: upload(content_type: 'application/pdf', bytes: pdf_bytes, filename: 'logo.pdf') }
|
||||
|
||||
expect(account.reload.logo.attached?).to be(false)
|
||||
expect(flash[:alert]).to include('PNG, JPEG, or SVG')
|
||||
end
|
||||
|
||||
it 'rejects files over 2 MB' do
|
||||
big = SecureRandom.bytes(Account::LOGO_MAX_BYTES + 1)
|
||||
|
||||
post settings_account_logo_path, params: { logo: upload(content_type: 'image/png', bytes: big) }
|
||||
|
||||
expect(account.reload.logo.attached?).to be(false)
|
||||
expect(flash[:alert]).to include('under 2 MB')
|
||||
end
|
||||
|
||||
it 'sanitises malicious SVG content before storing' do
|
||||
malicious = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect onload="hax()" /></svg>'
|
||||
|
||||
post settings_account_logo_path, params: { logo: upload(content_type: 'image/svg+xml', bytes: malicious, filename: 'logo.svg') }
|
||||
|
||||
expect(account.reload.logo.attached?).to be(true)
|
||||
stored = account.logo.download
|
||||
expect(stored).not_to include('<script')
|
||||
expect(stored).not_to include('alert(1)')
|
||||
expect(stored).not_to include('onload')
|
||||
expect(stored).not_to include('hax()')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /settings/account_logo' do
|
||||
it 'purges the attachment' do
|
||||
png_bytes = File.binread(Rails.root.join('public/favicon-32x32.png'))
|
||||
account.logo.attach(io: StringIO.new(png_bytes), filename: 'logo.png', content_type: 'image/png')
|
||||
expect(account.reload.logo.attached?).to be(true)
|
||||
|
||||
delete settings_account_logo_path
|
||||
|
||||
expect(account.reload.logo.attached?).to be(false)
|
||||
expect(response).to redirect_to(settings_personalization_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue