From a9a61c797991225fe383cc936a87aa3504eb6523 Mon Sep 17 00:00:00 2001 From: Wabo Date: Sat, 16 May 2026 16:41:55 -0400 Subject: [PATCH] Custom account logo with SVG sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: - ` + + + 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('