在 .NET Core + Vue.js 專案中實作 CSRF 防護

前言

最近在進行安全性掃描時,發現了 CSRF (Cross-Site Request Forgery) 的漏洞。本文將分享如何在 .NET Core Web API 搭配 Vue.js 的專案中實作完整的 CSRF 防護機制,並提供詳細的最佳實作建議。

CSRF 是什麼?

CSRF 是一種強迫使用者在他們已經通過身份驗證的網站上執行非預期操作的攻擊。

攻擊場景示例

考慮以下實際場景:

  1. 使用者登入了網路銀行
  2. 同時瀏覽了含有惡意程式碼的網站
  3. 惡意網站自動發送請求到網路銀行
  4. 由於使用者已登入,請求會帶著有效的 Cookie 執行

實際攻擊範例

1
2
3
4
5
6
7
8
<!-- 惡意網站的 HTML -->
<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>

安全性考量

  1. SameSite 設定

    • Strict: 最嚴格的設定,僅允許來自同一網站的請求
    • Lax: 較寬鬆的設定,允許部分跨站請求(如連結點擊)
    • 建議使用 Strict 以提供最高安全性
  2. HttpOnly 與 CSRF Token

    • CSRF Token Cookie 需設定 HttpOnly = false
    • 原因:前端 JavaScript 需要讀取 Token 值
    • 其他敏感 Cookie 應設定 HttpOnly = true
  3. 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";
    // 設定 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);

// 加入 Antiforgery 服務
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";
});

// 配置 CORS
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
// Middleware/Csrf/CsrfTokenMiddleware.cs
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
{
// 只為 GET 請求生成新的 Token
if (context.Request.Method == "GET")
{
// 生成 Token 並存入 Cookie
var tokens = _antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("X-CSRF-TOKEN",
tokens.RequestToken,
new CookieOptions
{
HttpOnly = false,
Secure = true,
SameSite = SameSiteMode.Strict,
// 設定 Cookie 過期時間
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
// Attributes/Csrf/ValidateCsrfTokenAttribute.cs
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
// Controllers/UsersController.cs
[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)] // 100MB 限制
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
// src/api/axios.js
import axios from 'axios';
import { handleApiError } from '@/utils/errorHandler';

// 建立 axios 實例
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')) {
// 處理 CSRF 錯誤
console.error('Security token validation failed');
// 重新整理 token
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
// src/components/UserForm.vue
<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
// src/utils/errorHandler.js
export const handleApiError = (error) => {
if (error.response) {
switch (error.response.status) {
case 400:
if (error.response.data.error.includes('security token')) {
// CSRF 錯誤處理
console.error('Security token validation failed');
// 觸發重新整理 token 的邏輯
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
// Tests/CsrfTokenMiddlewareTests.cs
public class CsrfTokenMiddlewareTests
{
[Fact]
public async Task Should_Add_Csrf_Token_For_Get_Request()
{
// Arrange
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
);

// Act
await middleware.InvokeAsync(context);

// Assert
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
// Tests/Integration/UserControllerTests.cs
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()
{
// Arrange
var client = _factory.CreateClient();
var request = new CreateUserRequest
{
Username = "test",
Email = "test@example.com"
};

// Act
var response = await client.PostAsJsonAsync("/api/users", request);

// Assert
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
    // 在瀏覽器控制台檢查 Cookie
    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
// 完整的 CORS 配置
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
// WebSocket 連接範例
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
// 檔案上傳元件
// src/components/FileUpload.vue
<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 =>
{
// 強制使用 HTTPS
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
// Token 快取服務
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 應用程式安全性的重要一環。通過本文介紹的實作方式,您可以:

  1. 建立完整的 CSRF 防護機制
  2. 正確處理各種特殊情況
  3. 實作適當的錯誤處理
  4. 優化效能與使用者體驗

記住,安全性是一個持續的過程,需要定期檢視和更新您的防護機制。建議定期進行安全性稽核,並持續關注最新的安全性最佳實作建議。