fix(security): fix 5 P1 backend issues — BACK-C-01/03/04, BACK-W-02

BACK-W-02: Replace string-interpolated SET LOCAL SQL with parameterized
set_config() calls in TenantMiddleware across 5 services (order, wallet,
inventory, catalog, fnb-engine). Eliminates SQL injection pattern;
set_config(key, $1, true) is local-to-transaction, same semantics as SET LOCAL.

BACK-C-01: Remove AllowAnyOrigin() from all 26 services. Switch to
WithOrigins() reading AllowedOrigins config array, with dev-only fallback
to localhost. In production, set AllowedOrigins=["https://goodgo.vn",
"https://admin.goodgo.vn"] via environment config.

BACK-C-03: Standardize OrdersController GET /orders/{id} 404 response
from {Message:...} to {success:false, error:{code,message}} per API contract.

BACK-C-04: Add complete ProblemDetails exception mappings to _template_dot_net:
ValidationException -> 400, DomainException -> 422, with TODO comments
for service-specific types (EntityNotFoundException -> 404, etc.).

BACK-C-02: wallet-service and booking-service already have full
IRequestManager idempotency implementation — no changes needed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-03-23 09:48:22 +07:00
parent 25f68781ad
commit 97b54ebd39
28 changed files with 181 additions and 45 deletions

View File

@@ -57,11 +57,63 @@ try
// EN: Add controllers / VI: Thêm controllers
builder.Services.AddControllers();
// EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware
// EN: Add ProblemDetails middleware (RFC 7807) with domain exception mappings.
// All services MUST map their domain exceptions here so ProblemDetails middleware
// handles them before the generic 500 fallback.
// VI: Thêm ProblemDetails middleware (RFC 7807) với domain exception mappings.
// Mọi service PHẢI map domain exceptions ở đây để ProblemDetails middleware
// xử lý chúng trước fallback 500 chung.
builder.Services.AddProblemDetails(options =>
{
options.IncludeExceptionDetails = (ctx, ex) =>
builder.Environment.IsDevelopment();
// EN: Map FluentValidation.ValidationException to 400 BadRequest with field-level errors.
// VI: Map FluentValidation.ValidationException sang 400 BadRequest với lỗi theo field.
options.Map<FluentValidation.ValidationException>(ex =>
{
var errors = ex.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
return new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(errors)
{
Title = "Validation Error",
Status = StatusCodes.Status400BadRequest,
Detail = "One or more validation errors occurred.",
Type = "https://httpstatuses.io/400"
};
});
// EN: Map DomainException (base) to 422 Unprocessable Entity.
// Replace with your specific domain exception types:
// e.g. DuplicateResourceException -> 409, EntityNotFoundException -> 404
// VI: Map DomainException (base) sang 422 Unprocessable Entity.
// Thay bằng các domain exception cụ thể của service:
// ví dụ DuplicateResourceException -> 409, EntityNotFoundException -> 404
options.Map<MyService.Domain.Exceptions.DomainException>(ex =>
new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Title = "Business Rule Violation",
Status = StatusCodes.Status422UnprocessableEntity,
Detail = ex.Message,
Type = "https://httpstatuses.io/422"
});
// EN: TODO — add service-specific mappings below following this pattern:
//
// options.Map<MyService.Domain.Exceptions.EntityNotFoundException>(ex =>
// new ProblemDetails { Title = "Not Found", Status = 404, Detail = ex.Message,
// Type = "https://httpstatuses.io/404" });
//
// options.Map<MyService.Domain.Exceptions.DuplicateResourceException>(ex =>
// new ProblemDetails { Title = "Conflict", Status = 409, Detail = ex.Message,
// Type = "https://httpstatuses.io/409" });
//
// VI: TODO — thêm mappings riêng cho từng service theo pattern này.
});
// EN: Add Swagger / VI: Thêm Swagger
@@ -85,12 +137,19 @@ try
name: "postgresql",
tags: ["db", "postgresql"]);
// EN: Add CORS / VI: Thêm CORS
// EN: Add CORS — restrict to allowed origins. In production, set AllowedOrigins in config
// to ["https://goodgo.vn", "https://admin.goodgo.vn"]. Dev fallback is localhost only.
// NOTE: Do NOT use AllowAnyOrigin() in any environment — Traefik handles external traffic.
// VI: Thêm CORS — giới hạn origins được phép. Trong production, đặt AllowedOrigins trong config
// thành ["https://goodgo.vn", "https://admin.goodgo.vn"]. Dev fallback chỉ localhost.
// LƯU Ý: KHÔNG dùng AllowAnyOrigin() trong bất kỳ môi trường nào — Traefik xử lý traffic ngoài.
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -114,7 +114,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -114,7 +114,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -114,7 +114,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -119,7 +119,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -114,7 +114,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -91,7 +91,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -64,15 +64,22 @@ public class TenantMiddleware
await npgsqlConnection.OpenAsync();
}
// EN: Set shop_id as the primary tenant identifier
// VI: Đặt shop_id làm tenant identifier chính
// EN: Set shop_id as the primary tenant identifier using parameterized set_config()
// to prevent SQL injection. set_config(key, value, is_local) is equivalent
// to SET LOCAL but supports proper parameter binding.
// VI: Đặt shop_id làm tenant identifier chính dùng set_config() có tham số
// để ngăn SQL injection. set_config(key, value, is_local) tương đương
// SET LOCAL nhưng hỗ trợ parameter binding đúng cách.
await using var cmd = npgsqlConnection.CreateCommand();
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
cmd.CommandText = "SELECT set_config('app.current_shop_id', $1, true)";
cmd.Parameters.AddWithValue(shopId.ToString("D"));
await cmd.ExecuteNonQueryAsync();
if (merchantId.HasValue)
{
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
cmd.Parameters.Clear();
cmd.CommandText = "SELECT set_config('app.current_merchant_id', $1, true)";
cmd.Parameters.AddWithValue(merchantId.Value.ToString("D"));
await cmd.ExecuteNonQueryAsync();
}

View File

@@ -127,7 +127,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -64,15 +64,22 @@ public class TenantMiddleware
await npgsqlConnection.OpenAsync();
}
// EN: Set shop_id as the primary tenant identifier
// VI: Đặt shop_id làm tenant identifier chính
// EN: Set shop_id as the primary tenant identifier using parameterized set_config()
// to prevent SQL injection. set_config(key, value, is_local) is equivalent
// to SET LOCAL but supports proper parameter binding.
// VI: Đặt shop_id làm tenant identifier chính dùng set_config() có tham số
// để ngăn SQL injection. set_config(key, value, is_local) tương đương
// SET LOCAL nhưng hỗ trợ parameter binding đúng cách.
await using var cmd = npgsqlConnection.CreateCommand();
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
cmd.CommandText = "SELECT set_config('app.current_shop_id', $1, true)";
cmd.Parameters.AddWithValue(shopId.ToString("D"));
await cmd.ExecuteNonQueryAsync();
if (merchantId.HasValue)
{
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
cmd.Parameters.Clear();
cmd.CommandText = "SELECT set_config('app.current_merchant_id', $1, true)";
cmd.Parameters.AddWithValue(merchantId.Value.ToString("D"));
await cmd.ExecuteNonQueryAsync();
}

View File

@@ -114,7 +114,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -243,7 +243,9 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -64,15 +64,22 @@ public class TenantMiddleware
await npgsqlConnection.OpenAsync();
}
// EN: Set shop_id as the primary tenant identifier
// VI: Đặt shop_id làm tenant identifier chính
// EN: Set shop_id as the primary tenant identifier using parameterized set_config()
// to prevent SQL injection. set_config(key, value, is_local) is equivalent
// to SET LOCAL but supports proper parameter binding.
// VI: Đặt shop_id làm tenant identifier chính dùng set_config() có tham số
// để ngăn SQL injection. set_config(key, value, is_local) tương đương
// SET LOCAL nhưng hỗ trợ parameter binding đúng cách.
await using var cmd = npgsqlConnection.CreateCommand();
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
cmd.CommandText = "SELECT set_config('app.current_shop_id', $1, true)";
cmd.Parameters.AddWithValue(shopId.ToString("D"));
await cmd.ExecuteNonQueryAsync();
if (merchantId.HasValue)
{
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
cmd.Parameters.Clear();
cmd.CommandText = "SELECT set_config('app.current_merchant_id', $1, true)";
cmd.Parameters.AddWithValue(merchantId.Value.ToString("D"));
await cmd.ExecuteNonQueryAsync();
}

View File

@@ -113,7 +113,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -162,7 +162,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -101,7 +101,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -91,7 +91,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -92,7 +92,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -92,7 +92,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -92,7 +92,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -96,7 +96,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -70,7 +70,7 @@ public class OrdersController : ControllerBase
if (result == null)
{
return NotFound(new { Message = $"Order with ID {id} not found" });
return NotFound(new { success = false, error = new { code = "ORDER_NOT_FOUND", message = $"Order with ID {id} not found" } });
}
return Ok(result);

View File

@@ -68,15 +68,22 @@ public class TenantMiddleware
await npgsqlConnection.OpenAsync();
}
// EN: Set shop_id as the primary tenant identifier
// VI: Đặt shop_id làm tenant identifier chính
// EN: Set shop_id as the primary tenant identifier using parameterized set_config()
// to prevent SQL injection. set_config(key, value, is_local) is equivalent
// to SET LOCAL but supports proper parameter binding.
// VI: Đặt shop_id làm tenant identifier chính dùng set_config() có tham số
// để ngăn SQL injection. set_config(key, value, is_local) tương đương
// SET LOCAL nhưng hỗ trợ parameter binding đúng cách.
await using var cmd = npgsqlConnection.CreateCommand();
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
cmd.CommandText = "SELECT set_config('app.current_shop_id', $1, true)";
cmd.Parameters.AddWithValue(shopId.ToString("D"));
await cmd.ExecuteNonQueryAsync();
if (merchantId.HasValue)
{
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
cmd.Parameters.Clear();
cmd.CommandText = "SELECT set_config('app.current_merchant_id', $1, true)";
cmd.Parameters.AddWithValue(merchantId.Value.ToString("D"));
await cmd.ExecuteNonQueryAsync();
}

View File

@@ -136,7 +136,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -135,7 +135,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -142,7 +142,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});

View File

@@ -64,15 +64,22 @@ public class TenantMiddleware
await npgsqlConnection.OpenAsync();
}
// EN: Set shop_id as the primary tenant identifier
// VI: Đặt shop_id làm tenant identifier chính
// EN: Set shop_id as the primary tenant identifier using parameterized set_config()
// to prevent SQL injection. set_config(key, value, is_local) is equivalent
// to SET LOCAL but supports proper parameter binding.
// VI: Đặt shop_id làm tenant identifier chính dùng set_config() có tham số
// để ngăn SQL injection. set_config(key, value, is_local) tương đương
// SET LOCAL nhưng hỗ trợ parameter binding đúng cách.
await using var cmd = npgsqlConnection.CreateCommand();
cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'";
cmd.CommandText = "SELECT set_config('app.current_shop_id', $1, true)";
cmd.Parameters.AddWithValue(shopId.ToString("D"));
await cmd.ExecuteNonQueryAsync();
if (merchantId.HasValue)
{
cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'";
cmd.Parameters.Clear();
cmd.CommandText = "SELECT set_config('app.current_merchant_id', $1, true)";
cmd.Parameters.AddWithValue(merchantId.Value.ToString("D"));
await cmd.ExecuteNonQueryAsync();
}

View File

@@ -155,7 +155,9 @@ try
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
.AllowAnyMethod()
.AllowAnyHeader();
});