Skip to content

Commit

Permalink
Security: Force all GET requests to be read-only
Browse files Browse the repository at this point in the history
The W3C makes a clear distinction between GET and POST requests.  GET
requests should only cause "safe" actions, and the user should never be
held accountable for making GET requests.  See the following for an
overview:

  http://www.w3.org/2001/tag/doc/whenToUseGet.html

The Rails 'protect_against_forgery' function (and possibly some web
browsers) rely on the distinction between GET and POST to provide
protection against CSRF attacks.  See:

  http://en.wikipedia.org/wiki/Cross-site_request_forgery
  http://guides.rubyonrails.org/security.html#_csrf_countermeasures

Unfortunately, enforcing these rules in rather difficult, especially in
a large application with lots of controllers and plugins.  So this patch
applies a rather heavy-handed fix: We globally block database writes
during GET requests, and specifically override that policy in one or two
places.

All of our current overrides invoke User#reset_token!.  I haven't
performed a full security analysis of allowing User#reset_token! (or
updates to session[:user] based on our "remember me" token) in a GET
request.  For now, I'm going to go ahead and allow this activity--if we
actually have some sort of vulnerability here, it affects a wide range
of web applications.

Note that this patch may break some part of the /admin interface.  I've
tried posting articles and other basic stuff, but I haven't used the
lesser-known corners of /admin since making these changes.  Please
report any problems.
  • Loading branch information
emk committed Dec 20, 2008
1 parent ff0113a commit c500bf8
Show file tree
Hide file tree
Showing 6 changed files with 102 additions and 2 deletions.
5 changes: 4 additions & 1 deletion app/controllers/account_controller.rb
Expand Up @@ -43,7 +43,10 @@ def forget
def activate
self.current_user = site.user_by_token(params[:id])
if logged_in?
current_user.reset_token!
# TODO - See security comments on AuthenticatedSystem#login_from_cookie.
ActiveRecord::Base.with_writable_records do
current_user.reset_token!
end
else
flash[:error] = "Invalid token. Try resending your forgotten password request."
end
Expand Down
1 change: 1 addition & 0 deletions app/controllers/admin/assets_controller.rb
Expand Up @@ -3,6 +3,7 @@ class Admin::AssetsController < Admin::BaseController
skip_before_filter :login_required
before_filter :find_asset, :except => [:index, :new, :create, :latest, :search, :upload, :clear_bucket]
before_filter :login_required
before_filter :protect_action, :except => [:index, :new, :latest, :search]

def index
search_assets 24
Expand Down
16 changes: 16 additions & 0 deletions app/controllers/application.rb
Expand Up @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base

include Mephisto::CachingMethods
before_filter :set_cache_root
around_filter :get_requests_are_readonly
helper_method :site
attr_reader :site

Expand Down Expand Up @@ -95,6 +96,21 @@ def set_cache_root
end
end

# Much of a web browser's built-in protection against CSRF attacks
# assumes that all GET requests are "safe". Since we don't want to
# rely on getting this 100% right in every controller and plugin, let's
# just enforce this policy globally. You can override this using
# <code>skip_filter :get_requests_are_readonly</code>.
def get_requests_are_readonly
if request.method == :get
ActiveRecord::Base.with_readonly_records do
yield
end
else
yield
end
end

def with_site_timezone
old_tz = ENV['TZ']
ENV['TZ'] = site.timezone.name
Expand Down
35 changes: 35 additions & 0 deletions config/initializers/readonly_records.rb
@@ -0,0 +1,35 @@
module ActiveRecord
class Base
# Are ActiveRecord::Base objects currently readonly?
def self.all_records_are_readonly?
Thread.current[:all_records_are_readonly]
end

def readonly_with_global_flag?
self.class.all_records_are_readonly? || readonly_without_global_flag?
end
alias_method_chain :readonly?, :global_flag

# Make all ActiveRecord::Base objects readonly within a block.
def self.with_readonly_records # :yield:
saved = all_records_are_readonly?
begin
Thread.current[:all_records_are_readonly] = true
yield
ensure
Thread.current[:all_records_are_readonly] = saved
end
end

# Make all ActiveRecord::Base objects writable within a block.
def self.with_writable_records # :yield:
saved = all_records_are_readonly?
begin
Thread.current[:all_records_are_readonly] = false
yield
ensure
Thread.current[:all_records_are_readonly] = saved
end
end
end
end
12 changes: 11 additions & 1 deletion lib/authenticated_system.rb
Expand Up @@ -74,7 +74,17 @@ def self.included(base)
def login_from_cookie
return unless cookies[:token] && !logged_in?
self.current_user = site.user_by_token(cookies[:token])
cookies[:token] = { :value => self.current_user.reset_token! , :expires => self.current_user.token_expires_at } if logged_in?
# TODO - We allow the token to be changed on GET requests and we log
# the user in. I haven't fully analyzed the consequences of allowing
# session and token updates on hostile GET requests triggered by CSRF
# attacks. If this helps out in some kind of attack, it would affect
# almost every single web application in existence.
ActiveRecord::Base.with_writable_records do
cookies[:token] = {
:value => self.current_user.reset_token!,
:expires => self.current_user.token_expires_at
} if logged_in?
end
true
end

Expand Down
35 changes: 35 additions & 0 deletions spec/models/readonly_spec.rb
@@ -0,0 +1,35 @@
require File.dirname(__FILE__) + '/../spec_helper'

# Verify that our readonly_record patches are working.
describe "Any record" do
before :each do
@article = Article.make(:title => "Original title")
end

it "should be writable by default" do
assert !ActiveRecord::Base.all_records_are_readonly?
@article.title = "Hello!"
@article.save!
end

it "should not be writable inside with_readonly_records" do
assert_raise ActiveRecord::ReadOnlyRecord do
ActiveRecord::Base.with_readonly_records do
assert ActiveRecord::Base.all_records_are_readonly?
@article.title = "Hello!"
@article.save!
end
end
end

it "should be writable inside with_writable_records" do
ActiveRecord::Base.with_readonly_records do
ActiveRecord::Base.with_writable_records do
assert !ActiveRecord::Base.all_records_are_readonly?
@article.title = "Hello!"
@article.save!
end
end
end
end

0 comments on commit c500bf8

Please sign in to comment.