Files
danbooru/app/models/dmail.rb
evazion 73219f38ce dmails: fix security issues with dmail permalinks.
Fix a couple security issues related to dmail permalinks. Dmails have a
permalink that you can give to a Mod to let them read the dmail. This is
done with a key param that grants access when the dmail is opened by
another user. The key param had several problems:

* The key contained a full copy of the message's title and body encoded in
  base64. This meant that anyone given a dmail permalink could read the
  full dmail just by decoding the key in the link, without even having
  to open the link.

* The key was derived from the dmail's title and body. If you knew or
  could guess a dmail's title and body you could open the dmail. One
  case when this was possible was when sending dmails. You could send
  someone a dmail, take the permalink from your sent copy of the dmail,
  then increment the dmail id to open the receiver's copy of the dmail.
  Since the sent copy and the received copy both had the same title and
  body, they both had the same dmail key. This let you check whether a
  person had read your dmail, and what time they read it at.

* The key verification was done with an insecure string comparison
  rather than a secure constant-time comparison. This was potentially
  vulnerable to timing attacks.

* Opening a dmail belonging to another user would mark it as read for them.

The fix to all this is to use the dmail's id as the key instead of the
dmail's title and body. This means that old permalinks no longer work.
This is unavoidable given the issues above.

Other changes:

* The name of the 'Permalink' link is now 'Share'.
* Anyone with the 'Share' link can view the dmail, not just Mods.
2020-02-02 22:27:49 -06:00

219 lines
5.2 KiB
Ruby

require 'digest/sha1'
class Dmail < ApplicationRecord
# if a person sends spam to more than 10 users within a 24 hour window, automatically ban them for 3 days.
AUTOBAN_THRESHOLD = 10
AUTOBAN_WINDOW = 24.hours
AUTOBAN_DURATION = 3
validates_presence_of :title, :body, on: :create
validate :validate_sender_is_not_banned, on: :create
belongs_to :owner, :class_name => "User"
belongs_to :to, :class_name => "User"
belongs_to :from, :class_name => "User"
after_initialize :initialize_attributes, if: :new_record?
after_save :update_unread_dmail_count
after_commit :send_email, on: :create
api_attributes including: [:key]
scope :active, -> { where(is_deleted: false) }
scope :deleted, -> { where(is_deleted: true) }
scope :read, -> { where(is_read: true) }
scope :unread, -> { where(is_read: false) }
scope :spam, -> { where(is_spam: true) }
scope :visible, -> { where(owner: CurrentUser.user) }
scope :sent, -> { where("dmails.owner_id = dmails.from_id") }
scope :received, -> { where("dmails.owner_id = dmails.to_id") }
concerning :SpamMethods do
class_methods do
def is_spammer?(user)
return false if user.is_gold?
spammed_users = sent_by(user).where(is_spam: true).where("created_at > ?", AUTOBAN_WINDOW.ago).distinct.count(:to_id)
spammed_users >= AUTOBAN_THRESHOLD
end
def ban_spammer(spammer)
spammer.bans.create! do |ban|
ban.banner = User.system
ban.reason = "Spambot."
ban.duration = AUTOBAN_DURATION
end
end
end
def spam?
SpamDetector.new(self).spam?
end
end
module AddressMethods
def to_name=(name)
self.to = User.find_by_name(name)
end
def initialize_attributes
self.from_id ||= CurrentUser.id
self.creator_ip_addr ||= CurrentUser.ip_addr
end
end
module FactoryMethods
extend ActiveSupport::Concern
module ClassMethods
def create_split(params)
copy = nil
Dmail.transaction do
# recipient's copy
copy = Dmail.new(params)
copy.owner_id = copy.to_id
copy.is_spam = copy.spam?
copy.save unless copy.to_id == copy.from_id
# sender's copy
copy = Dmail.new(params)
copy.owner_id = copy.from_id
copy.is_read = true
copy.save
Dmail.ban_spammer(copy.from) if Dmail.is_spammer?(copy.from)
end
copy
end
def create_automated(params)
CurrentUser.as_system do
dmail = Dmail.new(from: User.system, **params)
dmail.owner = dmail.to
dmail.save
dmail
end
end
end
def build_response(options = {})
Dmail.new do |dmail|
if title =~ /Re:/
dmail.title = title
else
dmail.title = "Re: #{title}"
end
dmail.owner_id = from_id
dmail.body = quoted_body
dmail.to_id = from_id unless options[:forward]
dmail.from_id = to_id
end
end
end
module SearchMethods
def sent_by(user)
where("dmails.from_id = ? AND dmails.owner_id != ?", user.id, user.id)
end
def folder_matches(folder)
case folder
when "received"
active.received
when "unread"
active.received.unread
when "sent"
active.sent
when "spam"
active.spam
when "deleted"
deleted
else
all
end
end
def search(params)
q = super
q = q.search_attributes(params, :to, :from, :is_spam, :is_read, :is_deleted, :title, :body)
q = q.text_attribute_matches(:title, params[:title_matches])
q = q.text_attribute_matches(:body, params[:message_matches], index_column: :message_index)
q = q.folder_matches(params[:folder])
q.apply_default_order(params)
end
end
concerning :AuthorizationMethods do
def verifier
@verifier ||= Danbooru::MessageVerifier.new(:dmail_link)
end
def key
verifier.generate(id)
end
def valid_key?(key)
decoded_id = verifier.verified(key)
id == decoded_id
end
def visible_to?(user, key)
owner_id == user.id || valid_key?(key)
end
end
include AddressMethods
include FactoryMethods
extend SearchMethods
def self.mark_all_as_read
unread.update(is_read: true)
end
def validate_sender_is_not_banned
if from.try(:is_banned?)
errors[:base] << "Sender is banned and cannot send messages"
end
end
def quoted_body
"[quote]\n#{from.pretty_name} said:\n\n#{body}\n[/quote]\n\n"
end
def send_email
if is_recipient? && !is_spam? && to.receive_email_notifications? && to.email =~ /@/
UserMailer.dmail_notice(self).deliver_now
end
end
def is_automated?
from == User.system
end
def is_sender?
owner == from
end
def is_recipient?
owner == to
end
def update_unread_dmail_count
return unless saved_change_to_id? || saved_change_to_is_read? || saved_change_to_is_deleted?
owner.with_lock do
unread_count = owner.dmails.active.unread.count
owner.update!(unread_dmail_count: unread_count)
end
end
def reportable_by?(user)
is_recipient? && !is_automated? && !from.is_moderator?
end
end