Skip to content

Commit b7cb822

Browse files
committed
Security: Fix XSS attack against new comment form
WARNING: If you're one of the first people testing this commit, please use a backup database. How to reproduce: Create a new comment, and set all fields to <script>alert("Pwned")</script>. Submit it. You will see a JavaScript alert dialog, which is bad. What's happening: Untrusted fields in Comment objects are sanitized immediately before they're written to the database for the first time. But if validation fails, it leaves the application with an unsanitized comment object. When the "can't submit comment" error is displayed, this unsanitized comment object can be passed straight throught to Liquid, which assumes that all HTML tags have been escaped. (This may look like "self XSS" attack only, but hostile pages can trigger it by tricking you into submitting a comment form back to your own site, preloaded with malicious data.) How we fix it: We make HTML escaping the responsibility of CommentDrop, not the Comment model. This means that we need to unescape several existing fields in the database. Possible issues: This means that we're storing dangerous, untrusted data in our database, and that we need to rely on the proper use of 'h' and 'CGI.escapeHTML'. In the case of 'h', we're already using SafeERB, so insecure admin templates will be caught automatically, and dangerous data should never be sent to the user. In the case of Liquid, we need to carefully examine our CommentDrop class to make sure that we're not passing any unescaped data through to the Liquid templates. But this is a pretty manageable "proof obligation"--and remember that the old "sanitize on create" code actually suffered from XSS attacks, because it was too easy to do the sanitization in the wrong place.
1 parent bf1a5de commit b7cb822

File tree

5 files changed

+55
-19
lines changed

5 files changed

+55
-19
lines changed

app/drops/comment_drop.rb

+14-5
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@ class CommentDrop < BaseDrop
22
include Mephisto::Liquid::UrlMethods
33

44
timezone_dates :published_at, :created_at
5-
liquid_attributes.push(*[:author, :author_email, :author_ip, :title])
5+
liquid_attributes.push(:title) # Not sure who uses this.
66

77
def initialize(source)
88
super
9-
@liquid.update 'is_approved' => @source.approved?, 'body' => ActionView::Base.white_list_sanitizer.sanitize(@source.body_html)
9+
@liquid.update('is_approved' => @source.approved?,
10+
'body' => ActionView::Base.white_list_sanitizer.sanitize(@source.body_html))
11+
12+
# We used to escape these fields when we saved them to the database.
13+
# Now we've unescaped everything in the database, but we still need to
14+
# preserve backwards compatibility with old themes, which expect these
15+
# values to be escaped. So we escape these fields manually here.
16+
[:author, :author_email, :author_ip].each do |a|
17+
@liquid.update(a.to_s => CGI.escapeHTML(@source.send(a) || ''))
18+
end
1019
end
11-
20+
1221
def author_url
1322
return nil if source.author_url.blank?
14-
@source.author_url =~ /^https?:\/\// ? @source.author_url : "http://" + @source.author_url
23+
CGI.escapeHTML(@source.author_url =~ /^https?:\/\// ? @source.author_url : "http://" + @source.author_url)
1524
end
1625

1726
def url
@@ -23,7 +32,7 @@ def new_record
2332
end
2433

2534
def author_link
26-
@source.author_url.blank? ? "<span>#{@source.author}</span>" : %Q{<a href="#{author_url}">#{@source.author}</a>}
35+
@source.author_url.blank? ? "<span>#{@liquid['author']}</span>" : %Q{<a href="#{author_url}">#{@liquid['author']}</a>}
2736
end
2837

2938
def presentation_class

app/models/comment.rb

-7
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ class Comment < Content
77
before_validation :clean_up_author_url
88
after_validation_on_create :snag_article_attributes
99
before_create :check_comment_expiration
10-
before_create :sanitize_attributes
1110
before_save :update_counter_cache
1211
before_destroy :decrement_counter_cache
1312
belongs_to :article
@@ -78,12 +77,6 @@ def mark_as_ham(site, request)
7877
end
7978

8079
protected
81-
def sanitize_attributes
82-
[:author, :author_url, :author_email, :author_ip, :user_agent, :referrer].each do |a|
83-
self.send("#{a}=", CGI::escapeHTML(self.send(a).to_s))
84-
end
85-
end
86-
8780
def snag_article_attributes
8881
self.filter ||= article.site.filter
8982
[:site, :title, :published_at, :permalink].each { |a| self.send("#{a}=", article.send(a)) }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# We were storing these fields in the database "pre-escaped", which (oddly
2+
# enough) actually increased the number of security problems in our
3+
# application, because we didn't escape the fields until after the record
4+
# was validated, so error pages tended to vulnerable to XSS attacks. So
5+
# let's just rely on SafeERB and our CommentDrop to make sure we escape on
6+
# output.
7+
class UnescapeCommentFields < ActiveRecord::Migration
8+
class Content < ActiveRecord::Base
9+
end
10+
11+
class Comment < Content
12+
end
13+
14+
# Taken from the old sanitize_attributes method in Content.
15+
ATTRIBUTES =
16+
[:author, :author_url, :author_email, :author_ip, :user_agent, :referrer]
17+
18+
def self.up
19+
Comment.find(:all).each do |c|
20+
ATTRIBUTES.each do |a|
21+
c.send("#{a}=", CGI::unescapeHTML(c.send(a).to_s)) if c.send(a)
22+
end
23+
c.save!
24+
end
25+
end
26+
27+
def self.down
28+
Comment.find(:all).each do |c|
29+
ATTRIBUTES.each do |a|
30+
c.send("#{a}=", CGI::escapeHTML(c.send(a).to_s)) if c.send(a)
31+
end
32+
c.save!
33+
end
34+
end
35+
end

db/schema.rb

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# This file is auto-generated from the current state of the database. Instead of editing this file,
2-
# please use the migrations feature of ActiveRecord to incrementally modify your database, and
2+
# please use the migrations feature of Active Record to incrementally modify your database, and
33
# then regenerate this schema definition.
44
#
55
# Note that this schema.rb definition is the authoritative source for your database schema. If you need
@@ -9,7 +9,7 @@
99
#
1010
# It's strongly recommended to check this file into your version control system.
1111

12-
ActiveRecord::Schema.define(:version => 76) do
12+
ActiveRecord::Schema.define(:version => 20081219130711) do
1313

1414
create_table "assets", :force => true do |t|
1515
t.string "content_type"
@@ -110,8 +110,8 @@
110110
t.integer "assets_count", :default => 0
111111
end
112112

113-
add_index "contents", ["published_at"], :name => "idx_articles_published"
114113
add_index "contents", ["article_id", "approved", "type"], :name => "idx_comments"
114+
add_index "contents", ["published_at"], :name => "idx_articles_published"
115115

116116
create_table "events", :force => true do |t|
117117
t.string "mode"

test/unit/comment_drop_test.rb

+3-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class CommentDropTest < Test::Unit::TestCase
55

66
def setup
77
@comment = contents(:welcome_comment).to_liquid
8-
@mock_comment = [:published_at, :created_at, :author, :author_email, :author_ip, :title, :approved?].inject({:body_html => 'foo'}) { |h, i| h.update i => true }
8+
@mock_comment = [:published_at, :created_at, :title, :approved?].inject({:body_html => 'foo', :author => 'Bob', :author_email => 'bob@example.com', :author_ip => '127.0.0.1' }) { |h, i| h.update i => true }
99
end
1010

1111
def test_should_convert_comment_to_drop
@@ -36,8 +36,7 @@ def test_should_return_correct_author_link
3636
assert_equal %Q{<a href="https://abc">rico</a>}, @comment.author_link
3737
@comment.source.author = '<strong>rico</strong>'
3838
@comment.source.author_url = '<strong>https://abc</strong>'
39-
@comment.source.send(:sanitize_attributes)
40-
assert_equal %Q{<a href="http://&lt;strong&gt;https://abc&lt;/strong&gt;">&lt;strong&gt;rico&lt;/strong&gt;</a>}, @comment.author_link
39+
assert_equal %Q{<a href="http://&lt;strong&gt;https://abc&lt;/strong&gt;">&lt;strong&gt;rico&lt;/strong&gt;</a>}, @comment.source.to_liquid.author_link
4140
end
4241

4342
def test_should_show_filtered_text
@@ -74,4 +73,4 @@ def create_comment_stub(options)
7473
def stub.id() 55; end
7574
end
7675
end
77-
end
76+
end

0 commit comments

Comments
 (0)