Là gì
Rò rỉ thông tin là khi ứng dụng tiết lộ những gì kẻ tấn công cần để tấn công tiếp: stack trace, phiên bản framework, đường dẫn nội bộ, tên bảng, khoá API trong bundle JavaScript, hay chỉ là sự khác nhau giữa hai thông báo lỗi. Bản thân nó ít khi là sự cố; nó là bước một của mọi sự cố khác.
Vì sao bạn quan tâm
Đây là topic dễ bị coi nhẹ nhất, và lý do là mức độ của nó luôn được đánh giá đơn lẻ: "chỉ là một stack trace" — CVSS thấp, đóng ticket. Nhưng giá trị của nó nằm ở chỗ nó biến việc đoán thành việc biết:
- Biết phiên bản framework → tra đúng CVE thay vì thử hàng trăm payload
- Biết tên bảng và tên cột từ một lỗi SQL → viết
UNION SELECTđúng ngay lần đầu - Biết đường dẫn tuyệt đối từ một stack trace → payload path traversal chính xác
- Biết email nào có tài khoản → password spraying vào một danh sách thật, không phải danh sách đoán
Ba đặc điểm khiến nó phổ biến hơn mọi topic khác:
- Nó là mặc định. Rò rỉ là cái xảy ra khi không ai làm gì cả:
ASPNETCORE_ENVIRONMENTđặt sai,.git/đi theo lúc deploy,Server:header của framework,sourceMappingURLtrong bundle production. - Nó không có triệu chứng. Không có gì hỏng, không có exception, không có ai báo lỗi. Ứng dụng chạy hoàn toàn bình thường trong lúc rò.
- Kênh rò thường là chính thứ giúp bạn debug. Cùng một stack trace vừa cứu bạn lúc 2 giờ sáng vừa là bản đồ cho người khác. Nên bản vá không phải "bỏ thông tin đi" mà là đổi hướng nó: vào log, không vào response.
Cơ chế hoạt động
Không có một cơ chế; có năm kênh, và biết năm kênh này là cách đi tìm chúng có hệ thống.
flowchart LR A[Kẻ tấn công] --> C1["① Thông báo lỗi<br/>stack trace, lỗi SQL"] A --> C2["② Header response<br/>Server, X-Powered-By, X-AspNet-Version"] A --> C3["③ File lộ ra<br/>.git/, .env, .DS_Store, backup~, /debug"] A --> C4["④ Client-side<br/>source map, khoá API, comment, endpoint chưa dùng"] A --> C5["⑤ Kênh phụ<br/>chênh lệch thời gian, khác biệt mã lỗi"] C1 --> M["Bản đồ hệ thống"] C2 --> M C3 --> M C4 --> M C5 --> M M --> X["Tấn công có mục tiêu"]| Kênh | Ví dụ cụ thể | Tìm nó bằng cách nào |
|---|---|---|
| ① Lỗi | Npgsql.PostgresException: relation "core.app_user" does not exist at /src/app/Repositories/UserRepository.cs:42 | Gửi input sai kiểu vào mọi endpoint |
| ② Header | Server: Kestrel, X-Powered-By: ASP.NET, X-AspNet-Version: 8.0.4 | curl -I |
| ③ File | /.git/config, /.env, /appsettings.Production.json, /backup.sql, /swagger ở production | Wordlist |
| ④ Client | //# sourceMappingURL=main.js.map, const STRIPE_KEY="sk_live_…" | Đọc bundle |
| ⑤ Kênh phụ | Email có tài khoản: 780ms; không có: 60ms | Đo thời gian |
Kênh ⑤ đáng nói riêng vì nó là kênh duy nhất không thể "bỏ thông tin đi" — thông tin nằm trong hành vi, không trong nội dung. Cơ chế: hàm đăng nhập kiểm email trước, thấy không có thì trả về ngay; thấy có thì chạy Argon2 để so mật khẩu. Argon2 cố tình chậm (~700ms), nên chênh lệch thời gian chính là câu trả lời cho câu hỏi "email này có tài khoản không" — và nó đúng ngay cả khi hai response giống nhau từng byte.
Biến thể của cùng kênh đó, không cần đo thời gian: POST /register với email đã tồn tại trả 409 "Email already in use", còn email mới trả 201. Đó là một API liệt kê người dùng, được ghi trong tài liệu.
Mô tả sơ đồ: Sơ đồ cho thấy kẻ tấn công thu thập thông tin qua năm kênh song song: thông báo lỗi chứa stack trace và lỗi SQL; header response như Server và X-Powered-By; file lộ ra như .git, .env, .DS_Store và trang swagger; dữ liệu phía client như source map, khoá API và endpoint chưa dùng; và kênh phụ như chênh lệch thời gian phản hồi hay khác biệt mã lỗi. Cả năm kênh cùng đóng góp vào một bản đồ hệ thống, và bản đồ đó dẫn tới một cuộc tấn công có mục tiêu thay vì đoán mò.
Ví dụ cụ thể
Bốn request vô hại, và cùng nhau chúng là một bản đồ.
# ① Gửi sai kiểu để lấy stack trace. Không cần payload nào.GET /api/orders/abc HTTP/1.1 HTTP/1.1 500 Internal Server Error System.FormatException: The input string 'abc' was not in a correct format. at SecLab.Api.Controllers.OrdersController.Get(Int64 id) in /src/app/…/OrdersController.cs:line 34 at Npgsql.NpgsqlConnection.Open() — Host=prod-db-01.internal;Database=seclab;Username=postgresBa thứ vừa lộ: đường dẫn tuyệt đối trên server, tên host DB nội bộ, và user DB là postgres (superuser).
# ② Header. Một lệnh, và bây giờ đã biết tra CVE nào.HEAD / HTTP/1.1 HTTP/1.1 200 OKServer: KestrelX-Powered-By: ASP.NETX-AspNet-Version: 8.0.4# ③ .git đi theo lúc deploy. Đây là toàn bộ source code và toàn bộ lịch sử commit.GET /.git/config HTTP/1.1 HTTP/1.1 200 OK[remote "origin"] url = https://x-token-auth:ATBB3xK…@bitbucket.org/acme/seclab.gitVà trong URL đó có một token truy cập repository.
# ⑤ Kênh phụ. Hai response giống nhau từng byte; thời gian thì không.for e in alice@acme.com khong-ton-tai@acme.com; do curl -s -o /dev/null -w "$e %{time_total}s\n" -X POST https://app.example.com/api/login \ -d "{\"email\":\"$e\",\"password\":\"x\"}" -H 'Content-Type: application/json'done# alice@acme.com 0.781s ← có tài khoản (Argon2 đã chạy)# khong-ton-tai@acme.com 0.058s ← không có (trả về ngay)// ── Kênh ① · Thông báo lỗi ──────────────────────────────────────────────────var app = builder.Build(); // ❌ Không có điều kiện IsDevelopment(). Một biến môi trường đặt sai ở MỘT môi// trường là đủ, và không dòng code nào ở đây "có lỗi" nên không test nào đỏ.app.UseDeveloperExceptionPage(); app.MapControllers(); // ── Kênh ① · và cùng lỗi ở tầng handler ─────────────────────────────────────[HttpGet("/api/orders/{id:long}")]public async Task<IActionResult> Get(long id, CancellationToken ct){ try { return Ok(await _orders.GetByIdAsync(id, _currentUser.UserId, ct)); } catch (Exception ex) { // ❌ ex.ToString() là stack trace ĐẦY ĐỦ: đường dẫn tuyệt đối trên server, // tên assembly, số dòng, và với lỗi Npgsql thì cả chuỗi kết nối. return StatusCode(500, new { error = ex.ToString() }); }} // ── Kênh ⑤ · Liệt kê người dùng qua thời gian ───────────────────────────────public async Task<LoginResult> LoginAsync(string email, string password, CancellationToken ct){ var user = await _users.FindByEmailAsync(email, ct); // ❌ TRẢ VỀ SỚM. Thông báo lỗi ở hai nhánh giống nhau từng byte, nên bản vá này // TRÔNG như đã đúng. Nhưng nhánh này về ngay (~50ms) còn nhánh dưới chạy // Argon2 (~700ms) — và 700ms là câu trả lời rõ ràng cho "email này có tài // khoản không". if (user is null) return LoginResult.Invalid; if (!_hasher.Verify(user.PasswordHash, password)) return LoginResult.Invalid; return LoginResult.Success(user);}Chuyện đã xảy ra
Uber, 2016 — 57 triệu người dùng. Điểm vào là một khoá AWS trong một repository GitHub riêng tư mà nhân viên vô tình đưa lên; từ đó kẻ tấn công đọc được backup trên S3. Đây là kênh ④ ở dạng mà mọi người coi là "không phải lỗ hổng ứng dụng" — và nó là điểm vào của một trong những sự cố được biết nhiều nhất thập kỷ, cộng thêm một bản án hình sự cho CSO vì che giấu.
Cả họ sự cố .git lộ ra công khai. Nhiều nghiên cứu quét Internet tìm /.git/HEAD trả 200 và tìm ra hàng trăm nghìn site — mỗi cái là toàn bộ source code cộng toàn bộ lịch sử commit, và lịch sử commit là nơi những secret đã bị "xoá" vẫn còn nguyên. Đáng nhớ vì nguyên nhân luôn là một dòng trong Dockerfile: COPY . .
Kênh ⑤ trong thực tế: nhiều báo cáo bug bounty công khai về liệt kê người dùng qua chênh lệch thời gian ở endpoint đăng nhập và reset mật khẩu. Không sự cố nào lớn, nhưng nó là thứ biến một danh sách email mua được thành một danh sách mục tiêu đã xác nhận cho credential stuffing.
Cách phòng chống
Đổi hướng thông tin, không bỏ nó đi — correlation id là bản vá
bắt buộcKhối 2 nói rằng kênh rò thường chính là thứ giúp bạn debug. Nên bản vá đúng không phải "trả về thông báo lỗi chung chung" — nó là giữ nguyên toàn bộ chi tiết, gửi vào log, và cho client một id để tra.
Response chứa: một mã lỗi ổn định (ER_ORDER_NOT_FOUND), một câu người đọc hiểu được, và một correlation id. Log chứa: stack trace đầy đủ, câu truy vấn, giá trị đầu vào, cùng correlation id đó. Hỗ trợ nhận id từ người dùng và tìm ra đúng dòng log — nên không mất gì về khả năng vận hành.
Trong ASP.NET Core cụ thể:
- Một exception filter/middleware ở một chỗ duy nhất biến mọi exception thành envelope đó. Đây là điểm quan trọng: một
try/catchtrong từng controller là 40 chỗ để làm sai. app.UseDeveloperExceptionPage()chỉ trongIsDevelopment(), và kiểm điều đó bằng test, không bằng niềm tin — biến môi trường đặt sai là nguyên nhân thật của phần lớn trường hợp.- Validation error nói trường nào sai, không nói giá trị nào (xem topic xxe: giá trị echo lại chính là kênh rò).
ProblemDetails (RFC 9457) là định dạng chuẩn cho việc này, và nó có sẵn chỗ cho traceId — dùng nó thay vì tự nghĩ ra hình dạng.
/// <summary>/// Một chỗ duy nhất biến mọi exception thành response. Middleware chứ không phải/// try/catch trong từng controller: 40 controller là 40 chỗ để làm sai, và chỗ thứ/// 41 sẽ thiếu.////// Nguyên tắc là ĐỔI HƯỚNG, không phải BỎ ĐI: toàn bộ chi tiết vẫn được ghi, chỉ là/// vào log thay vì vào response. Hỗ trợ nhận traceId từ người dùng và tìm ra đúng/// dòng log — nên không mất gì về khả năng vận hành./// </summary>public sealed class ExceptionEnvelopeMiddleware(RequestDelegate next, ILogger<ExceptionEnvelopeMiddleware> log){ public async Task InvokeAsync(HttpContext ctx) { try { await next(ctx); } catch (Exception ex) { // TraceId của Activity hiện tại: nó đã có trong mọi dòng log của request // này nhờ OpenTelemetry, nên không cần sinh id riêng. var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier; // TOÀN BỘ chi tiết vào log — stack trace, inner exception, đường dẫn. log.LogError(ex, "Unhandled exception on {Method} {Path} (trace {TraceId})", ctx.Request.Method, ctx.Request.Path, traceId); var (status, code) = ex switch { NotFoundException nf => (404, nf.ErrorCode), ApplicationGeneralException age => (422, age.ErrorCode), OperationCanceledException => (499, "ER_CLIENT_CLOSED"), _ => (500, "ER_INTERNAL"), }; ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/problem+json"; // RFC 9457. Ba thứ và chỉ ba thứ: mã lỗi ổn định để client xử lý, một // câu người đọc hiểu được, và traceId để tra. KHÔNG có ex.Message — // message của một PostgresException chứa tên bảng và tên cột. await ctx.Response.WriteAsJsonAsync(new ProblemDetails { Status = status, Title = code, Detail = status == 500 ? "An unexpected error occurred." : SafeDetail(ex), Extensions = { ["traceId"] = traceId }, }); } } /// <summary> /// Chỉ message của exception NGHIỆP VỤ được ra ngoài — chúng do ta viết và ta /// biết chúng không chứa gì. Message của exception hạ tầng thì không bao giờ. /// </summary> private static string? SafeDetail(Exception ex) => ex is NotFoundException or ApplicationGeneralException ? ex.Message : null;} // Program.cs — DeveloperExceptionPage CHỈ ở Development, và khối 7 có một test// khẳng định điều đó trên môi trường Production.if (app.Environment.IsDevelopment()){ app.UseDeveloperExceptionPage(); app.MapScalarApiReference(); // Swagger/Scalar cũng chỉ ở đây}else{ app.UseMiddleware<ExceptionEnvelopeMiddleware>();} // Kênh ② — bỏ header nhận diện. Một dòng.builder.WebHost.ConfigureKestrel(o => o.AddServerHeader = false); // ── Kênh ⑤ · Hằng thời gian ─────────────────────────────────────────────────public sealed class LoginService{ /// <summary> /// Hash của một mật khẩu ngẫu nhiên, sinh MỘT lần lúc khởi động với ĐÚNG tham /// số Argon2 mà hash thật dùng. Cùng tham số là điều kiện để thời gian bằng nhau — /// một hash giả với memory cost thấp hơn vẫn để lại chênh lệch đo được. /// </summary> private static readonly string DummyHash = new PasswordHasher().Hash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))); public async Task<LoginResult> LoginAsync(string email, string password, CancellationToken ct) { var user = await _users.FindByEmailAsync(email, ct); // KHÔNG trả về sớm. Không tìm thấy user thì vẫn chạy phép so hash lên // DummyHash, nên hai nhánh tốn thời gian như nhau. // // Task.Delay KHÔNG thay được chỗ này: nó để lại phương sai đo được, và nó // làm chậm cả request thật thay vì làm hai nhánh giống nhau. var hash = user?.PasswordHash ?? DummyHash; var ok = _hasher.Verify(hash, password); // Và kết quả chỉ đúng khi CẢ HAI đúng — user tồn tại và hash khớp. if (user is null || !ok) { // Ghi log để phát hiện (lớp 3) — email vào log, không vào response. _log.LogInformation("Failed login for {Email}", email); return LoginResult.Invalid; } return LoginResult.Success(user); }}# ── Dockerfile · multi-stage là BẢN VÁ, không phải tối ưu kích thước ─────────dockerfile: | # Stage build: có source, có .git nếu ai đó copy vào, có mọi thứ. Không sao — # stage này không bao giờ được deploy. FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet publish src/SecLab.Api -c Release -o /app/publish # Stage runtime: CHỈ output của publish. Không có .git/, không có .env, không có # appsettings.Development.json, không có file test — vì không có COPY . . nào ở đây. # # Đây là dòng thay thế cho mọi phép kiểm "đã xoá .git chưa": thứ chưa bao giờ được # copy vào thì không cần xoá. FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY --from=build /app/publish . # Không phải root, và root filesystem chỉ đọc — xem topic command-injection. USER $APP_UID ENTRYPOINT ["dotnet", "SecLab.Api.dll"] # ── .dockerignore · lưới THỨ HAI ────────────────────────────────────────────# Nó không thay được multi-stage build: nếu Dockerfile có COPY . . vào stage runtime# thì một dòng thiếu ở đây là đủ để .git đi theo. Nó ở đây để stage build cũng gọn# và để build cache không vỡ mỗi lần đổi README.dockerignore: | .git .gitignore .env .env.* **/appsettings.Development.json **/appsettings.Local.json **/node_modules **/bin **/obj **/*.md **/.DS_Store tests/ # ── nginx · lưới THỨ BA ─────────────────────────────────────────────────────# Cho ngày có người thêm một static file handler mới, hoặc mount một volume vào# thư mục được phục vụ.nginx: | # Mọi đường dẫn bắt đầu bằng dấu chấm: .git, .env, .DS_Store, .aws, .ssh location ~ /\. { deny all; return 404; # 404 chứ không 403 — 403 xác nhận là file có thật } # Backup và file tạm mà editor để lại location ~* \.(sql|bak|old|swp|tmp|log)$ { return 404; } # Header nhận diện của tầng proxy server_tokens off; proxy_hide_header X-Powered-By; proxy_hide_header X-AspNet-Version;Response giống hệt nhau VÀ hằng thời gian ở mọi endpoint nói về danh tính
bắt buộcBản vá cho kênh ⑤, và nó có hai nửa — nửa thứ hai gần như luôn bị bỏ.
Nửa 1 — nội dung giống hệt. Đăng nhập sai: cùng một mã lỗi, cùng một câu, cùng một HTTP status, dù email có tồn tại hay không. Đăng ký: cùng một 202 Accepted "kiểm email của bạn" cho cả email mới lẫn email đã có — thư gửi đi thì khác nhau, nhưng response thì không. Reset mật khẩu: y hệt.
Nửa 2 — thời gian giống nhau. Đây là nửa quyết định: nếu code trả về sớm khi không tìm thấy user thì hai response giống nhau từng byte vẫn khác nhau 700ms, và 700ms là một câu trả lời rõ ràng.
Cách làm: khi không tìm thấy user, vẫn chạy phép so hash lên một hash giả có cùng tham số. Trong .NET: PasswordHasher.VerifyHashedPassword(dummyUser, DummyHash, password). Không phải Task.Delay — độ trễ cố định vẫn để lại phương sai đo được, và nó làm chậm mọi request thật.
Và cùng lúc, rate limit theo email chứ không chỉ theo IP: liệt kê 10.000 email cần 10.000 request, nên một giới hạn theo email biến việc đó thành không khả thi ngay cả khi có kênh rò.
Deploy chỉ artifact đã build — không bao giờ copy cả thư mục làm việc
bắt buộcBản vá cho kênh ③, và nguyên nhân của nó luôn là một dòng: COPY . . trong Dockerfile.
Cách đúng là multi-stage build: stage build có source, stage runtime chỉ có output của dotnet publish. Không có .git/, không có .env, không có appsettings.Development.json, không có file test, không có node_modules của dev — vì chúng chưa bao giờ được copy vào image chạy thật.
Cộng ba việc:
.dockerignoreliệt kê.git,.env*,**/node_modules,**/bin,**/obj,*.md. Nó là lưới thứ hai, không phải lưới đầu.- Chặn ở reverse proxy: mọi đường dẫn bắt đầu bằng dấu chấm trả 404 (
location ~ /\. { return 404; }). Lưới thứ ba, cho trường hợp một static handler mới xuất hiện. - Swagger/Scalar chỉ ở Development. Ở production nó là danh mục đầy đủ mọi endpoint, kèm hình dạng request — kể cả endpoint mà UI không dùng và không ai kiểm quyền (xem topic access-control).
Bỏ header nhận diện, và tách secret khỏi bundle client
bắt buộcKênh ② và ④.
Header — bỏ những gì chỉ nói cho kẻ tấn công biết tra CVE nào:
builder.WebHost.ConfigureKestrel(o => o.AddServerHeader = false);Cộng X-Powered-By và X-AspNet-Version gỡ ở reverse proxy. Đây là lớp 2 vì nó chỉ làm chậm — phiên bản vẫn suy ra được từ hành vi — nhưng nó rẻ và nó loại bỏ toàn bộ lớp quét tự động.
Bundle client — luật một dòng: mọi thứ trong bundle là công khai. Không có "khoá API chỉ dùng ở frontend" nào là bí mật.
- Tiền tố
NEXT_PUBLIC_/VITE_là một quyết định, không phải một chi tiết cú pháp: biến không có tiền tố đó không được rò ra client, và có tiền tố đó nghĩa là "tôi chấp nhận nó công khai". - Không publish source map ở production, hoặc upload chúng lên Sentry/tương đương rồi xoá khỏi thư mục phục vụ. Source map là toàn bộ source code, có comment.
- Secret scanning trong CI (gitleaks, trufflehog) chặn merge, và quét cả lịch sử commit — đó là nơi secret đã "xoá" vẫn còn.
Phát hiện: 404 và 500 là tín hiệu, không phải nhiễu
Khối 2 nói rằng rò rỉ không có triệu chứng. Nên phát hiện phải nhìn vào thứ mà người ta thường lọc ra khỏi dashboard:
- Tốc độ 404 theo IP. Quét wordlist tìm
.git,.env,/backup.sqlsinh ra hàng trăm 404 trong vài giây. Đây là tín hiệu độ nhiễu rất thấp — người dùng thật không tạo ra hình dạng đó. - Bất kỳ 500 nào là một bug, và có thể là một lần dò. 500 với
id=abcở một endpoint nhận{id:long}nghĩa là ai đó đang thử sai kiểu để lấy stack trace. Alert theo mã lỗi mới xuất hiện, không theo tổng số. - Canary token trong file dễ đoán. Đặt một AWS key giả (canarytokens.org) vào
/.env.examplevà một chỗ trong lịch sử git. Nó không có quyền gì, nhưng khi có người dùng nó, bạn biết chính xác là đã có ai đọc được thứ họ không nên đọc — và biết trước khi họ tìm được khoá thật.
Đây là lớp 3 vì không biện pháp nào ở đây chặn gì. Nhưng với một họ lỗi không có triệu chứng, phát hiện là thứ duy nhất cho bạn biết bước một đã xảy ra — trước khi bước hai tới.
Kiểm chứng đã vá
1. Test chặn merge cho kênh ①. Test khẳng định response 500 không chứa dấu hiệu của stack trace, đường dẫn tuyệt đối, hay chuỗi kết nối. Xem tab csharp / test. Đây là phép kiểm duy nhất bắt được lỗi "ASPNETCORE_ENVIRONMENT đặt sai ở production" — thứ mà không dòng code nào có lỗi.
2. Test hằng thời gian cho kênh ⑤. Đo nhiều lần và so trung vị giữa email tồn tại và không tồn tại. Trung vị chứ không trung bình: một lần GC pause làm trung bình vô nghĩa. Chênh lệch phải dưới một ngưỡng, và ngưỡng đó nên rộng (2×) để test không nhấp nháy trên CI chậm.
3. Quét đường dẫn lộ ra — chạy trên staging sau mỗi deploy (kênh ③):
B=https://staging.example.comFAIL=0for p in /.git/HEAD /.git/config /.env /.env.production /appsettings.json \ /appsettings.Production.json /.DS_Store /backup.sql /web.config \ /swagger /scalar /debug /actuator/env /server-status; do C=$(curl -s -o /dev/null -w '%{http_code}' "$B$p") [ "$C" = "404" ] || { echo "LỘ RA: $p → $C"; FAIL=1; }doneexit $FAIL4. Kiểm header (kênh ②):
H=$(curl -sI $B/ | tr -d '\r')for bad in '^Server:' '^X-Powered-By:' '^X-AspNet' '^X-Runtime'; do echo "$H" | grep -qiE "$bad" && { echo "header lộ: $bad"; exit 1; }doneexit 05. Quét bundle client và secret trong lịch sử git (kênh ④):
# Source map không được publishfind ./dist ./.next -name '*.map' -print -quit | grep -q . \ && { echo "source map trong artifact build"; exit 1; } # Secret trong bundle, và trong CẢ lịch sử commit — đó là nơi secret "đã xoá" còn nguyêngitleaks detect --no-banner --redact --exit-code 1grep -rEo '(sk_live_|AKIA|ghp_|xox[baprs]-)[A-Za-z0-9]{10,}' ./dist ./.next 2>/dev/null \ && { echo "secret trong bundle"; exit 1; }exit 06. Đối chiếu endpoint anonymous với danh sách đã duyệt — cùng phép kiểm ở topic access-control. Một endpoint mới không cần đăng nhập là một kênh rò mới, và nó xuất hiện qua một dòng [AllowAnonymous].
public class DisclosureTests : IClassFixture<ProductionLikeFixture>{ private readonly ProductionLikeFixture _fx; // ASPNETCORE_ENVIRONMENT=Production public DisclosureTests(ProductionLikeFixture fx) => _fx = fx; /// <summary> /// Kênh ① — phép kiểm quan trọng nhất của topic này, vì lỗi mà nó bắt được KHÔNG /// nằm ở dòng code nào: nó nằm ở một biến môi trường đặt sai. Không có test này /// thì không có gì đỏ khi ai đó deploy với Development ở production. /// </summary> [Theory] [InlineData("/api/orders/abc")] // sai kiểu → FormatException [InlineData("/api/orders/99999999999999999999")] // tràn Int64 [InlineData("/api/boom")] // endpoint cố tình ném lỗi public async Task Error_responses_never_contain_internals(string path) { var res = await _fx.Client.GetAsync(path); var body = await res.Content.ReadAsStringAsync(); // Dấu hiệu của stack trace và của hạ tầng. Danh sách này là DỮ LIỆU: khi ai // đó phát hiện một dạng rò mới, họ thêm một dòng ở đây. foreach (var leak in new[] { "at SecLab.", // khung stack "/src/", // đường dẫn tuyệt đối lúc build "Exception:", // tên exception ".cs:line", // số dòng "Npgsql", // tên thư viện hạ tầng "Host=", "Password=", // chuỗi kết nối "core.app_user", // tên bảng }) Assert.DoesNotContain(leak, body, StringComparison.OrdinalIgnoreCase); // Và khẳng định mặt còn lại: có traceId để tra. Một bản vá "trả chuỗi rỗng" // cũng pass mọi khẳng định ở trên, và nó làm hệ thống không debug được. Assert.Contains("traceId", body); } /// <summary>Kênh ② — header nhận diện. Một dòng cấu hình, và nó bị mất khi đổi host.</summary> [Fact] public async Task Response_headers_do_not_advertise_the_stack() { var res = await _fx.Client.GetAsync("/"); Assert.False(res.Headers.Contains("Server")); Assert.False(res.Headers.Contains("X-Powered-By")); Assert.False(res.Headers.Contains("X-AspNet-Version")); } /// <summary>Swagger ở production là danh mục đầy đủ mọi endpoint, kèm hình dạng request.</summary> [Theory] [InlineData("/swagger")] [InlineData("/swagger/v1/swagger.json")] [InlineData("/scalar")] public async Task Api_documentation_is_not_served_in_production(string path) { Assert.Equal(HttpStatusCode.NotFound, (await _fx.Client.GetAsync(path)).StatusCode); } /// <summary> /// Kênh ⑤ — hằng thời gian. /// /// So TRUNG VỊ, không so trung bình: một lần GC pause hay một lần cold start làm /// trung bình vô nghĩa. Và ngưỡng rộng (2×) để test không nhấp nháy trên CI chậm — /// một test nhấp nháy sẽ bị ai đó tắt đi, và lúc đó kênh rò mở lại. /// </summary> [Fact] public async Task Login_timing_does_not_reveal_whether_the_account_exists() { await _fx.SeedUserAsync("alice@acme.com", "correct-horse-battery"); var existing = await MeasureMedianAsync("alice@acme.com", samples: 15); var missing = await MeasureMedianAsync("no-such-user@acme.com", samples: 15); var ratio = Math.Max(existing, missing) / Math.Min(existing, missing); Assert.True(ratio < 2.0, $"thời gian lệch {ratio:F1}× (tồn tại {existing:F0}ms, không tồn tại {missing:F0}ms) " + "— nhánh không tìm thấy user đang trả về sớm"); } /// <summary>Và nội dung cũng phải giống hệt — nửa còn lại của kênh ⑤.</summary> [Fact] public async Task Login_responses_are_byte_identical() { await _fx.SeedUserAsync("bob@acme.com", "correct-horse-battery"); var a = await PostLoginAsync("bob@acme.com", "wrong-password"); var b = await PostLoginAsync("no-such-user@acme.com", "wrong-password"); Assert.Equal(a.StatusCode, b.StatusCode); Assert.Equal(await a.Content.ReadAsStringAsync(), await b.Content.ReadAsStringAsync()); } /// <summary>Đăng ký không được nói email đã tồn tại — đó là một API liệt kê người dùng.</summary> [Fact] public async Task Registration_does_not_reveal_existing_accounts() { await _fx.SeedUserAsync("carol@acme.com", "x"); var existing = await PostRegisterAsync("carol@acme.com"); var fresh = await PostRegisterAsync("dave@acme.com"); Assert.Equal(HttpStatusCode.Accepted, existing.StatusCode); Assert.Equal(HttpStatusCode.Accepted, fresh.StatusCode); Assert.Equal(await existing.Content.ReadAsStringAsync(), await fresh.Content.ReadAsStringAsync()); // Sự khác nhau nằm trong THƯ gửi đi, không trong response. Assert.Equal("password-reset-hint", _fx.LastEmailTemplateFor("carol@acme.com")); Assert.Equal("confirm-address", _fx.LastEmailTemplateFor("dave@acme.com")); } private async Task<double> MeasureMedianAsync(string email, int samples) { var times = new List<double>(samples); // Một lần chạy nóng để loại cold start khỏi phép đo. await PostLoginAsync(email, "wrong"); for (var i = 0; i < samples; i++) { var sw = Stopwatch.StartNew(); await PostLoginAsync(email, "wrong"); times.Add(sw.Elapsed.TotalMilliseconds); } times.Sort(); return times[times.Count / 2]; }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Trả về "Đã có lỗi xảy ra" và không có gì khác | Đóng kênh rò và đóng luôn khả năng vận hành. Correlation id là phần làm bản vá này dùng được |
Đặt ASPNETCORE_ENVIRONMENT=Production rồi tin là xong | Không dòng code nào có lỗi, nên không test nào đỏ khi ai đó đặt sai ở một môi trường. Phép kiểm 1 ở khối 7 là thứ bắt được |
try/catch trong từng controller | 40 chỗ để làm sai, và chỗ thứ 41 sẽ thiếu. Một middleware là một chỗ |
| Response giống hệt nhau, nhưng trả về sớm khi không có user | Nội dung giống, thời gian lệch 700ms. Kênh ⑤ vẫn mở, và nó vẫn đo được |
Task.Delay để làm đều thời gian | Phương sai vẫn đo được, và mọi request thật cũng chậm theo. Chạy phép so hash giả mới đúng |
.dockerignore mà vẫn COPY . . | Lưới thứ hai không thay được lưới đầu. Multi-stage build là bản vá |
| Xoá secret khỏi code rồi commit | Nó vẫn nằm trong lịch sử git, và lịch sử git đi theo .git/ lộ ra. Phải rotate khoá |
| Khoá API "chỉ dùng ở frontend" | Mọi thứ trong bundle là công khai. Nếu nó cần bí mật thì lời gọi phải đi qua server |
| Đăng ký trả 409 cho email đã tồn tại | Đó là một API liệt kê người dùng, có tài liệu. Trả 202 cho cả hai và nói khác nhau trong THƯ |
Sai lầm về mức độ, và nó là sai lầm chính của topic này: đánh giá từng kênh rò một cách đơn lẻ rồi kết luận "rủi ro thấp". Giá trị của rò rỉ là cộng dồn: phiên bản framework cộng đường dẫn tuyệt đối cộng danh sách email có tài khoản không phải ba lỗi thấp — nó là một cuộc tấn công đã được nhắm sẵn.
Sai lầm về phạm vi: chỉ nhìn response của API. Kênh ③ và ④ nằm ở tầng build và deploy, không ở trong code ứng dụng — nên chúng không xuất hiện trong bất kỳ lần code review nào.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…