diff --git a/CHANGELOG b/CHANGELOG index 633cebfb17..e569647006 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ - Upgraded gems: - rails-html-sanitizer, sqlite3, websocket-driver - Bugs fixes: + - Models: scrub invalid UTF-8 byte sequences from every :string and :text column before saving - [entity]: - [future tense verb] [bug fix] - Bug tracker items: diff --git a/config/initializers/scrub_invalid_encoding.rb b/config/initializers/scrub_invalid_encoding.rb new file mode 100644 index 0000000000..aa2261e9a8 --- /dev/null +++ b/config/initializers/scrub_invalid_encoding.rb @@ -0,0 +1,19 @@ +# Content can contain invalid UTF-8 byte sequences. MySQL can be configured +# to reject these at the DB layer, but CE runs on SQLite, which has no +# charset enforcement at all, so DB-level rejection can't be the shared fix +# across editions. Scrub instead, so an unscrubbed value never reaches the +# DB and never raises an unhandled ArgumentError later, wherever it happens +# to be read first rather than where it was written. +# +# ActiveModel::Type::String is the shared base type behind every :string and +# :text column (ActiveRecord::Type::String and ActiveRecord::Type::Text both +# inherit from it), so scrubbing here covers every column of both types, on +# every model and every adapter, without registering per model or column. +module ScrubsInvalidEncoding + def cast(value) + value = value.scrub if value.is_a?(String) + super + end +end + +ActiveModel::Type::String.prepend(ScrubsInvalidEncoding) diff --git a/spec/initializers/scrub_invalid_encoding_spec.rb b/spec/initializers/scrub_invalid_encoding_spec.rb new file mode 100644 index 0000000000..df39e8e324 --- /dev/null +++ b/spec/initializers/scrub_invalid_encoding_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe 'scrubbing invalid encoding' do + let(:bad_bytes) { "before \xC3\x28 after" } + + it 'scrubs invalid byte sequences on cast' do + type = ActiveModel::Type::String.new + + expect(type.cast(bad_bytes).valid_encoding?).to eq(true) + end + + it 'leaves valid strings untouched' do + type = ActiveModel::Type::String.new + + expect(type.cast('valid string')).to eq('valid string') + end + + it 'passes non-string values through to the wrapped type' do + type = ActiveModel::Type::String.new + + expect(type.cast(42)).to eq('42') + end + + it 'covers :string columns' do + node = build(:node, label: bad_bytes) + + node.save! + + expect(node.reload.label.valid_encoding?).to eq(true) + end + + it 'covers :text columns' do + note = build(:note, text: bad_bytes) + + note.save! + + expect(note.reload.text.valid_encoding?).to eq(true) + end + + it 'covers :string columns without per-model opt-in' do + card = create(:card, name: bad_bytes) + + expect(card.reload.name.valid_encoding?).to eq(true) + end +end