当前位置:   article > 正文

Python网络编程 11.1 Flask框架Web应用程序及安全性分析

Python网络编程 11.1 Flask框架Web应用程序及安全性分析

前面我们已经知道,HTTP协议是一种通用机制。客户端使用HTTP向服务器请求文档,而服务器通过HTTP向客户端提供文档。

然而,HTTP——超文本传输协议的”超文本“如何体现?

其实HTTP的设计初衷并非只是将其作为一种用于传输文件的新方法,也不是将其作为旧式文件传输协议(如FTP)的一个更复杂的提供缓存功能的替代品。当然HTTP能够传输书籍、图片以及视频这些独立的文件,但是尽管如此,HTTP的目的其实远不止于此,它还允许师姐各地的服务器发送文档,并通过相互之间的交叉引用形成一张互相连接的信息网————HTTP就是为万维网(World Wide Web)设计的。

1 超媒体与URL

万维网——可以直接成为Web——的工作所实现的梦想就是把寻找引用的任务交给机器来负责。

假设有一段文字”第九章关于cookie的讨论“,这段文字本来是孤立的,与外界没有联系,但是如果它出现在电脑屏幕上,加了下划线,并且被点击之后可以转到所引用的文本,那么这段文字就成为了一个超链接(hyperlink)。文本中包含内嵌超链接的整个文档就叫做超文本文档。如果文档中又加入了图片、声音以及视频,该文档就成了超媒体(hypermedia)。

其中前缀hyper表示后面的媒介能够理解文档之间相互引用的机制,并且能够为用户生成链接。为了操作超媒体,人们发明了统一资源定位符URL。它不仅为现代的超文本文档提供了一个统一的机制,还能够供以前的FTP文件和telnet服务器使用。在网络浏览器的地址栏可以看到很多类似下面这样的例子。

https://www,python.org     http://en.wikipedia.org/wiki/Python_(programming_language)   ftp://ssd.jpl.nasa.gov/pub/eph/planets/README.txt

第一个标记(如https、http)即为所使用的机制(scheme),它指明了获取文档所使用的协议。后面跟着一个冒号和两个斜杠,然后是主机名,接着可能还有端口号。URL最后是一个路径,用于在可用服务的所有文档中指明要获取的特定文档。

解析与构造URL:关于URL以及urllib的主要介绍,参见Python核心编程3-Web开发部分 1.Web客户端和服务器的内容,本篇会对链接中没有提到的部分进行一些补充。

  • 相对URL:URL中有类似于相对路径的概念。如果一个文档中的所有链接都是绝对URL的话,那么毫无疑问这些链接会指向正确的资源,但是如果文档中包含相对URL的话,我们就需要将文档本身的位置考虑进去了。Python提供了一个urljoin()函数,用于处理标准中的所有相关细节。假设从一个超文本文档中提取出了一个URL,该URL可能是相对的,也可能是绝对的。此时可以将其传递给urljoin(),由它负责填充剩余消息。如果URL是绝对URL,那么不会有任何问题,urljoin()会直接返回该URL。urljoin的参数顺序和os.path.join()是一样的。第一个参数是正在阅读的文档的基地址,第二个参数则是从该文档中提取出的URL。如果第二个参数是相对URL,那么有多种方法可以重写基地址的某些部分。.由于相对URL无需指定使用的协议机制,如果编写网页时并不知道使用HTTP还是HTTPS的话,那么使用相对URL就十分方便了(即使是用来编写网页的静态部分)。在这种情况下,urljoin()只会将基地址的协议复制到第二个参数提供的绝对URL中,构成一个完整的URL,以此作为返回值。                                                             如果准备在网站中使用相对URL的话,有一点是非常重要的:一定要注意页面URL的最后是否包含一个斜杠。因为最后包含斜杠和不包含斜杠的相对URL含义是不同的。第一个URL表示该请求是为了显示rfc3986这一文档而访问包含该文档的html目录,此时的当前工作目录是html目录,而后者则不同了。在真正的文件系统中,只有目录的结尾会有斜杠,因此它把rfc3986本身看作一个正在访问的目录。所以根据第二个URL构建出来的连接会直接在rfc3986/之后添加相对URL参数。                           在设计站点时,一定要确保当用户提供错误的URL时能够马上将其重定向至正确的路径。例如,如果要在访问上面例子的第二个URL,那么IETF的网络服务器会检测到后面多加了一个斜杠,然后它会在响应中声明一个Location头,给出正确的URL。  因此,相对URL并不一定相对于HTTP请求中提供的路径。如果网站的响应中包含一个Location头,那么相对URL必须相对于Location头中提供的路径。

2.超文本标记语言(HTML)

现在介绍推动Web发展的核心文档样式的书籍很多。此外还有一些现行的标准也对超文本文档的格式、使用层级样式表(CSS,Cascading Style Sheets)确定超文本文档样式的机制以及Javascript等浏览器内嵌语言的API做了描述。其中,JavaScript等浏览器内核语言可以在用户与页面交互或浏览器从服务器我获取更多信息时对文档进行实时的修改。下面是集合核心标准与资源的链接:http://www.w3.org/TR/html5/  、 http://www.w3.org/TR/CSS/ 、https://developer.mozilla.org/en-US/docs/Web/JavaScript   、 https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model                   

                  
HTML是一种使用大量尖括号(<...>)来装饰纯文本的机制。每对尖括号都创建了一个标签(tag),如果标签开头没有斜杠的话,就表示文档中某个新元素(element)的开始,否则就表示元素的结尾。下面的例子展示了一个简单的段落,该段落中包含了一个加粗的单词和一个斜体的单词。

<p>This is a paragraph with <b>bold</b> and <i>italic</i> words.</p>

某些标签是自包含的,不需要之后再使用对应的结束标记。最有名的例子就是<br>标签,它创建了段落中的一个空行。有些更为一丝不苟的开发者会把<br>写为<br/>,这是从扩展标记语言(XML)中学习过来的,但在HTML中这并不是必须的。比如,并不一定要为所有开始标签提供对应的结束标签。当一个用<ul>表示的无序列表结束的时候,无论其内部使用<li>表示的列表元素是否通过</li>标签表示元素结束,HTML解析器都会认为该无序列表包含的所有列表元素都已经结束。

从上面的实例段落中,可以清楚地认识到,HTML的标签是可以层层嵌套的。设计者在构建完整的Web页面时可以不断地在HTMl元素内部嵌入其他HTML元素。在构建页面的过程中,设计者大多会不可避免地不断重复使用HTML定义的有限元素集合中的元素。这些元素用于表示页面上不同类型的内容。监管最新的HTML5标准允许设计者直接在页面中创建新元素,但是设计者们还是会倾向于使用标准元素。              

一个大型的页面可能会处于各种不同的原因使用<div>(最通用的分块形式)或<span>(最通用的标记连续文本的方式)这样的通用标签。那么如果所有元素都使用了相同的<div>标签,如何使用CSS来合理地设置各元素的样式呢?又该如何使用Javascript来设置用户与各元素不同交互方式呢? ——答案就是,为每个元素指定一个class.这样,HTML编写者就可以为各元素提供一个特定的标记,之后就可以通过该标记来访问特定的元素了。要使用class,有两种常见的方法。

  • 第一种方法是,在设计时为所有的HTML元素都指定一个唯一的class.
    1. <div class="weather">
    2. <h5 class="city">Provo<h5/>
    3. <p class="temperature">61°F</p>
    4. </div>
    这样,对应的CSS和Javascript就可以通过.city和.temperature这样的选择器来引用特定的元素了。如果想要更细粒度一点,可以使用h5.city和p.temperature。最简单形式的CSS选择器只需要一个标签的名称,后面加上以句点作为前缀的class名称即可。这两者都不是必须的。
  • 有时候在class为weather的<div>内,设计者认为它们使用<h5>和<p>的目的都是唯一的,因此他们选择只为外层元素指定class的值
    <div class="weather"><h5>Provo<h5/><p>61°F</p></div>
    此时,要在CSS或Javascript中引用该<div>内部的<h5>和<p>,就需要使用更为复杂的模式了。我们使用空格来连接外层标签的class值与内层标签的名称:.weather h5  \  .weather p。
除了上述简单的选项外,如果还想要了解所有可用的选项,可以查阅CSS标准或其入门指南。如果想要了解选择器如何从浏览器中实时运行的代码中选取元素,可以阅读关于Javascript或是JQuery这样功能强大的文档操作库的介绍。
在现代浏览器中,可以通过浏览器的两个功能审查我们喜欢的网站的页面组织方式。首先,在当前访问的页面中,使用Ctrl+U可以查看页面的HTML代码,同时提供了语法的高亮显示。其次,可以右键点击任意元素,选择审查元素,进入调试工具。
在接下来的几段代码中,我们将着手对几个小型的Web应用程序进行实验。读者可以随时使用浏览器的审查元素功能来审查应用程序返回的值。

3.读写数据库

假设有一个简单的银行应用程序,想要允许账户持有人使用一个Web应用程序互相发送账单。这个应用程序至少需要一个存储账单的表、插入新账单的功能以及获取并显示与当前登录用户帐号有关的所有账单的功能。   下面的代码展示了一个简单的库,提供了实现上述3个功能的示例实现。该例子使用了Python标准库中的SQLite数据库。因此,下面例子中的代码应该能够在任何安装了Python的机器上正确运行。
  1. import os,pprint,sqlite3
  2. from collections import namedtuple
  3. def open_database(path='bank.db'):
  4. new = not os.path.exists(path)
  5. db = sqlite3.connect(path)
  6. if new:
  7. c = db.cursor()
  8. c.execute('CREATE TABLE payment (id INTEGER PRIMARY KEY,'
  9. 'debit TEXT,credit TEXT,dollars INTEGER,memo TEXT)')
  10. add_payment(db,'brandon','psf',125,'Registration for PyCon')
  11. add_payment(db,'brandon','liz',200,'Payment for writing that code')
  12. add_payment(db,'sam','brandon',25,'Gas money-thanks for the ride!')
  13. db.commit()
  14. return db
  15. def add_payment(db,debit,credit,dollars,memo):
  16. db.cursor().execute('INSERT INTO payment (debit,credit,dollars,memo)'
  17. 'VALUES (?,?,?,?)',(debit,credit,dollars,memo))
  18. def get_payments_of(db,account):
  19. c = db.cursor()
  20. c.execute('SELECT * FROM payment WHERE credit = ? or debit = ?'
  21. 'ORDER BY id',(account,account))
  22. Row = namedtuple('Row',[tup[0] for tup in c.description])
  23. return [Row(*row) for row in c.fetchall()]
  24. if __name__ == '__main__':
  25. db = open_database()
  26. pprint.pprint(get_payments_of(db,'brandon'))
SQLite引擎将每个数据库存储为磁盘上的一个独立文件,因此open_database()函数可以通过检查文件是否存在来确认数据库是否已经创建。如果数据库已经存在,那么只需要重新连接该数据库即可。在创建数据库时,open_database()函数创建了一张账单表,并向表中添加了3条示例账单信息,以便应用程序展示。
示例中的表格式机器简单,只是用来满足应用程序运行的最低要求。在现实生活中,还需要一个用户表来存数用户名及密码的安全散列值以及一个包含官方银行账号的表。款项最终会从官方银行账号提取,并会支付到官方银行账号中。本例中的应用程序并不真实,它允许用户任意创建实例账户。
在这个例子中,有一个很重要的操作值得借鉴:SQL调用的所有参数都进行了适当的转义。程序员在向SQL这样的解释型语言提交一些特殊字符时,有时并没有进行正确的转义。这是现在安全缺陷的主要来源之一。如果Web前端的一个恶意用户故意在Memo字段中包含了一些特殊的SQL代码,就会造成很严重的后果。最好的保护方法就是使用数据库自身提供的功能来正确地引用数据,而不使用自己构建的程序逻辑。——为正确地完成这一过程,上述代码在所有需要插入参数的地方都向SQLite提供了一个问号(?),而没有自己进行转义或是插入参数。
另一个重要操作就是为原始的数据库行赋予了更丰富的语义。fetchall()方法并不是sqlite3独有的,它是DB-API 2.0 的一部分。为了支持互操作性,所有现代Python数据库连接接口都只吃DB-APi2.0.除此之外,fetchall()没有为数据库查询返回的每一行结果返回一个对象,甚至没有返回一个字典,而是为每一行返回了一个元组。
(1,'brandon','psf',125.'Registration for PyCon')
直接操作这些原始的元组结果是一种糟糕的做法。在代码中,”欠款账户“或是”已付账款“这样简单的概念可能会以row[2]或row[3]这样的形式来表示,这大大降低了可读性。因此上述代码使用了一个简单的namedtuple类,该类同样支持使用row.credit和row.dollars这样的属性名。尽管每次调用SELECT时都要新创建一个类,这在效率上并不是最优的,但是却能够只用一两行简单的代码就提供了Web应用程序所需的语义,使得我们不会浪费精力在其他的事情上。

4.一个糟糕的Web应用程序(使用Flask框架)

下面是一个不安全的Web应用程序。仔细阅读,考虑该代码是否是糟糕且不可信的?它会不会导致安全威胁,损害公众的利益?它是不是危险的?
  1. import bank
  2. from flask import Flask,redirect,request,url_for
  3. from jinja2 import Environment,PackageLoader
  4. app = Flask(__name__)
  5. get = Environment(loader=PackageLoader(__name__,'templates')).get_template
  6. @app.route('/login',methods=['GET','POST'])
  7. def login():
  8. username = request.form.get('username','')
  9. password = request.form.get('password','')
  10. if request.method == 'POST':
  11. if (username,password) in [('brandon','atigdng'),('sam','wyzzy'),('gjy','330219')]:
  12. response = redirect(url_for('index'))
  13. response.set_cookie('username',username)
  14. return response
  15. return get('login.html').render(username=username)
  16. @app.route('/logout')
  17. def logout():
  18. response = redirect(url_for('login'))
  19. response.set_cookie('username','')
  20. return response
  21. @app.route('/')
  22. def index():
  23. username = request.cookies.get('username')
  24. if not username:
  25. return redirect(url_for('login'))
  26. payments = bank.get_payments_of(bank.open_database(),username)
  27. return get('index.html').render(payments=payments,username=username,
  28. flash_messages= request.args.getlist('flash'))
  29. @app.route('/pay',methods=['GET','POST'])
  30. def pay():
  31. username = request.cookies.get('username')
  32. if not username:
  33. return redirect(url_for('login'))
  34. account = request.form.get('account','').strip()
  35. dollars = request.form.get('dollars','').strip()
  36. memo = request.form.get('memo','').strip()
  37. complaint = None
  38. if request.method == 'POST':
  39. if account and dollars and dollars.isdigit() and memo:
  40. db = bank.open_database()
  41. bank.add_payment(db,username,account,dollars,memo)
  42. db.commit()
  43. return redirect(url_for('index',flash = 'Payment Successful.'))
  44. complaint = ('Dollars must be an integer' if not dollars.isdigit()
  45. else 'Please fill in all three fields')
  46. return get('pay.html').render(complaint=complaint,account=account,
  47. dollars=dollars,memo=memo)
  48. if __name__ == '__main__':
  49. app.debug = True #打开调试模式。一旦对运行中的代码做了修改,Flask就会自动重启并重新载入应用程序。这样就能在对代码进行微调时快速看到修
  1. #改的效果
  2. app.run()
我们会在本章接下来的几节中学习上述代码的弱点,并以此来了解一个应用程序要抵御网络攻击所需要采取的最基本的操作。代码中的这些弱点都来自于数据处理过程中发生的错误,与网站是否合理采用了TLS防止网络窃听无关。读者可以假设该网站已经采取了加密保护,比如在前端使用了一个反向代理服务器。我们只考虑攻击者在无法获取特定用户与应用程序之间传递的数据时所能进行的恶意行为。
该应用程序使用Flask框架来处理PythonWeb应用程序的一些一本操作:在请求应用程序没有定义的页面时返回404、从HTML表单中解析数据,以及使用模板生成HTML文本或重定向到另一个URL来简化HTTP响应的生成工程。除了文章的介绍内容之外,如果读者还想了解更多关于Flask的信息,可以访问 http://flask.pocoo.org/的文档。
假设上面的代码是由并不熟悉Web的程序员写的。他们听说过使用模板语言可以方便向HTML中加入自定义的文本,因此他们了解加载并运行jinja2的方法。除此之外,他们发现Flask这一微型框架的流行程度仅次于Django,并且喜欢Flask能够将一个应用程序放在一个单独的文件中这一特性,因此他们决定尝试使用Flask。整个程序的部署位置: 几个html文件需要放在template文件夹中(其中,index、login和pay都是基于base.html定义的页面框架之上来写的,这个叫做Jinja2模板语言,在运行成功后我们可以通过浏览器的审查元素功能查看对应的标准格式的HTML是什么样子的),style.css放在static文件夹里(可以尝试先运行没有css文件的服务器,样式就会是默认的,再运行有css文件的,观察二者样式有何区别,你会发现样式区别很大)。具体的文件请在 Ch11-源码下载。
  • 从上往下阅读代码,可以依次看到login()和logout()两个函数及其对应的页面。由于这个应用程序并没有真正的用户数据库,因此login()直接硬编码了两个虚拟的用户账号及密码。我们会在接下来的章节中学习更多有关表单逻辑的内容。不过从login()中已经看出,登陆和登出会导致cookie的创建和删除。如果后续的请求中提供了cookie,那么服务器会认为cookie标记的用户是授权用户。
  • 另外两个页面都不允许非授权用户查看,index()和pay()都会先查询cookie,如果没有找到cookie值,就会重定向到登陆界面。除了检查用户是否已经的登录之外,登录之后的视图只有两行代码。首先从数据库拉取当前用户的账单信息,然后与其他信息组合起来一起传递给HTML页面模板。我们需要向即将生成的页面提供用户名,这一点是很容易理解的。但是代码中为什么要检查名为”flash“的URL参数呢(Flask通过request.args字典来提供URL参数)?
    阅读一下Pay函数,答案就很明显了。在支付成功后,用户会被重定向到index页面,而此时用户可能需要一些提示,来确认自己提交的表单得到了正确的处理。这一功能是通过Web框架的flash消息来完成的。flash消息会显示在页面的顶部。(这里的flash与用来编写小广告的Adobe Flash没有任何关系,只是表示用户下次访问该页面时该信息会像flash广告一样呈现给用户,然后消失)在该Web程序的第一个版本中,只是简单地将flash消息设计为了URL中的一个查询字符串。
  • pay()函数剩下的部分:检查表单是否成功提交,如果成功,就进行一些操作。用户或浏览器有可能会提供或漏掉一些表单参数,因此很谨慎地在代码中使用request.form字典的get()方法进行了处理。如果某个键确实的话,就会返回一个默认值,在这里是空字符串''。如果请求满足条件,pay()函数就会将该账单永久添加到数据库中;否则,就将表单返回给用户。如果用户已经填写了一些信息,那么上面的代码不会直接将用户已经填写的信息丢掉,也不会返回空白的表单以及错误信息,而是将用户已经填写的值传回给模板。这样,在用户看到的页面中就能够重新显示他们已经填写过的值了。 

5.表单、HTTP方法以及安全性

HTML表单的默认action是GET,它可以只包括一个输入文本框。
  1. <form action="/search">
  2. <label>Search:<input name="q"></label>
  3. <button type="submint">Go</button>
  4. </form>
本书不会讨论表单的设计,因为它也是个很复杂的问题,有很多技术上的选择。关于Web设计的书籍和网站会详细讨论这些问题,本书仅讨论表单对于网络的意义。
进行GET的表单会把输入的字段直接放到URL中,然后将其作为HTTP请求的路径。
GET /search?q=python+network+programming HTTP/1.1
Host:example.com
这意味着,GET的参数是浏览历史中的一部分,任何人只要站在我们后面看着浏览器的地址栏就可以知道我们输入的字段。因此,我们绝对不能使用GET来传输密码或整数这样的敏感信息。当我们填写一个GET表单时,其实就是在指定接下来要访问的地址。最终,浏览器会根据表单信息构造一个URL,指向我们希望服务器生成的页面。填写之前的搜索表单中的3个不同的字段会生成3个独立的页面、浏览器的3条浏览历史以及3个URL。后期可以重新访问这三条浏览历史。如果希望好友也能查看同样的页面可以分享个这些URL给好友。
这与方法为POST、PUT和DELETE的表单很不同。对于这些表单来说,URL中绝对不会包含任何表单信息,因此表单信息也不会出现在HTTP请求的路径中。
  1. <form method = 'post' action = '/donate'>
  2. <label>charity:<input name='name'></label>
  3. <label>amount:<input name = 'dollars'></label>
  4. <button type = 'submit'>Donate</button>
  5. </form>

在提交上面这个表单时,浏览器会把所有数据都放入请求消息体中,而请求路径本身是没有变化的。因为我们POST提交的东西并不是我们想要访问的地址,所以表单参数不会被放在URL中。一个哲学家把这种动作称为“言语行为”,表示会在世界上创建一个新状态的言语。

另外,浏览器上传大型负载(如整个文件)时还可以使用一种基于MIME标准的表单编码multipart/forms。不过,无论使用哪种编码,POST表单的语义都是一样的。

Web浏览器知道POST请求是一个会造成状态变化的动作,因此它们在处理POST请求时是非常小心的。如果用户在浏览一个由POST请求返回的页面时重新加载网页,那么浏览器会弹出一个对话框,大致内容:此网页需要使用您之前输入的数据才能正常显示,返回至该页面可能会重新发送这些数据,是否继续操作?

为了防止用户在浏览POST返回的页面上进行重新加载,或在前进后退操作时不断收到浏览器弹出的对话框,有两种技术可供网站采用。

  • 使用Javascript或HTML5表单的输入限制来尝试事先防止用户提交非法值的表单。如果在表单数据全部符合要求并可以提交之前禁用提交按钮,或者使用Javascript在不需要重新加载页面的情况下提交整个表单并获取响应,那么用户就不会因为提交了非法数据而不断停留在POST请求返回的页面内并收到浏览器弹出的警告对话框。
  • 当表单正确提交并成为执行了POST请求的动作后,Web应用程序不应该直接返回一个表示动作已经完成的200 OK页面,而是应该返回一个303 See Other,并在Location头中指定将要重定向到的URL。这会强制浏览器在成功完成POST请求后立刻进行一个GET请求,用户浏览器会立刻转到该GET请求要访问的页面。此时,用户就可以进行重新加载他们想要访问的页面,或是在该页面执行前进、后退操作了。这些操作不会重复提交表单,只会对目标页面重复执行GET请求,而这是安全的。
这段代码中的应用程序是非常简单的,因此用户无法从中了解到包含非法信息的POST表单的具体返回细节,但是代码也会在/login和/pay表单操作成功时返回303 See Other。该功能是由Flask的redirect()构造函数提供的。这是所有Web框架中都应该提供的最佳实践。
误用了HTTP方法的Web应用程序会给一些自动化工具和浏览器带来些许问题,执行结果也会与用户的预期有所不同。因此,在访问地址时使用GET,而在执行修改状态的动作时使用POST是非常重要的。除了协议本身的设计之外,也有利于改进用户体验。
11.5.1安全的cookie与不安全的cookie
代码app_insecure.py中的Web应用程序试图保护用户的隐私。用户必须先成功登录,才能通过路径为"/"的GET请求查看账单列表。如果想要通过/pay表单的POST请求来进行支付,用户必须要先成功登录。
然而不幸的是,要利用这个应用程序的漏洞来冒充另一个用户进行支付并不难。比如:
恶意用户可以先试用自己的账号登陆网站,了解网站的工作原理,先打开浏览器的调试工具,然后登陆网站,在网络面板中查看请求头与响应头信息。那么,他们在登陆页面提交了用户名和密码后,会从相应信息中得到一个名为username的cookie,username的值被设置为了恶意用户自己的账号。显然,只要后续的请求中包含该cookie,那么网站就一定会认为发送这些请求的用户已经输入了正确的用户名和密码。
然而,发送请求的客户端可以随意设置这个cookie的值吗? ——恶意用户可以通过设置浏览器的隐私菜单来尝试伪造cookie,也可以使用Python来尝试访问网站。他们先使用Requests试试看能否获取到网页。

不出所料,没有得到授权的请求会被重定向到/login页面。  但是如果恶意用户将cookie的值设置为brandon而brandon恰好是一个已经登录的用户,结果会怎样呢?

很可怕的是,这样成功了!网站信任它已经设置过的cookie,因此会认为该HTTP请求来自已经登录的用户brandon,进而做出响应,返回请求的页面。恶意用户只需要知道账单系统的另一个已经登录的用户名,就能够伪造请求,向其他用户任意支付了。

这样,就从brandon的账户中支付了100美元到恶意用户控制的账户中。
这个例子告诉我们,设计cookie时,一定要保证用户不能自己构造cookie。假设用户非常聪明,最终能够了解我们勇于混淆用户名的一些手段:Base64编码、交换字母的顺序或是使用常量掩码进行简单的异或操作。此时,要保证cookie无法被伪造,有3种安全的方法。
  • 可以仍然保留cookie的可读性,但是使用数字签名对cookie进行签名。这会迫使攻击者对此无能为力。他们可以从cookie中看到用户名,也可以将他们想要伪造的用户名重新写入请求中,但是他们无法伪造数字签名来对请求中的cookie进行签名,因此网站不会信任他们重新构造的cookie。
  • 可以对cookie进行完全加密,这样用户甚至都无法读懂cookie的值。加密后的cookie是一个人类无法理解,甚至计算机也无法解析的值。
  • 可以使用一个纯随机的字符串作为cookie。该字符串本身没有任何意义,创建该字符串时可以使用一个标准的UUID库,将这些随机字符串存储在自己的数据库中,每个受信任的用户都有一个对应的随机字符串,之后的请求就用该字符串作为cookie,这样就可以通过服务器的认证(其实这就是session)。如果由一个用户发送的多个连续的HTTP请求可能被转发至多台不同的服务器,那么所有服务器都要能够访问这一持久化的Session存储。有些应用程序会把Session存储在核心数据库中,而另一些则使用Redis实例或其他存储期较短的方式,以防止核心数据库的查询负载过高。
在这个实例程序中,我们可以利用Flask的内置功能对cookie进行数字签名,这样就没办法伪造cookie了。在部署了真实生产环境的服务器上,需要将签名密钥和源代码保存在不同的地方。不过,在这个例子中,我们直接在源代码文件的顶部给出了签名密钥。如果直接在生产系统的源代码中包含签名密钥,那么任何能够访问版本控制系统的人都可以得到密钥,而且在开发机上和持续集成过程中都能够获取到证书。
  1. app.secret_key = 'saiGeij8AiS2ahMo5dahveixuV3'
  2. #有了签名密钥后,Flask就会通过Session对象来使用该密钥,设置cookie
  3. session['username'] = username
  4. session]['csrf_token'] = uuid.uuid4().hex
  5. #收到请求并提取出cookie之后,Flask会检查签名密钥,缺人密钥正确后才会信任此次请求。
  6. # 如果cookie的签名不正确,就认为该cookie是伪造的,因此尽管请求中提供了cookie,但是该cookie无效。
  7. username = session.get('username')
我们将会在后续的代码中进行上述改进。
关于cookie还一点需要注意:不应该使用未加密的HTTP传输cookie,否则的话,处于同一家咖啡店无线网络中的所有人便都能获取到别人的cookie了。许多网站在登录时都会使用HTTPS来安全地传输cookie。登录成功后,浏览器才会直接使用HTTP从同一主机处获取所有CSS、Javascript和图片,cookie只在使用HTTP时是暴露出来的。
为了防止暴露出cookie的情况发生,我们需要了解选择的Web框架在将cookie发送至浏览器时是如何设置Secure参数的。正确设置了Secure参数后,就绝对不会在非加密的请求中包含cookie了。这样一来,即使很多人可以查看非加密请求的内容,他们也无法从中得到cookie的内容。

现在恶意用户无法窃取或伪造cookie了,也就无法通过浏览器或Python程序伪装成另一个用户来执行操作。此时他们就需要换个思路了。如果他们能够控制另一个已登录用户的浏览器,那么他们甚至不需要查看cookie,只要通过该浏览器来发送请求,请求中就会自动包含正确的cookie。要进行这一类型的攻击,至少有三个著名的方法可供选择。
11.5.2非持久型跨站脚本
第一种是非持久型(nonpersistent)的跨站脚本(XSS,cross-site scripting)。在进行这种攻击时,攻击者自己编写了一些脚本,网站(如示例的账单系统)会把这些脚本看作网站本身的脚本来运行。假设攻击者想要向他们的一个账户支付110美元,那么他们可能会编写如下所示的Javascript脚本。
  1. <script>
  2. var x = new XMLHTTPRequest();
  3. x.open(‘POST’,'http://localhost:5000/pay');
  4. x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
  5. x.send('account=hacker&dollars=110&memo='Theft');
  6. </script>
用户在成功登录账单应用程序后,如果页面中包含这段代码,那么代码中描述的POST请求就会自动发送并以受害用户的身份支付账单。因为用户无法在最终生成的网页上看到<script>标记内的代码,所以除非他通过Ctrl+U查看了源代码,否则的话他甚至不知道已经被盗用了身份,支付了账单。就算是查看了源代码,也必须能发现<script>元素的异常,然而通常来说<script>并不是页面的主要部分。
那么攻击者如何将这段包含Javascript脚本的HTML植入页面中呢?
由于代码直接将flash参数插入到了/页面的页面模板中,攻击者可以直接通过flash参数来诸如这段HTML!因为app_insecure的作者没有仔细阅读文档,所以他并不能意识到通过原始的Jinja2表单没有自动对特殊字符如<和>进行转义。原因在于,只要我们不明确说明,Jinja2就没有办法知道我们在使用这些特殊字符来构造HTML。
攻击者在构造URL时,可以在flash参数中包含他们的脚本。
最后,攻击者需要编造出一个入口,诱使用户看到并单击指向上述URL的链接。如果只想攻击一个特定的用户,那么要让用户点击该连接这点还是有难度的。但是如果并不想攻击某个特定用户,而且面向的是一个大型的网站,该网站有大量用户,那么攻击者就不需要为每个用户专门设计攻击方案了。在成千上万收到恶意嵌入链接邮件的用户中,只有很少一部分会登录了支付系统并点击了恶意链接,但这已经足够为攻击者带来收入了。
我们尝试使用这个链接。如果已经正常登录,当打开链接的时候,会看到浏览器的提示

只有关闭了浏览器的保护,或是找到了更邪恶的方法来利用flash消息的漏洞,攻击者才能够通过本例中简单的攻击脚本来绕过优秀的现代浏览器的保护。
即使攻击成功了,但是用于显示flash消息的绿色消息框里没有任何消息,也还是有可能会引起用户的怀疑。作为一个联系,可以试着修改上面URL的这一缺陷,看看能不能在script标记外面提供类似于“欢迎回来”的真实文本,这样可以让绿色的消息框看起来更真实一些。
用户提交了/pay表单后,flash消息会显示在用户访问的下一个页面内,告诉用户表单已经提交完了。为了抵御非持久型XSS攻击,修改后的代码将从URL中完全删除flash消息。在接收到下一个请求之前,我们可以将flash消息保存在服务器端。和大多数其他Web框架一样,Flask已通过flash()和get_flashed_messages()这对函数支持了该功能。
11.5.3 持久型跨站脚本
  如果攻击者无法在一个又长又丑的URL中设置flash消息,那么就必须通过一些别的方法来注入Javascript脚本。扫视了一遍主页之后,他们可能会注意到用于显示账单信息的Memo字段。可以将什么样的字符输入到Memo字段中去呢?
当然,要在页面上精心设计的Memo比直接将Memo嵌入URL中会复杂一些。而且攻击者是可以直接将URL匿名提供给用户的。要在页面上显示Memo(),就必须先试用虚假的个人信息注册网站,或是盗用另一个用户的账户向受害者进行一次支付,并且在Memo字段中包含<script>元素以及上面的attack.js脚本。
先支付给受害者:

支付成功:
注意到这里sam的报表中也没有显示Memo中输入的script消息.
然后在以brandon的身份登入,重新载入页面。brandon用户每次访问首页的时候,都会从自己的账户中支出一笔账单。
这就是持久型的跨站脚本攻击。我们可以从上面的脚本中看到,这种攻击的威力是很大的。在非持久型跨站脚本攻击中,只有用户点击了URL才会进行攻击;而持久型跨站脚本攻击中,只要受害者访问了网站,Javascript脚本就会不断隐式运行,直到服务器上的数据全都被清空位置。当攻击者通过有漏洞的站点上的公共表单发起XSS攻击时,成千上万的用户都会受到影响,知道网站漏洞修复为止。
app_insecure抵御不了这一类型的攻击,是因为使用了Jinja2的开发者并没有真正理解Jinja2的使用方法。Jinja2的文档明确说明:它不会自动进行任何转义。只有打开了转义功能,Jinja2才会对<和>这些HTML的特殊字符进行保护。
  修改后的代码通过Flask的render_template()函数来调用Jinja2.只要render_template()参数中的模板文件名后缀为html,它就会自动打开HTML转义功能,这样就能抵御所有的XSS攻击。因此,请使用Web框架的通用模式,而不要自己重新造轮子,以免因为一些粗心的设计失误而影响应用的安全性。
11.5.4 跨站请求伪造
现在,我们已经对网站的所有内容进行了合适的转义,就不用再担心XSS攻击了。但是攻击者还有一招:既然他们已经没必要从我们的网站中提交表单了,那么就可以尝试从另一个完全不同的网站提交表单。他们可以事先弄明白所有表单字段的意义,然后从我们可能访问过的任何网站发送一个/pay表单请求。
他们唯一需要做的就是诱使我们访问一个隐藏了恶意Javascript脚本的网站。如果他们发现我们使用过的某个网站论坛没有对帖子的评论进行合适的转义,或没有将评论中的script标记删除,那么也可以将Javascript脚本嵌入到论坛帖子的评论中。
读者可能会认为,攻击者必须构造一个用于支付的表单,然后精心设计,诱使用户单击表单中的提交按钮。然而事实并非如此。由于用户的浏览器可能启用了Javascript,攻击者可以直接把attack.js插入到用户要载入的页面、论坛帖子或评论中,然后就可以坐等受害者的钱流入他们的账户中了。
这就是经典的跨站请求伪造(CSRF Cross-Site Request Forgery)攻击。不需要攻击者攻击支付系统本身,它们只需要找到并解析一个易于构造的支付表单,然后将Javascript脚本嵌入到用户可能访问到的任何网站中即可。也就是说,要抵御这种攻击,我们访问的所有网站都必须是安全的。
因此我们编写的应用程序需要能够抵御CSRF攻击。那么如何防御?——增加构造及提交表单的难度。
除了要完成支付必须填写的字段之外,表单中还需要一个额外的字段,其中包含只对表单的合法用户或合法用户的浏览器可见的私钥,用户无法在浏览器中获取该私钥或是使用表单来获取该私钥。这样一来,由于攻击者并不知道/pay表单的隐藏字段信息,因此也就无法伪造出服务器信任的POST请求。
之前提到过,Flask支持在每位用户登录时为其分配一个随机字符串作为私钥,并放在cookie中安全地发送给客户端。为了抵御CSRF攻击,改进后的代码也利用了这一功能。当然,在这个例子中,我们假设支付网站在现实生活中使用了HTTPS,以保证网页或cookie中的私钥在传输过程中无法被窃取。
在决定为每个用户会话分配一个随机私钥后,支付网站就可以把该私钥添加到所有用户都可以访问的./pay表单中,并且将其隐藏。隐藏的表单属性是HTML的一个内置特性,该特性的目的之一就是抵御CSRF攻击。我们将下面的字段添加到pay2.html中,并且在改进的代码中使用pay2.html代替原来的pay.html。
<input name="csrf_token" type="hidden" value="{{csrf_token}}">
现在,每次提交表单时,都会先检查表单中的CSRF值是否与合法用户可见的HTML版本的表单中一致。如果两者不一致,网站就会认为有攻击者在试图伪装成另一个用户,因此会拒绝表单请求,返回403 Forbidden。
改进后的代码对CSRF的保护是手动完成的,这样读者就可以比较两个代码的不同之处。理解为什么包含随机私钥的隐藏字段可以防止攻击者猜测出构造合法表单的方法。在现实生活中,我们应该使用Web框架内置的功能或标准扩展来提供CSRF保护。Flask社区推荐了若干种方法,其中包括流行的Flask_WTF库(一个用于构建与解析HTML表单的库)内置的CSRF保护功能。
11.5.5 改进后的应用程序
源码网站中有资源可以自行下载,也可以复制下面带注释的:
  1. import bank,uuid
  2. from flask import (Flask,abort,flash,get_flashed_messages,
  3. redirect,render_template,request,session,url_for)
  4. app = Flask(__name__)
  5. app.secret_key = 'saiGeij8AiS2ahMo5dahveixuV3'
  6. #有了签名密钥后,Flask就会通过Session对象来使用该密钥,设置cookie
  7. #收到请求并提取出cookie之后,Flask会检查签名密钥,缺人密钥正确后才会信任此次请求。
  8. #如果cookie的签名不正确,就认为该cookie是伪造的,因此尽管请求中提供了cookie,但是该cookie无效。
  9. @app.route('/login',methods=['GET','POST'])
  10. def login():
  11. username = request.form.get('username','')
  12. password = request.form.get('password','')
  13. if request.method == 'POST':
  14. if (username,password) in [('brandon', 'pswd'), ('sam', 'pswd')]:
  15. session['username']=username
  16. session['csrf_token']=uuid.uuid4().hex
  17. return redirect(url_for('index'))
  18. return render_template('login.html',username=username) #通过render_template函数来调用Jinja2,只要其参数中的模板文件名是html后缀,
  19. #就会自动打开HTML转义功能。抵御XSS攻击
  20. @app.route('/logout')
  21. def logout():
  22. session.pop('username',None)
  23. return redirect(url_for('login'))
  24. @app.route('/')
  25. def index():
  26. username = session.get('username')
  27. if not username:
  28. return redirect(url_for('login'))
  29. payments = bank.get_payments_of(bank.open_database(),username)
  30. return render_template('index.html',payments=payments,username=username,
  31. flash_messages=get_flashed_messages())
  32. @app.route('/pay',methods=['GET','POST'])
  33. def pay():
  34. username = session.get('username')
  35. if not username:
  36. return redirect(url_for('login'))
  37. account = request.form.get('account','').strip()
  38. dollars = request.form.get('dollars','').strip()
  39. memo = request.form.get( 'memo' ,'').strip()
  40. complaint = None
  41. if request.method == 'POST':
  42. if request.form.get('csrf_token') != session['csrf_token']:
  43. abort(403)
  44. if account and dollars and dollars.isdigit() and memo:
  45. db = bank.open_database()
  46. bank.add_payment(db,username,account,dollars,memo)
  47. db.commit()
  48. flash('Payment Successful')
  49. return redirect(url_for('index'))
  50. complaint = ('Dollars must be an integer' if not dollars.isdigit()
  51. else 'Pleaser fill in all three fields')
  52. return render_template('pay2.html',complaint=complaint,account=account,
  53. dollars=dollars,memo=memo,csrf_token=session['csrf_token'])
  54. if __name__ == '__main__':
  55. app.debug = True
  56. app.run()
首先,在模板中进行了合适的转义。此时就算在Memo中再加入script,系统也不会出错,如图

可以正常在页面中显示了。 然后使用内部存储来存储flash消息,使用了flash()和get_flashed_messages()这对函数,而没有通过用户的浏览器来发送flash消息(即app_insecure中的flash_messages=request.args.getlist('flash') 和return redirect(url_for('index', flash='Payment successful'))。以及,在用户填写的每个表单中都包含一个隐藏的随机UUID,以防止表单被伪造。
需要注意的是,有两个主要的改进都是通过使用Flask内置的标准机制代替自己设计的代码完成的。第一是使用内部存储来存储flash消息,第二是启用Jinja2对特殊字符的转义功能。       这说明,如果我们仔细阅读有关Web框架的文档,并且尽量使用框架提供的特性,那么不仅可以使得应用程序变得更短小、精确,通常也能使应用程序变得更安全。
现在,这个应用程序在进行网络交互操作时的自动化程度已经很高了。但是,在处理视图和表单时,还是需要进行不少手动操作。
首先,需要在代码中手动检测用户是否登录。其次,需要从请求中将每个表单字段手动复制到HTML中,这样用户就不需要重新输入这些字段了。除斥之外,与数据库的交互操作还是相当底层的,我们必须自己打开数据库会话,如果要将账单永久存储在SQLite的话,需要自己进行提交。
在解决这些通用的问题时,可以从Flask社区内找到很多可以遵循的最佳实践以及可用的第三方工具。不过,为了避免太单调,加下来我们将使用另一个Web框架来编写这个应用程序。相比于Flask,接下来要介绍的框架可以为我们完成更多工作。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/AllinToyou/article/detail/439904
推荐阅读
相关标签
  

闽ICP备14008679号