Tornado源码分析 --- Cookie和XSRF机制
Cookie和Session的理解:
具体Cookie的介绍,可以参考:HTTP Cookie详解
可以先查看之前的一篇文章:Tornado的Cookie过期问题
XSRF跨域请求伪造(Cross-Site-Request-Forgery):
简单的说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去执行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
详细细节请参考:XSRF介绍
因为Tornado中XSRF机制的实现是基于Cookie的(XSRF验证信息是保存在Cookie中),所以我们先来分析Tornado中Cookie源码的实现。
Tornado中Cookie源码分析:
set_secure_cookie模块:
用法介绍:
set_secure_cookie方法是对set_cookie方法的包装。要使用该方法,必须在 Application 中的 settings中指定"cookie_secret"(应该是一个经过HMAC加密的够长且随机的字节序列),如果想读取这个cookie设置,可以通过get_secure_cookie方法(后文会进行介绍)。
参数介绍:
expires_days:设置cookie在浏览器端的有效期,通过源码可以知道,其默认为30天。注意,其该参数,跟get_secure_cookie方法中的”max_age_days“没有必然的联系,可以使用一个小于”expires_days“的”max_age_days“在服务端来控制安全Cookie的有效期。
version:该参数主要是为了兼容旧的签名方式,版本号1使用SHA1签名,版本2使用SHA256签名;对于不同的版本,在后文get_secure_cookie方法中,解析Cookie的时候对应着不同的版本解析方法,最新版本的Tornado默认是使用SHA156签名的版本2。
name,value:这个则是相应的Cookie名称和对应的Cookie的值。
源码:
1 def set_secure_cookie(self, name, value, expires_days=30, version=None,**kwargs): 2 self.set_cookie(name, self.create_signed_value(name, value,version=version), 3 expires_days=expires_days, **kwargs) 4 5 def create_signed_value(self, name, value, version=None): 6 self.require_setting("cookie_secret", "secure cookies") 7 secret = self.application.settings["cookie_secret"] 8 key_version = None 9 if isinstance(secret, dict): 10 if self.application.settings.get("key_version") is None: 11 raise Exception("key_version setting must be used for secret_key dicts") 12 key_version = self.application.settings["key_version"] 13 return create_signed_value(secret, name, value, version=version,key_version=key_version)
开始直接调用 self.require_setting("cookie_secret", "secure cookies") 来判断是否设置了签名密钥。
之后就通过 create_signed_value() 方法对不同的cookie进行不同的签名方式:
1 def create_signed_value(secret, name, value, version=None, clock=None, 2 key_version=None): 3 if version is None: 4 version = DEFAULT_SIGNED_VALUE_VERSION 5 if clock is None: 6 clock = time.time 7 8 timestamp = utf8(str(int(clock()))) 9 value = base64.b64encode(utf8(value)) 10 if version == 1: 11 signature = _create_signature_v1(secret, name, value, timestamp) 12 value = b"|".join([value, timestamp, signature]) 13 return value 14 elif version == 2: 15 def format_field(s): 16 return utf8("%d:" % len(s)) + utf8(s) 17 to_sign = b"|".join([ 18 b"2", 19 format_field(str(key_version or 0)), 20 format_field(timestamp), 21 format_field(name), 22 format_field(value), 23 b'']) 24 25 if isinstance(secret, dict): 26 assert key_version is not None, 'Key version must be set when sign key dict is used' 27 assert version >= 2, 'Version must be at least 2 for key version support' 28 secret = secret[key_version] 29 30 signature = _create_signature_v2(secret, to_sign) 31 return to_sign + signature 32 else: 33 raise ValueError("Unsupported version %d" % version)
Cookie 值通过 value = base64.b64encode(utf8(value))
进行 base64 编码转换,所以 set_secure_cookie
能支持任意的字符,这与 set_cookie
方法不同:
分析:
看下set_cookie()的源码(仅截取部分):
1 def set_cookie(self, name, value, domain=None, expires=None, path="/", 2 expires_days=None, **kwargs): 3 name = escape.native_str(name) 4 value = escape.native_str(value) 5 if re.search(r"[\x00-\x20]", name + value): 6 raise ValueError("Invalid cookie %r: %r" % (name, value))
在escape模块中找到对应的 native_str() 方法:escape.py
1 if str is unicode_type: 2 native_str = to_unicode 3 else: 4 native_str = utf8
对于 unicode_type 的判断,其定义在 util模块中:util.py
1 bytes_type = bytes 2 if PY3: 3 unicode_type = str 4 basestring_type = str 5 else: 6 # The names unicode and basestring don't exist in py3 so silence flake8. 7 unicode_type = unicode # noqa 8 basestring_type = basestring # noqa
结论:
python2 是转换为 str,python3 时转换为 unicode string,且不允许输入 “\x00-\x20” 之间的字符,其实现代码中由正则表达式来检查。
接着回过头看上面的源码:
version字段,默认是设置为DEFAULT_SIGNED_VALUE_VERSION(在源码中最开始定义了 DEFAULT_SIGNED_VALUE_VERSION = 2)。如果要指定版本,则需要在 set_secure_cookie() 方法中通过参数传递进来进行设置,我们也可以发现:
-
- 对于版本1,version=1:简单的 “value|timestamp|signature” 拼接
- 对于版本2,version=2:其增加了几个字段,并且返回记录了字符串的长度,尤其是预留的
key_version
字段为后续轮流使用多个cookie_secret
提供了支持。并且对整个字符串进行了加密处理,版本1仅仅加密了value。
版本1签名方式:使用的SHA1
1 def _create_signature_v1(secret, *parts): 2 hash = hmac.new(utf8(secret), digestmod=hashlib.sha1) 3 for part in parts: 4 hash.update(utf8(part)) 5 return utf8(hash.hexdigest())
版本2签名方式:使用的SHA256
1 def _create_signature_v2(secret, s): 2 hash = hmac.new(utf8(secret), digestmod=hashlib.sha256) 3 hash.update(utf8(s)) 4 return utf8(hash.hexdigest())
get_secure_cookie模块:
get_secure_cookie
方法签名中的 value
参数指的是通过 set_secure_cookie
加密签名后的 Cookie 值,默认是 None
则会从客户端发送回来的 Cookies 中获取指定名称name的 Cookie 值作为 value。然后再传入 max_age_days, min_version等值进行Cookie的解码验证。
源码:
1 def get_secure_cookie(self, name, value=None, max_age_days=31, 2 min_version=None): 3 self.require_setting("cookie_secret", "secure cookies") 4 if value is None: 5 value = self.get_cookie(name) 6 return decode_signed_value(self.application.settings["cookie_secret"], 7 name, value, max_age_days=max_age_days, 8 min_version=min_version)
解码验证函数:decode_signed_value()
1 def decode_signed_value(secret, name, value, max_age_days=31, 2 clock=None, min_version=None): 3 if clock is None: 4 clock = time.time 5 if min_version is None: 6 min_version = DEFAULT_SIGNED_VALUE_MIN_VERSION 7 if min_version > 2: 8 raise ValueError("Unsupported min_version %d" % min_version) 9 if not value: 10 return None 11 12 value = utf8(value) 13 version = _get_version(value) 14 15 if version < min_version: 16 return None 17 if version == 1: 18 return _decode_signed_value_v1(secret, name, value, 19 max_age_days, clock) 20 elif version == 2: 21 return _decode_signed_value_v2(secret, name, value, 22 max_age_days, clock) 23 else: 24 return None
默认的 min_version 为 DEFAULT_SIGNED_VALUE_MIN_VERSION(在源码中最开始定义了 DEFAULT_SIGNED_VALUE_MIN_VERSION = 1),对于旧版本(版本 1 )加密签名的 cookie 数据中没有版本号这个字段,默认取 1。然后与指定的 min_version
进行比较,仅当大于等于 min_version
才进行下一步验证。版本 1 由函数 _decode_signed_value_v1
验证,版本 2 由 函数 _decode_signed_value_v2
验证,这两个函数主要就是按照对应签名格式解析数据,并对目标签名和时间戳等字段进行比较验证。
版本1解码:
1 def _decode_signed_value_v1(secret, name, value, max_age_days, clock): 2 parts = utf8(value).split(b"|") 3 if len(parts) != 3: 4 return None 5 signature = _create_signature_v1(secret, name, parts[0], parts[1]) 6 if not _time_independent_equals(parts[2], signature): 7 gen_log.warning("Invalid cookie signature %r", value) 8 return None 9 timestamp = int(parts[1]) 10 if timestamp < clock() - max_age_days * 86400: 11 gen_log.warning("Expired cookie %r", value) 12 return None 13 if timestamp > clock() + 31 * 86400: 14 gen_log.warning("Cookie timestamp in future; possible tampering %r", 15 value) 16 return None 17 if parts[1].startswith(b"0"): 18 gen_log.warning("Tampered cookie %r", value) 19 return None 20 try: 21 return base64.b64decode(parts[0]) 22 except Exception: 23 return None
版本2解码:
1 def _decode_signed_value_v2(secret, name, value, max_age_days, clock): 2 try: 3 key_version, timestamp, name_field, value_field, passed_sig = _decode_fields_v2(value) 4 except ValueError: 5 return None 6 signed_string = value[:-len(passed_sig)] 7 8 if isinstance(secret, dict): 9 try: 10 secret = secret[key_version] 11 except KeyError: 12 return None 13 14 expected_sig = _create_signature_v2(secret, signed_string) 15 if not _time_independent_equals(passed_sig, expected_sig): 16 return None 17 if name_field != utf8(name): 18 return None 19 timestamp = int(timestamp) 20 if timestamp < clock() - max_age_days * 86400: 21 # The signature has expired. 22 return None 23 try: 24 return base64.b64decode(value_field) 25 except Exception: 26 return None
注:需要说一下的是由于版本 1 的设计缺陷,没有对 timestamp
进行签名,为了尽可能防止攻击者篡改时间戳来进行攻击, _decode_signed_value_v1
函数对 timestamp
执行了额外的检查(timestamp > clock() + 31 * 86400
),但这个检查并不能完全杜绝此类攻击。这应该也是重新设计版本 2 的一个原因。
Tornado中XSRF源码分析:
通过上面的Cookie分析后,知道不同版本的Cookie含有的相应组成字段,如果我们想要使用XSRF机制的话,我们需要在Application的Settings中设置参数:“xsrf_cookie_version”。我们会在Cookie中,设置一个“_xsrf”字段,然后所有的POST请求中包含一个“_xsrf”字段,如果其与服务器上的“_xsrf”值无法匹配,那么服务器会认为其有一个潜在的跨域伪造风险而拒绝表单的提交。从而防止跨域请求伪造。
在tornado.web.RequestHandler
中与生成跨站请求伪造 token 直接相关的是 xsrf_token
属性和 xsrf_form_html
方法。
xsrf_token() 模块:
1 def xsrf_token(self): 2 if not hasattr(self, "_xsrf_token"): 3 version, token, timestamp = self._get_raw_xsrf_token() 4 output_version = self.settings.get("xsrf_cookie_version", 2) 5 cookie_kwargs = self.settings.get("xsrf_cookie_kwargs", {}) 6 if output_version == 1: 7 self._xsrf_token = binascii.b2a_hex(token) 8 elif output_version == 2: 9 mask = os.urandom(4) 10 self._xsrf_token = b"|".join([ 11 b"2", 12 binascii.b2a_hex(mask), 13 binascii.b2a_hex(_websocket_mask(mask, token)), 14 utf8(str(int(timestamp)))]) 15 else: 16 raise ValueError("unknown xsrf cookie version %d", 17 output_version) 18 if version is None: 19 expires_days = 30 if self.current_user else None 20 self.set_cookie("_xsrf", self._xsrf_token, 21 expires_days=expires_days, 22 **cookie_kwargs) 23 return self._xsrf_token
首先,通过
_get_raw_xsrf_token() 方法,从cookie中解析出相应的字段:
1 def _get_raw_xsrf_token(self): 2 if not hasattr(self, '_raw_xsrf_token'): 3 cookie = self.get_cookie("_xsrf") 4 if cookie: 5 version, token, timestamp = self._decode_xsrf_token(cookie) 6 else: 7 version, token, timestamp = None, None, None 8 if token is None: 9 version = None 10 token = os.urandom(16) 11 timestamp = time.time() 12 self._raw_xsrf_token = (version, token, timestamp) 13 return self._raw_xsrf_token
找到名为 “_xsrf” 的cookie,然后通过 _decode_xsrf_token() 方法解码出 (version,token,timestamp)以元祖的形式返回,同时其会对版本1进行兼容(版本1没有timestamp和version字段),:
1 def _decode_xsrf_token(self, cookie): 2 try: 3 m = _signed_value_version_re.match(utf8(cookie)) 5 if m: 6 version = int(m.group(1)) 7 if version == 2: 8 _, mask, masked_token, timestamp = cookie.split("|") 10 mask = binascii.a2b_hex(utf8(mask)) 11 token = _websocket_mask( 12 mask, binascii.a2b_hex(utf8(masked_token))) 13 timestamp = int(timestamp) 14 return version, token, timestamp 15 else: 16 raise Exception("Unknown xsrf cookie version") 17 else: 18 version = 1 19 try: 20 token = binascii.a2b_hex(utf8(cookie)) 21 except (binascii.Error, TypeError): 22 token = utf8(cookie) 23 timestamp = int(time.time()) 24 return (version, token, timestamp) 25 except Exception: 26 gen_log.debug("Uncaught exception in _decode_xsrf_token", 27 exc_info=True) 28 return None, None, None
xsrf_token检测check_xsrf_cookie模块:
对 xsrf_token 的检查在 _execute
方法(仅仅显示部分代码)中委托 check_xsrf_cookie
方法进行,代码如下所示:
1 def _execute(self, transforms, *args, **kwargs): 2 ...... 3 if self.request.method not in ("GET", "HEAD", "OPTIONS") and \ 4 self.application.settings.get("xsrf_cookies"): 5 self.check_xsrf_cookie() 6 ......
1 def check_xsrf_cookie(self): 2 token = (self.get_argument("_xsrf", None) or 3 self.request.headers.get("X-Xsrftoken") or 4 self.request.headers.get("X-Csrftoken")) 5 if not token: 6 raise HTTPError(403, "'_xsrf' argument missing from POST") 7 _, token, _ = self._decode_xsrf_token(token) 8 _, expected_token, _ = self._get_raw_xsrf_token() 9 if not token: 10 raise HTTPError(403, "'_xsrf' argument has invalid format") 11 if not _time_independent_equals(utf8(token), utf8(expected_token)): 12 raise HTTPError(403, "XSRF cookie does not match POST argument")
check_xsrf_cookie
方法代码显示与 cookie 中的 token 进行比较的 token 来源于请求参数 _xsrf
或者 HTTP 头域(X-Xsrftoken
或者 X-Csrftoken
)。目前仅比较 token 值,对其中的 timestamp 和 version 字段不做比较验证。
最后对xsrf_form_html方法进行介绍:
xsrf_form_html
就是返回一个隐藏的 HTML < input/> 元素,用于包含在页面的 Form 元素中以便在 POST 请求时将 token 发送给服务端验证。
它定义了“_xsrf”输入值,其会检查所有POST要求防止跨站点请求伪造。如果在Application中的settings中已经设置好了“xsrf_cookies=True”,那么必须在所有HTML表单中的包含该HTML函数。
在template中,这个方法可以被调用通过 “{%module xsrf_form_html()%}”
1 def xsrf_form_html(self): 2 return '<input type="hidden" name="_xsrf" value="' + \ 3 escape.xhtml_escape(self.xsrf_token) + '"/>'