Security Misconfiguration trong ASP.NET Core: debug endpoint, CORS lỏng, và headers thiếu

Security Misconfiguration nhảy từ #5 lên #2 trong OWASP Top 10 2025 — không phải vì lỗi này mới xuất hiện, mà vì attack surface ngày càng rộng hơn: cloud config, container defaults, framework middleware. Với ASP.NET Core, 6 misconfiguration hay gặp nhất là: Swagger/debug endpoints còn bật trong production, CORS quá lỏng, security headers thiếu, error details bị lộ, default credentials không đổi, và server info bị expose. Bài này fix từng cái với code cụ thể. ---

Vấn đề

Trong một pentest gần đây, bước đầu tiên tôi làm là thử các URL quen thuộc:

https://api.target.com/swagger
https://api.target.com/swagger/index.html
https://api.target.com/swagger/v1/swagger.json

URL thứ ba trả về toàn bộ OpenAPI specification — bao gồm mọi endpoint, schema, authentication method, và example request/response. Không cần đăng nhập.

Từ đó, tôi biết chính xác API surface cần test mà không cần dò tìm. Đây là ví dụ điển hình của Security Misconfiguration: không phải lỗi code, mà là config đúng ở dev, sai ở production.


Misconfiguration 1: Swagger và debug endpoints trên production

Vấn đề

// ❌ NGUY HIỂM: Swagger bật unconditionally
var app = builder.Build();

app.UseSwagger();                 // Expose OpenAPI spec
app.UseSwaggerUI();               // Expose interactive UI
app.UseDeveloperExceptionPage();  // Expose stack trace đầy đủ

app.UseRouting();
app.MapControllers();
app.Run();

Swagger không chỉ tiện cho developer — nó là bản đồ chi tiết của toàn bộ API cho attacker.

Fix

var app = builder.Build();

// Swagger chỉ trong development environment
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1");
        // Staging: thêm basic auth cho Swagger nếu cần access ngoài dev
    });
    app.UseDeveloperExceptionPage();
}
else
{
    // Production: generic error page, không có stack trace
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.UseRouting();
app.MapControllers();
app.Run();

Nếu cần Swagger trên staging cho QA team, bảo vệ bằng authentication:

// Swagger trên staging — require internal token
if (!app.Environment.IsProduction())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
// Hoặc dùng IP whitelist middleware trước Swagger

Misconfiguration 2: CORS AllowAnyOrigin với credentials

Vấn đề

// ❌ NGUY HIỂM: AllowAnyOrigin + AllowCredentials = lỗi phổ biến nhất
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
        policy.AllowAnyOrigin()      // Bất kỳ site nào cũng request được
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials());  // Và gửi kèm cookie/auth header
});

Thực ra AllowAnyOrigin() kết hợp với AllowCredentials() sẽ throw runtime exception trong ASP.NET Core vì browser spec không cho phép. Nhưng nhiều team "fix" bằng cách bỏ AllowCredentials() — khiến CORS mở hoàn toàn cho API không cần credentials, nhưng lại expose data cho cross-site requests.

Fix

// ✅ ĐÚNG: whitelist explicit, khác nhau giữa environments
var allowedOrigins = app.Environment.IsProduction()
    ? new[] { "https://app.company.com", "https://admin.company.com" }
    : new[] { "http://localhost:3000", "http://localhost:5173", "https://staging.company.com" };

builder.Services.AddCors(options =>
{
    options.AddPolicy("AppPolicy", policy =>
        policy.WithOrigins(allowedOrigins)
              .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
              .WithHeaders("Authorization", "Content-Type", "X-Request-ID")
              .AllowCredentials()
              .SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});

// Áp dụng policy — không dùng [EnableCors] trên từng controller
app.UseCors("AppPolicy");  // Global, trước UseRouting

Một điểm hay bị bỏ qua: CORS không phải security mechanism server-side — nó là browser enforcement. API của bạn vẫn nhận request từ curl, Postman, hoặc bất kỳ non-browser client nào dù CORS config thế nào. CORS chỉ bảo vệ browser users khỏi cross-site request bất ngờ.


Misconfiguration 3: Security headers thiếu

Vấn đề

Fresh ASP.NET Core project không có security headers theo mặc định. Kiểm tra nhanh bằng curl:

curl -I https://your-api.com/health

# Output thường thấy — nhiều header bị thiếu, server version bị lộ:
HTTP/2 200
Content-Type: application/json
Server: Microsoft-IIS/10.0       ← Lộ server version
X-Powered-By: ASP.NET            ← Lộ framework
# Thiếu: X-Content-Type-Options, X-Frame-Options, CSP, HSTS

Fix

// SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        var headers = context.Response.Headers;

        // Ngăn MIME sniffing — browser không đoán Content-Type
        headers["X-Content-Type-Options"] = "nosniff";

        // Ngăn iframe embedding — chống clickjacking
        headers["X-Frame-Options"] = "DENY";

        // Referrer policy — không leak URL trong cross-origin request
        headers["Referrer-Policy"] = "strict-origin-when-cross-origin";

        // Content Security Policy — cho API (không phải web app)
        // API thường không serve HTML nên CSP đơn giản hơn
        headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";

        // Permissions Policy — tắt browser features không cần thiết
        headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()";

        // Xóa headers tiết lộ server info
        headers.Remove("Server");
        headers.Remove("X-Powered-By");
        headers.Remove("X-AspNet-Version");
        headers.Remove("X-AspNetMvc-Version");

        await next(context);
    }
}

// Program.cs — đặt trước middleware khác
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseHsts(); // HSTS header: Strict-Transport-Security

Verify kết quả sau khi add middleware:

curl -I https://your-api.com/health
# Mong muốn:
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY
# Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
# Strict-Transport-Security: max-age=31536000; includeSubDomains
# Không có: Server, X-Powered-By

Misconfiguration 4: Error details lộ ra production

Vấn đề

// ❌ Error handler trả về exception details
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new
        {
            error = exception?.Message,    // "Connection timeout: Server=prod-db-01..."
            stack = exception?.StackTrace, // Lộ file paths, line numbers, framework version
            type = exception?.GetType().FullName
        });
    });
});

Stack trace tiết lộ: internal file paths, class names, framework versions — tất cả là thông tin hữu ích cho attacker trong bước reconnaissance.

Fix

// ✅ Log chi tiết internally, response generic
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
        var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();

        // Log đầy đủ để investigate — chỉ internal
        logger.LogError(exception,
            "Unhandled exception on {Method} {Path} for user {UserId}",
            context.Request.Method,
            context.Request.Path,
            context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "anonymous");

        // Response: generic, không có internal details
        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/problem+json";

        await context.Response.WriteAsJsonAsync(new
        {
            type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
            title = "An unexpected error occurred",
            status = 500,
            // traceId để correlate với internal logs — nhưng không có details
            traceId = Activity.Current?.Id ?? context.TraceIdentifier
        });
    });
});

Misconfiguration 5: Default credentials không đổi

Vấn đề phổ biến trong môi trường containerized

# docker-compose.yml — credentials mặc định từ docs
services:
  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      SA_PASSWORD: "YourStrong@Passw0rd"  # Copy từ Microsoft docs, không đổi
      ACCEPT_EULA: "Y"

  redis:
    image: redis:7
    # Không có auth — mặc định Redis không require password
    # Bất kỳ ai access được network đều đọc/ghi được cache

  rabbitmq:
    image: rabbitmq:3-management
    environment:
      RABBITMQ_DEFAULT_USER: guest   # Default user — nổi tiếng với attackers
      RABBITMQ_DEFAULT_PASS: guest   # Default pass

Fix

# docker-compose.prod.yml — credentials từ secret manager
services:
  db:
    environment:
      SA_PASSWORD: "${DB_SA_PASSWORD}"  # Inject từ environment, không hardcode

  redis:
    command: redis-server --requirepass "${REDIS_PASSWORD}" --bind 127.0.0.1

  rabbitmq:
    environment:
      RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER}"
      RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASS}"

Và thêm network isolation — services không cần expose port ra host:

services:
  api:
    ports:
      - "443:443"   # Chỉ API expose ra ngoài
  db:
    # Không expose port 1433 — chỉ accessible trong internal network
    networks:
      - internal
  redis:
    networks:
      - internal

networks:
  internal:
    driver: bridge
    internal: true  # Không route ra internet

Misconfiguration 6: Logging quá nhiều hoặc quá ít

Quá nhiều — lộ sensitive data

// ❌ Log toàn bộ request — bao gồm Authorization header, body với credentials
app.UseHttpLogging();  // Mặc định log headers và body

// Log đâu đó trong code
logger.LogInformation("Processing request: {Body}", JsonSerializer.Serialize(request));
// Request body có thể chứa: password, token, credit card

Quá ít — không detect khi bị tấn công

// ❌ Không log auth failures — attacker brute-force không để lại trace
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
    var user = await _userService.AuthenticateAsync(request.Email, request.Password);
    if (user == null)
        return Unauthorized(); // Silent failure — không log gì cả
    // ...
}

Fix — log đúng thứ, không log sensitive data

// HttpLogging: chỉ log metadata, không log body/headers nhạy cảm
builder.Services.AddHttpLogging(logging =>
{
    logging.LoggingFields =
        HttpLoggingFields.RequestMethod |
        HttpLoggingFields.RequestPath |
        HttpLoggingFields.ResponseStatusCode |
        HttpLoggingFields.Duration;
    // Không include: RequestBody, ResponseBody, RequestHeaders (có thể chứa Authorization)
});

// Auth events: log đủ để detect + investigate, không log credentials
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
    var user = await _userService.AuthenticateAsync(request.Email, request.Password);
    if (user == null)
    {
        _logger.LogWarning(
            "Failed login attempt for {Email} from {IP} at {Time}",
            request.Email,  // Email OK — biz context cần để investigate
            GetClientIp(HttpContext),
            DateTime.UtcNow);
        return Unauthorized();
    }

    _logger.LogInformation("Successful login for user {UserId}", user.Id);
    // ...
}

Automated check — thêm vào CI/CD pipeline

#!/bin/bash
# security-headers-check.sh — chạy sau deployment

API_URL="${1:-https://api.staging.company.com}"
FAILED=0

check_header() {
    local header=$1
    local expected=$2
    local value=$(curl -sI "$API_URL/health" | grep -i "^${header}:" | cut -d' ' -f2- | tr -d '\r')

    if [ -z "$value" ]; then
        echo "❌ MISSING: $header"
        FAILED=1
    elif [[ "$value" == *"$expected"* ]]; then
        echo "✅ OK: $header: $value"
    else
        echo "⚠️  WRONG: $header = '$value' (expected contains '$expected')"
        FAILED=1
    fi
}

# Check Swagger không accessible trên production
SWAGGER_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/swagger")
if [ "$SWAGGER_STATUS" != "404" ] && [ "$SWAGGER_STATUS" != "401" ]; then
    echo "❌ CRITICAL: Swagger accessible in production (HTTP $SWAGGER_STATUS)"
    FAILED=1
else
    echo "✅ OK: Swagger not accessible ($SWAGGER_STATUS)"
fi

# Check security headers
check_header "X-Content-Type-Options" "nosniff"
check_header "X-Frame-Options" "DENY"
check_header "Strict-Transport-Security" "max-age"
check_header "Content-Security-Policy" "default-src"

# Check server info không bị lộ
SERVER=$(curl -sI "$API_URL/health" | grep -i "^Server:" | tr -d '\r')
if [ -n "$SERVER" ]; then
    echo "❌ Server header exposed: $SERVER"
    FAILED=1
else
    echo "✅ OK: Server header not exposed"
fi

exit $FAILED

Kết

Security Misconfiguration nhảy lên #2 OWASP vì nó là loại lỗi "vô hình" — code đúng nhưng config sai, thường là sai ở boundary giữa development và production. Tự động hóa check quan trọng hơn manual review ở đây: header check script, Swagger URL probe, và CORS origin validation nên là một phần của CD pipeline.

Bài cuối cùng trong series: Dependency confusion attack — sâu hơn vào một vector cụ thể trong supply chain, khi package nội bộ bị shadow bởi public package cùng tên và cách ngăn trong NuGet ecosystem.


Thiết Kiếm — BKGlobal Tech Team

#BKGlobal #appsec #security #owasp #misconfiguration #dotnet #aspnetcore

Bảo mật & AppSec

Xem tất cả