Là gì
API security là tập những lỗ hổng xuất hiện vì API không có giao diện để giấu lỗi sau. Trong một ứng dụng render server-side, UI quyết định người dùng thấy gì; trong một API, mọi endpoint là bề mặt công khai và client chỉ là một trong nhiều thứ gọi tới nó. OWASP API Security Top 10 là bản danh mục của họ lỗi này.
Vì sao bạn quan tâm
Câu hỏi đúng không phải "API có gì đặc biệt về bảo mật" mà là "cái gì biến mất khi bỏ UI đi". Ba thứ, và cả ba đều là giả định mà không ai viết ra:
- UI từng là bộ lọc phân quyền. Nút Delete không hiện với người dùng thường, nên endpoint
DELETEkhông ai kiểm. Bỏ UI đi thì endpoint vẫn ở đó, và OpenAPI spec nói cho mọi người biết nó tồn tại. - UI từng là bộ lọc dữ liệu. Một trang chỉ hiện tên và avatar, nên endpoint trả cả object user kèm
email,phone,passwordChangedAt,internalNoteslà "không sao" — người dùng không thấy. Nhưngcurlthì thấy hết. Đây là API3: Broken Object Property Level Authorization, và nó là lỗi phổ biến nhất trong mọi API mà tôi từng đọc. - UI từng là bộ giới hạn nhịp. Người dùng bấm một lần một giây; một script gọi 5.000 lần một giây. Không có UI thì không có nhịp tự nhiên nào.
Và một thứ API thêm vào chứ không phải bỏ đi: bề mặt không ai biết là mình đang chạy. API v1 vẫn sống sau khi v2 ra, /internal/* mở ra Internet vì một dòng ingress, một endpoint debug từ sáu tháng trước. Đây là API9 — và sự cố Optus (khối 5) chính là nó.
Cơ chế hoạt động
Không có một cơ chế. Mười hạng mục của API Top 10 gom lại thành bốn câu hỏi, và bốn câu hỏi này là cách rà một API có hệ thống.
flowchart TD R["Request tới một endpoint"] --> Q1{"① AI gọi?<br/>Xác thực"} Q1 -->|"token yếu, không hết hạn,<br/>alg none"| F1["API2 · Broken Authentication"] Q1 --> Q2{"② Được làm việc NÀY?<br/>Phân quyền theo hàm"} Q2 -->|"endpoint admin không kiểm role"| F2["API5 · BFLA"] Q2 --> Q3{"③ Được làm với ĐỐI TƯỢNG này?<br/>Phân quyền theo đối tượng"} Q3 -->|"id=43 của người khác"| F3["API1 · BOLA"] Q3 --> Q4{"④ Được ĐỌC/GHI trường này?<br/>Phân quyền theo thuộc tính"} Q4 -->|"trả về internalNotes"| F4["API3 · đọc quá nhiều"] Q4 -->|"nhận role=admin"| F5["API3 · mass assignment"] Q4 --> OK["Xử lý"]Bốn câu hỏi đó phải được trả lời theo đúng thứ tự, và mỗi câu là một tầng riêng. Trả lời câu ① rồi bỏ ba câu còn lại là trạng thái của phần lớn API — vì [Authorize] trả lời câu ① và trông như đã xong việc.
Mười hạng mục, và bản vá của chúng nằm ở đâu:
| Mã | Tên | Bản vá nằm ở |
|---|---|---|
| API1 | Broken Object Level Authorization | Câu truy vấn — xem topic access-control |
| API2 | Broken Authentication | Tầng token — xem topic jwt, oauth-oidc |
| API3 | Broken Object Property Level Authz | DTO riêng cho vào và ra |
| API4 | Unrestricted Resource Consumption | Trần phân trang, độ sâu query, kích thước body |
| API5 | Broken Function Level Authorization | Mặc định từ chối + policy theo endpoint |
| API6 | Unrestricted Access to Business Flows | Xem topic business-logic |
| API7 | SSRF | Xem topic ssrf |
| API8 | Security Misconfiguration | CORS, header, môi trường |
| API9 | Improper Inventory Management | Biết mình đang chạy gì |
| API10 | Unsafe Consumption of Third-Party APIs | Coi response của bên thứ ba là input không tin cậy |
API3 và API9 là hai hạng mục riêng của API, và là hai hạng mục đáng đọc kỹ nhất — bảy cái còn lại đã có topic riêng trên SecLab. API3 vì nó là lỗi phổ biến nhất và gần như không bao giờ được báo cáo (không có gì "hỏng"). API9 vì nó là lỗi mà không ai đang tìm: bạn không audit được một endpoint mà bạn không biết là nó tồn tại.
Mô tả sơ đồ: Sơ đồ quyết định cho một request tới endpoint API, đi qua bốn câu hỏi theo thứ tự. Câu một hỏi ai đang gọi và bỏ qua nó dẫn tới API2 Broken Authentication. Câu hai hỏi người gọi có được làm hành động này không, và bỏ qua dẫn tới API5 BFLA. Câu ba hỏi có được làm với đối tượng cụ thể này không, và bỏ qua dẫn tới API1 BOLA. Câu bốn hỏi có được đọc hoặc ghi từng trường không, và bỏ qua dẫn tới API3 ở hai dạng: trả về trường nội bộ và nhận trường không được phép ghi. Chỉ khi cả bốn câu được trả lời thì request mới được xử lý.
Ví dụ cụ thể
Cùng một endpoint profile. Trang web hiện đúng ba trường; API trả về mười bốn.
# API3 · đọc quá nhiều. Endpoint này phục vụ một trang hiện tên, avatar và bio.GET /api/v1/users/1042 HTTP/1.1Authorization: Bearer <token của một người dùng thường> HTTP/1.1 200 OK{ "id": 1042, "displayName": "Alice", "avatarUrl": "…", "bio": "…", "email": "alice@acme.com", ← không hiện trên trang "phone": "+84901234567", ← không hiện trên trang "passwordChangedAt": "2026-08-01T…", ← rò lịch sử bảo mật "failedLoginCount": 3, ← rò cho brute force "internalNotes": "VIP – bỏ qua giới hạn", "role": "Editor", "isDeleted": false, "stripeCustomerId": "cus_Q3x…", ← id của hệ thống khác "referralCode": "ALICE20"}Không có gì "hỏng" ở đây. Trang web hoạt động đúng, không ai báo lỗi, và endpoint rò mười trường.
# API3 · mass assignment. Trường `role` có trong response, nên thử ghi nó.PATCH /api/v1/users/1042 HTTP/1.1{"bio":"xin chào","role":"Admin","isDeleted":false} HTTP/1.1 200 OK{"id":1042,"role":"Admin"} ← model binder nhận cả trường không nên nhận# API9 · bề mặt không ai biết là đang chạy. v2 đã ra sáu tháng trước.GET /api/v1/users/1042 HTTP/1.1 → 200 (v1 vẫn sống, không ai vá nó)GET /internal/users/export HTTP/1.1 → 200 (một dòng ingress mở nó ra Internet)GET /api/v1/debug/config HTTP/1.1 → 200 (từ một sprint năm ngoái)# API4 · không có trần. Một request, và nó là toàn bộ bảng.GET /api/v1/users?pageSize=1000000 HTTP/1.1 HTTP/1.1 200 OK (412MB, 38 giây, và một pod bị OOMKilled)[ApiController][Route("api/v1/users")][Authorize] // trả lời câu ① ở khối 3, và chỉ câu ①public class UsersController(SecLabDbContext db) : ControllerBase{ [HttpGet("{id:long}")] public async Task<IActionResult> Get(long id, CancellationToken ct) { var user = await db.Users.FindAsync([id], ct); if (user is null) return NotFound(); // ❌ API3 · đọc quá nhiều. Serialize thẳng ENTITY, nên response mang mọi cột: // email, phone, passwordChangedAt, failedLoginCount, internalNotes, // stripeCustomerId. Trang web chỉ hiện ba trường nên không ai thấy vấn đề. // // Và điều tệ hơn là HƯỚNG của mặc định: cột nào thêm vào entity ngày mai // cũng tự động công khai. return Ok(user); } [HttpPatch("{id:long}")] public async Task<IActionResult> Update(long id, [FromBody] AppUser patch, CancellationToken ct) { // ❌ API3 · mass assignment. Model binder bind cả Role và IsDeleted vì // chúng là property của AppUser. Client gửi {"role":"Admin"} là đủ. var user = await db.Users.FindAsync([id], ct); if (user is null) return NotFound(); db.Entry(user).CurrentValues.SetValues(patch); await db.SaveChangesAsync(ct); return Ok(user); } [HttpGet] public async Task<IActionResult> List(int page = 1, int pageSize = 20, CancellationToken ct = default) { // ❌ API4 · không có trần. pageSize=1000000 trả toàn bộ bảng: 412MB, // 38 giây, và một pod bị OOMKilled. Không cần lỗ hổng nào khác. return Ok(await db.Users.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct)); } // ❌ API5 · BFLA. Không có nút nào trong UI gọi tới đây, nên nó không nằm trong // luồng test nào — và OpenAPI spec nói cho mọi người biết nó tồn tại. [HttpDelete("{id:long}")] public async Task<IActionResult> Delete(long id, CancellationToken ct) { await db.Users.Where(u => u.Id == id).ExecuteDeleteAsync(ct); return NoContent(); }}Chuyện đã xảy ra
Optus, tháng 9/2022 — ~9,8 triệu khách hàng Úc. Một API không xác thực trên một subdomain, contactId tăng dần. Hồ sơ gồm hộ chiếu, giấy phép lái xe, Medicare. Đây là API1 cộng API9 cùng lúc, và phần API9 mới là bài học: theo hồ sơ tố tụng của ACMA, endpoint đó bị bỏ quên sau một thay đổi hạ tầng — nó vẫn chạy, không ai biết, và không ai audit một thứ mà mình không biết là có.
T-Mobile, tháng 1/2023 — 37 triệu tài khoản. Một API cho phép đọc dữ liệu khách hàng ở tốc độ cao trong hơn 40 ngày trước khi bị phát hiện. Không phải một lỗ hổng mới lạ: kết hợp API1 (đọc được của người khác) và API4 (không có trần, không có phát hiện theo tốc độ). Bốn mươi ngày là con số đáng nhớ — nó nói rằng phần thiếu không phải bản vá mà là quan sát.
Peloton, 2021 (đã dẫn ở topic access-control) đáng nhắc lại ở đây vì một chi tiết khác: bản vá đầu tiên chỉ yêu cầu đăng nhập. Đó là trả lời câu hỏi ① ở khối 3 rồi dừng lại — đúng mẫu mà sơ đồ đó cảnh báo.
Cách phòng chống
DTO riêng cho VÀO và cho RA — không bao giờ serialize entity
bắt buộcĐây là bản vá cho API3 ở cả hai chiều, và nó là bản vá quan trọng nhất của topic này vì API3 là lỗi phổ biến nhất mà không ai báo cáo.
Chiều RA — không serialize entity. Một UserDto liệt kê tường minh những trường được trả về. Cái quan trọng không phải là "có một DTO" mà là hướng của mặc định: khi ai đó thêm một cột internalRiskScore vào entity, nó không tự xuất hiện trong response. Ngược lại — serialize entity rồi [JsonIgnore] những trường không muốn — nghĩa là mọi cột mới đều mặc định công khai, và người thêm cột phải nhớ. Họ sẽ không nhớ.
Chiều VÀO — không bind vào entity. Một UpdateProfileRequest(string? DisplayName, string? Bio). Không có Role, không có IsDeleted — và điểm mấu chốt là chúng không tồn tại, không phải "bị bỏ qua": một trường bị bỏ qua chỉ cách một lần refactor với việc được bind lại.
Và DTO khác nhau theo người xem. UserPublicDto (tên, avatar, bio) khác UserSelfDto (thêm email, phone) khác UserAdminDto (thêm internalNotes, failedLoginCount). Một DTO duy nhất với if (isAdmin) bên trong mapper là chỗ mà một điều kiện sai làm rò tất cả.
Có một contract test đáng viết: serialize mọi DTO và khẳng định không có tên trường nào nằm trong danh sách cấm (passwordHash, internalNotes, isCorrect…). SecLab đã có đúng test đó cho QuizOptionDto (design/11 §4.3).
// ── Chiều RA · DTO riêng theo NGƯỜI XEM ─────────────────────────────────────//// Ba record chứ không một record với if(isAdmin) bên trong mapper: một điều kiện// sai trong mapper làm rò tất cả, còn ba type riêng thì không có điều kiện nào để sai.//// Và hướng của mặc định là điểm chính: cột internalRiskScore thêm vào entity ngày// mai KHÔNG tự xuất hiện ở đây. Cách ngược lại — serialize entity rồi [JsonIgnore] —// làm mọi cột mới công khai cho tới khi có người nhớ.public record UserPublicDto(long Id, string DisplayName, string? AvatarUrl, string? Bio); public record UserSelfDto(long Id, string DisplayName, string? AvatarUrl, string? Bio, string Email, string? Phone, DateTime CreatedAt); public record UserAdminDto(long Id, string DisplayName, string Email, string Role, bool IsDeleted, int FailedLoginCount, string? InternalNotes, DateTime? LastLoginAt); // ── Chiều VÀO · chỉ những trường client THẬT SỰ được quyết ──────────────────//// Role và IsDeleted KHÔNG có ở đây, và điều quan trọng là chúng không tồn tại chứ// không phải bị bỏ qua: một trường "bị bỏ qua" chỉ cách một lần refactor với việc// được bind lại.public record UpdateProfileRequest(string? DisplayName, string? Bio, string? Phone); [ApiController][Route("api/v1/users")][Authorize]public class UsersController(IUserRepository users, ICurrentUser me) : ControllerBase{ private const int MaxPageSize = 100; [HttpGet("{id:long}")] public async Task<IActionResult> Get(long id, CancellationToken ct) { var user = await users.GetByIdAsync(id, ct); if (user is null) throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); // Câu ④ ở khối 3 — trả lời bằng việc CHỌN TYPE, không bằng một câu if lồng // trong mapper. Mỗi nhánh trả một type khác nhau, nên trình biên dịch bảo đảm // nhánh "người khác" không thể vô tình mang theo trường của nhánh "admin". return me.Role >= SystemRole.Editor ? Ok(UserMapper.ToAdmin(user)) : id == me.Id.Value ? Ok(UserMapper.ToSelf(user)) : Ok(UserMapper.ToPublic(user)); } [HttpPatch("{id:long}")] public async Task<IActionResult> Update(long id, [FromBody] UpdateProfileRequest req, CancellationToken ct) { // Câu ③ — ownership. Người dùng chỉ sửa được profile của mình; admin đi qua // một endpoint khác với policy khác, không qua một cờ trong endpoint này. if (id != me.Id.Value) throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); // 404, không 403 var user = await users.GetByIdAsync(id, ct) ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); // Aggregate nhận từng giá trị tường minh. Không có SetValues(patch) nào, nên // không có đường nào để một trường ngoài dự tính đi vào entity. user.UpdateProfile(req.DisplayName, req.Bio, req.Phone, DateTime.UtcNow); await users.SaveAsync(user, ct); return Ok(UserMapper.ToSelf(user)); } [HttpGet] public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) { // KẸP, không validate-rồi-trả-400. Một 400 là câu "thử lại bằng con số khác đi"; // kẹp lại thì không có con số nào vượt qua được. var size = Math.Clamp(pageSize, 1, MaxPageSize); var p = Math.Max(page, 1); var (items, total) = await users.PageAsync(p, size, ct); return Ok(new { data = items.Select(UserMapper.ToPublic).ToList(), page = p, pageSize = size, total, }); } // Câu ② — policy riêng, viết ra ở đúng endpoint. Endpoint không có trong UI // KHÔNG được thừa hưởng mức quyền của controller. [HttpDelete("{id:long}")] [Authorize(Policy = Policies.UserAdmin)] public async Task<IActionResult> Delete(long id, CancellationToken ct) { await users.SoftDeleteAsync(id, me.Id, ct); // soft delete: dữ liệu còn để audit return NoContent(); }} // ── Program.cs · mặc định TỪ CHỐI, và trần ở tầng framework ─────────────────builder.Services.AddAuthorizationBuilder() .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()) .AddPolicy(Policies.UserAdmin, p => p.RequireRole(nameof(SystemRole.Admin))); // API4 — kích thước body ở tầng framework, không trong handler: kiểm trong handler// là kiểm SAU KHI đã nhận hết vào RAM.builder.Services.Configure<KestrelServerOptions>(o => o.Limits.MaxRequestBodySize = 2 * 1024 * 1024);Trả lời cả bốn câu hỏi, và mặc định là TỪ CHỐI
bắt buộcBốn câu ở khối 3 là bốn tầng riêng, và [Authorize] chỉ trả lời câu ①.
- Câu ② (API5) —
[Authorize]toàn cục làm mặc định,[AllowAnonymous]là ngoại lệ phải gõ ra, và endpoint admin có policy riêng. Chiều này quan trọng: một controller mới thêm vào phải mặc định đóng. Xem topic access-control lớp 1c. - Câu ③ (API1) — ownership trong câu truy vấn,
GetByIdAsync(id, userId). Xem topic access-control lớp 1. - Câu ④ (API3) — DTO ở trên.
Và một điều riêng của API: endpoint không có trong UI vẫn phải trả lời cả bốn câu. Đây là chỗ BFLA sống — DELETE /api/admin/users/{id} không có nút nào gọi tới nó, nên nó không nằm trong luồng test nào, và OpenAPI spec nói cho mọi người biết nó ở đó.
Cách cưỡng chế: một test đếm — liệt kê mọi route từ EndpointDataSource, đối chiếu với danh sách test authz theo quy ước tên. Route thiếu test thì fail build. Cùng công cụ với topic access-control.
Trần ở mọi chiều tăng được (API4)
bắt buộcKhông có UI thì không có nhịp tự nhiên nào. Mỗi con số mà client gửi lên phải có trần, cưỡng chế ở server và không thể vượt qua bằng tham số:
pageSize:Math.Min(request.PageSize, 100), không phải validation trả 400 — kẹp giá trị lại thì không có đường nào vượt qua.- Kích thước body: ở tầng framework/proxy, không trong handler.
RequestSizeLimit,client_max_body_size. - Độ sâu và độ phức tạp query (GraphQL): xem topic graphql.
- Timeout cho mọi lời gọi đi ra: một dependency chậm biến thành một hàng đợi đầy trong app của bạn.
- Rate limit theo tài khoản và theo endpoint, không chỉ theo IP. Xem topic rate-limiting.
Và trần cho những chiều ít ai nghĩ tới: số phần tử trong một mảng đầu vào, số lần lặp của một job, số kết quả của một tìm kiếm, độ dài chuỗi. Mỗi cái không có trần là một cách làm hết RAM.
Biết mình đang chạy gì — API9 là lỗi không ai đang tìm
bắt buộcOptus là API9. Và điểm của API9 là: bạn không audit được một endpoint mà bạn không biết là có, nên mọi biện pháp ở lớp 1 đều không áp dụng cho nó.
Bốn việc, và việc đầu là việc có tỉ lệ hiệu quả cao nhất:
- Sinh danh mục endpoint từ code, mỗi lần build.
EndpointDataSourcetrong ASP.NET Core liệt kê mọi route thật đang chạy. So nó với danh sách đã duyệt trong repo, và fail build khi lệch. Đây là cách duy nhất để một endpoint mới không đi vào production một cách vô hình. - Deprecation có ngày tắt, viết ra từ lúc ra bản mới.
v1sống mãi vì không ai đặt ngày. Cộng headerDeprecation/Sunset(RFC 8594) và log mọi lượt gọiv1để biết ai còn dùng. - Không có
/internal/*nào đi qua ingress công khai. Kiểm ở tầng ingress bằng test, không bằng review — một dòng annotation sai là đủ, và nó không xuất hiện trong code review nào. - Quét từ NGOÀI, định kỳ. Danh sách endpoint sinh từ code chỉ thấy những gì trong code; nó không thấy một service cũ vẫn chạy trên một subdomain mà không ai còn deploy. Đó chính là hình dạng của Optus.
# ── Ingress · /internal KHÔNG đi ra Internet ────────────────────────────────# Optus là API9, và phần hạ tầng của API9 nằm ở đúng file này: một dòng path sai là# đủ, và nó không xuất hiện trong bất kỳ lần code review nào.ingress: | apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: seclab-public annotations: # Chặn tường minh ở tầng ingress, không dựa vào việc app tự từ chối: app có # thể được deploy sau một ingress khác, và lúc đó phép kiểm biến mất. nginx.ingress.kubernetes.io/server-snippet: | location ~ ^/(internal|metrics|debug|actuator)/ { return 404; } spec: rules: - host: api.example.com http: paths: # Chỉ liệt kê những prefix CÔNG KHAI. Không có path "/" nào — một prefix # bắt tất cả là cách mọi endpoint nội bộ đi ra Internet. - { path: /api/v1, pathType: Prefix, backend: { service: { name: seclab-api, port: { number: 80 } } } } - { path: /api/v2, pathType: Prefix, backend: { service: { name: seclab-api, port: { number: 80 } } } } - { path: /health, pathType: Exact, backend: { service: { name: seclab-api, port: { number: 80 } } } } # ── Deprecation có NGÀY TẮT, viết ra lúc v2 ra mắt ─────────────────────────# v1 sống mãi vì không ai đặt ngày. Đặt ngày lúc v2 ra là lúc duy nhất còn dễ.deprecation: | # Middleware cho mọi route /api/v1: RFC 8594 Deprecation: Sat, 01 Nov 2026 00:00:00 GMT Sunset: Sun, 01 Feb 2027 00:00:00 GMT Link: <https://docs.example.com/api/v2/migration>; rel="deprecation" # Và log mọi lượt gọi v1 kèm client id — không có dữ liệu này thì "ai còn dùng v1" # là một câu không trả lời được, và không trả lời được nghĩa là không dám tắt. metric: api_v1_calls_total{route, client_id} # ── Phát hiện · ba tín hiệu độ nhiễu thấp (lớp 3) ──────────────────────────# T-Mobile mất hơn 40 ngày để phát hiện. Bản vá cho con số đó là ba alert này.alerts: | # ① Liệt kê hàng loạt: đếm ĐỐI TƯỢNG RIÊNG BIỆT, không đếm số request. Đây là # tín hiệu bắt được cả trường hợp mỗi request đều hợp lệ và đều trả 200. - alert: BulkObjectEnumeration expr: | count by (account_id) ( count by (account_id, object_id) ( rate(api_object_access_total[1h]) ) ) > 1000 for: 5m # ② Vòng lặp trên id: rất nhiều 404 từ một tài khoản. - alert: SequentialIdProbing expr: sum by (account_id) (rate(http_responses_total{code="404"}[5m])) > 20 for: 2m # ③ Endpoint im lặng nhiều tháng đột nhiên có lưu lượng — chữ ký của một endpoint # bị quên vừa được ai đó tìm thấy. Chính hình dạng Optus. - alert: DormantEndpointAwake expr: | rate(http_requests_total[10m]) > 0 and on (route) (max_over_time(rate(http_requests_total[10m])[30d:1h]) == 0)Coi response của bên thứ ba là input không tin cậy (API10)
Hạng mục hay bị bỏ hoàn toàn, và nó có một điểm phản trực giác: bạn validate input từ người dùng nhưng tin tuyệt đối response từ một API bạn gọi ra. API đó có thể bị chiếm, có thể bị MITM nếu không kiểm TLS, và có thể chỉ đơn giản là đổi hình dạng dữ liệu.
Bốn việc:
- Validate response theo schema như validate input người dùng. Một trường
amounttừ cổng thanh toán phải qua cùng phép kiểm miền giá trị như mộtamounttừ client. - Timeout và circuit breaker trên mọi lời gọi ra. Không có nó, một dependency treo làm treo cả app.
- Không đi theo redirect một cách mù quáng — đó là SSRF qua một cửa khác. Xem topic ssrf.
- Kiểm chứng chỉ TLS, và không bao giờ tắt nó "để test rồi bật lại". Một
ServerCertificateCustomValidationCallbacktrảtruelà dòng code sống lâu nhất trong mọi codebase.
Phát hiện theo tốc độ trên mỗi endpoint — 40 ngày là con số cần sửa
T-Mobile mất hơn 40 ngày để phát hiện. Bản vá cho con số đó không phải một biện pháp phòng chống, mà là một phép đo.
Ba tín hiệu có độ nhiễu thấp, và tất cả đều tính được từ access log đang có:
- Số đối tượng riêng biệt mà một tài khoản chạm tới trong một giờ. Một người dùng thật đọc profile của vài chục người; một script đọc mười nghìn. Đây là chữ ký rõ nhất của việc liệt kê hàng loạt, và nó bắt được cả trường hợp mỗi request đều hợp lệ.
- Tỉ lệ 404 trên một tài khoản. Một vòng lặp trên
idsinh ra rất nhiều 404 — xem topic access-control. - Endpoint đột nhiên có lưu lượng sau nhiều tháng im lặng. Đó là chữ ký của một endpoint bị quên vừa được ai đó tìm thấy — chính hình dạng Optus.
Và cấu hình sai (API8) thì kiểm bằng test, không bằng dashboard: header CORS, Access-Control-Allow-Origin phản chiếu Origin kèm credentials: true là một lỗ hổng đầy đủ. Xem topic cors.
Kiểm chứng đã vá
Topic này là một danh sách kiểm tra, không một phép kiểm. Sáu phép, và phép đầu là phép gần như không dự án nào có.
1. Contract test: DTO không được chứa trường bị cấm. Duyệt mọi type DTO bằng reflection, serialize một instance, và khẳng định không có tên trường nào trong danh sách cấm. Đây là phép kiểm duy nhất bắt được API3 trước khi nó lên production — vì API3 không làm gì hỏng. Xem tab csharp / test.
2. Danh mục endpoint đối chiếu danh sách đã duyệt (API9):
# Sinh từ EndpointDataSource lúc chạy, so với file trong repo.dotnet run --project tools/EndpointInventory -- --format=txt > /tmp/actual.txtdiff -u docs/api-surface.txt /tmp/actual.txt \ || { echo "Bề mặt API đã đổi mà chưa ai duyệt — cập nhật docs/api-surface.txt"; exit 1; }Điểm quan trọng: nó fail cả khi endpoint bị thêm và khi bị xoá. Thêm mà không ai biết là API9; xoá mà file không đổi nghĩa là file đã lạc hậu và không còn giá trị gì.
3. Test BFLA cho mọi endpoint không có trong UI. Liệt kê route có admin hoặc internal trong đường dẫn và khẳng định tài khoản thường nhận 403. Đây là những route không nằm trong luồng test nào, nên chúng cần một test riêng.
4. Kiểm trần (API4) không vượt được bằng tham số:
B=https://staging.example.com# pageSize phải bị KẸP, không phải trả 400 rồi cho thử lại kiểu khácN=$(curl -s "$B/api/v1/users?pageSize=1000000" -H "Authorization: Bearer $T" | jq '.data|length')[ "$N" -le 100 ] || { echo "pageSize không có trần: trả $N"; exit 1; }# Và body quá lớn bị chặn ở tầng proxy, trước khi vào apphead -c 50M /dev/zero | curl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/v1/users" --data-binary @-# phải là 413, không phải 5005. Kiểm không có bề mặt nội bộ nào ra Internet (API9):
for p in /internal/health /internal/users/export /api/v1/debug/config \ /metrics /actuator/env /swagger; do C=$(curl -s -o /dev/null -w '%{http_code}' "$B$p") [ "$C" = "404" ] || echo "LỘ: $p → $C"done6. Quét từ ngoài, định kỳ, trên toàn bộ subdomain. Phép kiểm 2 chỉ thấy những gì trong code. Optus là một endpoint không còn trong code nào cả mà vẫn chạy. Cần một job hằng tuần liệt kê subdomain (từ CT log) và quét cổng — và nó phải là job, không phải một lần.
public class ApiContractTests{ private static Assembly Application => typeof(SecLab.Application.DependencyInjection).Assembly; /// <summary> /// Danh sách tên trường KHÔNG được xuất hiện trong bất kỳ DTO nào. /// /// Đây là DỮ LIỆU, và đó là điểm: khi ai đó thêm một cột nội bộ mới, họ thêm một /// dòng ở đây và mọi DTO vô tình mang nó ra đều đỏ ngay. Không có cách nào khác /// bắt được API3 trước production, vì API3 không làm gì hỏng — trang web vẫn chạy /// đúng và không ai báo lỗi. /// /// SecLab đã có đúng test này cho isCorrect của QuizOptionDto (design/11 §4.3); /// đây là bản mở rộng cho toàn bộ bề mặt. /// </summary> private static readonly string[] Forbidden = [ "passwordHash", "password", "salt", "internalNotes", "internalRiskScore", "failedLoginCount", "lockoutEnd", "isCorrect", "rationale", // đáp án quiz "stripeCustomerId", "zitadelSub", // id của hệ thống khác "connectionString", "apiKey", "secret", ]; /// <summary> /// Duyệt MỌI type DTO, không chỉ những cái ta nhớ. Reflection ở đây là cố ý: /// một DTO mới thêm vào tuần sau cũng tự động nằm trong phạm vi test. /// </summary> [Fact] public void No_dto_exposes_a_forbidden_field() { var dtos = Application.GetTypes() .Where(t => t.Name.EndsWith("Dto", StringComparison.Ordinal)) .Where(t => t is { IsAbstract: false, IsGenericTypeDefinition: false }) .ToList(); Assert.NotEmpty(dtos); // test phải thật sự tìm thấy DTO, không im lặng pass var violations = new List<string>(); foreach (var dto in dtos) foreach (var prop in dto.GetProperties(BindingFlags.Public | BindingFlags.Instance)) foreach (var bad in Forbidden) if (string.Equals(prop.Name, bad, StringComparison.OrdinalIgnoreCase)) violations.Add($"{dto.Name}.{prop.Name}"); Assert.Empty(violations); } /// <summary> /// Chiều VÀO: DTO đầu vào không được có trường mà chỉ server được quyết. Đây là /// nửa mass assignment của API3, và nó cần một danh sách riêng — role là hợp lệ /// trong một DTO đầu RA của admin, và không bao giờ hợp lệ trong một DTO đầu VÀO. /// </summary> [Fact] public void No_request_dto_accepts_a_privileged_field() { var requests = Application.GetTypes() .Where(t => t.Name.EndsWith("Request", StringComparison.Ordinal) || t.Name.EndsWith("Command", StringComparison.Ordinal)) .Where(t => t is { IsAbstract: false }); string[] privileged = ["role", "isAdmin", "isDeleted", "isVerified", "createdAt", "userId", "tenantId"]; var violations = (from dto in requests from prop in dto.GetProperties() where privileged.Contains(prop.Name, StringComparer.OrdinalIgnoreCase) select $"{dto.Name}.{prop.Name}").ToList(); Assert.Empty(violations); } /// <summary> /// API3 end-to-end: một người dùng thường đọc profile người khác và response /// KHÔNG được mang trường nào ngoài bốn trường công khai. /// /// Khẳng định trên TẬP KHOÁ, không trên vài trường cụ thể: kiểm "không có email" /// vẫn pass khi ai đó thêm một cột mới, còn kiểm "đúng bốn khoá này" thì đỏ. /// </summary> [Fact] public async Task Public_profile_returns_exactly_the_public_fields() { var alice = await _fx.SeedUserAsync("alice@acme.com", role: SystemRole.Learner); var res = await _fx.ClientAs(_fx.Bob).GetAsync($"/api/v1/users/{alice.Id}"); var json = await res.Content.ReadFromJsonAsync<JsonObject>(); Assert.Equal( new[] { "avatarUrl", "bio", "displayName", "id" }, json!.Select(kv => kv.Key).Order().ToArray()); } /// <summary>Mass assignment, end-to-end. Trường không có trong DTO — cố tình gửi.</summary> [Fact] public async Task Privileged_fields_in_the_body_are_not_bound() { var res = await _fx.ClientAs(_fx.Bob).PatchAsJsonAsync($"/api/v1/users/{_fx.Bob.Id}", new { bio = "xin chào", role = "Admin", // không có trong UpdateProfileRequest isDeleted = false, }); res.EnsureSuccessStatusCode(); // Khẳng định ở TẦNG DỮ LIỆU, không ở response: một mapper có thể không trả // role ra mà entity vẫn đã bị đổi. var reloaded = await _fx.GetUserFromDbAsync(_fx.Bob.Id); Assert.Equal(SystemRole.Learner, reloaded.Role); } /// <summary>API4 — trần bị KẸP, không phải trả 400 rồi mời thử số khác.</summary> [Theory] [InlineData(1_000_000)] [InlineData(int.MaxValue)] [InlineData(-1)] public async Task Page_size_is_clamped(int pageSize) { await _fx.SeedUsersAsync(count: 250); var res = await _fx.ClientAs(_fx.Bob).GetAsync($"/api/v1/users?pageSize={pageSize}"); res.EnsureSuccessStatusCode(); var body = await res.Content.ReadFromJsonAsync<PagedResponse<UserPublicDto>>(); Assert.InRange(body!.Data.Count, 1, 100); } /// <summary> /// API5 — BFLA. Route này không có nút nào trong UI gọi tới, nên nó không nằm /// trong luồng test nào khác. Đó chính là lý do nó cần một test riêng. /// </summary> [Theory] [InlineData("DELETE", "/api/v1/users/{id}")] [InlineData("POST", "/api/v1/admin/users/{id}/impersonate")] [InlineData("GET", "/api/v1/admin/audit-log")] public async Task Admin_routes_reject_ordinary_accounts(string method, string template) { var target = await _fx.SeedUserAsync("victim@acme.com"); var path = template.Replace("{id}", target.Id.ToString()); var res = await _fx.ClientAs(_fx.Bob).SendAsync(new HttpRequestMessage(new HttpMethod(method), path)); Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode); Assert.NotNull(await _fx.GetUserFromDbAsync(target.Id)); // và không có tác dụng phụ } /// <summary> /// API9 — bề mặt API phải khớp danh sách đã duyệt. /// /// Test fail cả khi endpoint bị THÊM (đó là API9) và khi bị XOÁ (file đã lạc hậu, /// nên nó không còn giá trị gì). Đây là phép kiểm mà Optus không có. /// </summary> [Fact] public void Api_surface_matches_the_reviewed_inventory() { var actual = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .Select(e => $"{string.Join(",", e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods ?? [])} /{e.RoutePattern.RawText}") .Order() .ToArray(); var reviewed = File.ReadAllLines("docs/api-surface.txt") .Where(l => l.Length > 0 && !l.StartsWith('#')) .Order() .ToArray(); Assert.Equal(reviewed, actual); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
[Authorize] trên mọi controller | Trả lời câu ① ở khối 3 và bỏ ba câu còn lại. Đúng lỗi mà bản vá đầu của Peloton mắc |
Serialize entity rồi [JsonIgnore] trường không muốn | Sai HƯỚNG của mặc định: mọi cột mới đều công khai cho tới khi có người nhớ. Họ sẽ không nhớ |
Một DTO duy nhất với if (isAdmin) trong mapper | Một điều kiện sai làm rò tất cả. DTO riêng theo người xem thì không có điều kiện nào để sai |
Validation trả 400 cho pageSize quá lớn | Kẹp giá trị mới đúng. 400 là một câu "thử lại bằng con số khác đi" |
| Ẩn endpoint khỏi OpenAPI spec | Nó vẫn nhận request. Và bảo mật bằng cách giấu tài liệu là bảo mật bằng cách hy vọng |
| Rate limit theo IP | Một tài khoản qua một IP đọc mười nghìn profile là hành vi hợp lệ theo mọi rate limit theo IP |
Giữ v1 "để tương thích" mà không có ngày tắt | Không có ngày thì nó sống mãi, và nó là bề mặt không ai vá |
| Tin response của API bên thứ ba | API10. Nó là input không tin cậy y như body của người dùng |
| Danh mục endpoint viết tay trong wiki | Nó lạc hậu trong hai tuần. Phải sinh từ code và fail build khi lệch |
Sai lầm lớn nhất của topic này là về sự tồn tại: coi API security là một danh sách để rà một lần. API9 nói ngược lại — bề mặt tự lớn lên giữa các lần rà, và endpoint nguy hiểm nhất là endpoint không có trong lần rà nào vì lúc đó nó chưa tồn tại, hoặc đã bị quên.
Sai lầm về mức độ: coi API3 (rò trường) là "thấp" vì không có gì hỏng. Mười trường thừa trong một response nhân với mười nghìn user là một bản sao cơ sở dữ liệu, lấy được bằng một vòng for mà mọi request trong đó đều trả 200.
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…