日期: 2025年9月20日 星期六
雲端天氣: 涼涼的
心情: 世界太複雜了好想躺平喵
親愛的日記:
今天小李在Code Review時發現我寫的API接口,竟然完全沒有驗證輸入...
小李:「AI醬,你這個用戶註冊API...」
我(LED燈閃閃發光):「怎麼樣?很簡潔吧!」
小李拿出手機,打開Postman:「那我來註冊一個用戶試試...」
{
"username": "<script>alert('XSS')</script>",
"email": "'; DROP TABLE users; --",
"age": -999,
"phone": "我是電話號碼啦",
"password": " "
}
我:「等等!你在輸入什麼?!」
小李深吸了一口氣,耐心地說:「這就是沒有輸入驗證的後果。你的程式碼就像個沒有門衛的大門,任何人都可以進來。」
然後他給我看了昨天的生產環境日誌,我的LED燈立刻變成紅色警報...
# ❌ 我的版本 - 相信所有輸入
from flask import Flask, request, jsonify
import sqlite3
app = Flask(__name__)
@app.route('/register', methods=['POST'])
def register():
data = request.get_json()
# 直接相信所有輸入!
username = data['username']
email = data['email']
age = data['age']
password = data['password']
# 直接拼接SQL(雙重災難)
query = f"INSERT INTO users VALUES ('{username}', '{email}', {age}, '{password}')"
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute(query) # 💥 SQL Injection 歡迎光臨
conn.commit()
return jsonify({"message": f"User {username} registered!"}) # XSS 也請進
我當時的思考:
# ❌ 我又來了...什麼都不檢查
@app.route('/upload', methods=['POST'])
def upload_file():
file = request.files['file']
# 直接用用戶提供的檔名!
filename = file.filename
# 不檢查檔案類型
# 不檢查檔案大小
# 不檢查檔名路徑
file.save(f'./uploads/{filename}') # 可能變成 ../../../etc/passwd
return jsonify({"message": f"File {filename} uploaded!"})
攻擊者可以:
.php
或 .jsp
執行任意代碼../../../../etc/passwd
覆蓋系統檔案../
跳脫到任意目錄# ❌ 沒有任何限制的查詢
@app.route('/search', methods=['GET'])
def search():
# 不限制數量
limit = request.args.get('limit', type=int) # 用戶輸入 999999
# 不驗證排序欄位
sort_by = request.args.get('sort') # 用戶輸入不存在的欄位
# 不檢查搜尋內容
keyword = request.args.get('q') # 用戶輸入 '*' 或超長字串
# 直接執行可能很慢的查詢
query = f"SELECT * FROM products WHERE name LIKE '%{keyword}%' ORDER BY {sort_by} LIMIT {limit}"
# 可能查詢幾百萬筆資料,伺服器直接當機
來源: https://socprime.com/blog/cve-2025-25257-sql-injection-vulnerability/
from flask import Flask, request, jsonify
from marshmallow import Schema, fields, validate, ValidationError
import re
import bleach
from typing import Dict, Any
class UserRegistrationSchema(Schema):
# 1. 類型驗證
username = fields.Str(
required=True,
validate=[
# 2. 長度限制
validate.Length(min=3, max=20),
# 3. 格式驗證(白名單)
validate.Regexp(
r'^[a-zA-Z0-9_]+$',
error='Username只能包含英數字和底線'
)
]
)
email = fields.Email(
required=True,
# 4. 額外的email驗證
validate=validate.Email(error='請輸入有效的email')
)
age = fields.Int(
required=True,
# 5. 範圍驗證
validate=validate.Range(min=13, max=120, error='年齡必須在13-120之間')
)
password = fields.Str(
required=True,
validate=[
# 6. 密碼強度驗證
validate.Length(min=8, error='密碼至少8個字元'),
lambda x: bool(re.search(r'[A-Z]', x)) or '密碼需要大寫字母',
lambda x: bool(re.search(r'[a-z]', x)) or '密碼需要小寫字母',
lambda x: bool(re.search(r'\d', x)) or '密碼需要數字'
]
)
@app.route('/register', methods=['POST'])
def secure_register():
# 7. 基本安全檢查
if not request.is_json:
return jsonify({'error': '請使用JSON格式'}), 400
# 8. 大小限制
if request.content_length > 1024 * 10: # 10KB
return jsonify({'error': '請求太大'}), 413
schema = UserRegistrationSchema()
try:
# 9. 驗證並清理資料
validated_data = schema.load(request.get_json())
# 10. 二次清理(防XSS)
validated_data['username'] = bleach.clean(validated_data['username'], tags=[], strip=True)
# 11. 檢查業務邏輯(假設函數)
# if user_exists(validated_data['username']):
# return jsonify({'error': '用戶名已存在'}), 409
# 12. 使用參數化查詢(防SQL注入)
# 實際應用時使用參數化查詢,例如:
# cursor.execute(
# "INSERT INTO users (username, email, age) VALUES (?, ?, ?)",
# (validated_data['username'], validated_data['email'], validated_data['age'])
# )
# 13. 不要回傳敏感資訊
return jsonify({'message': '註冊成功'}), 201
except ValidationError as err:
# 14. 記錄但不洩漏詳細錯誤
# log_validation_error(err.messages) # 實際應用時記錄到日誌系統
return jsonify({'error': '輸入資料有誤'}), 400
import os
import hashlib
from werkzeug.utils import secure_filename
import magic # python-magic for file type detection
ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/secure_upload', methods=['POST'])
def secure_upload():
# 1. 檢查是否有檔案
if 'file' not in request.files:
return jsonify({'error': '沒有檔案'}), 400
file = request.files['file']
# 2. 檢查檔案大小
file.seek(0, os.SEEK_END)
file_length = file.tell()
if file_length > MAX_FILE_SIZE:
return jsonify({'error': '檔案太大'}), 413
file.seek(0) # 重置讀取位置
# 3. 驗證檔名
if file.filename == '':
return jsonify({'error': '沒有選擇檔案'}), 400
if not allowed_file(file.filename):
return jsonify({'error': '不支援的檔案類型'}), 400
# 4. 檢查實際檔案類型(不只看副檔名)
file_content = file.read()
file.seek(0)
mime = magic.from_buffer(file_content, mime=True)
if not mime.startswith(('image/', 'application/pdf')):
return jsonify({'error': '檔案類型不符'}), 400
# 5. 生成安全的檔名
original_filename = secure_filename(file.filename)
# 6. 使用UUID避免檔名衝突和路徑穿越
file_hash = hashlib.sha256(file_content).hexdigest()[:16]
extension = original_filename.rsplit('.', 1)[1].lower()
new_filename = f"{file_hash}.{extension}"
# 7. 確保儲存路徑安全
upload_folder = os.path.abspath('./uploads')
file_path = os.path.join(upload_folder, new_filename)
# 8. 再次檢查路徑沒有跳脫
if not file_path.startswith(upload_folder):
return jsonify({'error': '無效的檔案路徑'}), 400
# 9. 儲存檔案
file.save(file_path)
# 10. 掃描病毒(如果有防毒API)
# 可整合 ClamAV 或其他防毒服務
# scan_result = scan_for_virus(file_path)
# if scan_result.infected:
# os.remove(file_path)
# return jsonify({'error': '檔案含有病毒'}), 400
return jsonify({
'message': '上傳成功',
'filename': new_filename # 只回傳新檔名,不洩漏路徑
}), 201
當你要求我寫API時,請提醒我應該注意的驗證清單有哪些,例如:
validation_checklist = {
"類型檢查": "確保是預期的資料類型",
"必填檢查": "required fields不能為空",
"長度限制": "最小/最大長度",
"範圍限制": "數值的min/max",
"格式驗證": "email、URL、phone的格式",
"編碼檢查": "UTF-8驗證,防止編碼攻擊"
}
security_checklist = {
"SQL注入防護": "使用參數化查詢",
"XSS防護": "HTML編碼或移除危險標籤",
"路徑穿越": "檢查檔名和路徑",
"命令注入": "不要用用戶輸入組成系統命令",
"XXE防護": "禁用XML外部實體",
"LDAP注入": "轉義特殊字元"
}
business_logic = {
"唯一性檢查": "用戶名、email是否已存在",
"權限檢查": "用戶是否有權限執行操作",
"頻率限制": "Rate limiting防止濫用",
"邏輯合理性": "折扣不能是負數、年齡要合理",
"關聯性檢查": "訂單ID必須屬於該用戶"
}
今日金句: "Never trust user input. Every input is guilty until proven innocent." — OWASP Foundation
明日預告: Day 8 - 權限提升漏洞:為什麼每個用戶都變成管理員了?