Fix #5365: Don't allow whitespace-only text submission.

Fix bug where it was possible to submit blank text in various text fields.

Caused by `String#blank?` not considering certain Unicode characters as blank. `blank?` is defined
as `match?(/\A[[:space:]]*\z/)`, where `[[:space:]]` matches ASCII spaces (space, tab, newline, etc)
and Unicode characters in the Space category ([1]). However, there are other space-like characters
not in the Space category. This includes U+200B (Zero-Width Space), and many more.

It turns out the "Default ignorable code points" [2][3] are what we're after. These are the set of 400
or so formatting and control characters that are invisible when displayed.

Note that there are other control characters that aren't invisible when rendered, instead they're
shown with a placeholder glyph. These include the ASCII C0 and C1 control codes [4], certain Unicode
control characters [5], and unassigned, reserved, and private use codepoints.

There is one outlier: the Braille pattern blank (U+2800) [6]. This character is visually blank, but is
not considered to be a space or an ignorable code point.

[1]: https://codepoints.net/search?gc[]=Z
[2]: https://codepoints.net/search?DI=1
[3]: https://www.unicode.org/review/pr-5.html
[4]: https://codepoints.net/search?gc[]=Cc
[5]: https://codepoints.net/search?gc[]=Cf
[6]: https://codepoints.net/U+2800
[7]: https://en.wikipedia.org/wiki/Whitespace_character
[8]: https://character.construction/blanks
[9]: https://invisible-characters.com
This commit is contained in:
evazion
2022-12-05 01:22:20 -06:00
parent 640a20d81c
commit d9dc84325f
30 changed files with 152 additions and 23 deletions

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
# A custom validator for ensuring a string isn't blank. Like the `presence` validator, but checks for certain invisible
# Unicode characters such as zero-width spaces too.
#
# @example
# validates :body, visible_string: true
#
# @see https://invisible-characters.com/
# @see https://guides.rubyonrails.org/active_record_validations.html#presence
# @see https://guides.rubyonrails.org/active_record_validations.html#custom-validators
class VisibleStringValidator < ActiveModel::EachValidator
def validate_each(record, attr, string)
return if options[:allow_empty] && string.empty?
if string.nil? || string.invisible?
record.errors.add(attr, "can't be blank")
end
end
end

View File

@@ -14,7 +14,7 @@ class Ban < ApplicationRecord
validates :duration, presence: true
validates :duration, inclusion: { in: [1.day, 3.days, 1.week, 1.month, 3.months, 6.months, 1.year, 100.years], message: "%{value} is not a valid ban duration" }, if: :duration_changed?
validates :reason, presence: true
validates :reason, visible_string: true
validate :user, :validate_user_is_bannable, on: :create
scope :unexpired, -> { where("bans.created_at + bans.duration > ?", Time.zone.now) }

View File

@@ -10,9 +10,9 @@ class BulkUpdateRequest < ApplicationRecord
belongs_to :forum_post, optional: true
belongs_to :approver, optional: true, class_name: "User"
validates :reason, presence: true, on: :create
validates :script, presence: true
validates :title, presence: true, if: ->(rec) { rec.forum_topic_id.blank? }
validates :reason, visible_string: true, on: :create
validates :script, visible_string: true
validates :title, visible_string: true, if: ->(rec) { rec.forum_topic_id.blank? }
validates :forum_topic, presence: true, if: ->(rec) { rec.forum_topic_id.present? }
validates :status, inclusion: { in: STATUSES }
validate :validate_script, if: :script_changed?

View File

@@ -13,7 +13,7 @@ class Comment < ApplicationRecord
has_many :active_votes, -> { active }, class_name: "CommentVote"
has_many :mod_actions, as: :subject, dependent: :destroy
validates :body, presence: true, length: { maximum: 15_000 }, if: :body_changed?
validates :body, visible_string: true, length: { maximum: 15_000 }, if: :body_changed?
before_create :autoreport_spam
before_save :handle_reports_on_deletion

View File

@@ -4,8 +4,8 @@ class Dmail < ApplicationRecord
attr_accessor :creator_ip_addr, :disable_email_notifications
validate :validate_sender_is_not_limited, on: :create
validates :title, presence: true, length: { maximum: 200 }, if: :title_changed?
validates :body, presence: true, length: { maximum: 50_000 }, if: :body_changed?
validates :title, visible_string: true, length: { maximum: 200 }, if: :title_changed?
validates :body, visible_string: true, length: { maximum: 50_000 }, if: :body_changed?
belongs_to :owner, :class_name => "User"
belongs_to :to, :class_name => "User"

View File

@@ -5,7 +5,7 @@ class FavoriteGroup < ApplicationRecord
before_validation :normalize_name
validates :name, presence: true
validates :name, visible_string: true
validates :name, uniqueness: { case_sensitive: false, scope: :creator_id }
validate :validate_name, if: :name_changed?
validate :creator_can_create_favorite_groups, :on => :create

View File

@@ -16,7 +16,7 @@ class ForumPost < ApplicationRecord
has_one :tag_implication
has_one :bulk_update_request
validates :body, presence: true, length: { maximum: 200_000 }, if: :body_changed?
validates :body, visible_string: true, length: { maximum: 200_000 }, if: :body_changed?
validate :validate_deletion_of_original_post
validate :validate_undeletion_of_post

View File

@@ -27,7 +27,7 @@ class ForumTopic < ApplicationRecord
has_many :tag_implications
has_many :mod_actions, as: :subject, dependent: :destroy
validates :title, presence: true, length: { maximum: 200 }, if: :title_changed?
validates :title, visible_string: true, length: { maximum: 200 }, if: :title_changed?
validates :category_id, inclusion: { in: CATEGORIES.keys }
validates :min_level, inclusion: { in: MIN_LEVELS.values }

View File

@@ -7,7 +7,7 @@ class IpBan < ApplicationRecord
has_many :mod_actions, as: :subject, dependent: :destroy
validate :validate_ip_addr
validates :reason, presence: true
validates :reason, visible_string: true
after_save :create_mod_action

View File

@@ -10,7 +10,7 @@ class ModerationReport < ApplicationRecord
has_many :mod_actions, as: :subject, dependent: :destroy
before_validation(on: :create) { model.lock! }
validates :reason, presence: true
validates :reason, visible_string: true
validates :model_type, inclusion: { in: MODEL_TYPES }
validates :creator, uniqueness: { scope: [:model_type, :model_id], message: "have already reported this message." }, on: :create

View File

@@ -11,7 +11,7 @@ class Note < ApplicationRecord
validates :y, presence: true
validates :width, presence: true
validates :height, presence: true
validates :body, presence: true
validates :body, visible_string: true
validate :note_within_image
after_save :update_post
after_save :create_version

View File

@@ -6,7 +6,7 @@ class Pool < ApplicationRecord
array_attribute :post_ids, parse: /\d+/, cast: :to_i
validates :name, uniqueness: { case_sensitive: false }, if: :name_changed?
validates :name, visible_string: true, uniqueness: { case_sensitive: false }, if: :name_changed?
validate :validate_name, if: :name_changed?
validates :category, inclusion: { in: %w[series collection] }
validate :updater_can_edit_deleted

View File

@@ -4,7 +4,7 @@ class PostAppeal < ApplicationRecord
belongs_to :creator, :class_name => "User"
belongs_to :post
validates :reason, length: { maximum: 140 }
validates :reason, visible_string: { allow_empty: true }, length: { maximum: 140 }
validate :validate_post_is_appealable, on: :create
validate :validate_creator_is_not_limited, on: :create
validates :creator, uniqueness: { scope: :post, message: "have already appealed this post" }, on: :create

View File

@@ -10,7 +10,7 @@ class PostFlag < ApplicationRecord
belongs_to :post
before_validation { post.lock! }
validates :reason, presence: true, length: { in: 1..140 }
validates :reason, visible_string: true, length: { in: 1..140 }
validate :validate_creator_is_not_limited, on: :create
validate :validate_post, on: :create
validates :creator_id, uniqueness: { scope: :post_id, on: :create, unless: :is_deletion, message: "have already flagged this post" }

View File

@@ -11,7 +11,7 @@ class SavedSearch < ApplicationRecord
normalize :query, :normalize_query
normalize :labels, :normalize_labels
validates :query, presence: true
validates :query, visible_string: true
validate :validate_count, on: :create
scope :labeled, ->(label) { where_array_includes_any_lower(:labels, [normalize_label(label)]) }

View File

@@ -7,7 +7,7 @@ class UserFeedback < ApplicationRecord
belongs_to :user
belongs_to :creator, class_name: "User"
validates :body, presence: true
validates :body, visible_string: true
validates :category, presence: true, inclusion: { in: %w[positive negative neutral] }
after_create :create_dmail, unless: :disable_dmail_notification
after_update :create_mod_action

View File

@@ -14,7 +14,7 @@ class WikiPage < ApplicationRecord
array_attribute :other_names # XXX must come after `normalize :other_names`
validates :title, tag_name: true, presence: true, uniqueness: true, if: :title_changed?
validates :body, presence: true, unless: -> { is_deleted? || other_names.present? }
validates :body, visible_string: true, unless: -> { is_deleted? || other_names.present? }
validate :validate_rename
validate :validate_other_names