SecLab
Server-sideĐầy đủ

API security

API Top 10
01

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.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

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 DELETE khô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, internalNotes là "không sao" — người dùng không thấy. Nhưng curl thì 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ó.

03

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.

Nguồn sơ đồ
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:

TênBản vá nằm ở
API1Broken Object Level AuthorizationCâu truy vấn — xem topic access-control
API2Broken AuthenticationTầng token — xem topic jwt, oauth-oidc
API3Broken Object Property Level AuthzDTO riêng cho vào và ra
API4Unrestricted Resource ConsumptionTrần phân trang, độ sâu query, kích thước body
API5Broken Function Level AuthorizationMặc định từ chối + policy theo endpoint
API6Unrestricted Access to Business FlowsXem topic business-logic
API7SSRFXem topic ssrf
API8Security MisconfigurationCORS, header, môi trường
API9Improper Inventory ManagementBiết mình đang chạy gì
API10Unsafe Consumption of Third-Party APIsCoi 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ý.

04

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.

HTTP
# 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.

HTTP
# 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
HTTP
# 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)
HTTP
# 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)
C#Ba hạng mục trong một controller: API3 hai chiều và API4.
[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();    }}
05

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.

06

Cách phòng chống

Lớp 1

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).

C# · Layer 1DTO riêng theo người xem cho chiều RA, DTO hẹp cho chiều VÀO, trần kẹp lại, policy cho admin.
// ── 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);
Lớp 1b

Trả lời cả bốn câu hỏi, và mặc định là TỪ CHỐI

bắt buộc

Bố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.

Lớp 1c

Trần ở mọi chiều tăng được (API4)

bắt buộc

Khô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.

Lớp 2

Biết mình đang chạy gì — API9 là lỗi không ai đang tìm

bắt buộc

Optus 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:

  1. Sinh danh mục endpoint từ code, mỗi lần build. EndpointDataSource trong 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.
  2. Deprecation có ngày tắt, viết ra từ lúc ra bản mới. v1 sống mãi vì không ai đặt ngày. Cộng header Deprecation/Sunset (RFC 8594) và log mọi lượt gọi v1 để biết ai còn dùng.
  3. 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.
  4. 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.
YAML · Layer 2API9 ở tầng hạ tầng: ingress không để /internal ra ngoài, và có ngày tắt cho v1.
# ── 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)
Lớp 2b

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 amount từ cổng thanh toán phải qua cùng phép kiểm miền giá trị như một amount từ 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 ServerCertificateCustomValidationCallback trả true là dòng code sống lâu nhất trong mọi codebase.
Lớp 3

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 id sinh 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.

07

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):

Shell
# 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ố:

Shell
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 500

5. Kiểm không có bề mặt nội bộ nào ra Internet (API9):

Shell
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"done

6. 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.

C#Contract test bằng reflection — phép kiểm duy nhất bắt được API3 trước production.
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);    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
[Authorize] trên mọi controllerTrả 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ốnSai 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 mapperMộ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ớnKẹ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 specNó 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 IPMộ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ắtKhô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ứ baAPI10. 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 wikiNó 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.

09

Nguồn tham khảo

Bậc 1OWASP API Security Top 10 2023 · OWASP · API Security Top 10 · 2023
Bậc 1API3:2023 Broken Object Property Level Authorization · OWASP · API Security Top 10 · 2023
Bậc 1API9:2023 Improper Inventory Management · OWASP · API Security Top 10 · 2023
Bậc 1RFC 8594 — The Sunset HTTP Header Field · IETF · RFC · RFC 8594
Bậc 1Rate limiting middleware in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Bậc 2API testing · PortSwigger · Web Security Academy
Bậc 2REST Security Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 3ACMA commences proceedings against Optus over 2022 data breach · Australian Communications and Media Authority
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…