From e6330cbdff736ceddc4d8ae3187f39c5efee075c Mon Sep 17 00:00:00 2001 From: MattBudz Date: Thu, 23 Jul 2026 17:18:38 +0200 Subject: [PATCH] Scrub invalid UTF-8 byte sequences from every string/text column 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. A per-model callback or opt-in list only protects the columns someone remembered to register it on. ActiveRecord::Type::String and ActiveRecord::Type::Text both inherit from ActiveModel::Type::String, so prepending the scrub onto that shared base type covers every :string and :text column, on every model and every adapter, with a single registration. --- CHANGELOG | 1 + config/initializers/scrub_invalid_encoding.rb | 19 ++++++++ .../scrub_invalid_encoding_spec.rb | 45 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 config/initializers/scrub_invalid_encoding.rb create mode 100644 spec/initializers/scrub_invalid_encoding_spec.rb 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