第六章:編寫安全應用
很多時候,安全應用是以犧牲復雜度(以及開發者的頭痛)為代價的。Tornado Web服務器從設計之初就在安全方面有了很多考慮,使其能夠更容易地防範那些常見的漏洞。安全cookies防止用戶的本地狀態被其瀏覽器中的惡意代碼暗中修改。此外,瀏覽器cookies可以與HTTP請求參數值作比較來防範跨站請求偽造攻擊。在本章中,我們將看到使防範這些漏洞更簡單的Tornado功能,以及使用這些功能的一個用戶驗證示例。
6.1 Cookie漏洞
許多網站使用瀏覽器cookies來存儲瀏覽器會話間的用戶標識。這是一個簡單而又被廣泛兼容的方式來存儲跨瀏覽器會話的持久狀態。不幸的是,瀏覽器cookies容易受到一些常見的攻擊。本節將展示Tornado是如何防止一個惡意腳本來篡改你應用存儲的cookies的。
6.1.1 Cookie偽造
有很多方式可以在瀏覽器中截獲cookies。JavaScript和Flash對於它們所執行的頁面的域有讀寫cookies的權限。瀏覽器插件也可由編程方法訪問這些數據。跨站腳本攻擊可以利用這些訪問來修改訪客瀏覽器中cookies的值。
6.1.2 安全Cookies
Tornado的安全cookies使用加密簽名來驗證cookies的值沒有被服務器軟件以外的任何人修改過。因為一個惡意腳本並不知道安全密鑰,所以它不能在應用不知情時修改cookies。
6.1.2.1 使用安全Cookies
Tornado的set_secure_cookie()和get_secure_cookie()
代碼清單6-1中的應用將渲染一個統計瀏覽器中頁面被加載次數的頁面。如果沒有設置cookie(或者cookie已經被篡改了),應用將設置一個值為1的新cookie。否則,應用將從cookie中讀到的值加1。
代碼清單6-1 安全Cookie示例:cookie_counter.pyimport tornado.httpserver import tornado.ioloop import tornado.web import tornado.options from tornado.options import define, options define("port", default=8000, help="run on the given port", type=int) class MainHandler(tornado.web.RequestHandler): def get(self): cookie = self.get_secure_cookie("count") count = int(cookie) + 1 if cookie else 1 countString = "1 time" if count == 1 else "%d times" % count self.set_secure_cookie("count", str(count)) self.write( ‘<html><head><title>Cookie Counter</title></head>‘ ‘<body><h1>You’ve viewed this page %s times.</h1>‘ % countString + ‘</body></html>‘ ) if __name__ == "__main__": tornado.options.parse_command_line() settings = { "cookie_secret": "bZJc2sWbQLKos6GkHn/VB9oXwQt8S0R0kRvJ5/xJ89E=" } application = tornado.web.Application([ (r‘/‘, MainHandler) ], **settings) http_server = tornado.httpserver.HTTPServer(application) http_server.listen(options.port) tornado.ioloop.IOLoop.instance().start()
如果你檢查瀏覽器中的cookie值,會發現count儲存的值類似於MQ==|1310335926|8ef174ecc489ea963c5cdc26ab6d41b49502f2e2。Tornado將cookie值編碼為Base-64字符串,並添加了一個時間戳和一個cookie內容的HMAC簽名。如果cookie的時間戳太舊(或來自未來),或簽名和期望值不匹配,get_secure_cookie()函數會認為cookie已經被篡改,並返回None,就好像cookie從沒設置過一樣。
傳遞給Application構造函數的cookie_secret值應該是唯一的隨機字符串。在Python shell下執行下面的代碼片段將產生一個你自己的值:
>>> import base64, uuid >>> base64.b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes) ‘bZJc2sWbQLKos6GkHn/VB9oXwQt8S0R0kRvJ5/xJ89E=‘
然而,Tornado的安全cookies仍然容易被竊聽。攻擊者可能會通過腳本或瀏覽器插件截獲cookies,或者幹脆竊聽未加密的網絡數據。記住cookie值是簽名的而不是加密的。惡意程序能夠讀取已存儲的cookies,並且可以傳輸他們的數據到任意服務器,或者通過發送沒有修改的數據給應用偽造請求。因此,避免在瀏覽器cookie中存儲敏感的用戶數據是非常重要的。
我們還需要註意用戶可能修改他自己的cookies的可能性,這會導致提權攻擊。比如,如果我們在cookie中存儲了用戶已付費的文章剩余的瀏覽數,我們希望防止用戶自己更新其中的數值來獲取免費的內容。httponly和secure屬性可以幫助我們防範這種攻擊。
6.1.2.2 HTTP-Only和SSL Cookies
Tornado的cookie功能依附於Python內建的Cookie模塊。因此,我們可以利用它所提供的一些安全功能。這些安全屬性是HTTP cookie規範的一部分,並在它可能是如何暴露其值給它連接的服務器和它運行的腳本方面給予瀏覽器指導。比如,我們可以通過只允許SSL連接的方式減少cookie值在網絡中被截獲的可能性。我們也可以讓瀏覽器對JavaScript隱藏cookie值。
為cookie設置secure屬性來指示瀏覽器只通過SSL連接傳遞cookie。(這可能會產生一些困擾,但這不是Tornado的安全cookies,更精確的說那種方法應該被稱為簽名cookies。)從Python 2.6版本開始,Cookie對象還提供了一個httponly屬性。包括這個屬性指示瀏覽器對於JavaScript不可訪問cookie,這可以防範來自讀取cookie值的跨站腳本攻擊。
為了開啟這些功能,你可以向set_cookie和set_secure_cookie方法傳遞關鍵字參數。比如,一個安全的HTTP-only cookie(不是Tornado的簽名cookie)可以調用self.set_cookie(‘foo‘, ‘bar‘, httponly=True, secure=True)
發送。
既然我們已經探討了一些保護存儲在cookies中的持久數據的策略,下面讓我們看看另一種常見的攻擊載體。下一節我們將看到一種防範向你的應用發送偽造請求的惡意網站。
6.2 請求漏洞
任何Web應用所面臨的一個主要安全漏洞是跨站請求偽造,通常被簡寫為CSRF或XSRF,發音為"sea surf"。這個漏洞利用了瀏覽器的一個允許惡意攻擊者在受害者網站註入腳本使未授權請求代表一個已登錄用戶的安全漏洞。讓我們看一個例子。
6.2.1 剖析一個XSRF
假設Alice是Burt‘s Books的一個普通顧客。當她在這個在線商店登錄帳號後,網站使用一個瀏覽器cookie標識她。現在假設一個不擇手段的作者,Melvin,想增加他圖書的銷量。在一個Alice經常訪問的Web論壇中,他發表了一個帶有HTML圖像標簽的條目,其源碼初始化為在線商店購物的URL。比如:
<img src="http://store.burts-books.com/purchase?title=Melvins+Web+Sploitz" />
Alice的瀏覽器嘗試獲取這個圖像資源,並且在請求中包含一個合法的cookies,並不知道取代小貓照片的是在線商店的購物URL。
6.2.2 防範請求偽造
有很多預防措施可以防止這種類型的攻擊。首先你在開發應用時需要深謀遠慮。任何會產生副作用的HTTP請求,比如點擊購買按鈕、編輯賬戶設置、改變密碼或刪除文檔,都應該使用HTTP POST方法。無論如何,這是良好的RESTful做法,但它也有額外的優勢用於防範像我們剛才看到的惡意圖像那樣瑣碎的XSRF攻擊。但是,這並不足夠:一個惡意站點可能會通過其他手段,如HTML表單或XMLHTTPRequest API來向你的應用發送POST請求。保護POST請求需要額外的策略。
為了防範偽造POST請求,我們會要求每個請求包括一個參數值作為令牌來匹配存儲在cookie中的對應值。我們的應用將通過一個cookie頭和一個隱藏的HTML表單元素向頁面提供令牌。當一個合法頁面的表單被提交時,它將包括表單值和已存儲的cookie。如果兩者匹配,我們的應用認定請求有效。
由於第三方站點沒有訪問cookie數據的權限,他們將不能在請求中包含令牌cookie。這有效地防止了不可信網站發送未授權的請求。正如我們看到的,Tornado同樣會讓這個實現變得簡單。
6.2.3 使用Tornado的XSRF保護
你可以通過在應用的構造函數中包含xsrf_cookies參數來開啟XSRF保護:
settings = { "cookie_secret": "bZJc2sWbQLKos6GkHn/VB9oXwQt8S0R0kRvJ5/xJ89E=", "xsrf_cookies": True } application = tornado.web.Application([ (r‘/‘, MainHandler), (r‘/purchase‘, PurchaseHandler), ], **settings)
當這個應用標識被設置時,Tornado將拒絕請求參數中不包含正確的_xsrf值的POST、PUT和DELETE請求。Tornado將會在幕後處理_xsrf cookies,但你必須在你的HTML表單中包含XSRF令牌以確保授權合法請求。要做到這一點,只需要在你的模板中包含一個xsrf_form_html調用即可:
<form action="/purchase" method="POST"> {% raw xsrf_form_html() %} <input type="text" name="title" /> <input type="text" name="quantity" /> <input type="submit" value="Check Out" /> </form>
6.2.3.1 XSRF令牌和AJAX請求
AJAX請求也需要一個_xsrf參數,但不是必須顯式地在渲染頁面時包含一個_xsrf值,而是通過腳本在客戶端查詢瀏覽器獲得cookie值。下面的兩個函數透明地添加令牌值給AJAX POST請求。第一個函數通過名字獲取cookie,而第二個函數是一個添加_xsrf參數到傳遞給postJSON函數數據對象的便捷函數。
function getCookie(name) { var c = document.cookie.match("\\b" + name + "=([^;]*)\\b"); return c ? c[1] : undefined; } jQuery.postJSON = function(url, data, callback) { data._xsrf = getCookie("_xsrf"); jQuery.ajax({ url: url, data: jQuery.param(data), dataType: "json", type: "POST", success: callback }); }
這些預防措施需要思考很多,而Tornado的安全cookies支持和XSRF保護減輕了應用開發者的一些負擔。可以肯定的是,內建的安全功能也非常有用,但在思考你應用的安全性方面需要時刻保持警惕。有很多在線Web應用安全文獻,其中一個更全面的實踐對策集合是Mozilla的安全編程指南。
6.3 用戶驗證
既然我們已經看到了如何安全地設置和取得cookies,並理解了XSRF攻擊背後的原理,現在就讓我們看一個簡單用戶驗證系統的演示示例。在本節中,我們將建立一個應用,詢問訪客的名字,然後將其存儲在安全cookie中,以便之後取出。後續的請求將認出回客,並展示給她一個定制的頁面。你將學到login_url參數和tornado.web.authenticated裝飾器的相關知識,這將消除在類似應用中經常會涉及到的一些頭疼的問題。
6.3.1 示例:歡迎回來
在這個例子中,我們將只通過存儲在安全cookie裏的用戶名標識一個人。當某人首次在某個瀏覽器(或cookie過期後)訪問我們的頁面時,我們展示一個登錄表單頁面。表單作為到LoginHandler路由的POST請求被提交。post方法的主體調用set_secure_cookie()來存儲username請求參數中提交的值。
代碼清單6-2中的Tornado應用展示了我們本節要討論的驗證函數。LoginHandler類渲染登錄表單並設置cookie,而LogoutHandler類刪除cookie。
代碼清單6-2 驗證訪客:cookies.pyimport tornado.httpserver import tornado.ioloop import tornado.web import tornado.options import os.path from tornado.options import define, options define("port", default=8000, help="run on the given port", type=int) class BaseHandler(tornado.web.RequestHandler): def get_current_user(self): return self.get_secure_cookie("username") class LoginHandler(BaseHandler): def get(self): self.render(‘login.html‘) def post(self): self.set_secure_cookie("username", self.get_argument("username")) self.redirect("/") class WelcomeHandler(BaseHandler): @tornado.web.authenticated def get(self): self.render(‘index.html‘, user=self.current_user) class LogoutHandler(BaseHandler): def get(self): if (self.get_argument("logout", None)): self.clear_cookie("username") self.redirect("/") if __name__ == "__main__": tornado.options.parse_command_line() settings = { "template_path": os.path.join(os.path.dirname(__file__), "templates"), "cookie_secret": "bZJc2sWbQLKos6GkHn/VB9oXwQt8S0R0kRvJ5/xJ89E=", "xsrf_cookies": True, "login_url": "/login" } application = tornado.web.Application([ (r‘/‘, WelcomeHandler), (r‘/login‘, LoginHandler), (r‘/logout‘, LogoutHandler) ], **settings) http_server = tornado.httpserver.HTTPServer(application) http_server.listen(options.port) tornado.ioloop.IOLoop.instance().start()
代碼清單6-3和6-4是應用templates/目錄下的文件。
代碼清單6-3 登錄表單:login.html<html> <head> <title>Please Log In</title> </head> <body> <form action="/login" method="POST"> {% raw xsrf_form_html() %} Username: <input type="text" name="username" /> <input type="submit" value="Log In" /> </form> </body> </html>
代碼清單6-4 歡迎回客:index.html
<html> <head> <title>Welcome Back!</title> </head> <body> <h1>Welcome back, {{ user }}</h1> </body> </html>
6.3.2 authenticated裝飾器
為了使用Tornado的認證功能,我們需要對登錄用戶標記具體的處理函數。我們可以使用@tornado.web.authenticated裝飾器完成它。當我們使用這個裝飾器包裹一個處理方法時,Tornado將確保這個方法的主體只有在合法的用戶被發現時才會調用。讓我們看看例子中的WelcomeHandler吧,這個類只對已登錄用戶渲染index.html模板。
class WelcomeHandler(BaseHandler): @tornado.web.authenticated def get(self): self.render(‘index.html‘, user=self.current_user)
在get方法被調用之前,authenticated裝飾器確保current_usr屬性有值。(我們將簡短的討論這個屬性。)如果current_user值為假(None、False、0、""),任何GET或HEAD請求都將把訪客重定向到應用設置中login_url指定的URL。此外,非法用戶的POST請求將返回一個帶有403(Forbidden)狀態的HTTP響應。
如果發現了一個合法的用戶,Tornado將如期調用處理方法。為了實現完整功能,authenticated裝飾器依賴於current_user屬性和login_url設置,我們將在下面看到具體講解。
6.3.2.1 current_user屬性
請求處理類有一個current_user屬性(同樣也在處理程序渲染的任何模板中可用)可以用來存儲為當前請求進行用戶驗證的標識。其默認值為None。為了authenticated裝飾器能夠成功標識一個已認證用戶,你必須覆寫請求處理程序中默認的get_current_user()方法來返回當前用戶。
實際的實現由你決定,不過在這個例子中,我們只是從安全cookie中取出訪客的姓名。很明顯,你希望使用一個更加魯棒的技術,但是出於演示的目的,我們將使用下面的方法:
class BaseHandler(tornado.web.RequestHandler): def get_current_user(self): return self.get_secure_cookie("username")
盡管這裏討論的例子並沒有在存儲和取出用戶密碼或其他憑證上有所深入,但本章中討論的技術可以以最小的額外努力來擴展到查詢數據庫中的認證。
6.3.2.2 login_url設置
讓我們簡單看看應用的構造函數。記住這裏我們傳遞了一個新的設置給應用:login_url是應用登錄表單的地址。如果get_current_user方法返回了一個假值,帶有authenticated裝飾器的處理程序將重定向瀏覽器的URL以便登錄。
settings = { "template_path": os.path.join(os.path.dirname(__file__), "templates"), "cookie_secret": "bZJc2sWbQLKos6GkHn/VB9oXwQt8S0R0kRvJ5/xJ89E=", "xsrf_cookies": True, "login_url": "/login" } application = tornado.web.Application([ (r‘/‘, WelcomeHandler), (r‘/login‘, LoginHandler), (r‘/logout‘, LogoutHandler) ], **settings)
當Tornado構建重定向URL時,它還會給查詢字符串添加一個next參數,其中包含了發起重定向到登錄頁面的URL資源地址。你可以使用像self.redirect(self.get_argument(‘next‘, ‘/‘))這樣的行來重定向登錄後用戶回到的頁面。
第六章:編寫安全應用