Prevent whitespace unicode characters from being used in article title and tag (#14909)

* test: whitespace unicode characters cannot be used as titles or tags

* feat: add localized error message

* refactor: use localized error message

* fix: whitespace unicode characters cannot be used as titles or tags

* chore: fix locale after merge

* refactor: fix indentation

* Fix spelling of prohibited in method names

Additionally, rubocop removed a redundant user_id validation since
Article belongs to user.

* Fix spelling

Missed one method call on the same line (fixed the first of two
instances needing changes)

* add failing test cases

The reorganization to remove let was due to a limit on nesting rspec
contexts (it inside context inside describe inside describe when
trying to use different titles in let blocks in contexts)

The first test was clarified (the "U+202D" string that looks like the
code for a unicode point is valid, but the character \u202d is
expected to be invalid

The remaining two tests are based on the feedback I'd given in the PR,
failing because we don't assert title is present after removing
unicode whitespace, and we don't actually set the title (gsub is
non-destructive, returning a value).

* Set title to sanitized title

contains_prohibitied_unicode_characters? was using == (which is not a
good match for strings to regexes), use =~ instead

Change invalid example characters in titles from \u202d (bidi override) to
\u200a (hair space)

Assert that the empty title is blank and can't be blank (even though
it contains no prohibitd characters after replacement)

* change the definition of prohibited characters

From the description, it looks like "unicode space characters" was the
desired rejection set. It was unclear what the existing regex was
matching on (I couldn't get the expected examples to match correctly).

Given the intent, I select "unicode space property, except the ascii
space character", which may _also_ be incorrect but passed the tests.

* Remove now-invalid expectation for presence of user id

Since this was redundant (belongs_to user), we no longer will validate
presence of user id, and we should not test for it.

* Only reject bidirectional text controls

The original issue pointed to the BIDI controls as problematic.

While they called out other "whitespace" characters, as we accept a
wider range of input languages, the need to handle non-printing
punctuation (for example a group separating space for some asian
languages).

It's possible a wider list of characters should be added - if that's
the case I suggest this regex and the sanitization be moved to a
standalone class, and each of the recommendations in
https://www.w3.org/TR/unicode-xml/#Charlist be checked for
applicability.

* Check for blank title after sanitizing disallowed characters

Since validate_title modifies the title, and doesn't set error
messages on the model itself, check for non-empty title _after_
potentially removing any disallowed characters.

* Remove invalid characters before validation

Remove the validate_title and contains_prohibited_characters methods,
and center all logic on the remove prohibitied unicode characters
method.

Remove unneeded and unused arguments (input string is always title,
replacement is always removal/empty string).

* handle case when validating null title

If the title's nil, we don't want to call match? since it will fail.

Title will sometimes be nil when validating.

Co-authored-by: Ben Halpern <bendhalpern@gmail.com>
Co-authored-by: Dan Uber <dan@forem.com>
This commit is contained in:
Derek Crosson 2022-01-03 16:37:03 +02:00 committed by GitHub
parent 5430c2e203
commit ae27d15be7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 44 additions and 3 deletions

View file

@ -32,6 +32,7 @@ class Article < ApplicationRecord
MAX_USER_MENTION_LIVE_AT = Time.utc(2021, 4, 7).freeze
UNIQUE_URL_ERROR = "has already been taken. " \
"Email #{ForemInstance.email} for further details.".freeze
PROHIBITED_UNICODE_CHARACTERS_REGEX = /[\u202a-\u202e]/ # BIDI embedding controls
has_one :discussion_lock, dependent: :delete
@ -81,7 +82,6 @@ class Article < ApplicationRecord
validates :slug, presence: { if: :published? }, format: /\A[0-9a-z\-_]*\z/
validates :slug, uniqueness: { scope: :user_id }
validates :title, presence: true, length: { maximum: 128 }
validates :user_id, presence: true
validates :user_subscriptions_count, presence: true
validates :video, url: { allow_blank: true, schemes: %w[https http] }
validates :video_closed_caption_track_url, url: { allow_blank: true, schemes: ["https"] }
@ -101,6 +101,7 @@ class Article < ApplicationRecord
validate :validate_co_authors_exist, unless: -> { co_author_ids.blank? }
before_validation :evaluate_markdown, :create_slug
before_validation :remove_prohibited_unicode_characters
before_save :update_cached_user
before_save :set_all_dates
@ -847,4 +848,10 @@ class Article < ApplicationRecord
::Articles::EnrichImageAttributesWorker.perform_async(id)
end
def remove_prohibited_unicode_characters
return unless title&.match?(PROHIBITED_UNICODE_CHARACTERS_REGEX)
self.title = title.gsub(PROHIBITED_UNICODE_CHARACTERS_REGEX, "")
end
end

View file

@ -77,7 +77,7 @@ class Tag < ActsAsTaggableOn::Tag
# [:alnum:] is not used here because it supports diacritical characters.
# If we decide to allow diacritics in the future, we should replace the
# following regex with [:alnum:].
errors.add(:name, "contains non-alphanumeric characters") unless name.match?(/\A[[:alnum:]]+\z/i)
errors.add(:name, I18n.t("errors.messages.contains_prohibited_characters")) unless name.match?(/\A[[:alnum:]]+\z/i)
end
def errors_as_sentence

View file

@ -140,6 +140,7 @@ en:
bad_uri: is an invalid url
blank: can't be blank
confirmation: doesn't match %{attribute}
contains_prohibited_characters: contains non-alphanumeric or prohibited unicode characters
empty: can't be empty
equal_to: must be equal to %{count}
even: must be even

View file

@ -157,6 +157,7 @@ fr:
blank: ne peut pas être vide
confirmation: ne correspond pas à %{attribute}
confirmation_period_expired: doit être confirmé dans %{period}, veuillez en demander un nouveau
contains_prohibited_characters: contient des caractères unicode non alphanumériques ou interdits
empty: ne peut pas être vide
equal_to: doit être égal à %{count}
even: doit être pair

View file

@ -47,7 +47,6 @@ RSpec.describe Article, type: :model do
it { is_expected.to validate_presence_of(:reactions_count) }
it { is_expected.to validate_presence_of(:user_subscriptions_count) }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:user_id) }
it { is_expected.to validate_uniqueness_of(:slug).scoped_to(:user_id) }
@ -233,6 +232,34 @@ RSpec.describe Article, type: :model do
end
end
describe "title validation" do
it "produces a proper title" do
test_article = build(:article, title: "An Article Title")
test_article.validate
expect(test_article.title).to eq("An Article Title")
end
it "sanitizes the title" do
test_article = build(:article, title: "\u202dThis starts with BIDI override")
test_article.validate
expect(test_article.title).not_to match(/\u202d/)
expect(test_article.title).to eq("This starts with BIDI override")
end
it "rejects empty titles after sanitizing" do
test_article = build(:article, title: "\u202a\u202b\u202c\u202d\u202e")
test_article.validate
expect(test_article).not_to be_valid
expect(test_article.errors_as_sentence).to match("Title can't be blank")
end
end
describe "tag validation" do
let(:article) { build(:article, user: user) }

View file

@ -86,6 +86,11 @@ RSpec.describe Tag, type: :model do
end
end
it "fails validation if name is a prohibited (whitespace) unicode character" do
tag.name = "U+202D"
expect(tag).not_to be_valid
end
describe "alias_for" do
it "passes validation if the alias refers to an existing tag" do
tag = create(:tag)