Wolfmans Howlings

A programmers Blog about Ruby, Rails and a few other issues

RSpec testing views for escaped HTML

Posted by Jim Morris Sat, 07 Jul 2007 01:22:00 GMT

For my social networking site snowdogsr.us I decided to escape all user input that gets displayed. I know people like to trick out their profiles with HTML but I want to avoid the various hacks that it allows.

So thinking I had done a good job of using h everywhere I output user input fields, I decided to see if I could actually test this with RSpec view tests.

I recently switched to RSpec for my testing needs, its cool :)

One thing it does is isolate the various things for testing using built in mocking, and views can be entirely tested standalone without accessing a model or a controller.

So how would it do testing for escaped user input I wondered?

Very well actually.

An example is worth a thousand words, so here is my RSpec for my home page.

BTW I found about 4 places where embedded HTML in user input was bleeding through, so it was well worth the effort.

So this goes in spec/views/home/home_spec.rb...

  it "should escape all user input" do
    @place= mock_model(Place, :name => 'place name<b>', :location => 'place location<b>', :tag_list => "place taglist <b>", :rated? => false)
    @event= mock_model(Event, :name => 'event name<b>', :where => 'event where<b>', :tag_list => "event taglist <b>", :date_time => DateTime.now, :hosted_by => 'Event host<b>')
    @post= mock_model(Input, :input => 'input body <b>', :tag_list => "post taglist <b>", :updated_at => DateTime.now, :created_at => DateTime.now, :created_by => 'post created by person<b>', :rated? => false)
    @picture= mock_model(Picture, :public_filename => "filename<b>.png")
    @pictures= [@picture]
    @pet= mock_model(Pet, :name => 'pet name<b>', :owned_by => "pet owner <b>", :breed => 'breed <b>', :description => "pet description <b>", :neutered => true, :gender => 'M<b>', :pictures => @pictures, :owned_by? => false)

    @posts= [@post]
    @events= [@event]
    @places= [@place]
    @top_places= [@place]
    @new_pets= [@pet]

    @comment= mock_model(Comment)
    @comment.stub!(:user).and_return(@user)
    @comment.stub!(:created_at).and_return(DateTime.now)
    @comment.stub!(:comment).and_return('comment body <b>')
    @comments= [@comment]
    @post.should_receive(:comments).and_return(@comments)

    @new_stuff= []
    @new_stuff << {:list => @posts, :title => 'Posts', :link => '#'}
    @new_stuff << {:list => @events, :title => 'Events', :link => '#'}
    @new_stuff << {:list => @places, :title => 'Places', :link => '#'}

    @top= []
    @top << {:list => @top_places, :title => 'Hot Places', :link => '#'}

    assigns[:new_stuff] = @new_stuff
    assigns[:top] = @top
    assigns[:new_pets] = @new_pets

    render "/home/logged_in"   
    #puts excerpt(response.body, "<b>")
    response.should_not have_text(/<b>/)
  end

Its quite complex as the home page renders a lot of summaries of the various lists I have.

First I mock the models that are called, and stub out the calls that are made to them. I force them all to return an embedded <b> which I don't use anyway, and with the new CSS oriented web styles shouldn't be used in HTML anyway.

Then I just test that <b> does not appear anywhere. If I have correctly used h to escape all the inputs then it should be rendered as &lt;b&gt; instead.

The response.should_not have_text(/<b>/) should do that test.

One cool thing is the mocking will tell you if any new inputs (ie calls to model attributes) have been added, or if you have forgotten any. So this should keep you honest in the future if you add new attributes that need escaping.

The

assigns[:new_stuff] = @new_stuff
assigns[:top] = @top
assigns[:new_pets] = @new_pets

Sets the assigns to the variables that my view uses,simulating what the controller would pass in.

The mock_model calls at the top also use a shortcut to define all the attributes that get called, and what they return. You can also explicitly do this...

@post.should_receive(:comments).and_return(@comments)

If you read the RSpec docs you can see that you can also test for parameters passed in, how many times it is called and various other nice things.

I added this snippet taken from the rails helpers to aid in finding any errant HTML that bleads through. (I'm not sure how to call it from the RSpec so I just copied the code into a private method).


 private

  def excerpt(text, phrase, radius = 100, excerpt_string = "...")
    if text.nil? || phrase.nil? then return end
    phrase = Regexp.escape(phrase)

    if found_pos = text.chars =~ /(#{phrase})/i
      start_pos = [ found_pos - radius, 0 ].max
      end_pos   = [ found_pos + phrase.chars.length + radius, text.chars.length ].min

      prefix  = start_pos > 0 ? excerpt_string : ""
      postfix = end_pos < text.chars.length ? excerpt_string : ""

      prefix + text.chars[start_pos..end_pos].strip + postfix
    else
      nil
    end
  end

and you can see the call that shows me where the errant <b> is...

puts excerpt(response.body, "<b>")

I also have some setup code that handles the login and log out mocking, but I'll leave that for the end user to sort out ;)

So I think this will make sure that now and in the future this particular view will not bleed user input HTML.

Once I did the complex one above the rest of the views were much easier and quicker to implement. Here is an example of a really simple one...

  it "should escape all user input" do
    @person= mock_model(Person, :name => 'person name <b>', :first_name => 'person first name <b>', :last_name => 'person last name <b>', :alias => 'person alias <b>', :show_gender => 'Male', :about_me => 'about <b>', :updated_at => DateTime.now, :created_at => DateTime.now, :pets => [])    

    assigns[:person] = @person

    render "/people/show"   

    response.should_not have_text(/<b>/)
  end

Couldn't be much simpler, but I found one place where I was not escaping the HTML!

Posted in ,  | Tags , ,  | 4 comments | no trackbacks

REST scaffold_resource security warning

Posted by Jim Morris Tue, 26 Jun 2007 22:14:04 GMT

This one is so blatantly obvious it bit me in the Butt at 4am this morning when I had to get up and fix it! I am so embarrassed, luckily no private data got out, as no-one has entered any private data yet.

I used the script/generate scaffold_resource to get started, and I left in those nice format.xml things in, thinking I may use them in the future. For the most part this is not a problem, but one of my controllers is a profile table. Much of the data in there is public anyway so no big deal, but a few columns are private data like email, date of birth, phone numbers etc. These are specifically private and not viewable publicly. This is enforced but not having a view that shows any of that stuff to the general public.

However the tricky little scaffold-generated code...

  def index
    @profiles = Profile.find(:all, :order => "first_name, last_name, alias")

    respond_to do |format|
      format.html # index.rhtml
      format.xml  { render :xml => @profiles.to_xml }
     end
  end

Has this cool .to_xml stanza, which happily takes every column and converts it to XML and sends it back as a response to the query /profiles.xml

Yikes, I woke up with a start when I realized that, and rushed to test it and yep it works as it is supposed to.

Obviously this is easy to fix, Just exclude the attributes you don't want shown:

@profiles.to_xml(:only => [:first_name, :last_name])

But it sure is a nasty back door if you forget!

Caveat Programmer!

Posted in  | Tags ,  | 4 comments | no trackbacks

Developing a social networking site part 3 - tag cloud

Posted by Jim Morris Sat, 23 Jun 2007 21:02:49 GMT

This is a simple one.

I use the excellent acts_as_taggable plugin, and I wanted to have a tag cloud like everyone does.

Of course I have different Models that can be tagged, and I want to keep everything DRY, so I created a helper and put the following in the application_helper.rb file

  # display a tag cloud for the given model
  def tag_cloud(model, title= nil)
    m= model.to_s.camelcase.constantize
    plural= model.to_s.capitalize.pluralize
    title ||= plural
    tags= m.tag_counts(:order => 'tags.name')
    gen= ""
    unless tags.empty?
      urlmeth= "tagged_#{model.to_s.pluralize}_path".to_sym
      gen= "<div class=\"tagcloud\">"
      gen += "<h3>#{title}</h3>"
      tags.each do |t|
        gen += "<span style=\"font-size:#{calc_size(t.count)}%\">"
        gen += link_to(h(t.name), self.send(urlmeth, :tag => t.name))
        gen += "</span> "
      end
      gen += "</div>"
      gen += "<hr/>"
    end
    gen
  end

This allows me to have multiple tag clouds for the different models, see snowdogsr.us on the left bar at the bottom.

The font is bigger for the more popular tags, and you can click on any tag to get a listing of all matching articles.

I use RESTful routing so I added this to routes.rb

  map.resources :places, :collection => { :tagged => :get }

which creates a named route tagged_places_path which returns an index with the places tagged with the tag that was clicked.

The places_controller method looks like this...

  def tagged
    @places= Place.find_tagged_with(params[:tag], :order => "updated_at DESC")
    @filter= "Tagged with #{params[:tag]}"
    respond_to do |format|
      format.html { render :action => 'index' }
      format.xml  { render :xml => @places.to_xml }
    end
  end

Posted in  | Tags , ,  | no comments | no trackbacks

Developing a social networking site part 2 - rating stars

Posted by Jim Morris Sat, 23 Jun 2007 20:49:00 GMT

In part 1 I outlined my project to implement snowdogsr.us a social networking site for snow dogs.

I am pleased to announce that version 1 of this site is up, however I had to make some trade-offs to get it up this far. I needed to prioritize my goals and just get the essentials implemented.

So the ability to create Posts, Events and Places, and add photos of your dogs was goal number 1. The linking to friends was postponed, although most of the logic was implemented, I couldn't figure out how to expose that in an intuitive UI.

The first thing I wanted was to be able to rate the various Posts and Places people entered, and how better to do that than to copy the Netflix and Google groups method of showing 5 stars and clicking on them to rate the item.

To that end some googling found an excellent CSS method to do this, all I had to do was integrate it into Rails. I had some extra business rules I needed to deal with

  • Only people logged in can rate an article
  • The creator of the article can not rate their own article
  • If the rating is readonly then it still needs to be displayed as stars, just not clickable ones.

Out of the box the Stars were always clickable, so I needed a way to show readonly stars when the user was not allowed to rate the article, luckily the above referenced blog explained how to do that in the blogs comments.

I'm using the acts_as_rated plugin, which works fine so long as you don't try using the optimized method of creating a stats table, that method didn't seem to work, and for now I'll just let the SQL do the math.

As I have many models that acts_as_rated I needed to write a helper to display the stars, and handle the rating clicks, and the business logic.

The helpers look a little ugly as you need to figure out what the parent model is.

So here is the code that goes in application_helper.rb

  def star_rating(rating, obj_type, id, allow_rate= true)
    per= rating > 0 ? (rating/5.0)*100 : 0;
    url_meth= "rate_#{obj_type}_path".to_sym
    if allow_rate
      links= [
        link_to('1', send(url_meth, {:id => id, :rate => 1}), :method => :put, :class => "one-star", :title =>"1 star out of 5"),
        link_to('2', send(url_meth, {:id => id, :rate => 2}), :method => :put, :class => "two-stars", :title =>"2 stars out of 5"),
        link_to('3', send(url_meth, {:id => id, :rate => 3}), :method => :put, :class => "three-stars", :title =>"3 stars out of 5"),
        link_to('4', send(url_meth, {:id => id, :rate => 4}), :method => :put, :class => "four-stars", :title =>"4 stars out of 5"),
        link_to('5', send(url_meth, {:id => id, :rate => 5}), :method => :put, :class => "five-stars", :title =>"5 stars out of 5")]
    end

    r= " "
    r += "<span class=\"inline-rating\">  <ul class=\"star-rating\"> <li class=\"current-rating\" style=\"width:#{per}%;\"></li>"

    # if we can't rate then just show stars
    if allow_rate
      (0..4).each do |i|
        r += "<li>#{links[i]}</li>"
      end
    end

    r += "</ul></span>"
    r += '<span class="prompt">Click star to rate</span>' if allow_rate
    r
  end

  def render_rating(o, allow_rating=true)  
    can_rate= allow_rating && logged_in? && ! o.created_by?(current_user)
    o_type= o.class.to_s.downcase

    if o.rated?
      r= "Rating #{o.rating_average}/5 by #{pluralize(o.rated_count, 'person')}"
      r += star_rating(o.rating_average, o_type, o.id, can_rate)
    elsif can_rate
      r= "Not yet Rated"
      r += star_rating(0, o_type, o.id, can_rate)
    else
      r= ""
    end
    r
  end

The render_rating method is what you call from your view, the star_rating method generates the HTML.

(I presume you included the star_rating.css which is available from the above mentioned blog, and the acts_as_rated plugin.)

The render_rating method takes the model object being rated, and an optional override to allow_rating or not (which determines if the stars are clickable or not).

I include some text saying what the rating is and how many people have rated the article, followed by the stars showing the rating.

I have all my ratings set to 1 to 5, YMMV.

In my view (HAML) code I have...

= render_rating(place)

Which will render the rating for the current place article.

the Place model has this code...

acts_as_rated(:rating_range => 1..5, :rater_class => 'Person')

because I have a Person class as the rater rather than the default User.

The rating percentage is calculated using..

  per= rating > 0 ? (rating/5.0)*100 : 0;

Which is used by the CSS to highlight the stars correctly.

I use RESTful routing so the links are generated using named routes, however as the named route will be different depending on the Model object being rated I need to use this code...

# added to routes.rb to provide a rate method
map.resources :places, :member => { :rate => :put }

# added to places_controller.rb to handle the new route
 def rate
    @place = Place.find(params[:id])
    rating= params[:rate].to_i
    @place.rate(rating, current_user.person)

    respond_to do |format|
      format.html { redirect_to :back }
      format.xml  { head :ok }
    end

  end

# code snippets from the application helper above
# get the named route to use in this case rate_place_path
url_meth= "rate_#{obj_type}_path".to_sym

...

# generate the named route, equivalent to 
# rate_place_path(:id => id, :rate => 1)

send(url_meth, {:id => id, :rate => 1})

One last thing slightly on topic, I added the following to the acts_as_rated.rb file so I could get a list of the top rated articles, seemed like a logical thing that anyone would want to do ;)

# Find the top rated items, ordered by average rating
def find_top_rated(limit=nil)
  rating_class = acts_as_rated_options[:rating_class].constantize
  base_sql = <<-EOS
    select #{table_name}.*,COALESCE(average,0) AS rating_average from #{table_name} left outer join
      (select avg(rating) as average, rated_id  
         from #{rating_class.table_name}
         where rated_type = '#{class_name}' 
         group by rated_id) as rated 
         on rated_id=id 
       order by rating_average DESC
  EOS
  base_sql += " LIMIT #{limit}" if limit
  find_by_sql base_sql
end

I hope this helps others trying the same thing, as always comments and code improvements always welcome.

Posted in  | Tags , , ,  | 11 comments | no trackbacks

Older posts: 1 2 3 4 5 ... 10