iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

上一篇我們處理了「出錯之後怎麼收拾」,讓錯誤變成看得懂的訊息和正確的狀態碼。

不過那時候也說了,有些問題其實可以更早擋下來。

例如這些請求,現在都還會被我們的 API 接受:

{"name": "   ", "completed": false}
{"name": "(貼了一篇 5000 字的文章)", "completed": false}
{"name": "洗碗", "completed": false, "priority": -1}

今天就來做 Input Validation(輸入驗證),在資料進來的時候就先檢查。

為什麼一定要在後端檢查?

「前端不是已經有檢查了嗎?」

這是很常見的想法,但答案是:前端的檢查不算數。

因為前端的檢查只是為了讓使用者操作順一點,它擋不住:

有人直接用 Swagger、Postman 或程式送 Request(我們前面幾天就一直這樣做)
有人把前端的 JavaScript 改掉
有人故意送奇怪的資料來試探

後端才是真正的守門員。

有一句話蠻適合記起來的:

永遠不要相信前端傳進來的資料。

如果不檢查,可能會發生:

資料庫裡面存了一堆奇怪的資料(空白的作業名稱、型別不對的值)
程式在某個地方突然爆掉,變成 500
更嚴重的是資安問題,例如 Day 27 要講的 SQL Injection

驗證可以分成三層

我自己整理下來,大概可以分成三層:

第一層:格式驗證
有沒有給必填欄位?型別對不對?
→ 交給 Flask-RESTX 的 validate=True

第二層:內容(規則)驗證
名稱是不是只有空白?會不會太長?優先度可不可以是負的?
→ 自己寫檢查

第三層:資料庫的限制
NOT NULL、欄位長度
→ 最後一道防線,Day 24 已經用 errorhandler 接住了

三層都要有,因為每一層擋的東西不一樣。

先把 task_input 補完整

Day 23 我們在 task_response 補上了 due_date 和 priority,
但 task_input 還是只有 name 和 completed,所以其實沒辦法透過 API 設定那兩個欄位。

既然今天要做驗證,順便一起補上:

task_input = api.model('TaskInput', {
    'name': fields.String(required=True, description='作業名稱', example='洗碗'),
    'completed': fields.Boolean(required=True, description='是否完成', example=False),
    'due_date': fields.String(description='截止日期 YYYY-MM-DD', example='2026-10-15'),
    'priority': fields.Integer(description='優先度,數字越大越優先', example=0),
})

只有 name 和 completed 寫了 required=True,另外兩個是選填。

第一層:validate=True

Day 13 有提過一個很容易誤會的地方:

@api.expect(task_input)

預設只是「文件上的說明」,不會真的擋。

只要加上 validate=True:

@api.expect(task_input, validate=True)

Flask-RESTX 就會依照 task_input 的定義去檢查 Request Body:

name 有沒有傳、是不是字串
completed 有沒有傳、是不是布林值
due_date 如果有傳,是不是字串
priority 如果有傳,是不是整數

測試看看,送出:

{
  "completed": false
}

這次會直接回 400,而且告訴我們哪裡不對:

{
  "errors": {
    "name": "'name' is a required property"
  },
  "message": "Input payload validation failed"
}

少傳 name 時,回傳 400 和 errors 說明
https://ithelp.ithome.com.tw/upload/images/20261009/201838659JyurzKiQQ.png
注意這裡的錯誤訊息是英文的,因為那是底層 JSON Schema 產生的。

如果想要中文訊息,就要自己在第二層處理。

(另外,如果想讓整個 API 都預設開啟驗證,可以加上設定 app.config['RESTX_VALIDATE'] = True,
這樣就不用每個 @api.expect 都寫 validate=True。)

用 Swagger 測型別錯誤,會遇到一件有趣的事

來試試看送一個型別錯的:

{
  "name": "洗碗",
  "completed": "是"
}

按下 Execute 之後,畫面不是我們預期的 400,而是跳出:

Please correct the following validation errors and try again.
propKey: completed error: Value must be a boolean
https://ithelp.ithome.com.tw/upload/images/20261009/20183865WZApgQZlor.png
Swagger 自己先擋下來,Execute 根本沒有送出去

而且 Server response 那一區完全沒有更新。

因為這是 Swagger UI 自己的檢查,它發現我們填的不符合 model,就直接不送了。

也就是說,這個請求根本沒有到後端。

那後端到底有沒有擋住?這就是今天最重要的地方。

Swagger 只是一個網頁,它的檢查跟前端的檢查是同一回事,只要不透過它,就完全不會經過這道檢查。

我們可以直接用程式送同一包資料試試看:

import json, urllib.request, urllib.error

req = urllib.request.Request(
    'http://127.0.0.1:5000/tasks',
    data=json.dumps({"name": "洗碗", "completed": "是"}).encode(),
    headers={'Content-Type': 'application/json'},
)

try:
    urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
    body = json.loads(e.read())
    print(e.code, json.dumps(body, ensure_ascii=False))

執行後會看到:

400 {"errors": {"completed": "'是' is not of type 'boolean'"}, "message": "Input payload validation failed"}

Swagger 擋不住的時候,validate=True 擋住了。

這剛好證明了前面說的那句話:
前端的檢查只是讓使用者操作順一點,真正的守門員是後端。

(順帶一提,這也是為什麼測試 API 的時候不能只用 Swagger,
用 Postman 或直接寫程式送,才測得到後端真正的行為。)

第二層:自己寫規則檢查

validate=True 幫我們擋掉了「沒填」和「型別錯」。

但它擋不住這種:

{
  "name": "   ",
  "completed": false
}

因為 ” ” 確實是一個字串,型別完全正確。

可是對我們的作業來說,這是一個沒有意義的名稱。

這種屬於「業務規則」,要自己檢查:

def validate_task_data(data):
    """檢查輸入資料,回傳整理過的資料"""
    name = data.get("name")

    if not isinstance(name, str) or not name.strip():
        api.abort(400, "name 不可以是空白")

    name = name.strip()

    if len(name) > 100:
        api.abort(400, "name 最多只能 100 個字")

    completed = data.get("completed", False)
    if not isinstance(completed, bool):
        api.abort(400, "completed 必須是 true 或 false")

    due_date = data.get("due_date")
    if due_date is not None and not isinstance(due_date, str):
        api.abort(400, "due_date 必須是字串,格式為 YYYY-MM-DD")

    priority = data.get("priority", 0)
    if not isinstance(priority, int) or isinstance(priority, bool):
        api.abort(400, "priority 必須是整數")

    if priority < 0:
        api.abort(400, "priority 不可以小於 0")

    return {
        "name": name,
        "completed": completed,
        "due_date": due_date,
        "priority": priority,
    }

這個函式做了幾件事:

擋掉空白的名稱
順手做 strip(),把前後的空白去掉
限制長度(跟 Model 的 String(100) 對應)
擋掉負數的優先度
最後回傳「整理乾淨」的資料

有一個小地方要注意:

if not isinstance(priority, int) or isinstance(priority, bool):

為什麼要多檢查一次 bool?

因為在 Python 裡 True 其實就是 1、False 就是 0,isinstance(True, int) 會回傳 True。
如果不多擋一次,priority 傳 true 就會被當成 1 存進去。

順便講一個蠻重要的習慣

注意這個函式最後是回傳一個新的字典,而不是直接用 api.payload。

然後在 post() 裡面是這樣用:

data = validate_task_data(api.payload)

task = Task(
    name=data["name"],
    completed=data["completed"],
    due_date=data["due_date"],
    priority=data["priority"],
)

為什麼不直接寫成這樣?

task = Task(**api.payload)   # 不建議

因為這樣等於「使用者傳什麼欄位,我就照單全收」。

如果之後 Task 多了像是 is_admin、owner_id 這種欄位,
使用者只要在 JSON 裡多塞一個欄位,就可能改到不該改的東西。

所以比較安全的做法是白名單:只取我們明確需要的欄位,其他一律忽略。

改好之後的程式

只列出有改的部分(PUT 也要一起改):

@api.route('/tasks')
class TaskList(Resource):
    @api.expect(task_input, validate=True)
    @api.marshal_with(task_response, code=201)
    @api.response(400, '輸入資料有問題')
    def post(self):
        """新增一筆作業"""
        data = validate_task_data(api.payload)

        task = Task(
            name=data["name"],
            completed=data["completed"],
            due_date=data["due_date"],
            priority=data["priority"],
        )

        try:
            db.session.add(task)
            db.session.commit()
        except IntegrityError:
            db.session.rollback()
            api.abort(400, "資料不符合規則,請檢查輸入的內容")

        return task, 201

@api.route('/tasks/<int:task_id>')
class TaskDetail(Resource):
    @api.expect(task_input, validate=True)
    @api.marshal_with(task_response)
    def put(self, task_id):
        """修改指定編號的作業"""
        task = find_task(task_id)
        data = validate_task_data(api.payload)

        task.name = data["name"]
        task.completed = data["completed"]
        task.due_date = data["due_date"]
        task.priority = data["priority"]

        db.session.commit()
        return task

PUT 也要驗證,而且因為 task_input 的 name 和 completed 都是必填,所以修改的時候要把完整的資料傳進來。

這其實就是 Day 15 說的 PUT 語意:整筆取代。

如果想做成「只傳想改的欄位」,就要另外做一個 PATCH,並且用另一個所有欄位都不必填的 model。

把八種情況全部測一次

我把每一種都實際送了一次,結果整理如下:

  1. 正常的資料
{"name": "洗碗", "completed": false, "due_date": "2026-10-15", "priority": 2}

→ 201,新增成功

  1. 沒有 name
{"completed": false}

→ 400 {"errors": {"name": "'name' is a required property"}, …}

  1. name 是數字
{"name": 12345, "completed": false}

→ 400 {"errors": {"name": "12345 is not of type 'string'"}, …}

  1. name 只有空白
{"name": "   ", "completed": false}

→ 400 {"message": "name 不可以是空白"}
https://ithelp.ithome.com.tw/upload/images/20261009/20183865I7tm5QiBAE.png
空白名稱被擋下來,而且是中文訊息

  1. name 超過 100 個字
    → 400 {“message”: “name 最多只能 100 個字”}
  2. completed 傳字串
{"name": "洗碗", "completed": "是"}

→ 400 {"errors": {"completed": "'是' is not of type 'boolean'"}, …}
(用 Swagger 測的話會被 Swagger 自己擋掉,要直接用程式送才看得到這個)

  1. priority 是負數
{"name": "洗碗", "completed": false, "priority": -1}

→ 400 {"message": "priority 不可以小於 0"}

  1. priority 傳字串
{"name": "洗碗", "completed": false, "priority": "abc"}

→ 400 {"errors": {"priority": "'abc' is not of type 'integer'"}, …}

測完之後會發現一件事:

第 2、3、6、8 的訊息是英文、而且有 errors 欄位 → 那是 validate=True 擋的
第 4、5、7 的訊息是中文 → 那是我們自己寫的 validate_task_data() 擋的
兩層剛好各擋各的,少一層都會有漏網之魚。

最後再用 GET /tasks 確認一次:只有第 1 筆進到資料庫,其他七種都被擋在外面了。

今天做了:

知道為什麼後端一定要驗證(前端的檢查擋不住直接打 API)
把 task_input 補上 due_date 和 priority
第一層:@api.expect(model, validate=True) 擋必填和型別
第二層:自己寫 validate_task_data() 擋空白、長度、負數等規則
第三層:資料庫的限制當最後防線
用白名單的方式只取需要的欄位,不要整包照收
發現 Swagger 自己也有一層檢查,但那不能算數

現在我們的 API 已經有基本的防護了。

不過還有一件事完全沒做:

任何人只要知道網址,就可以新增、修改、刪除我們的資料。

下一篇就來認識 API 安全的入門:Authentication 和 Authorization。


上一篇
Day 24|API 出錯怎麼辦?
系列文
從零開始的後端開發:用 Flask 實作 REST API,搞懂 API 與資料庫之間如何協作 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言