在 .NET Core + Vue.js 專案中實作 CSRF 防護
前言
最近在進行安全性掃描時,發現了 CSRF (Cross-Site Request Forgery) 的漏洞。本文將分享如何在 .NET Core Web API 搭配 Vue.js 的專案中實作完整的 CSRF 防護機制,並提供詳細的最佳實作建議。
CSRF 是什麼?
CSRF 是一種強迫使用者在他們已經通過身份驗證的網站上執行非預期操作的攻擊。
攻擊場景示例
考慮以下實際場景:
- 使用者登入了網路銀行
- 同時瀏覽了含有惡意程式碼的網站
- 惡意網站自動發送請求到網路銀行
- 由於使用者已登入,請求會帶著有效的 Cookie 執行
實際攻擊範例
1 2 3 4 5 6 7 8
| <form action="https://bank.example.com/transfer" method="POST" id="hack-form"> <input type="hidden" name="amount" value="1000000"> <input type="hidden" name="to" value="hacker-account"> </form> <script> document.getElementById('hack-form').submit(); </script>
|
安全性考量
Cookie 設定說明
SameSite 設定
Strict: 最嚴格的設定,僅允許來自同一網站的請求
Lax: 較寬鬆的設定,允許部分跨站請求(如連結點擊)
- 建議使用
Strict 以提供最高安全性
HttpOnly 與 CSRF Token
- CSRF Token Cookie 需設定
HttpOnly = false
- 原因:前端 JavaScript 需要讀取 Token 值
- 其他敏感 Cookie 應設定
HttpOnly = true
Token 生命週期管理
1 2 3 4 5 6 7 8 9
| builder.Services.AddAntiforgery(options => { options.Cookie.Name = "X-CSRF-TOKEN"; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.HttpOnly = false; options.Cookie.SameSite = SameSiteMode.Strict; options.HeaderName = "X-XSRF-TOKEN"; options.ExpireTimeSpan = TimeSpan.FromHours(1); });
|
.NET Core 的設定
1. Program.cs 配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery(options => { options.Cookie.Name = "X-CSRF-TOKEN"; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.HttpOnly = false; options.Cookie.SameSite = SameSiteMode.Strict; options.HeaderName = "X-XSRF-TOKEN"; });
builder.Services.AddCors(options => { options.AddPolicy("VueCorsPolicy", policy => { policy.WithOrigins("http://localhost:8080") .AllowCredentials() .AllowAnyHeader() .AllowAnyMethod(); }); });
|
2. CSRF Token 中介軟體
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| public class CsrfTokenMiddleware { private readonly RequestDelegate _next; private readonly IAntiforgery _antiforgery; private readonly ILogger<CsrfTokenMiddleware> _logger;
public CsrfTokenMiddleware( RequestDelegate next, IAntiforgery antiforgery, ILogger<CsrfTokenMiddleware> logger) { _next = next; _antiforgery = antiforgery; _logger = logger; }
public async Task InvokeAsync(HttpContext context) { try { if (context.Request.Method == "GET") { var tokens = _antiforgery.GetAndStoreTokens(context); context.Response.Cookies.Append("X-CSRF-TOKEN", tokens.RequestToken, new CookieOptions { HttpOnly = false, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.Now.AddHours(1) }); } } catch (Exception ex) { _logger.LogError($"CSRF Token generation failed: {ex.Message}"); throw new Exception("Security token generation failed"); }
await _next(context); } }
|
3. 自訂驗證 Attribute
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| public class ValidateCsrfTokenAttribute : TypeFilterAttribute { public ValidateCsrfTokenAttribute() : base(typeof(CsrfTokenFilter)) { }
private class CsrfTokenFilter : IAsyncActionFilter { private readonly IAntiforgery _antiforgery; private readonly ILogger<CsrfTokenFilter> _logger;
public CsrfTokenFilter( IAntiforgery antiforgery, ILogger<CsrfTokenFilter> logger) { _antiforgery = antiforgery; _logger = logger; }
public async Task OnActionExecutionAsync( ActionExecutingContext context, ActionExecutionDelegate next) { try { await _antiforgery.ValidateRequestAsync(context.HttpContext); await next(); } catch (AntiforgeryValidationException ex) { _logger.LogWarning($"CSRF validation failed: {ex.Message}"); _logger.LogDebug($"Request headers: {string.Join(", ", context.HttpContext.Request.Headers)}"); context.Result = new BadRequestObjectResult( new { error = "Invalid security token" }); } } } }
|
4. API Controller 實作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| [ApiController] [Route("api/[controller]")] public class UsersController : ControllerBase { private readonly IUserService _userService; private readonly ILogger<UsersController> _logger;
public UsersController( IUserService userService, ILogger<UsersController> logger) { _userService = userService; _logger = logger; }
[HttpPost] [ValidateCsrfToken] public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest request) { try { var result = await _userService.CreateUserAsync(request); return Ok(result); } catch (Exception ex) { _logger.LogError($"Create user failed: {ex.Message}"); return BadRequest(new { error = "Failed to create user" }); } }
[HttpPost("upload")] [ValidateCsrfToken] [RequestSizeLimit(100_000_000)] public async Task<IActionResult> UploadFile(IFormFile file) { } }
|
Vue.js 的設定
1. Axios 實例配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import axios from 'axios'; import { handleApiError } from '@/utils/errorHandler';
const api = axios.create({ baseURL: process.env.VUE_APP_API_URL, withCredentials: true });
api.interceptors.request.use( config => { const token = document.cookie .split('; ') .find(row => row.startsWith('X-CSRF-TOKEN=')) ?.split('=')[1];
if (token) { config.headers['X-XSRF-TOKEN'] = token; } else { console.warn('CSRF token not found'); }
return config; }, error => { console.error('Request error:', error); return Promise.reject(error); } );
api.interceptors.response.use( response => response, error => { if (error.response?.status === 400 && error.response?.data?.error?.includes('security token')) { console.error('Security token validation failed'); window.location.reload(); } return handleApiError(error); } );
export default api;
|
2. Vue 元件實作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| <template> <form @submit.prevent="submitForm" class="user-form"> <div v-if="error" class="error-message">{{ error }}</div> <input v-model="form.username" placeholder="使用者名稱" required /> <input v-model="form.email" type="email" placeholder="電子郵件" required /> <button type="submit" :disabled="loading"> {{ loading ? '處理中...' : '建立使用者' }} </button> </form> </template>
<script> import api from '@/api/axios';
export default { name: 'UserForm', data() { return { form: { username: '', email: '' }, loading: false, error: null } }, methods: { async submitForm() { this.loading = true; this.error = null; try { const response = await api.post('/api/users', this.form); this.$emit('user-created', response.data); this.resetForm(); } catch (error) { this.error = error.response?.data?.error || '建立使用者失敗'; } finally { this.loading = false; } }, resetForm() { this.form.username = ''; this.form.email = ''; } } } </script>
|
3. 錯誤處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| export const handleApiError = (error) => { if (error.response) { switch (error.response.status) { case 400: if (error.response.data.error.includes('security token')) { console.error('Security token validation failed'); window.location.reload(); } break; case 401: router.push('/login'); break; case 403: break; default: console.error('API Error:', error.response.data); break; } } else if (error.request) { console.error('No response received:', error.request); } else { console.error('Request error:', error.message); } throw error; };
|
測試相關
1. 單元測試範例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| public class CsrfTokenMiddlewareTests { [Fact] public async Task Should_Add_Csrf_Token_For_Get_Request() { var context = new DefaultHttpContext(); context.Request.Method = "GET"; var antiforgery = Mock.Of<IAntiforgery>(); var logger = Mock.Of<ILogger<CsrfTokenMiddleware>>(); var middleware = new CsrfTokenMiddleware( next: (innerContext) => Task.CompletedTask, antiforgery: antiforgery, logger: logger );
await middleware.InvokeAsync(context);
Assert.Contains( context.Response.Headers.SetCookie, cookie => cookie.StartsWith("X-CSRF-TOKEN") ); } }
|
2. 整合測試範例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| public class UserControllerTests : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory;
public UserControllerTests(WebApplicationFactory<Program> factory) { _factory = factory; }
[Fact] public async Task Should_Reject_Request_Without_Csrf_Token() { var client = _factory.CreateClient(); var request = new CreateUserRequest { Username = "test", Email = "test@example.com" };
var response = await client.PostAsJsonAsync("/api/users", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } }
|
效能最佳化
1. Token 快取策略
- 使用 HTTP Cache Headers 來快取 GET 請求的 Token
- 實作 Token 重用機制,避免每次請求都生成新 Token
- 適當設定 Token 過期時間,平衡安全性和使用者體驗
2. 多頁面應用的處理
- 使用 LocalStorage 暫存 Token(注意安全風險)
- 實作 Token 重新整理機制
- 處理頁面切換時的 Token 同步
常見問題排除
1. Token 驗證失敗
- 常見原因:
- Cookie 設定不正確
- 跨域設定問題
- Token 過期
- 請求標頭缺失
- 診斷步驟:
1 2 3 4 5 6 7 8 9 10 11 12
| document.cookie.split('; ').forEach(cookie => { if (cookie.startsWith('X-CSRF-TOKEN=')) { console.log('Found CSRF token:', cookie); } });
axios.interceptors.request.use(request => { console.log('Request headers:', request.headers); return request; });
|
2. 跨域問題解決
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| builder.Services.AddCors(options => { options.AddPolicy("VueCorsPolicy", policy => { policy.WithOrigins("http://localhost:8080") .AllowCredentials() .AllowAnyHeader() .AllowAnyMethod() .WithExposedHeaders("X-CSRF-TOKEN") .SetIsOriginAllowed(origin => { if (builder.Environment.IsDevelopment()) { return true; } return origin.EndsWith("yoursite.com"); }); }); });
|
3. 特殊請求處理
WebSocket 連接的 CSRF 防護
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const connectWebSocket = () => { const token = document.cookie .split('; ') .find(row => row.startsWith('X-CSRF-TOKEN=')) ?.split('=')[1];
const ws = new WebSocket( `wss://api.example.com/ws?csrf=${encodeURIComponent(token)}` ); ws.onopen = () => { console.log('WebSocket connected'); }; ws.onerror = (error) => { console.error('WebSocket error:', error); }; return ws; };
|
檔案上傳處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
|
<template> <div class="file-upload"> <input type="file" @change="handleFileChange" :accept="acceptTypes" /> <div v-if="progress" class="progress"> {{ progress }}% </div> </div> </template>
<script> import api from '@/api/axios';
export default { name: 'FileUpload', props: { acceptTypes: { type: String, default: '*/*' } }, data() { return { progress: 0 } }, methods: { async handleFileChange(event) { const file = event.target.files[0]; if (!file) return;
const formData = new FormData(); formData.append('file', file);
try { await api.post('/api/users/upload', formData, { onUploadProgress: (progressEvent) => { this.progress = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); } }); this.$emit('upload-complete'); } catch (error) { console.error('Upload failed:', error); this.$emit('upload-error', error); } } } } </script>
|
安全性最佳實作
1. 正式環境配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public void ConfigureProductionServices(IServiceCollection services) { services.AddAntiforgery(options => { options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromMinutes(20); options.Cookie.HttpOnly = false; options.Cookie.SameSite = SameSiteMode.Strict; options.RandomKey = RandomNumberGenerator.GetBytes(32); }); }
|
2. 安全標頭配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| app.Use(async (context, next) => { context.Response.Headers.Add( "X-Content-Type-Options", "nosniff"); context.Response.Headers.Add( "X-Frame-Options", "DENY"); context.Response.Headers.Add( "X-XSS-Protection", "1; mode=block"); context.Response.Headers.Add( "Referrer-Policy", "strict-origin-when-cross-origin"); await next(); });
|
3. 監控與日誌
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public class SecurityEventLogger { private readonly ILogger<SecurityEventLogger> _logger;
public SecurityEventLogger(ILogger<SecurityEventLogger> logger) { _logger = logger; }
public void LogCsrfValidationFailure(HttpContext context) { var logEvent = new { Timestamp = DateTime.UtcNow, IP = context.Connection.RemoteIpAddress?.ToString(), Path = context.Request.Path, Headers = context.Request.Headers .ToDictionary(h => h.Key, h => h.Value.ToString()) };
_logger.LogWarning( "CSRF validation failed: {@LogEvent}", logEvent); } }
|
效能優化建議
1. Token 管理優化
- 使用分散式快取(如 Redis)存儲 Token
- 實作 Token 輪換機制
- 使用記憶體快取減少資料庫查詢
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| public class TokenCacheService { private readonly IDistributedCache _cache; private readonly ILogger<TokenCacheService> _logger;
public TokenCacheService( IDistributedCache cache, ILogger<TokenCacheService> logger) { _cache = cache; _logger = logger; }
public async Task<string> GetOrCreateTokenAsync(string key) { var token = await _cache.GetStringAsync(key); if (token == null) { token = GenerateNewToken(); await _cache.SetStringAsync(key, token, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20) }); } return token; }
private string GenerateNewToken() { var randomBytes = new byte[32]; using (var rng = new RNGCryptoServiceProvider()) { rng.GetBytes(randomBytes); } return Convert.ToBase64String(randomBytes); } }
|
相關資源與參考
結論
實作 CSRF 防護是 Web 應用程式安全性的重要一環。通過本文介紹的實作方式,您可以:
- 建立完整的 CSRF 防護機制
- 正確處理各種特殊情況
- 實作適當的錯誤處理
- 優化效能與使用者體驗
記住,安全性是一個持續的過程,需要定期檢視和更新您的防護機制。建議定期進行安全性稽核,並持續關注最新的安全性最佳實作建議。