SecLab

Information disclosure

A01CWE-200
01

What it is

Information disclosure is the application revealing what an attacker needs for the next step: stack traces, framework versions, internal paths, table names, an API key in a JavaScript bundle, or merely the difference between two error messages. It is rarely the incident itself; it is step one of every other incident.

02

Why you should care

Relevance: CoreExpected: L2

This is the most under-rated topic, and the reason is that its severity is always assessed in isolation: "it is only a stack trace" — low CVSS, ticket closed. But its value is that it turns guessing into knowing:

  • Knowing the framework version → look up the right CVE instead of trying hundreds of payloads
  • Knowing table and column names from a SQL error → write the correct UNION SELECT first try
  • Knowing absolute paths from a stack trace → an exact path traversal payload
  • Knowing which emails have accounts → password spraying against a real list, not a guessed one

Three properties make it more common than any other topic:

  • It is the default. Leaking is what happens when nobody does anything: a wrong ASPNETCORE_ENVIRONMENT, .git/ shipped with the deploy, the framework's Server: header, a sourceMappingURL in a production bundle.
  • It has no symptoms. Nothing breaks, no exception, nobody files a bug. The application runs perfectly while it leaks.
  • The leak channel is usually the thing that helps you debug. The same stack trace that saved you at 2am is somebody else's map. So the fix is not "remove the information" but redirect it: into the log, not into the response.
03

How the attack works

There is no single mechanism; there are five channels, and knowing them is how you hunt systematically.

Diagram source
flowchart LR    A[Attacker] --> C1["① Error messages<br/>stack traces, SQL errors"]    A --> C2["② Response headers<br/>Server, X-Powered-By, X-AspNet-Version"]    A --> C3["③ Exposed files<br/>.git/, .env, .DS_Store, backup~, /debug"]    A --> C4["④ Client-side<br/>source maps, API keys, comments, unused endpoints"]    A --> C5["⑤ Side channels<br/>timing differences, differing status codes"]    C1 --> M["A map of the system"]    C2 --> M    C3 --> M    C4 --> M    C5 --> M    M --> X["Targeted attack"]
ChannelConcrete exampleHow to find it
① ErrorsNpgsql.PostgresException: relation "core.app_user" does not exist at /src/app/Repositories/UserRepository.cs:42Send wrong-typed input to every endpoint
② HeadersServer: Kestrel, X-Powered-By: ASP.NET, X-AspNet-Version: 8.0.4curl -I
③ Files/.git/config, /.env, /appsettings.Production.json, /backup.sql, /swagger in productionA wordlist
④ Client//# sourceMappingURL=main.js.map, const STRIPE_KEY="sk_live_…"Read the bundle
⑤ Side channelEmail with an account: 780ms; without: 60msMeasure timing

Channel ⑤ deserves its own note because it is the only one where you cannot "remove the information" — the information is in the behaviour, not the content. The mechanism: the login function checks the email first, returns immediately if there is no such user, and runs Argon2 to compare the password if there is. Argon2 is deliberately slow (~700ms), so the timing difference is the answer to "does this email have an account" — and it holds even when both responses are byte-identical.

A variant of the same channel, needing no stopwatch: POST /register with an existing email returns 409 "Email already in use", a new one returns 201. That is a user-enumeration API, and it is in your documentation.

Diagram description: The diagram shows an attacker gathering information through five parallel channels: error messages carrying stack traces and SQL errors; response headers such as Server and X-Powered-By; exposed files such as .git, .env, .DS_Store and a swagger page; client-side data such as source maps, API keys and unused endpoints; and side channels such as response timing differences or differing status codes. All five feed one map of the system, and that map turns guesswork into a targeted attack.

04

Concrete example

Four harmless requests, and together they are a map.

HTTP
# ① Send the wrong type to get a stack trace. No payload needed.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=postgres

Three things just leaked: absolute server paths, the internal DB hostname, and that the DB user is postgres (a superuser).

HTTP
# ② Headers. One command, and now you know which CVE to look up.HEAD / HTTP/1.1 HTTP/1.1 200 OKServer: KestrelX-Powered-By: ASP.NETX-AspNet-Version: 8.0.4
HTTP
# ③ .git shipped with the deploy. That is the whole source tree and the whole commit history.GET /.git/config HTTP/1.1 HTTP/1.1 200 OK[remote "origin"]  url = https://x-token-auth:ATBB3xK…@bitbucket.org/acme/seclab.git

And that URL contains a repository access token.

Shell
# ⑤ Side channel. Both responses are byte-identical; the timings are not.for e in alice@acme.com no-such-user@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   ← has an account (Argon2 ran)# no-such-user@acme.com   0.058s   ← does not (returned immediately)
C#Three channels in three snippets, and none of them looks like a vulnerability.
// ── 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);}
05

What happened in the wild

Uber, 2016 — 57 million users. The entry point was an AWS key in a private GitHub repository an employee had committed; from there the attackers read S3 backups. This is channel ④ in the form everyone dismisses as "not an application vulnerability" — and it was the way into one of the decade's best-known breaches, plus a criminal conviction for the CSO over the cover-up.

The whole family of publicly exposed .git directories. Internet-wide scans for /.git/HEAD returning 200 find hundreds of thousands of sites — each one the complete source tree plus the complete commit history, and commit history is where "deleted" secrets still live. Memorable because the cause is always one line in a Dockerfile: COPY . .

Channel ⑤ in the wild: many public bug bounty reports on user enumeration via timing differences at the login and password-reset endpoints. No single large incident, but it is what converts a purchased email list into a confirmed target list for credential stuffing.

06

How to defend

Layer 1

Redirect the information, do not delete it — the correlation id is the fix

mandatory

Block 2 argues the leak channel is usually the thing that helps you debug. So the correct fix is not "return a generic error" — it is keeping every detail, sending it to the log, and handing the client an id to quote.

The response carries: a stable error code (ER_ORDER_NOT_FOUND), a human-readable sentence, and a correlation id. The log carries: the full stack trace, the query, the input values, and that same correlation id. Support takes the id from the user and finds the exact log line — so nothing is lost operationally.

In ASP.NET Core specifically:

  • One exception filter or middleware in exactly one place turns every exception into that envelope. This is the important part: a try/catch in each controller is 40 chances to get it wrong.
  • app.UseDeveloperExceptionPage() only under IsDevelopment(), and assert that with a test, not with belief — a misconfigured environment variable is the actual cause in most cases.
  • Validation errors name which field failed, not which value (see the xxe topic: the echoed value is itself the exfiltration channel).

ProblemDetails (RFC 9457) is the standard shape for this and already has a place for traceId — use it rather than inventing a format.

C# · Layer 1Detail to the log with a traceId; the response carries a code and an id. Plus a dummy hash.
/// <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);    }}
YAML · Layer 1Channel ③: the multi-stage build is the fix, .dockerignore and the proxy are nets two and three.
# ── 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;
Layer 1b

Identical AND constant-time responses on every identity endpoint

mandatory

The fix for channel ⑤, and it has two halves — the second is almost always skipped.

Half 1 — identical content. Failed login: the same error code, the same sentence, the same HTTP status, whether or not the email exists. Registration: the same 202 Accepted "check your email" for both a new address and an existing one — the email sent differs, the response does not. Password reset: identical.

Half 2 — identical timing. This is the deciding half: if the code returns early when no user is found, two byte-identical responses still differ by 700ms, and 700ms is a clear answer.

How: when no user is found, still run the hash comparison against a dummy hash with the same parameters. In .NET: PasswordHasher.VerifyHashedPassword(dummyUser, DummyHash, password). Not Task.Delay — a fixed delay still leaves measurable variance, and it slows every real request too.

And alongside it, rate limit per email rather than only per IP: enumerating 10,000 emails needs 10,000 requests, so a per-email limit makes it impractical even where a channel exists.

Layer 1c

Deploy only the built artefact — never copy the working tree

mandatory

The fix for channel ③, and its cause is always one line: COPY . . in a Dockerfile.

The right approach is a multi-stage build: the build stage has the source, the runtime stage has only the output of dotnet publish. No .git/, no .env, no appsettings.Development.json, no test files, no dev node_modules — because they were never copied into the runtime image.

Plus three things:

  • A .dockerignore listing .git, .env*, **/node_modules, **/bin, **/obj, *.md. That is the second net, not the first.
  • Block at the reverse proxy: any path starting with a dot returns 404 (location ~ /\. { return 404; }). The third net, for when a new static handler appears.
  • Swagger/Scalar in Development only. In production it is a complete catalogue of every endpoint with request shapes — including endpoints the UI never calls and nobody authorised (see the access-control topic).
Layer 2

Strip identifying headers, and keep secrets out of the client bundle

mandatory

Channels ② and ④.

Headers — remove what only tells an attacker which CVE to look up:

C#
builder.WebHost.ConfigureKestrel(o => o.AddServerHeader = false);

Plus X-Powered-By and X-AspNet-Version stripped at the reverse proxy. Layer 2 because it only slows things down — the version is still inferable from behaviour — but it is cheap and it removes the entire automated-scanning tier.

Client bundle — the one-line rule: everything in the bundle is public. There is no such thing as a secret "frontend-only API key".

  • The NEXT_PUBLIC_/VITE_ prefix is a decision, not a syntax detail: a variable without it must not reach the client, and one with it means "I accept this is public".
  • Do not publish source maps to production, or upload them to Sentry (or equivalent) and delete them from the served directory. A source map is your entire source, comments included.
  • Secret scanning in CI (gitleaks, trufflehog) blocking merge, and scanning the commit history — that is where "deleted" secrets remain.
Layer 3

Detection: 404s and 500s are signals, not noise

Block 2 argues that leaks have no symptoms. So detection has to look at exactly what people normally filter out of the dashboard:

  • 404 rate per IP. A wordlist scan for .git, .env, /backup.sql produces hundreds of 404s in seconds. This is a very low-noise signal — real users do not produce that shape.
  • Any 500 is a bug, and possibly a probe. A 500 from id=abc on an endpoint taking {id:long} means somebody is trying wrong types to get a stack trace. Alert on newly appearing error codes, not on totals.
  • A canary token in a guessable file. Put a fake AWS key (canarytokens.org) in /.env.example and somewhere in git history. It grants nothing, but when somebody uses it you know for certain that someone read what they should not have — and you know before they find the real key.

Layer 3 because nothing here blocks anything. But for a family with no symptoms, detection is the only thing that tells you step one happened — before step two arrives.

07

Verifying the fix

1. A merge-blocking test for channel ①. The test asserts a 500 response does not contain the signature of a stack trace, an absolute path, or a connection string. See the csharp / test tab. This is the only check that catches "ASPNETCORE_ENVIRONMENT is wrong in production" — a fault in no line of code.

2. A constant-time test for channel ⑤. Measure repeatedly and compare the median between existing and non-existing emails. Median, not mean: one GC pause makes a mean meaningless. The difference must be under a threshold, and the threshold should be generous (2×) so the test does not flake on slow CI.

3. Scan for exposed paths — run on staging after every deploy (channel ③):

Shell
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 "EXPOSED: $p → $C"; FAIL=1; }doneexit $FAIL

4. Check the headers (channel ②):

Shell
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 "leaking header: $bad"; exit 1; }doneexit 0

5. Scan the client bundle and the git history for secrets (channel ④):

Shell
# No source maps publishedfind ./dist ./.next -name '*.map' -print -quit | grep -q . \  && { echo "source map in the build artefact"; exit 1; } # Secrets in the bundle, and in the FULL commit history — where "deleted" secrets remaingitleaks 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 in the bundle"; exit 1; }exit 0

6. Diff the anonymous endpoints against a reviewed list — the same check as in the access-control topic. A new endpoint that needs no login is a new leak channel, and it arrives via one [AllowAnonymous] line.

C#Tests that catch an environment misconfiguration — a fault no line of code owns.
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];    }}
08

Common mistakes

The "fix"Why it is wrong
Return "An error occurred" and nothing elseCloses the leak and closes your operability with it. The correlation id is what makes this fix usable
Set ASPNETCORE_ENVIRONMENT=Production and consider it doneNo line of code is at fault, so no test fails when somebody sets it wrong in one environment. Check 1 in block 7 is what catches it
A try/catch in each controller40 chances to get it wrong, and the 41st will be missing. One middleware is one place
Identical responses, but returning early when there is no userSame content, 700ms apart. Channel ⑤ is still open, and still measurable
Task.Delay to even out the timingThe variance is still measurable, and every real request slows too. Running a dummy hash comparison is the fix
A .dockerignore while still doing COPY . .The second net does not replace the first. The multi-stage build is the fix
Delete the secret from the code and commitIt is still in git history, and git history ships with an exposed .git/. You must rotate the key
A "frontend-only" API keyEverything in the bundle is public. If it must stay secret, the call goes through your server
Registration returning 409 for an existing emailThat is a documented user-enumeration API. Return 202 for both and differ in the EMAIL

The severity mistake, and it is this topic's central one: assessing each channel in isolation and concluding "low risk". The value of disclosure is cumulative: a framework version plus absolute paths plus a confirmed list of registered emails is not three low findings — it is a pre-aimed attack.

The scoping mistake: looking only at API responses. Channels ③ and ④ live in the build and deploy layers, not in application code — so they never appear in any code review.

09

References

Tier 1RFC 9457 — Problem Details for HTTP APIs · IETF · RFC · RFC 9457
Tier 1Handle errors in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Tier 2Information disclosure · PortSwigger · Web Security Academy
Tier 2Error Handling Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…