安全不是功能,而是品質的基石。每個程式設計師都應該具備安全意識,在編碼時就考慮安全問題。
為什麼安全很重要?#
安全漏洞的影響#
一個安全漏洞可能導致:
- 使用者資料洩露
- 系統被入侵
- 經濟損失
- 聲譽損害
- 法律責任
著名安全事件#
| 事件 | 漏洞類型 | 影響 |
|---|---|---|
| Heartbleed | 緩衝區溢位 | 數百萬網站受影響 |
| Equifax 洩露 | 未修補漏洞 | 1.47 億使用者資料外洩 |
| GoToFail | 編碼錯誤 | SSL 驗證失效 |
如何評估安全缺陷#
CVSS 評分系統#
Common Vulnerability Scoring System 是業界標準的漏洞評分系統:
| 等級 | 分數 | 處理優先級 |
|---|---|---|
| 嚴重 | 9.0-10.0 | 立即修復 |
| 高 | 7.0-8.9 | 盡快修復 |
| 中 | 4.0-6.9 | 計劃修復 |
| 低 | 0.1-3.9 | 可接受風險 |
評估維度#
- 攻擊向量 - 遠端還是本地?
- 攻擊複雜度 - 是否容易利用?
- 所需權限 - 是否需要驗證?
- 影響範圍 - 機密性、完整性、可用性
整數運算的安全威脅#
整數溢位#
// 危險:可能溢位
int totalPrice = quantity * price;
// 安全:檢查溢位
try {
int totalPrice = Math.multiplyExact(quantity, price);
} catch (ArithmeticException e) {
// 處理溢位
}Java 安全整數運算#
// Java 8+ 提供的安全方法
Math.addExact(a, b); // 溢位時拋例外
Math.subtractExact(a, b);
Math.multiplyExact(a, b);
Math.incrementExact(a);
Math.decrementExact(a);
Math.negateExact(a);類型轉換風險#
// 危險:大數轉小數可能丟失資訊
long bigNumber = 3_000_000_000L;
int smallNumber = (int) bigNumber; // 結果不是預期值!
// 安全:使用 toIntExact
try {
int smallNumber = Math.toIntExact(bigNumber);
} catch (ArithmeticException e) {
// 處理轉換失敗
}陣列與集合的安全陷阱#
防禦性拷貝#
// 危險:直接暴露內部陣列
public class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = start; // 危險!
this.end = end;
}
public Date getStart() {
return start; // 危險!外部可修改
}
}
// 安全:防禦性拷貝
public class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime()); // 拷貝
this.end = new Date(end.getTime());
}
public Date getStart() {
return new Date(start.getTime()); // 拷貝
}
}使用不可變集合#
// 危險:回傳可變集合
public List<String> getNames() {
return names;
}
// 安全:回傳不可變視圖
public List<String> getNames() {
return Collections.unmodifiableList(names);
}
// 更安全:回傳拷貝
public List<String> getNames() {
return new ArrayList<>(names);
}
// Java 9+:使用不可變工廠方法
List<String> immutable = List.of("a", "b", "c");敏感資訊處理#
敏感資訊類型#
- 密碼、金鑰
- 個人身份資訊(PII)
- 信用卡號
- 健康資訊
處理原則#
1. 最小化保存
// 不要記錄敏感資訊
logger.info("User login: " + username + ", password: " + password); // 危險!
logger.info("User login: " + username); // 安全2. 使用 char[] 而非 String 存儲密碼
// String 是不可變的,會在記憶體中駐留
String password = "secret"; // 不推薦
// char[] 可以被清除
char[] password = new char[]{'s','e','c','r','e','t'};
try {
authenticate(password);
} finally {
Arrays.fill(password, '\0'); // 清除
}3. 加密存儲
// 密碼應該雜湊存儲,不是加密
String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt());
// 驗證時比較雜湊值
if (BCrypt.checkpw(inputPassword, hashedPassword)) {
// 密碼正確
}合約式設計與進攻式編程#
縱深防禦回答「築幾道牆」,合約式設計(Design by Contract)回答「每道牆由誰負責」。釐清責任邊界,才不會處處重複檢查、或處處假設別人檢查過。
前置條件、後置條件、不變量#
| 概念 | 由誰保證 | 含義 |
|---|---|---|
| 前置條件(precondition) | 呼叫者 | 呼叫前必須成立的條件(如參數為正數) |
| 後置條件(postcondition) | 被呼叫者 | 函式正常返回時保證成立(如回傳值非負) |
| 不變量(invariant) | 物件自身 | 物件存續期間始終維持(如餘額不為負) |
public final class Account {
private BigDecimal balance; // 不變量:balance >= 0
public void withdraw(BigDecimal amount) {
// 前置條件:呼叫者的義務
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (amount.compareTo(balance) > 0) {
throw new InsufficientBalanceException();
}
balance = balance.subtract(amount);
// 不變量檢查:開發期暴露違例
assert balance.signum() >= 0 : "invariant violated: negative balance";
}
}進攻式編程:開發期暴露,產品期恢復#
防禦的「力度」應隨階段不同:
- 開發/測試期(進攻式):讓所有異常盡量大聲炸出來。用
assert立即中止、對「不可能」的分支拋例外、寧可崩潰也不要默默吞錯。錯誤越早、越吵,越好抓。 - 產品期(防禦式):對使用者優雅恢復——記錄日誌、回退到安全狀態、給出友善訊息,而非整個崩潰。
switch (status) {
case ACTIVE: return handleActive();
case CLOSED: return handleClosed();
default:
// 「不該發生」的分支:開發期就要炸出來
assert false : "unexpected status: " + status;
// 產品期 assert 關閉時的保底:記錄並安全降級
logger.error("unexpected status {}", status);
return safeDefault();
}Java 的
assert預設關閉(需-ea啟用),天然契合「開發期進攻、產品期關閉」的策略。但因此assert只能用於檢查「程式內部假設」,絕不可用來驗證外部輸入——那必須用if + throw,永遠生效。
防禦分層:公開介面驗證 vs 內部 assert#
不要每一層都重複完整驗證,那本身會變成維護負擔(過度防禦)。分層處理:
flowchart LR
A[外部輸入] --> B[公開介面<br>if + throw 嚴格驗證]
B --> C[內部私有方法<br>assert 檢查假設]
C --> D[核心邏輯<br>信任已驗證的資料]- 公開介面 / 信任邊界:對外部輸入嚴格驗證(
if + throw),這是必守防線。 - 內部私有方法:假設資料已被上游驗證,僅用
assert抓「程式設計錯誤」。
以 null/Optional 取代魔法值#
用 -1、Integer.MAX_VALUE、空字串等「魔法值」代表「沒有值」是常見 bug 來源——呼叫者很容易忘記判斷,把哨兵值當成真實資料拿去計算。
// 壞味道:-1 代表「找不到」,呼叫者極易漏判
int findPrice(String sku) {
return found ? price : -1; // 若忘記檢查 -1,價格就錯了
}
// 改善:以 Optional 讓型別系統強制處理「可能沒有值」
Optional<Integer> findPrice(String sku) {
return found ? Optional.of(price) : Optional.empty();
}
int price = findPrice(sku)
.orElseThrow(() -> new SkuNotFoundException(sku));
Optional適合用於「回傳值可能不存在」的語意;不建議用於欄位或集合元素。集合本身用空集合(List.of())表達「沒有元素」,而非null,呼叫者就無需到處做 null 檢查。
繼承的安全缺陷#
問題#
子類別可以破壞父類別的約定:
public class SecureList<E> extends ArrayList<E> {
@Override
public boolean add(E e) {
// 子類別可以繞過安全檢查
return super.add(e);
}
}解決方案#
1. 使用 final 防止繼承
public final class ImmutablePoint {
private final int x;
private final int y;
}2. 優先使用組合而非繼承
// 使用組合
public class SecureList<E> {
private final List<E> list = new ArrayList<>();
public boolean add(E e) {
// 完全控制行為
if (isValid(e)) {
return list.add(e);
}
return false;
}
}信任邊界#
什麼是信任邊界?#
信任邊界劃分了可信區域和不可信區域。來自不可信區域的資料必須驗證。
flowchart LR
subgraph 不可信區域
U[使用者輸入]
A[外部 API]
F[檔案上傳]
end
subgraph 信任邊界
V[驗證層]
end
subgraph 可信區域
B[業務邏輯]
end
U --> V
A --> V
F --> V
V --> B
style 不可信區域 fill:#ffcdd2
style 信任邊界 fill:#fff9c4
style 可信區域 fill:#c8e6c9輸入驗證原則#
永遠不要信任外部輸入
public void processInput(String input) {
// 1. 非空檢查
if (input == null || input.isEmpty()) {
throw new IllegalArgumentException("Input cannot be empty");
}
// 2. 長度限制
if (input.length() > MAX_LENGTH) {
throw new IllegalArgumentException("Input too long");
}
// 3. 格式驗證
if (!VALID_PATTERN.matcher(input).matches()) {
throw new IllegalArgumentException("Invalid format");
}
// 4. 編碼/轉義
String safeInput = encode(input);
// 現在可以安全使用
process(safeInput);
}常見攻擊與防禦#
| 攻擊類型 | 防禦措施 |
|---|---|
| SQL 注入 | 使用參數化查詢 |
| XSS | HTML 編碼輸出 |
| 路徑遍歷 | 白名單驗證路徑 |
| 命令注入 | 避免 shell 命令,使用 API |
// SQL 注入防禦
// 危險
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
// 安全:參數化查詢
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE name = ?");
stmt.setString(1, name);序列化的危害#
風險#
Java 序列化存在嚴重的安全問題:
- 反序列化可執行任意程式碼
- 繞過建構函式
- 破壞單例模式
Java 原生序列化是安全隱患的主要來源,應盡量避免使用。
安全替代方案#
// 避免:Java 原生序列化
ObjectOutputStream.writeObject(obj);
ObjectInputStream.readObject();
// 推薦:使用 JSON
String json = objectMapper.writeValueAsString(obj);
MyClass obj = objectMapper.readValue(json, MyClass.class);
// 推薦:使用 Protocol Buffers
byte[] bytes = myProto.toByteArray();
MyProto proto = MyProto.parseFrom(bytes);如果必須使用序列化#
public class SafeClass implements Serializable {
private static final long serialVersionUID = 1L;
// 使用序列化代理
private Object writeReplace() {
return new SerializationProxy(this);
}
// 防止直接反序列化
private void readObject(ObjectInputStream stream)
throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
private static class SerializationProxy implements Serializable {
// 代理實作
}
}程式碼權限控制#
最小權限原則#
程式碼應該只擁有完成任務所需的最小權限。
// 不好:請求所有權限
System.setSecurityManager(null);
// 好:精確指定所需權限
grant codeBase "file:/app/lib/*" {
permission java.io.FilePermission "/data/-", "read";
permission java.net.SocketPermission "api.example.com:443", "connect";
};敏感操作保護#
public void sensitiveOperation() {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new MyPermission("sensitive"));
}
// 執行敏感操作
}縱深防禦策略#
不依賴單一防線,建立多層防禦:
flowchart TB
L1[第一層:輸入驗證] --> L2[第二層:認證授權]
L2 --> L3[第三層:安全編碼]
L3 --> L4[第四層:加密傳輸]
L4 --> L5[第五層:日誌審計]
L5 --> L6[第六層:監控告警]
style L1 fill:#ffcdd2
style L2 fill:#ffe0b2
style L3 fill:#fff9c4
style L4 fill:#c8e6c9
style L5 fill:#b3e5fc
style L6 fill:#e1bee7每一層都假設其他層可能失效。
安全編碼檢查清單#
安全編碼清單
輸入處理
- 驗證所有外部輸入
- 使用白名單而非黑名單
- 限制輸入長度
- 編碼/轉義輸出
資料保護
- 敏感資料加密存儲
- 密碼使用雜湊(bcrypt/scrypt)
- 傳輸使用 TLS
- 不在日誌中記錄敏感資訊
認證授權
- 實施最小權限原則
- 工作階段逾時機制
- 防止暴力破解
程式碼安全
- 使用安全的整數運算
- 防禦性拷貝
- 避免原生序列化
- 及時釋放資源
錯誤處理
- 不暴露堆疊追蹤給使用者
- 記錄安全相關事件
- 實施適當的錯誤訊息
最佳實踐總結#
- 安全是設計問題,不是測試問題 - 從設計階段就考慮安全
- 縱深防禦 - 不依賴單一防線
- 最小權限 - 只給必要的權限
- 不信任原則 - 驗證所有外部輸入
- 保持更新 - 及時修補已知漏洞
- 安全審計 - 定期 Code Review 關注安全
安全是每個開發者的責任,不只是安全團隊的事。