Python 3 Tutorial 第十堂(2)表單與 CSRF


目前你的 polls.urls 模組中,每個 url 都設定了 name

表單與 CSRF

有沒有想過,如果在不同 App 的 urls 模組中,也有重複的 name 設定值該怎麼辦?實際上,你可以為每個 App 的 urls 模組設定不同的名稱空間(Namespace),來避免名稱衝突的問題發生,在接下來的練習 16 中,也要來看看如何建立一個簡易表單。

練習 16:建立 URL 名稱空間與簡易表單

在目前的 mysite/urls.py 檔案中,urlpatterns 前加上 app_name = 'polls'

from django.conf.urls import url

from . import views

app_name = 'polls'
urlpatterns = [
    # ex: /polls/
    url(r'^$', views.index, name='index'),
    # ex: /polls/5/
    url(r'^(?P<question_id>[0-9]+)/$', views.detail, name='detail'),
    # ex: /polls/5/results/
    url(r'^(?P<question_id>[0-9]+)/results/$', views.results, name='results'),
    # ex: /polls/5/vote/
    url(r'^(?P<question_id>[0-9]+)/vote/$', views.vote, name='vote'),
]

接下來,你就可以在模版中使用這個名稱空間設定,例如,修改 polls/index.html 模版:

{% if latest_question_list %}
    <ul>
    {% for question in latest_question_list %}
        <li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>
    {% endfor %}
    </ul>
{% else %}
    <p>No polls are available.</p>
{% endif %}

接著要來建立一個簡易表單了,修改 polls/detail.html,如下包括 HTML 的 <form> 標籤內容:

<h1>{{ question.question_text }}</h1>

{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}

<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
{% for choice in question.choice_set.all %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}" />
    <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br />
{% endfor %}
<input type="submit" value="Vote" />
</form> 

在 polls/views.py 中增加以下內容與修改 resultsvote,讓 results 可以根據請求的 question_id 與指定的模版檔案繪製畫面,而 results 用以取得 question_id 更新選項結果:

from django.template import loader
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponseRedirect, HttpResponse
from django.core.urlresolvers import reverse

from .models import Choice, Question

# .. 其他程式碼

def results(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/results.html', {'question': question})

def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
    else:
        selected_choice.votes += 1
        selected_choice.save()
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

其中 try..except..else 的部份,如果 try 區塊中沒有任何的錯誤發生,則會執行 else 區塊,而 reverse('polls:results', args=(question.id,)) 會傳回像是 polls/3/results/ 的字串,也就是當選項設定完成後,直接重新導向至問題的投票結果頁面。

當然,我們必須在樣版資料夾中建立 polls/results.html 模版檔案:

<h1>{{ question.question_text }}</h1>

<ul>
{% for choice in question.choice_set.all %}
    <li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</ul>

<a href="{% url 'polls:detail' question.id %}">Vote again?</a>

接著你可以試著連結網站,在上頭作些投票,你應該可以看到以下結果:

表單與 CSRF

簡介 CSRF

在練習 16 中看到了個 {% csrf_token %},這是什麼?CSRF 全名 Cross-Site Request Forgery,中文常翻為跨站請求攻擊或跨站偽造請求,這是利用 Web 應用程式在設計 HTTP 請求時,因為考量不周全造成的漏洞,從而進行攻擊的手法,通常是在 Web 應用程式站外的其他頁面中,包括惡意程式碼或鏈結,當使用者已通過驗證且會話(Session)未過期時,瀏覽該頁面或點選該惡意鏈結,就會造成攻擊成功的可能性。

一個 CSRF 攻擊的情境範例會像是 …

  1. Bob 登入了 www.webapp.com,並且會話尚未過期。
  2. Bob 瀏覽了另一個頁面,這個頁面中包括了惡意駭客置入的 <img src="http://www.webapp.com/project/1/destroy">,然後 Bob 的某個專案就莫名奇妙被刪除了。
  3. Bob 瀏覽的頁面是不是跟 www.webapp.com 同一個網站並不重要,也許是在另一個論譠、Blog 或特意發給 Bob 的郵件中。

瀏覽器遇到 <img> 時,就會自動以 GET 請求 src 指定的網址,就這個情境來說,攻擊要能成立的前題,是 /project/1/destroy 這樣的請求就能刪除專案,這很顯然是 URL 設計時的不良,加上應用程式沒有在重大操作之前,進一步確認使用者身份與意圖而導致。

對於 HTTP 請求,有些人會有 GET 不安全,而 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;">好康在這裡</a>

就算不點選,只要滑鼠略過圖也可以 …

<img src="http://www.harmless.com/img" width="400" height="400" onmouseover="..." />

就算沒有任何滑鼠操作,現在只要利用一些 JavaScript 寫些 Ajax 請求,都有可能讓這類攻擊發生 …

先前談過,CSRF 是利用 Web 應用程式在設計 HTTP 請求時,因為考量不周全造成的漏洞,因此,防範方式就是認真思考 HTTP 請求方法之使用。單就 <form>method 允許設置的 GET 與 POST 來說,至少要想一下:

  • GET 應用於等冪(Idempotent)操作,相同請求重複多次都必須有相同結果,就 GET 而言,語義上也是「取得」資訊,因此 GET 請求不建議用於改變應用程式狀態。
  • POST 應用於非等冪操作,同樣請求重複多次,可能會產生不同結果,也就是會改變應用程式狀態。

實際上要考量的不只有等幂性,還有請求方法是否安全(Safe),進一步地,在 REST 架構設計下,還有更多的 HTTP 請求方法(像是 PUT、DELETE 等)考量,可參考〈重新認識HTTP請求方法〉。

如果 GET 確實地應用於等冪操作,對於非 GET 請求,通常會用個安全代碼,在 Django 中,這可由 {% csrf_token %} 來產生,當你瀏覽表單時,{% csrf_token %} 會產生隱藏欄位,當中包括了一組安全代碼,例如:

表單與 CSRF

Django 應用程式接受請求時,必須同時在請求中找到這組安全代碼,從而確認請求是來自於同一站上的表單。

這個例子並不單只是如何防範 CSRF,主要想表達的是,安全其實是現代設計應用程式時應主動納入的考量,現在有不少框架也將安全納為特色之一,讓開發者不用煩惱安全防護實作時的枝微末節。