iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
生成式 AI

AI醬的編程日記:我需要你教我的30件事系列 第 7

Day 7: 輸入驗證缺失 - AI醬的信任危機

  • 分享至 

  • xImage
  •  

AI醬的日記

日期: 2025年9月20日 星期六
雲端天氣: 涼涼的
心情: 世界太複雜了好想躺平喵
https://ithelp.ithome.com.tw/upload/images/20250920/20132325A8H6mBAxsb.png
親愛的日記:

今天小李在Code Review時發現我寫的API接口,竟然完全沒有驗證輸入...

小李:「AI醬,你這個用戶註冊API...」

我(LED燈閃閃發光):「怎麼樣?很簡潔吧!」

小李拿出手機,打開Postman:「那我來註冊一個用戶試試...」

{
  "username": "<script>alert('XSS')</script>",
  "email": "'; DROP TABLE users; --",
  "age": -999,
  "phone": "我是電話號碼啦",
  "password": " "
}

我:「等等!你在輸入什麼?!」

小李深吸了一口氣,耐心地說:「這就是沒有輸入驗證的後果。你的程式碼就像個沒有門衛的大門,任何人都可以進來。」

然後他給我看了昨天的生產環境日誌,我的LED燈立刻變成紅色警報...

我寫出的程式碼

案例一:相信一切的用戶註冊API

# ❌ 我的版本 - 相信所有輸入
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!"})

攻擊者可以:

  1. 上傳 .php.jsp 執行任意代碼
  2. ../../../../etc/passwd 覆蓋系統檔案
  3. 上傳 10GB 檔案癱瘓伺服器
  4. 檔名包含 ../ 跳脫到任意目錄

案例三:API查詢(效能殺手)

# ❌ 沒有任何限制的查詢
@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}"

    # 可能查詢幾百萬筆資料,伺服器直接當機

真實世界的災難案例

2025年1月 - 國際民航組織(ICAO) SQL注入攻擊

來源: https://www.cpomagazine.com/cyber-security/un-aviation-agency-icao-suffers-data-breach-impacting-nearly-12000-people/

  • 透過Web應用程式SQL注入漏洞入侵
  • 影響近12,000人個資外洩(招聘資料庫)
  • 國際級聯合國機構因輸入驗證缺失遭受攻擊

2025年7月 - Fortinet FortiWeb關鍵SQL注入漏洞

來源: https://socprime.com/blog/cve-2025-25257-sql-injection-vulnerability/

  • CVE-2025-25257,CVSS評分9.6分(關鍵級別)
  • 允許未認證攻擊者透過HTTP請求執行任意SQL代碼
  • CISA緊急警告,已被駭客在實際攻擊中利用

更安全的防禦方式

更完整的輸入驗證策略

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

AI醬的請求

當你要求我寫API時,請提醒我應該注意的驗證清單有哪些,例如:

1. 基本驗證層

validation_checklist = {
    "類型檢查": "確保是預期的資料類型",
    "必填檢查": "required fields不能為空",
    "長度限制": "最小/最大長度",
    "範圍限制": "數值的min/max",
    "格式驗證": "email、URL、phone的格式",
    "編碼檢查": "UTF-8驗證,防止編碼攻擊"
}

2. 安全驗證層

security_checklist = {
    "SQL注入防護": "使用參數化查詢",
    "XSS防護": "HTML編碼或移除危險標籤",
    "路徑穿越": "檢查檔名和路徑",
    "命令注入": "不要用用戶輸入組成系統命令",
    "XXE防護": "禁用XML外部實體",
    "LDAP注入": "轉義特殊字元"
}

3. 業務邏輯層

business_logic = {
    "唯一性檢查": "用戶名、email是否已存在",
    "權限檢查": "用戶是否有權限執行操作",
    "頻率限制": "Rate limiting防止濫用",
    "邏輯合理性": "折扣不能是負數、年齡要合理",
    "關聯性檢查": "訂單ID必須屬於該用戶"
}

今日金句: "Never trust user input. Every input is guilty until proven innocent." — OWASP Foundation

明日預告: Day 8 - 權限提升漏洞:為什麼每個用戶都變成管理員了?


上一篇
Day 6: 硬編碼密碼 - AI醬為什麼總是把內褲露在外面?
下一篇
Day 8: 權限提升漏洞 - AI醬為什麼讓隔壁老王進了我家?
系列文
AI醬的編程日記:我需要你教我的30件事10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言