PHP 作為最流行的伺服端語言,其安全問題往往源於開發者對「不信任輸入」原則的忽視。本文整理出 PHP 安全開發的六大核心習慣,涵蓋輸入驗證、資料庫防護、配置管理、來源驗證、會話保護、XSS 防禦,每項均提供錯誤與正確代碼對比,助你建立安全第一的開發思維。
核心原則:定義「允許什麼」,而非「禁止什麼」。黑名單永遠跟不上攻擊變體。
<?php
// 錯誤 1: 嘗試過濾「壞字符」 - 永遠不完整
$username = str_replace(['<', '>', '"', "'", '--', ';'], '', $_POST['username']);
// 錯誤 2: 只信任 JavaScript 前端驗證
//
// 攻擊者可繞過前端、直接發送 POST 請求、或禁用 JS
<?php
/**
* 通用輸入驗證函數
* @param mixed $input 原始輸入
* @param array $rules 驗證規則 ['type' => 'int|email|alpha|alphanum|regex', ...]
* @return mixed|false 驗證通過返回過濾後值,失敗返回 false
*/
function validateInput($input, array $rules) {
// 1. 基礎清理
if (is_string($input)) {
$input = trim($input);
// 移除控制字符 (保留換行/製表視業務需求)
$input = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $input);
}
// 2. 型別/格式驗證
foreach ($rules as $field => $rule) {
$value = is_array($input) ? ($input[$field] ?? null) : $input;
switch ($rule['type']) {
case 'int':
if (!filter_var($value, FILTER_VALIDATE_INT, $rule['options'] ?? [])) return false;
break;
case 'email':
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) return false;
break;
case 'alpha': // 僅字母
if (!preg_match('/^[A-Za-z]+$/', $value)) return false;
break;
case 'alphanum': // 字母數字
if (!preg_match('/^[A-Za-z0-9]+$/', $value)) return false;
break;
case 'regex':
if (!preg_match($rule['pattern'], $value)) return false;
break;
case 'in_array': // 白名單枚舉
if (!in_array($value, $rule['values'], true)) return false;
break;
case 'string':
if (!is_string($value)) return false;
if (isset($rule['min']) && mb_strlen($value) < $rule['min']) return false;
if (isset($rule['max']) && mb_strlen($value) > $rule['max']) return false;
break;
}
}
return $input;
}
// --- 使用範例 ---
// 註冊表單驗證
$rules = [
'username' => ['type' => 'alphanum', 'min' => 3, 'max' => 20],
'email' => ['type' => 'email'],
'age' => ['type' => 'int', 'options' => ['min_range' => 18, 'max_range' => 120]],
'role' => ['type' => 'in_array', 'values' => ['user', 'editor', 'admin']],
'bio' => ['type' => 'string', 'max' => 500],
];
$cleanData = validateInput($_POST, $rules);
if ($cleanData === false) {
http_response_code(400);
exit('輸入資料格式錯誤');
}
// $cleanData 現在安全可用
?>
鐵律:永遠不要將使用者輸入直接拼接進 SQL 字串。mysql_real_escape_string 已棄用 (PHP 7 移除),唯一正解是 PDO / MySQLi Prepared Statements。
<?php
// 極度危險: 直接拼接
$sql = "SELECT * FROM users WHERE username = '" . $_POST['user'] . "' AND password = '" . $_POST['pass'] . "'";
// 過時且不完全: mysql_real_escape_string (僅對字串有效,數值型注入無效)
// 且 PHP 7+ 已移除 mysql_* 全部函數
<?php
// 資料庫連線 (單例模式)
class DB {
private static $pdo = null;
public static function get(): PDO {
if (self::$pdo === null) {
self::$pdo = new PDO(
'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
'db_user', 'strong_password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 關鍵: 使用真實預處理
]
);
}
return self::$pdo;
}
}
// 安全查詢範例
try {
$pdo = DB::get();
// 1. 查詢
$stmt = $pdo->prepare('SELECT id, username, email FROM users WHERE username = ? AND status = ?');
$stmt->execute([$cleanData['username'], 1]);
$user = $stmt->fetch();
// 2. 插入
$stmt = $pdo->prepare('INSERT INTO users (username, email, password_hash, created_at) VALUES (?, ?, ?, NOW())');
$stmt->execute([
$cleanData['username'],
$cleanData['email'],
password_hash($cleanData['password'], PASSWORD_ARGON2ID) // 密碼雜湊
]);
// 3. 更新 (具名參數更易讀)
$stmt = $pdo->prepare('UPDATE users SET last_login = NOW(), login_ip = :ip WHERE id = :id');
$stmt->execute([':ip' => $_SERVER['REMOTE_ADDR'], ':id' => $user['id']]);
} catch (PDOException $e) {
// 生產環境: 記錄錯誤日誌、回傳通用錯誤訊息
error_log('DB Error: ' . $e->getMessage());
http_response_code(500);
exit('系統繁忙,請稍後再試');
}
?>
| 配置項 | 建議值 | 說明 |
|---|---|---|
| magic_quotes_gpc | Off | PHP 5.4 已移除。若開啟會重複轉義導致資料損壞。請自行處理轉義。 |
| register_globals | Off | PHP 5.4 已移除。自動將 GET/POST/COOKIE 註冊為全域變數,極度危險。 |
| display_errors | Off (生產環境) | 錯誤訊息洩漏路徑、SQL、變數值。開發環境可開啟。 |
| log_errors | On | 錯誤寫入日誌檔案,而非輸出到瀏覽器。 |
| error_reporting | E_ALL & ~E_DEPRECATED & ~E_STRICT | 開發環境建議 E_ALL;生產環境記錄但不顯示。 |
| expose_php | Off | 隱藏 HTTP Header 中的 X-Powered-By: PHP/版本資訊。 |
| session.cookie_httponly | 1 | 防止 JavaScript 存取 Session Cookie (緩解 XSS 竊取)。 |
| session.cookie_secure | 1 (HTTPS 環境) | 僅透過 HTTPS 傳輸 Cookie。 |
| session.use_strict_mode | 1 | 拒絕未初始化的 Session ID,防止 Session Fixation。 |
誤區:依賴 $_SERVER['HTTP_REFERER'] —— 易偽造、隱私模式下不送出、代理伺服器可能移除。
<?php
// 1. 生成 CSRF Token (登入後、表單渲染前)
function generateCsrfToken(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// 2. 驗證 CSRF Token (表單提交、狀態變更 API)
function verifyCsrfToken(string $token): bool {
if (empty($_SESSION['csrf_token']) || empty($token)) return false;
// 使用 hash_equals 防止時序攻擊
$valid = hash_equals($_SESSION['csrf_token'], $token);
// 可選: 一次性 Token 驗證後銷毀 (高安全場景)
// if ($valid) unset($_SESSION['csrf_token']);
return $valid;
}
// --- 表單頁面 ---
<form action="/profile/update" method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(generateCsrfToken()) ?>">
<!-- 其他欄位 -->
<button type="submit">更新</button>
</form>
<script>
fetch('/api/user/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '<?= generateCsrfToken() ?>' // 從 meta tag 或 JS 變數獲取
},
body: JSON.stringify({name: '新名稱'})
});
</script>
<?php
// --- 處理端 ---
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!verifyCsrfToken($token)) {
http_response_code(403);
exit('CSRF Token 驗證失敗,請重新整理頁面再試');
}
// 執行業務邏輯...
}
?>
<?php
// Session 安全配置 (必須在 session_start() 之前)
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', isset($_SERVER['HTTPS'])); // 自動偵測 HTTPS
ini_set('session.cookie_samesite', 'Strict'); // 或 'Lax'
ini_set('session.use_strict_mode', 1);
ini_set('session.sid_length', 48); // 更長的 Session ID
session_start();
// 敏感資料加密儲存於 Session (如用戶 ID、權限)
function setSecureSession(string $key, $value): void {
$key = 'secure_' . $key;
// 使用 openssl 加密 (需金鑰管理)
$encKey = hash('sha256', $_ENV['SESSION_ENC_KEY'] ?? 'fallback_key_change_me', true);
$iv = random_bytes(16);
$ciphertext = openssl_serialize($value, 'aes-256-gcm', $encKey, OPENSSL_RAW_DATA, $iv, $tag);
$_SESSION[$key] = base64_encode($iv . $tag . $ciphertext);
}
function getSecureSession(string $key) {
$key = 'secure_' . $key;
if (empty($_SESSION[$key])) return null;
$data = base64_decode($_SESSION[$key], true);
$iv = substr($data, 0, 16);
$tag = substr($data, 16, 16);
$ciphertext = substr($data, 32);
$encKey = hash('sha256', $_ENV['SESSION_ENC_KEY'] ?? 'fallback_key_change_me', true);
return openssl_decrypt($ciphertext, 'aes-256-gcm', $encKey, OPENSSL_RAW_DATA, $iv, $tag);
}
// 使用範例
setSecureSession('user_id', 12345);
setSecureSession('permissions', ['read', 'write', 'admin']);
$userId = getSecureSession('user_id'); // 返回 12345 (int)
?>
核心:根據輸出上下文 (HTML Body、HTML Attribute、JavaScript、CSS、URL) 選擇正確編碼函數。htmlspecialchars() 僅適用於 HTML Body/Attribute。
| 輸出上下文 | 編碼函數/方法 | 範例 |
|---|---|---|
| HTML Body / 內容 | htmlspecialchars($str, ENT_QUOTES, 'UTF-8') | <div><?= h($user->bio) ?></div> |
| HTML Attribute (單/雙引號) | htmlspecialchars($str, ENT_QUOTES, 'UTF-8') | <input value="<?= h($user->name) ?>"> |
| JavaScript 字串字面量 | json_encode($str) (含引號) | <script>const name = <?= json_encode($user->name) ?>;</script> |
| URL 參數值 | rawurlencode($str) | <a href="search?q=<?= rawurlencode($kw) ?>"> |
| CSS 值 | 白名單驗證 / cssesc() (需庫) | 避免直接輸出使用者輸入到 CSS |
<?php
// 專案通用輸出函數
function h(string $str, int $flags = ENT_QUOTES): string {
return htmlspecialchars($str, $flags, 'UTF-8');
}
function hAttr(string $str): string {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
function js(string $str): string {
return json_encode($str, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}
function url(string $str): string {
return rawurlencode($str);
}
// 模板中使用
// = h($article->content) ?>
//
//
// 搜尋
?>
password_hash() / PASSWORD_ARGON2ID 儲存?display_errors = Off、錯誤記錄到日誌?composer audit、Snyk、Dependabot)?安全不是功能,是品質的基線。將這六大習慣內化為肌肉記憶,才能在快速迭代中守住底線。