Rails csrf_token 和 Session CookieStore 原理

Posted by wxianfeng Fri, 01 Mar 2013 22:59:00 GMT

环境: ruby 1.8.7 + rails 2.3.x, ruby 1.9.2 + rails 3.x

前段时间处理手机客户端post请求问题, 经常遇到csrf token 问题, 另外web上也经常遇到和session相关的问题, 不深究下去, 很多东西云里雾里, 于是把rails源码csrf_token, 和 session cookiestore 相关的代码研究了下.

csrf_token 原理

这个相信做rails的, 没有不知道的,是rails framework中为了防止 XSS攻击的, 可是你知道它的原理吗?
好, 顺着代码跟进去, 这是 rails 3.x 的源码.
入口就是 ApplicationController 中的 protect_from_forgery

    # File actionpack/lib/action_controller/metal/request_forgery_protection.rb, line 85
       def protect_from_forgery(options = {})
         self.request_forgery_protection_token ||= :authenticity_token
         prepend_before_filter :verify_authenticity_token, options
       end

再找到 verify_authenticity_token

       def verify_authenticity_token
         verified_request? || handle_unverified_request
       end

再找到 verified_request?

发现判断合法的请求方法:

1, 跳过不验证的
2, GET 请求
3, csrf_token 和参数中 authenticity_token 值相同的
4, http header 中 X-CSRF-Token 和 csrf_token 的值相同的

不合法请求会 reset_session

       def handle_unverified_request
         reset_session
       end

来看看 rails 2.x 的, 为什么说 rails 2.x 的,和 rails 3.x 不一样, 影响也很大.

合法的请求:

1, 跳过检查的
2, GET 请求
3, ajax 请求
4, 不是 html 格式请求, 例如 json, xml 格式请求都是合法的
5, csrf_token 值和 参数中 authenticity_token 值相同的

不合法的请求会 raise error

       def verify_authenticity_token
         verified_request? || raise(ActionController::InvalidAuthenticityToken)
       end

上面的处理都有漏洞, 来看看

rails 3.x 的, 假如用户登录是 post, 登录前还没有 session ,此时会 reset_ssession,因为本来就没有登录后的session,reset_session后,后面的代码继续执行, 假如用户知道用户用户名,密码,利用http client 工具就可以成功获得登录后的session, 虽然 csrf 会验证失败, 所以可以自己打个patch使用 rails 2.x 的方式, 直接 raise

rails2.x 的当请求格式不是 html,是 json 就可以成功跳过 csrf 验证, 例如我这个更新redmine的脚本就是利用这个漏洞实现的.

https://gist.github.com/wxianfeng/5070599

那么 csrf_token 的值又是存在什么地方的呢, 在 session[:_csrf_token],rails 默认session是 cookie store, 这就涉及到cookiestore原理了.

关于 csrf_token 还有一个需要注意的地方, 在 test env 下是不需要 csrf_token 的, 顺着 csrf_meta_tag 跟进去可以看到.

Rails Session CookieStore 原理

在rails后端调试下 session, 打印出来的结果是一个hash, 以github 为例, 先反向得到 session 数据, 用firebug可以看到github的cookie中有一个 _gh_session, 如下:

_gh_sess=BAh7CjoPc2Vzc2lvbl9pZCIlMjM1OGMwZjFhYmU2MTQ0MGRlYWUzYWVhODVhM2U2MTk6EF9jc3JmX3Rva2VuSSIxcHNLWEFoYittaXVVVnZXU3BxMDBJaE52Z0QvQ0kyYjg1cU5pNTJMU2R6TT0GOgZFRjoJdXNlcmkD2IsBOhBmaW5nZXJwcmludCIlZGFmNjBhOGFlYTJlZWE3YThjNWY1OGRmMzg2YzhhNWQ6DGNvbnRleHRJIgYvBjsHRg%3D%3D--0320c02623b8a27a66bbbcd38d095511c459e1f3;

取出 — 前面的部分, 假设为 data

::Marshal.load ::Base64.decode64(data) 后会得到一个hash, 这个就是后端的 session数据

ruby-1.9.2-p290 :016 >   data = "BAh7CjoPc2Vzc2lvbl9pZCIlMjM1OGMwZjFhYmU2MTQ0MGRlYWUzYWVhODVhM2U2MTk6EF9jc3JmX3Rva2VuSSIxcHNLWEFoYittaXVVVnZXU3BxMDBJaE52Z0QvQ0kyYjg1cU5pNTJMU2R6TT0GOgZFRjoJdXNlcmkD2IsBOhBmaW5nZXJwcmludCIlZGFmNjBhOGFlYTJlZWE3YThjNWY1OGRmMzg2YzhhNWQ6DGNvbnRleHRJIgYvBjsHRg%3D%3D"
 => "BAh7CjoPc2Vzc2lvbl9pZCIlMjM1OGMwZjFhYmU2MTQ0MGRlYWUzYWVhODVhM2U2MTk6EF9jc3JmX3Rva2VuSSIxcHNLWEFoYittaXVVVnZXU3BxMDBJaE52Z0QvQ0kyYjg1cU5pNTJMU2R6TT0GOgZFRjoJdXNlcmkD2IsBOhBmaW5nZXJwcmludCIlZGFmNjBhOGFlYTJlZWE3YThjNWY1OGRmMzg2YzhhNWQ6DGNvbnRleHRJIgYvBjsHRg%3D%3D" 
ruby-1.9.2-p290 :017 > ::Marshal.load ::Base64.decode64(data)                                                          
=> {:session_id=>"2358c0f1abe61440deae3aea85a3e619", :_csrf_token=>"psKXAhb+miuUVvWSpq00IhNvgD/CI2b85qNi52LSdzM=", :user=>101336, :fingerprint=>"daf60a8aea2eea7a8c5f58df386c8a5d", :context=>"/"}

再来正向生成

data = ::Base64.encode64 Marshal.dump(h)

那—后面的 digest 是怎么生成的, 和rails中 secrect 合起来加密生成的, 这样别人就不能伪造cookie了,术语叫 cookie 签名.

大概生成算法是这样:

session = {"_csrf_token"="xxxxx","user_id"=>4}
session_data = ::Base64.encode64 Marshal.dump(session)
session_data = "#{session_data}--#{generate_hmac(session_data, @secrets.first)}"

def generate_hmac(data, secret)
     OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, secret, data)
end

源码位置:

/Users/wangxianfeng/.rvm/gems/ruby-1.9.2-p290/gems/rack-1.4.3/lib/rack/session/cookie.rb
/home/wxianfeng/.rvm/gems/ruby-1.9.2-p320/gems/activesupport-3.2.2/lib/active_support/message_verifier.rb

总结起来一句话, rails 的session cookiestore 是存在浏览器的cookie中的, 由 http 协议的 headers 带到后端, 后端解包出来的.

OVER, 是不是没想到啊?


WEB GUI 执行rake任务

Posted by wxianfeng Sat, 15 Dec 2012 02:51:00 GMT

情况是这样的, 公司内部测试服务器经常需要更新代码供测试人员使用网站, 每次都是我们后端开发人员部署的, 这样就加大了工作量,效率低下,话说我们部署也是使用capistrano 的, 只需一条命令就可以顺利部署, 但是还是不如非开发人员部署来的方便,于是就有了 rake_ui
rake_ui gem 是我发布的,但是是在修改别人代码的基础上发布的,下面介绍使用方法:

首先看下效果图:

1, 环境

Node.js
Socket.io
Rails 3.x

2,Gemfile

gem 'rake_ui', '0.6.0'

3, 在你的 routes.rb 中添加路由

Rails.application.routes.draw do
  mount RakeUi::Engine => "/rake_ui"
end

4, 配置 config/rake_ui.yml

host: '192.168.10.107'
log: '/data/projects/entos/log/rake.log'

host是你的ip地址,Nodejs 要用, log 是你项目下log目录下rake.log 会被自动创建

5, 配置 config/tasks.yml

- 'rake about'  
- 'rake routes'

把你需要执行的rake任务写在这个 yaml 中

6, 启动 nodejs server

rake start_node_server

ok, 你可以访问 /rake_ui 看到你的 web gui 界面了, 把你的部署方案写在rake任务中, 然后在这个界面可以点击部署.

该gem有可能被更新,看到最新的说明请移到步这里:

HERE


rails 集成 OAuth 第三方登录

Posted by wxianfeng Fri, 26 Oct 2012 18:04:00 GMT

最近需要为一个站集成 OAuth 协议 第三方站点账号登录, 目前实现了两个 sina weibo 和 qq, 说下大致步骤 和注意事项,关键用了rails里面经典的gem omniauth.

weibo:

1, 到 http://open.weibo.com 申请应用, 会给你 key和secret

2, 有一步会验证你的 webmaster 权限,按照说明来即可

3, 我使用的gem

gem "omniauth", '1.1.0'
gem 'omniauth-weibo-oauth2', '0.2.0'

4, 为 omniauth 配置 weibo provider

config/initliazers/omniauth.rb:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :weibo, '1978113365', '82fa3e1654c905b5b545a16945ahjiyb'
end

5, 为了便于调试,修改 hosts 把域名指向本地

/etc/hosts

127.0.0.1 www.abc.com

6, 配置 callback 路由
config/routes.rb

match "/auth/:provider/callback" => "sessions#auth"

7, 访问weibo

www.abc.com:3000/auth/weibo
注意这里不能用 localhost访问,不然会得到:

redirect_uri_mismatch

错误

还有测试的时候,需要到 open.weibo.com 中指定测试账号,这个也需要注意下.

8, 成功后你的callback action中会收到返回给的数据,接下来就是你的事情了,基本的 你可以这样看到

Rails.logger.debug request.env["omniauth.auth"]

QQ:

大致步骤和 weibo 一样,但是不同的是 qq 需要配置 回调地址

我在open qq中配置的回调地址是 abc.com

但是本地调试时, 访问abc.com:3000/auth/qq 总是得到

redirect uri is illegal(100010)!

错误

最后找到的原因是 不能用 3000 端口, 需要使用 80 端口, 最后用 nginx + unicorn 配置80端口访问 解决了.

qq 用的gem是

gem 'omniauth-qq', :git => 'git://github.com/blankyao/omniauth-qq.git'

Ok, That’s ALL!!!


capistrano 多机部署

Posted by wxianfeng Thu, 12 Apr 2012 03:39:00 GMT

核心使用 task 指令 实现多机部署

# encoding:utf-8
# >cap local deploy
# >cap remote deploy

set :application, "entos"
set :deploy_to, "/data/projects/entos"

set :scm, "git"
set :repository,  "git@114.255.155.167:entos.git"
set :branch, "master"
set :use_sudo, false
set :rails_env,"production"

task :remote do
  set :user, "entsea"
  set :deploy_via, :remote_cache
  set :copy_exclude, %w(external)
  server "114.255.155.166", :web, :app, :db, :primary => true
end

task :local do
  set :user, 'zzq'
  set :deploy_via, :remote_cache
  set :copy_exclude, %w(external)
  server '192.168.10.105', :web, :app, :db, :primary => true
end

namespace :deploy do
  task :start do; end
  task :stop do; end

  desc "Creating ln -s , example: database.yml"
  task :create_sync do
    run "ln -s #{shared_path}/config/database.yml #{current_path}/config/database.yml"
  end

  desc "Restarting unicorn"
  task :restart, :roles => :app, :except => { :no_release => true } do
    # run "/bin/sh restart_server.sh"
  end
end

after "deploy:symlink", "deploy:create_sync"

Rails 判断mysql数据库是否存在

Posted by wxianfeng Wed, 15 Feb 2012 20:56:00 GMT

环境: Rails 3.0.3
Rails中判断表是否存在,表的某个字段是否存在都提供了API,但是如何判断数据库存在,没有提供api

可以使用下面的方法判断出来:

> ActiveRecord::Base.connection.execute("USE INFORMATION_SCHEMA") # 连接mysql INFORMATION_SCHEMA 数据库
>ActiveRecord::Base.connection.execute("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = 'db'").to_a
=>[["db"]] # 存在db
>ActiveRecord::Base.connection.execute("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = 'db1'").to_a
=>[] # 不存在db1

Rails源码 attr_internal

Posted by wxianfeng Thu, 05 Jan 2012 18:34:00 GMT

环境:ruby 1.9.2 + rails 3.0.3 + ubuntu 10.10

params在rails中很常用,特别在表单提交的时候,params 产生的是一个Hash ,里面构造通过 form域的name构造 ,产生不同的 params 内容,今天 在看rails params 实现的时候 发现通过 attr_internal 的方法实现,params方法 的源码:

    def params
      @_params ||= request.parameters
    end

发现其实是从 request 这个方法得到的,那么request方法又是怎么定义的:

attr_internal :headers, :response, :request

就是 用了 attr_internal 方法

看下 整个 metal.rb文件: here

发现了 response,headers,session(借助delegate委派) ,status,params == 都是通过 attr_internal 实现的,来看看 attr_internal 到底是何须人也 :

源码: here

class Module
  # Declares an attribute reader backed by an internally-named instance variable.
  def attr_internal_reader(*attrs)
    attrs.each do |attr|
      module_eval "def #{attr}() #{attr_internal_ivar_name(attr)} end", __FILE__, __LINE__
    end
  end

  # Declares an attribute writer backed by an internally-named instance variable.
  def attr_internal_writer(*attrs)
    attrs.each do |attr|
      module_eval "def #{attr}=(v) #{attr_internal_ivar_name(attr)} = v end", __FILE__, __LINE__
    end
  end

  # Declares an attribute reader and writer backed by an internally-named instance
  # variable.
  def attr_internal_accessor(*attrs)
    attr_internal_reader(*attrs)
    attr_internal_writer(*attrs)
  end

  alias_method :attr_internal, :attr_internal_accessor

  class << self; attr_accessor :attr_internal_naming_format end
  self.attr_internal_naming_format = '@_%s'

  private
    def attr_internal_ivar_name(attr)
      Module.attr_internal_naming_format % attr
    end
end

发现其实就是通过 module_eval 给 对象 添加了 settet , getter 方法而已,但是命名格式是这样的:

  self.attr_internal_naming_format = '@_%s' 

DEMO:

require "active_support/core_ext/module/attr_internal"

class Foo
  
  attr_accessor :sex,:birthday # attr_accessor ruby里封装的method
  attr_internal :name,:city # attr_internal rails 封装的

  def bar
    name # call getter method # => @_name
  end

end

f = Foo.new
f.name = 'wxianfeng'
p f.instance_variables # => [:@_name]
p f.name # => "wxianfeng"
p f # => #<Foo:0x8630e18 @_name="wxianfeng">
p f.bar # => "wxianfeng"

所以 attr_internal 和 attr_accessor 其实是 等价的,只不过 从字面意思上看是内部变量(闭包变量的写法) ,attr_internal 希望你 通过方法名来调用,不用 @_%s 这个写法 来调用


所以 其实 一般我们在 controller 用的 request 方法 其实 可以直接这样写 @_request ,

request #=> @_request
params # => @_request.parameters
params # => @_params
headers #=> @_headers
status #=> @_status
.
.
.

但是一般 不建议这样写

还发现 这些和 http相关的东西都定义在 metal 模块, metal 是 rails 链接 rack 的中间件,源码中的解释:

ActionController::Metal provides a way to get a valid Rack application from a controller.

Rack 是一个 ruby实现的web server,封装了 http的请求和响应等,例如 rails,sinatra == 都是在 rack 基础上实现的……

有机会很有必要 深入学习下…

SEE:

http://rubyonrailswin.wordpress.com/2007/03/07/actioncontroller-and-what-the-heck-is-attr_internal/
http://www.oschina.net/p/rack


passenger 部署Rails项目后不写log

Posted by wxianfeng Mon, 24 Oct 2011 05:15:00 GMT

最近部署一个项目,采用的是centos + nginx+ passenger

发现rails project不打log, 是文件权限问题,passenger 规定文件权限不能是root ,如果你部署在ubuntu就不会有这个问题

passenger典型部署结构

lecai-
       |-- current
       |-- releases
       |-- shared

把根目录lecai的权限改了即可

>useradd deploy
>chown -R deploy:deploy lecai

北京 798 Ruby/Rails 活动

Posted by wxianfeng Fri, 29 Jul 2011 18:36:00 GMT

时间: 2011-07-24
收获:
发现北京ROR的公司不是一般的多,签到单上看到N多公司,技术上没有太大收获,都是介绍性的,没有实战性的,内容主要涉及: mirah , Mongodb,Erlang,Grape

进程:http://www.surveymonkey.com/s/MSY2L7T

PS : 798 很好玩,很有艺术特色

现场:

093

798 入口

059

Ruby活动地方

054

Rails rumble 创始人

049

现场job board

067

现场

061

清一色老外,清一色Mac

056

介绍Mirah

045

798

083

798

043

798

MORE


Rails 深入学习 Migration limit

Posted by wxianfeng Sat, 16 Apr 2011 21:09:00 GMT

环境:ruby1.9.2 + rails 3.0.3

一直以为

add_column :users , :age  ,  :integer  , :limit=> 4  

在数据库里对应的类型是 int(4)

其实是错误的!!!

看下 limit的说明文档:

:limit - Requests a maximum column length. This is number of characters for :string and :text columns and number of bytes for :binary and :integer columns.

对于 string 和 text 比较简单,例如

add_column :users,:name , :string , :limit=> 60 

那么数据库中的 类型就是 varchar(60)

对于 binary 和 integer 的就不一样了 , 表示的是字节数 , 但是 :limit =>11 不是表示 11个字节的整数 , 是4 个字节整数

对应关系:

:limit          Numeric Type          Column Size
1                      tinyint                  1 byte
2                      smallint                  2 bytes
3                      mediumint             3 bytes
nil, 4, 11      int(11)                  4 bytes
5 to 8              bigint                  8 bytes

而mysql的integer类型(也是int型) 表示大小如下:

详细 here

rails 里的实现代码 here

核心代码:

 # Maps logical Rails types to MySQL-specific data types.
      def type_to_sql(type, limit = nil, precision = nil, scale = nil)
        return super unless type.to_s == 'integer'

        case limit
        when 1; 'tinyint'
        when 2; 'smallint'
        when 3; 'mediumint'
        when nil, 4, 11; 'int(11)'  # compatibility with MySQL default
        when 5..8; 'bigint'
        else raise(ActiveRecordError, "No integer type has byte size #{limit}")
        end
      end

从上面代码可以看出, 当limit 为 nil,4,11 的时候 , mysql的类型就是 int(11), 也就是常在 migration看到的 integer , :limit=>11

另外可以从已经有的表中得到字段的字节数

ruby-1.9.2-p0 > ActiveRecord::Migration.add_column :forms , :int9, :integer , :limit=>11
-- add_column(:forms, :int9, :integer, {:limit=>11})
  SQL (300.9ms)  ALTER TABLE `forms` ADD `int9` int(11)
   -> 0.3012s
 => nil 
ruby-1.9.2-p0 > Form.reset_column_information
 => nil 
ruby-1.9.2-p0 > Form.columns_hash["int9"].limit 
 => 4 
ruby-1.9.2-p0 > Form.columns_hash["int9"]
 => #<ActiveRecord::ConnectionAdapters::Mysql2Column:0xb8407c8 @null=true, @sql_type="int(11)", @name="int9", @scale=nil, @precision=nil, @limit=4, @type=:integer, @default=nil, @primary=false>

看到没,在迁移的时候 指定 :limit => 11 , 但是 通过 columns_hash 得到的 limit 确是4 , 也就是说 mysql 的 Column Size 是4 bytes , 如果指定 :limit => nil 或者 :limit=>4 得到的 都是 4 bytes

因为 rails 里面 主键id 默认是有符号的 int(11) ,所以 mysql的主键最大id 是 2147483647 , 如果改成无符号的 最大可以到 4294967295

ruby 如何得到 整数的字节数?

size 方法

ruby-1.9.2-p0 > 10_000_000_000.size
 => 8 
ruby-1.9.2-p0 > 1.size
 => 4 

如何让我的integer类型的字段变成无符号的?

ruby-1.9.2-p0 > ActiveRecord::Migration.add_column :forms , :int8, "integer unsigned"
-- add_column(:forms, :int8, "integer unsigned")
  SQL (297.4ms)  ALTER TABLE `forms` ADD `int8` integer unsigned
   -> 0.2976s
 => nil 

如何正确 添加mysql中int(4)类型的字段?

ruby-1.9.2-p0 > ActiveRecord::Migration.add_column :users , :number , "int(4)"
-- add_column(:users, :number, "int(4)")
  SQL (280.4ms)  ALTER TABLE `users` ADD `number` int(4)
   -> 0.2808s

最后看下 migration 类型 对应 数据库类型的关系:

SEE:

http://www.snowgiraffe.com/tech/366/rails-migrations-mysql-unsigned-integers-primary-keys-and-a-lot-of-fun-times/
http://thewebfellas.com/blog/2008/6/2/unsigned-integers-for-mysql-on-rails
http://www.kuqin.com/rubycndocument/man/built-in-class/class_object_numeric_integer.html


Rails 注意索引 name

Posted by wxianfeng Thu, 14 Apr 2011 17:56:00 GMT

测试环境:rails 2.X + rails 3.0.3

今天发现 rails api上索引name的命名规则是错误的,here

测试:

ruby-1.9.2-p0 > ActiveRecord::Migration.add_index :users , :email
-- add_index(:users, :email)
  SQL (0.4ms)  SHOW KEYS FROM `users`
  SQL (367.1ms)  CREATE INDEX `index_users_on_email` ON `users` (`email`)
   -> 0.3680s

按照 文档上写的应该是 users_email ,但是实际上是 index_users_on_email

ruby-1.9.2-p0 > ActiveRecord::Migration.add_index :users , :name , :unique=>true
-- add_index(:users, :name, {:unique=>true})
  SQL (0.3ms)  SHOW KEYS FROM `users`
  SQL (340.4ms)  CREATE UNIQUE INDEX `index_users_on_name` ON `users` (`name`)
   -> 0.3413s

按照文档写的应该是 users_name ,但是实际上是 index_users_on_name

ruby-1.9.2-p0 > ActiveRecord::Migration.add_index :users , [:login,:name] 
-- add_index(:users, [:login, :name])
  SQL (0.3ms)  SHOW KEYS FROM `users`
  SQL (314.3ms)  CREATE INDEX `index_users_on_login_and_name` ON `users` (`login`, `name`)
   -> 0.3152s

按照文档下写的应该是 users_login_name 而实际是 index_users_on_login_and_name

为什么 name 如此重要,因为 remove_index 也是根据name 来的,所以 规则必须记住!

例如文档上一个demo:

# Remove the suppliers_name_index in the suppliers table.
#   remove_index :suppliers, :name

其实 是删除 name 为 index_suppliers_on_name 的索引 ,而不是文档上说的 suppliers_name_index

文档上是错误的,从 源码中 也可以看出来

idnex_name method 源码:

 def index_name(table_name, options) #:nodoc:
        if Hash === options # legacy support
          if options[:column]
            "index_#{table_name}_on_#{Array.wrap(options[:column]) * '_and_'}" # HERE
          elsif options[:name]
            options[:name]
          else
            raise ArgumentError, "You must specify the index name"
          end
        else
          index_name(table_name, :column => options)
        end
      end

rails查看 表的索引:

ruby-1.9.2-p0 > ActiveRecord::Migration.indexes("users") # users 是表名