系列文章: 前端工程師的 Modern Web 實踐之道 - Day 17
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前一篇文章中,我們深入探討了程式碼品質保證的工程化實踐。今天我們將聚焦於一個常被忽視但極其重要的主題——前端安全。這個主題將幫助你建立完整的 Web 應用安全防護體系,避免常見的安全漏洞,保護使用者資料和隱私。
許多前端工程師認為「安全是後端的責任」,這是一個危險的誤解。事實上:
XSS 是最常見的前端安全漏洞,攻擊者通過注入惡意腳本來竊取使用者資料或執行未授權操作。
三種 XSS 類型:
CSRF 攻擊利用使用者的已登入狀態,在使用者不知情的情況下發送惡意請求。
/**
* HTML 特殊字元編碼 - 防止 XSS 注入
* @param {string} str - 需要編碼的字串
* @returns {string} 編碼後的安全字串
*/
function escapeHtml(str) {
const htmlEscapeMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/'
};
return String(str).replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char]);
}
/**
* 安全的 DOM 更新函式
* @param {HTMLElement} element - 目標元素
* @param {string} content - 要插入的內容
*/
function safeSetContent(element, content) {
// 使用 textContent 而非 innerHTML
element.textContent = content;
}
/**
* 如果必須使用 HTML,請使用 DOMPurify 清理
*/
import DOMPurify from 'dompurify';
function setSafeHTML(element, html) {
const cleanHTML = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title', 'target']
});
element.innerHTML = cleanHTML;
}
// 實際使用範例
const userInput = '<script>alert("XSS")</script><b>Hello</b>';
const safeContent = escapeHtml(userInput);
console.log(safeContent); // <script>alert("XSS")</script><b>Hello</b>
// 使用 DOMPurify 清理 HTML
const container = document.getElementById('content');
setSafeHTML(container, userInput); // 只保留 <b>Hello</b>,移除 script 標籤
import React, { useState } from 'react';
import DOMPurify from 'isomorphic-dompurify';
/**
* 安全的使用者評論組件
*/
interface CommentProps {
author: string;
content: string;
allowHTML?: boolean;
}
const SafeComment: React.FC<CommentProps> = ({
author,
content,
allowHTML = false
}) => {
// React 預設會轉義文字內容,這是安全的
if (!allowHTML) {
return (
<div className="comment">
<strong>{author}</strong>
<p>{content}</p>
</div>
);
}
// 如果必須支援 HTML,使用 DOMPurify 清理
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title', 'target'],
ALLOW_DATA_ATTR: false
});
return (
<div className="comment">
<strong>{author}</strong>
<div
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
</div>
);
};
/**
* 處理使用者輸入的表單組件
*/
const CommentForm: React.FC = () => {
const [comment, setComment] = useState('');
const [error, setError] = useState('');
/**
* 輸入驗證 - 第一道防線
*/
const validateInput = (input: string): boolean => {
// 檢查長度
if (input.length > 1000) {
setError('評論內容不能超過 1000 字');
return false;
}
// 檢查危險模式
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+=/i, // 事件處理器如 onclick=
/<iframe/i
];
for (const pattern of dangerousPatterns) {
if (pattern.test(input)) {
setError('評論包含不允許的內容');
return false;
}
}
setError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateInput(comment)) {
return;
}
try {
// 送到後端前再次清理
const sanitized = DOMPurify.sanitize(comment);
await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: sanitized })
});
setComment('');
} catch (err) {
console.error('提交失敗:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="請輸入評論..."
maxLength={1000}
/>
{error && <p className="error">{error}</p>}
<button type="submit">送出評論</button>
</form>
);
};
export { SafeComment, CommentForm };
/**
* CSRF Token 管理器
*/
class CSRFTokenManager {
constructor() {
this.tokenKey = 'csrf_token';
this.tokenHeaderName = 'X-CSRF-Token';
}
/**
* 從 Cookie 或 Meta 標籤獲取 CSRF Token
*/
getToken() {
// 方法 1: 從 Meta 標籤讀取 (由後端渲染時注入)
const metaToken = document.querySelector('meta[name="csrf-token"]');
if (metaToken) {
return metaToken.getAttribute('content');
}
// 方法 2: 從 Cookie 讀取
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === this.tokenKey) {
return decodeURIComponent(value);
}
}
return null;
}
/**
* 為 fetch 請求添加 CSRF Token
*/
async secureFetch(url, options = {}) {
const token = this.getToken();
if (!token) {
throw new Error('CSRF token not found');
}
const secureOptions = {
...options,
headers: {
...options.headers,
[this.tokenHeaderName]: token,
},
credentials: 'same-origin', // 確保發送 Cookie
};
return fetch(url, secureOptions);
}
}
// 實際使用
const csrfManager = new CSRFTokenManager();
// 安全的 POST 請求
async function submitForm(data) {
try {
const response = await csrfManager.secureFetch('/api/user/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('請求失敗:', error);
throw error;
}
}
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
/**
* 創建安全的 Axios 實例
*/
function createSecureAxios(): AxiosInstance {
const instance = axios.create({
baseURL: '/api',
timeout: 10000,
withCredentials: true, // 發送 Cookie
});
// 請求攔截器 - 添加 CSRF Token
instance.interceptors.request.use(
(config) => {
// 只對非 GET 請求添加 CSRF Token
if (config.method && !['get', 'head', 'options'].includes(config.method.toLowerCase())) {
const token = getCSRFToken();
if (token) {
config.headers['X-CSRF-Token'] = token;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 回應攔截器 - 處理 CSRF 錯誤
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
const errorMessage = error.response.data?.message || '';
if (errorMessage.includes('CSRF')) {
// CSRF token 無效,可能需要重新整理頁面
console.error('CSRF token 驗證失敗,請重新整理頁面');
// 可以觸發重新載入或導向登入頁
}
}
return Promise.reject(error);
}
);
return instance;
}
function getCSRFToken(): string | null {
const metaToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
return metaToken?.content || null;
}
// 使用範例
const apiClient = createSecureAxios();
export async function updateUserProfile(data: any) {
try {
const response = await apiClient.post('/user/profile', data);
return response.data;
} catch (error) {
console.error('更新失敗:', error);
throw error;
}
}
/**
* 伺服器端 Cookie 設定範例 (Node.js/Express)
* 這是後端設定,但前端工程師需要了解
*/
// Express 中設定安全的 Session Cookie
app.use(session({
secret: process.env.SESSION_SECRET,
name: 'sessionId',
cookie: {
httpOnly: true, // 防止 JavaScript 存取 Cookie
secure: true, // 只在 HTTPS 連線中傳送
sameSite: 'strict', // 嚴格的同站策略,防止 CSRF
maxAge: 3600000, // 1 小時過期
domain: '.example.com', // 限制 Cookie 作用域
},
resave: false,
saveUninitialized: false
}));
// 設定 CSRF Token Cookie
app.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken(), {
httpOnly: false, // 允許 JavaScript 讀取 (用於發送請求)
secure: true,
sameSite: 'strict',
maxAge: 3600000
});
next();
});
CSP 是現代瀏覽器提供的強大安全機制,可以有效防止 XSS 攻擊。
/**
* CSP 設定範例 - 伺服器端 Header 設定
*/
// Express 中設定 CSP
const helmet = require('helmet');
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"], // 預設只允許同源資源
scriptSrc: [
"'self'", // 同源腳本
"'nonce-{RANDOM_NONCE}'", // 使用 nonce 的行內腳本
"https://cdn.jsdelivr.net", // 信任的 CDN
"https://www.google-analytics.com" // 分析工具
],
styleSrc: [
"'self'",
"'unsafe-inline'", // 允許行內樣式 (考慮使用 nonce 替代)
"https://fonts.googleapis.com"
],
imgSrc: [
"'self'",
"data:", // 允許 data: URI
"https:", // 允許 HTTPS 圖片
],
fontSrc: [
"'self'",
"https://fonts.gstatic.com"
],
connectSrc: [
"'self'",
"https://api.example.com", // API 伺服器
"wss://realtime.example.com" // WebSocket 連線
],
frameSrc: ["'none'"], // 不允許嵌入 iframe
objectSrc: ["'none'"], // 不允許 <object> 元素
baseUri: ["'self'"], // 限制 <base> 標籤
formAction: ["'self'"], // 限制表單提交目標
upgradeInsecureRequests: [], // 自動升級 HTTP 到 HTTPS
},
})
);
<!-- HTML 模板中使用 nonce -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- CSP Meta 標籤 (也可以用 HTTP Header) -->
<meta http-equiv="Content-Security-Policy"
content="script-src 'self' 'nonce-{RANDOM_NONCE}'">
</head>
<body>
<!-- 安全的行內腳本,使用 nonce 屬性 -->
<script nonce="{RANDOM_NONCE}">
// 這個腳本會被執行,因為 nonce 匹配
console.log('This is a safe inline script');
</script>
<script>
// 這個腳本會被 CSP 阻止,因為沒有 nonce
console.log('This will be blocked');
</script>
<!-- 外部腳本 -->
<script src="/js/app.js"></script>
</body>
</html>
/**
* 後端生成和注入 nonce 的範例
*/
const crypto = require('crypto');
app.use((req, res, next) => {
// 為每個請求生成唯一的 nonce
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
app.get('/', (req, res) => {
// 在模板中使用 nonce
res.render('index', {
nonce: res.locals.nonce
});
});
/**
* CSP 違規報告處理
*/
// 設定 CSP 報告端點
app.use(
helmet.contentSecurityPolicy({
directives: {
// ... 其他 CSP 設定
reportUri: '/api/csp-violation-report',
},
})
);
// 處理 CSP 違規報告
app.post('/api/csp-violation-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const violation = req.body['csp-report'];
// 記錄違規資訊
console.error('CSP Violation:', {
documentUri: violation['document-uri'],
violatedDirective: violation['violated-directive'],
blockedUri: violation['blocked-uri'],
sourceFile: violation['source-file'],
lineNumber: violation['line-number'],
columnNumber: violation['column-number'],
});
// 發送到日誌系統或監控平台
logToMonitoringService({
type: 'csp_violation',
...violation,
timestamp: new Date().toISOString(),
});
res.status(204).send();
});
/**
* 安全的本地儲存管理器
*/
class SecureStorage {
private readonly encryptionKey: string;
private readonly storagePrefix: string = 'secure_';
constructor(encryptionKey: string) {
this.encryptionKey = encryptionKey;
}
/**
* 簡單的 XOR 加密 (實際專案應使用 Web Crypto API)
*/
private encrypt(data: string): string {
let result = '';
for (let i = 0; i < data.length; i++) {
result += String.fromCharCode(
data.charCodeAt(i) ^ this.encryptionKey.charCodeAt(i % this.encryptionKey.length)
);
}
return btoa(result); // Base64 編碼
}
private decrypt(encryptedData: string): string {
const data = atob(encryptedData); // Base64 解碼
return this.encrypt(data); // XOR 加密是對稱的
}
/**
* 安全儲存資料
*/
setItem(key: string, value: any): void {
try {
const jsonData = JSON.stringify(value);
const encrypted = this.encrypt(jsonData);
localStorage.setItem(this.storagePrefix + key, encrypted);
} catch (error) {
console.error('儲存失敗:', error);
}
}
/**
* 安全讀取資料
*/
getItem<T>(key: string): T | null {
try {
const encrypted = localStorage.getItem(this.storagePrefix + key);
if (!encrypted) return null;
const decrypted = this.decrypt(encrypted);
return JSON.parse(decrypted) as T;
} catch (error) {
console.error('讀取失敗:', error);
return null;
}
}
/**
* 移除資料
*/
removeItem(key: string): void {
localStorage.removeItem(this.storagePrefix + key);
}
/**
* 清空所有安全儲存的資料
*/
clear(): void {
Object.keys(localStorage)
.filter(key => key.startsWith(this.storagePrefix))
.forEach(key => localStorage.removeItem(key));
}
}
// 使用範例
const storage = new SecureStorage('your-secret-key-here');
// 儲存敏感資料
storage.setItem('user_preferences', {
theme: 'dark',
notifications: true
});
// 讀取資料
const preferences = storage.getItem<{theme: string; notifications: boolean}>('user_preferences');
/**
* ⚠️ 重要提醒:
* 1. 不要在前端儲存真正敏感的資料 (如密碼、信用卡號)
* 2. localStorage 可以被 XSS 攻擊讀取
* 3. 使用 httpOnly Cookie 儲存驗證 Token
* 4. 實際專案應使用 Web Crypto API 進行加密
*/
# 檢查專案中的安全漏洞
npm audit
# 自動修復可修復的漏洞
npm audit fix
# 查看詳細的漏洞報告
npm audit --json
# 使用 Snyk 進行更深入的安全掃描
npx snyk test
# 檢查套件的授權問題
npx license-checker
{
"scripts": {
"security-check": "npm audit && npx snyk test",
"deps-update": "npx npm-check-updates -u",
"precommit": "npm run security-check"
},
"devDependencies": {
"snyk": "^1.1000.0",
"npm-check-updates": "^16.0.0"
}
}
// .eslintrc.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:security/recommended'
],
plugins: ['security'],
rules: {
// 禁止使用 eval
'no-eval': 'error',
// 禁止使用 innerHTML
'no-unsanitized/property': 'error',
'no-unsanitized/method': 'error',
// 警告使用 dangerouslySetInnerHTML
'react/no-danger': 'warn',
// 檢測不安全的正規表達式
'security/detect-unsafe-regex': 'error',
// 檢測可能的 SQL 注入
'security/detect-non-literal-fs-filename': 'warn',
// 禁止動態 require
'security/detect-non-literal-require': 'error',
}
};
/**
* 生產環境安全 Header 設定
*/
const helmet = require('helmet');
app.use(helmet({
// Content Security Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{RANDOM}'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
// 防止點擊劫持
frameguard: {
action: 'deny'
},
// 防止 MIME 類型嗅探
noSniff: true,
// 啟用 HSTS
hsts: {
maxAge: 31536000, // 1 年
includeSubDomains: true,
preload: true
},
// 禁用 X-Powered-By header
hidePoweredBy: true,
// Referrer Policy
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
},
// Permissions Policy
permissionsPolicy: {
features: {
camera: ["'none'"],
microphone: ["'none'"],
geolocation: ["'self'"],
payment: ["'self'"],
}
}
}));
/**
* 安全的 API 客戶端 - 整合所有安全實踐
*/
import axios, { AxiosInstance, AxiosError } from 'axios';
import DOMPurify from 'isomorphic-dompurify';
class SecureAPIClient {
private client: AxiosInstance;
private csrfToken: string | null = null;
constructor(baseURL: string) {
this.client = axios.create({
baseURL,
timeout: 10000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
this.initCSRFToken();
}
/**
* 初始化 CSRF Token
*/
private initCSRFToken(): void {
const metaToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
this.csrfToken = metaToken?.content || null;
}
/**
* 設定請求/回應攔截器
*/
private setupInterceptors(): void {
// 請求攔截器
this.client.interceptors.request.use(
(config) => {
// 添加 CSRF Token
if (this.csrfToken && config.method !== 'get') {
config.headers['X-CSRF-Token'] = this.csrfToken;
}
// 清理輸入資料
if (config.data) {
config.data = this.sanitizeData(config.data);
}
return config;
},
(error) => Promise.reject(error)
);
// 回應攔截器
this.client.interceptors.response.use(
(response) => {
// 清理回應資料中的 HTML
if (response.data) {
response.data = this.sanitizeData(response.data);
}
return response;
},
(error: AxiosError) => {
this.handleError(error);
return Promise.reject(error);
}
);
}
/**
* 清理資料中的危險內容
*/
private sanitizeData(data: any): any {
if (typeof data === 'string') {
return DOMPurify.sanitize(data);
}
if (Array.isArray(data)) {
return data.map(item => this.sanitizeData(item));
}
if (typeof data === 'object' && data !== null) {
const sanitized: any = {};
for (const [key, value] of Object.entries(data)) {
sanitized[key] = this.sanitizeData(value);
}
return sanitized;
}
return data;
}
/**
* 統一錯誤處理
*/
private handleError(error: AxiosError): void {
if (error.response?.status === 403) {
console.error('CSRF 驗證失敗,請重新整理頁面');
} else if (error.response?.status === 401) {
console.error('未授權,請重新登入');
// 導向登入頁
window.location.href = '/login';
} else {
console.error('API 請求失敗:', error.message);
}
}
/**
* GET 請求
*/
async get<T>(url: string, params?: any): Promise<T> {
const response = await this.client.get<T>(url, { params });
return response.data;
}
/**
* POST 請求
*/
async post<T>(url: string, data?: any): Promise<T> {
const response = await this.client.post<T>(url, data);
return response.data;
}
/**
* PUT 請求
*/
async put<T>(url: string, data?: any): Promise<T> {
const response = await this.client.put<T>(url, data);
return response.data;
}
/**
* DELETE 請求
*/
async delete<T>(url: string): Promise<T> {
const response = await this.client.delete<T>(url);
return response.data;
}
}
// 使用範例
const apiClient = new SecureAPIClient('https://api.example.com');
export default apiClient;
前端安全重要性: 前端是使用者接觸的第一道防線,安全漏洞會直接影響使用者資料和信任。現代 Web 應用的複雜性使得前端安全變得更加關鍵。
XSS 防護策略: 通過輸入驗證、輸出編碼、使用 DOMPurify 清理 HTML、避免使用 innerHTML 和 eval 等危險 API,建立多層防護體系。現代框架如 React 提供了基礎防護,但開發者仍需保持警惕。
CSRF 防護機制: 實作 CSRF Token 驗證、設定 SameSite Cookie 屬性、使用 HTTP-only Cookie 儲存敏感資訊。結合 Axios 攔截器實現自動化的 CSRF 防護。
CSP 安全策略: Content Security Policy 是防止 XSS 的強大工具,通過限制資源載入來源、使用 nonce 機制、監控違規報告,建立完整的內容安全體系。
安全的資料儲存: 避免在前端儲存敏感資料,使用加密儲存非關鍵資訊,優先使用 httpOnly Cookie 儲存驗證 Token,了解 localStorage 和 sessionStorage 的安全限制。
依賴套件安全: 定期執行 npm audit 檢查漏洞,使用 Snyk 等工具進行深度掃描,及時更新有安全問題的依賴套件,建立自動化的安全檢查流程。
使用 dangerouslySetInnerHTML 而不清理內容
// ❌ 錯誤: 直接使用使用者輸入
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ✅ 正確: 使用 DOMPurify 清理
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
在 URL 中傳遞敏感資訊
// ❌ 錯誤: 敏感資訊暴露在 URL 中
fetch(`/api/user?password=${userPassword}`);
// ✅ 正確: 使用 POST 請求 body
fetch('/api/user', {
method: 'POST',
body: JSON.stringify({ password: userPassword })
});
硬編碼 API 金鑰
// ❌ 錯誤: API 金鑰暴露在前端程式碼中
const API_KEY = 'sk_live_abc123xyz';
// ✅ 正確: 通過後端 API 處理需要金鑰的請求
// 前端不應該直接持有敏感金鑰
安全與使用者體驗的平衡: 如何在加強安全防護的同時,不影響使用者的正常使用體驗?例如,過於嚴格的 CSP 可能會影響第三方整合,如何找到適當的平衡點?
零信任架構: 在前端應用中如何實踐「永不信任,始終驗證」的原則?對於微前端架構,如何確保不同子應用之間的安全隔離?
新興威脅應對: 隨著 Web3、AI 等新技術的發展,前端安全面臨哪些新的挑戰?如何為智能合約互動、AI 生成內容等新場景建立安全防護?
實踐挑戰: