安全性掃描丟回來一張紅單
那次例行安全性掃描跑完,報告裡躺著一條 CSRF(Cross-Site Request Forgery)漏洞。專案是前後端分離:後端 .NET Core Web API,前端 Vue.js 跑在另一個來源(origin),用 Cookie 帶身分驗證。這種架構最容易踩到 CSRF,因為瀏覽器只要看到對應網域,就會自動把 Cookie 附上去,不管請求是誰發起的。
補這個洞花的時間比我預期久,主要卡在兩個地方:一是 ASP.NET Core 的 Antiforgery API 有不少網路文章寫錯,照抄根本編不過;二是 SameSite 設定跟前後端分離的架構互相打架,調了好幾次才搞懂。這篇把走過的彎路記下來,程式碼都實際跑過。
CSRF 到底在攻擊什麼
CSRF 利用的是「瀏覽器會自動帶 Cookie」這件事。攻擊不需要偷到你的帳密,它借用的是你「已經登入」的狀態。
典型流程是這樣:
- 使用者登入了網路銀行,瀏覽器存了驗證 Cookie
- 同個瀏覽器分頁打開了一個惡意網站
- 惡意網站偷偷送一個 POST 到網路銀行的轉帳端點
- 瀏覽器看到目標是網路銀行,就把驗證 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 對不對」,還要驗證一個攻擊者拿不到的祕密。這個祕密就是 Antiforgery Token——它存在一個 JavaScript 讀得到的 Cookie 裡,前端每次發請求時把它放進自訂 HTTP 標頭送回去。攻擊者的網站受同源政策限制,讀不到那個 Cookie,也就沒辦法偽造這個標頭。這套就是常見的 Double Submit Cookie 模式。
一個我繞了很久的坑:AntiforgeryOptions 到底有哪些屬性
先講最會害人的地方。網路上很多範例會這樣寫:
1 2 3 4 5
| builder.Services.AddAntiforgery(options => { options.HeaderName = "X-XSRF-TOKEN"; options.ExpireTimeSpan = TimeSpan.FromHours(1); options.RandomKey = RandomNumberGenerator.GetBytes(32); });
|
ExpireTimeSpan 和 RandomKey 都不是 AntiforgeryOptions 的成員,照抄會直接編譯失敗。查官方 API 文件確認過,AntiforgeryOptions 只有四個可設定屬性:
| 屬性 |
用途 |
預設值 |
Cookie |
設定存放 token 的 Cookie(名稱、SameSite、Secure 等) |
HttpOnly=true、SameSite=Strict |
FormFieldName |
表單欄位名稱 |
__RequestVerificationToken |
HeaderName |
從哪個標頭讀 token |
null(只看表單) |
SuppressXFrameOptionsHeader |
是否抑制 X-Frame-Options 標頭 |
false |
token 的有效期不是靠一個獨立的 ExpireTimeSpan 控制,而是綁在 Cookie 的存活期跟 Data Protection 金鑰的輪替週期上。金鑰也是由 ASP.NET Core 的 Data Protection 系統自動產生與管理,開發者不需要、也不能手動塞一把 RandomKey 進去。想控制 token 壽命,要去設定 Data Protection 的金鑰生命週期,或在寫入 JS-readable Cookie 時設 Expires,而不是去找 AntiforgeryOptions 上不存在的屬性。
正確的設定長這樣:
1 2 3 4 5 6 7 8 9 10
| builder.Services.AddAntiforgery(options => { options.Cookie.Name = ".AspNetCore.Antiforgery.App"; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Strict; options.HeaderName = "X-XSRF-TOKEN"; });
|
SameSite=Strict 跟前後端分離會打架
第二個坑更隱晦,因為它在開發機上「看起來正常」,上正式環境才爆。
很多文章(包括我一開始參考的)會說「SameSite 一律設 Strict,最安全」。這句話對單一網域的傳統網站成立,但對前後端分離的架構是錯的,會把整套防護弄到失效。
要先分清楚 site(站) 和 origin(來源) 的差別,因為 SameSite 跟 CORS 各看一個:
- SameSite 看的是 site:scheme + eTLD+1,連接埠不算。所以
http://localhost:8080 跟 http://localhost:5000 對 SameSite 來說是同一個 site,Strict 反而還會送 Cookie——這就是為什麼本機開發時用 Strict 常常「沒事」。
- CORS 看的是 origin:scheme + 網域 + 連接埠。換了埠就是不同 origin,所以你還是得設 CORS。
問題出在正式環境。實務上前端通常掛在 app.example.com、API 在 api.example.com,或乾脆是兩個不同網域。這時前端對 API 發的請求屬於跨站,瀏覽器在 SameSite=Strict 下會完全不帶這個 Cookie,前端 axios 攔截器自然讀不到 token,每個 POST 都被擋下來。本機跑得好好的,一上線全爛。
所以正確的判斷是看部署拓撲:
- 同站部署(前端與 API 在同一 eTLD+1,例如用反向代理把
/api 收到同網域底下):用 SameSite=Lax 或 Strict 都可以,這是最省事也最安全的做法,能擋掉絕大多數 CSRF。
- 真正的跨站部署(前端與 API 在不同 eTLD+1):JS-readable 的 token Cookie 必須設
SameSite=None 且 Secure=true,瀏覽器才會在跨站請求帶上它;同時後端 CORS 要明確列白名單並開 AllowCredentials。
我最後選了同站部署,用反向代理把 API 收進同一個網域,這樣 token Cookie 維持 SameSite=Lax 就夠,不必把保護降到 None。下面的 CORS 範例保留跨站情境的寫法,方便沒辦法同站部署的人參考。
另外提醒一個小陷阱:Secure=true(或 CookieSecurePolicy.Always)的 Cookie 在純 HTTP 下不會被瀏覽器寫入。本機若跑 http://localhost 又把 token Cookie 設成 Secure,會發現怎麼樣都讀不到。本機開發請用 HTTPS,或在開發環境把 SecurePolicy 放寬。
.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 = ".AspNetCore.Antiforgery.App"; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.HeaderName = "X-XSRF-TOKEN"; });
builder.Services.AddCors(options => { options.AddPolicy("VueCorsPolicy", policy => { policy.WithOrigins("http://localhost:8080") .AllowCredentials() .AllowAnyHeader() .AllowAnyMethod(); }); });
|
2. CSRF Token 中介軟體
關鍵觀念:GetAndStoreTokens 已經會把框架自己的 CookieToken 寫進 options.Cookie.Name 那個 Cookie。我們不要再去 Append 同名 Cookie——重複寫同名 Cookie 行為未定義,而且多餘。要做的是把 RequestToken 另外寫進一個 JS 讀得到的 Cookie(慣例叫 XSRF-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 49 50 51 52
| 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 (HttpMethods.IsGet(context.Request.Method)) { var tokens = _antiforgery.GetAndStoreTokens(context);
if (tokens.RequestToken is not null) { context.Response.Cookies.Append( "XSRF-TOKEN", tokens.RequestToken, new CookieOptions { HttpOnly = false, Secure = true, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddHours(1) }); } } } catch (Exception ex) { _logger.LogError(ex, "CSRF token generation failed"); throw; }
await _next(context); } }
|
注意這裡用 HttpMethods.IsGet(...) 取代手寫字串比對,避免大小寫問題;例外也直接 throw 而不是重包成 new Exception(...),後者會把堆疊資訊丟掉,正式環境的詳細錯誤本來就該交給全域例外處理器去過濾,不該在這裡硬塞。
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
| 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(ex, "CSRF validation failed"); context.Result = new BadRequestObjectResult( new { error = "Invalid security token" }); } } } }
|
把 [ValidateCsrfToken] 掛在會改變狀態的端點上(POST/PUT/DELETE),GET 不需要。
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 42
| [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(ex, "Create user failed"); return BadRequest(new { error = "Failed to create user" }); } }
[HttpPost("upload")] [ValidateCsrfToken] [RequestSizeLimit(100_000_000)] public async Task<IActionResult> UploadFile(IFormFile file) { return Ok(); } }
|
Vue.js 前端設定
1. Axios 攔截器
前端做兩件事:請求送出前,從 XSRF-TOKEN Cookie 撈出 token 塞進 X-XSRF-TOKEN 標頭;收到 token 失效的回應時重新整理。withCredentials: true 必須開,否則跨來源請求不會帶 Cookie。
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
| import axios from 'axios'; import { handleApiError } from '@/utils/errorHandler';
const api = axios.create({ baseURL: process.env.VUE_APP_API_URL, withCredentials: true });
function readCookie(name) { return document.cookie .split('; ') .find(row => row.startsWith(`${name}=`)) ?.split('=')[1]; }
api.interceptors.request.use( config => { const token = readCookie('XSRF-TOKEN'); if (token) { config.headers['X-XSRF-TOKEN'] = decodeURIComponent(token); } else { console.warn('CSRF token not found'); } return config; }, error => 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;
|
decodeURIComponent 別漏。RequestToken 含 base64 字元,寫進 Cookie 時會被 URL 編碼(+ 變 %2B 等),不解碼直接送回去,後端驗證會對不上,這個我也卡過。
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
| <!-- 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
| import router from '@/router';
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; };
|
測試
單元測試:中介軟體有寫出 token Cookie
這段測試有兩個容易踩的雷。第一,建構子第一個參數型別是 RequestDelegate,直接丟一個沒型別標註的 lambda,C# 推不出來會報 cannot convert lambda to RequestDelegate,要明確轉型。第二,Mock.Of<IAntiforgery>() 的 GetAndStoreTokens 預設回傳 null,中介軟體裡讀 tokens.RequestToken 會 NRE,所以要把回傳值 setup 好:
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
| public class CsrfTokenMiddlewareTests { [Fact] public async Task Should_Write_Xsrf_Cookie_For_Get_Request() { var context = new DefaultHttpContext(); context.Request.Method = "GET";
var tokenSet = new AntiforgeryTokenSet( requestToken: "test-request-token", cookieToken: "test-cookie-token", formFieldName: "__RequestVerificationToken", headerName: "X-XSRF-TOKEN");
var antiforgery = new Mock<IAntiforgery>(); antiforgery .Setup(a => a.GetAndStoreTokens(It.IsAny<HttpContext>())) .Returns(tokenSet);
var logger = Mock.Of<ILogger<CsrfTokenMiddleware>>();
var middleware = new CsrfTokenMiddleware( next: (RequestDelegate)(_ => Task.CompletedTask), antiforgery: antiforgery.Object, logger: logger);
await middleware.InvokeAsync(context);
Assert.Contains( context.Response.Headers.SetCookie, cookie => cookie.StartsWith("XSRF-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
| 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); } }
|
常見排錯
token 驗證一直失敗
我自己卡過的幾個原因,從高頻到低頻:
- Cookie 沒被寫入:在純 HTTP 下設了
Secure=true,瀏覽器拒寫。改用 HTTPS 或開發環境放寬 SecurePolicy。
- SameSite 跟部署不符:跨站部署卻設了
Strict,跨站請求不帶 Cookie。改 None + Secure,或改成同站部署。
- **前端沒
decodeURIComponent**:URL 編碼過的 token 直接送回去對不上。
withCredentials 沒開:跨來源請求不帶 Cookie,後端收不到。
- **CORS 沒開
AllowCredentials**:瀏覽器擋下回應。注意 AllowCredentials() 不能跟 AllowAnyOrigin() 並用,必須明列來源。
診斷時先在瀏覽器主控台確認 Cookie 跟標頭到底有沒有送出去:
1 2 3 4 5 6 7 8 9 10
| console.log('XSRF cookie:', document.cookie .split('; ') .find(c => c.startsWith('XSRF-TOKEN=')));
api.interceptors.request.use(req => { console.log('X-XSRF-TOKEN:', req.headers['X-XSRF-TOKEN']); return req; });
|
跨域(CORS)設定
跨站部署時 CORS 要列白名單,正式環境別用 SetIsOriginAllowed(_ => true):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| builder.Services.AddCors(options => { options.AddPolicy("VueCorsPolicy", policy => { if (builder.Environment.IsDevelopment()) { policy.WithOrigins("http://localhost:8080") .AllowCredentials() .AllowAnyHeader() .AllowAnyMethod(); } else { policy.WithOrigins("https://app.yoursite.com") .AllowCredentials() .AllowAnyHeader() .AllowAnyMethod(); } }); });
|
檔案上傳
FormData 走的是 multipart/form-data,攔截器照樣會帶上 X-XSRF-TOKEN 標頭,後端的 [ValidateCsrfToken] 一樣驗得到,不需要特別處理:
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
| <!-- 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: (e) => { this.progress = Math.round((e.loaded * 100) / e.total); } }); this.$emit('upload-complete'); } catch (error) { console.error('Upload failed:', error); this.$emit('upload-error', error); } } } }; </script>
|
順手把安全標頭也補上
掃描器除了 CSRF,通常也會抱怨缺安全標頭。加一個中介軟體統一處理。這裡有一個常被照抄的過時建議要避開:X-XSS-Protection。
1 2 3 4 5 6 7 8 9 10 11 12 13
| app.Use(async (context, next) => { var headers = context.Response.Headers; headers["X-Content-Type-Options"] = "nosniff"; headers["X-Frame-Options"] = "DENY"; headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; headers["X-XSS-Protection"] = "0"; headers["Content-Security-Policy"] = "default-src 'self'";
await next(); });
|
X-XSS-Protection: 1; mode=block 是很多舊文章還在貼的寫法,但 Chrome 早就移除了 XSS Auditor、Firefox 從沒實作、Edge 也退役了它的 XSS 過濾器。更糟的是這個標頭在某些情境反而會引入新的漏洞,所以 OWASP 跟 MDN 現在的建議是設成 0 把它關掉,把真正的防護交給 CSP。這裡用 headers["X"] = "Y" 賦值而不是 Headers.Add(...),後者在 key 已存在時某些版本會丟例外。
如果要更嚴謹地管理金鑰輪替與 token 壽命,可以另外設定 Data Protection 的金鑰生命週期,例如把金鑰存進 Redis 並設輪替週期:
1 2 3
| builder.Services.AddDataProtection() .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys") .SetDefaultKeyLifetime(TimeSpan.FromDays(14));
|
需要產生其他用途的隨機字串時,直接用 RandomNumberGenerator,別再用已被標記過時(SYSLIB0023)的 RNGCryptoServiceProvider:
1 2 3
| var randomBytes = RandomNumberGenerator.GetBytes(32); var token = Convert.ToBase64String(randomBytes);
|
收尾
這套補完之後重跑掃描,CSRF 那條紅單消失了。回頭看,最該記住的不是哪段程式碼,而是三個判斷:
- API 要照官方文件確認,不要照網路範例抄。
AntiforgeryOptions.ExpireTimeSpan、RandomKey 這類不存在的屬性在中文教學裡到處都是,抄了直接編不過,浪費的是你 debug 的時間。
- SameSite 要看部署拓撲,不是一律 Strict。同站部署 Lax 就夠;真跨站才需要 None + Secure,而且本機跑得起來不代表正式環境沒事。
- 安全建議會過期。
X-XSS-Protection、RNGCryptoServiceProvider 都是當年正確、現在已被棄用的例子,掃描器的舊規則也可能跟著一起過時。
CSRF 防護不是設一次就一勞永逸的東西,但只要把 token 流向跟 Cookie 屬性這兩件事弄清楚,剩下的就是照部署環境把參數調對而已。