Learning Objectives
- 說明為什麼要把展示邏輯從 view 函數分到模板
- 用 render_template 把資料傳給模板,並存取字典與串列
- 使用 default、length、upper、round、join、truncate 等過濾器
- 在模板裡寫 if、for,並使用 loop.index 與 for-else
- 說明自動跳脫如何防止 XSS,以及 safe 與 render_template_string 的風險
為什麼要把 HTML 從程式裡分開
到目前為止,view 函數都直接回傳字串。頁面只有一行時沒問題,但一個真正的頁面有幾十行 HTML,寫在 Python 字串裡既難讀也難改,把使用者輸入接進字串還很容易出現安全問題。
一個 view 函數其實在做兩件不同的事:
| 負責什麼 | 放在哪裡 | |
|---|---|---|
| 商業邏輯 | 查資料、判斷權限、計算 | view 函數 |
| 展示邏輯 | 資料要排成什麼樣子 | 模板(template) |
Flask 用 Jinja2 當模板引擎。模板檔案預設放在程式旁邊的 templates 資料夾:
app.py
templates/
course.html
render_template('course.html', name=name) 會找到這個檔案,把 name 代入,回傳算好的 HTML 字串。
逐步看:資料怎麼交給模板
模板是 templates/course.html(在輸出區的檔案分頁可以看到)。請注意 render_template 後面的引數名稱,和模板裡 {{ }} 的名稱是對應的。逐步動畫只追蹤 .py,模板在 render_template 那一行一次算完。
三種符號與常用過濾器
Jinja2 的語法只有三種符號:
| 符號 | 用途 | 範例 |
|---|---|---|
{{ … }} |
輸出一個值 | {{ teacher.name }} |
{% … %} |
控制結構 | {% if x %}、{% for a in b %} |
{# … #} |
註解,不會送到瀏覽器 | {# 待修改 #} |
過濾器(filter)用直線 | 接在值的後面,把值轉換一次再輸出,可以串接多個:
| 過濾器 | 作用 | 要注意的地方 |
|---|---|---|
default('匿名') |
變數沒有定義時用這個值 | 空字串不算沒定義;要連空字串也換掉,寫 default('匿名', true) |
length |
字串長度、串列項目數 | |
upper |
轉成大寫 | |
round(1) |
四捨五入到小數第 1 位 | |
join('、') |
把串列接成一個字串 | |
truncate(30) |
截短,結尾加 ... |
預設不切斷英文單字;只超過一點點(預設 5 個字元以內)時不截短 |
safe |
標記為安全,不跳脫 | 有風險,下面會實際操作 |
完整清單見 Jinja 內建過濾器。
逐步看:過濾器、判斷與迴圈
同一個模板 notices.html 被開了兩次:一次有公告,一次是空串列。請比較兩段輸出,尤其是暱稱那兩行,以及 {% for %} 的 {% else %} 什麼時候出現。
自動跳脫防止的是什麼
留言板、搜尋框、暱稱都是使用者輸入。如果有人把暱稱取成 <script>…</script>,而伺服器把它原樣放進 HTML,瀏覽器就會執行這段程式。其他人打開頁面時,程式在他們的瀏覽器裡以你的網站的身分執行,這叫跨站腳本攻擊(XSS,Cross-Site Scripting)。
Flask 替 .html、.htm、.xml、.xhtml、.svg 模板開啟了自動跳脫(autoescape):{{ }} 輸出時把 <、>、&、引號換成 HTML 實體,瀏覽器會把它們當成文字顯示,不當標籤解析。第 3 課手動呼叫的 escape() 做的就是這件事。
逐步看:同一段字串,兩種輸出
模板 preview.html 把同一個 msg 輸出兩次,第二次多了 |safe。請比較輸出的第一行和第二行。
safe 與 render_template_string 的風險
什麼時候可以用 safe: 只有當內容來自你信任的來源,例如你自己寫的 HTML,或是已經經過 HTML 清理工具處理的內容。使用者輸入的留言不符合這個條件。第 17 課會實際看到這類漏洞被利用的結果。
render_template_string 不要接使用者輸入。 它把字串當成模板來編譯,所以下面的寫法很危險:
# 有漏洞的寫法,不要這樣寫
return render_template_string('<p>' + request.args['q'] + '</p>')
使用者輸入 {{ 7*7 }} 會看到 49,代表他的輸入被當成程式執行了。模板裡預設還可以存取 config(裡面有 secret_key),這類漏洞叫伺服器端模板注入(SSTI)。正確的做法是把模板寫死,使用者輸入當成變數傳進去:
return render_template_string('<p>{{ q }}</p>', q=request.args['q'])
動手做:完成讀書會頁面
修改 PAGE 這個模板(程式最下面的測試與兩個路由不要改):
- 標題的
group轉成大寫 note是空字串時顯示「沒有備註」- 用過濾器算出成員人數,取代
? - 用
for列出成員,格式是<li>1. 小安</li>;沒有成員時改成一行<li>還沒有成員</li>
其中一位成員的名字含有 HTML 標籤,請確認它會被跳脫,不要用 safe。
想一想
下面的程式會印出什麼?
from flask import Flask, render_template_string
app = Flask(__name__)
with app.app_context():
print(render_template_string(
"{% for x in items %}{{ loop.index }}{{ x }} {% endfor %}",
items=["a", "b"]))
下面的程式會印出什麼?
from flask import Flask, render_template_string
app = Flask(__name__)
with app.app_context():
print(render_template_string("[{{ nick|default('匿名') }}]", nick=""))
msg 的值是 <b>hi</b>,模板是 {{ msg }},用 render_template 輸出(檔名是 .html)。回應內容是什麼?
下面哪一個寫法有伺服器端模板注入(SSTI)的風險?
留言內容來自使用者,模板寫成 {{ comment|safe }},會發生什麼事?
重點整理
- view 函數準備資料,模板負責排版;模板預設放在
templates資料夾 {{ }}輸出、{% %}控制、{# #}註解;過濾器用|串接default預設只處理沒定義的變數;loop.index從 1 開始;for可以接else.html模板會自動跳脫,這是 XSS 的主要防線;safe會關掉它,不要用在使用者輸入上render_template_string的模板本身不可以接使用者輸入
現在每個模板都要重複寫一次頁首與導覽列。下一課用模板繼承解決這件事,並加上 CSS 與圖片。