SecLab

API security

API Top 10
01

What it is

API security is the set of flaws that appear because an API has no UI to hide behind. In a server-rendered application the UI decides what the user sees; in an API every endpoint is public surface and the client is just one of many callers. The OWASP API Security Top 10 is the catalogue of this family.

02

Why you should care

Relevance: CoreExpected: L2

The right question is not "what is special about API security" but "what disappears when the UI goes away". Three things, and all three are assumptions nobody wrote down:

  • The UI used to be the authorisation filter. The Delete button is hidden from normal users, so nobody authorised the DELETE endpoint. Remove the UI and the endpoint is still there, and the OpenAPI spec tells everyone it exists.
  • The UI used to be the data filter. A page shows only a name and avatar, so returning the whole user object with email, phone, passwordChangedAt and internalNotes is "fine" — the user never sees it. But curl sees all of it. This is API3: Broken Object Property Level Authorization, and it is the most common flaw in every API I have read.
  • The UI used to be the rate limiter. A person clicks once a second; a script calls 5,000 times a second. With no UI there is no natural pace.

And one thing an API adds rather than removes: surface nobody knows is running. v1 still lives after v2 ships, /internal/* is on the internet because of one ingress line, a debug endpoint from six months ago. That is API9 — and the Optus incident (block 5) is exactly it.

03

How the attack works

There is no single mechanism. The ten API Top 10 categories collapse into four questions, and those four are how you audit an API systematically.

Diagram source
flowchart TD    R["A request reaches an endpoint"] --> Q1{"① WHO is calling?<br/>Authentication"}    Q1 -->|"weak token, no expiry,<br/>alg none"| F1["API2 · Broken Authentication"]    Q1 --> Q2{"② May they do THIS?<br/>Function-level authz"}    Q2 -->|"admin endpoint checks no role"| F2["API5 · BFLA"]    Q2 --> Q3{"③ May they do it to THIS OBJECT?<br/>Object-level authz"}    Q3 -->|"id=43 belongs to someone else"| F3["API1 · BOLA"]    Q3 --> Q4{"④ May they READ/WRITE this field?<br/>Property-level authz"}    Q4 -->|"returns internalNotes"| F4["API3 · over-reading"]    Q4 -->|"accepts role=admin"| F5["API3 · mass assignment"]    Q4 --> OK["Handle it"]

Those four must be answered in that order, and each is its own layer. Answering ① and skipping the other three is the state of most APIs — because [Authorize] answers ① and looks like the job is done.

The ten categories, and where their fixes live:

CodeNameFix lives in
API1Broken Object Level AuthorizationThe query — see the access-control topic
API2Broken AuthenticationThe token layer — see the jwt and oauth-oidc topics
API3Broken Object Property Level AuthzSeparate DTOs for in and out
API4Unrestricted Resource ConsumptionPage-size caps, query depth, body size
API5Broken Function Level AuthorizationDeny by default plus per-endpoint policy
API6Unrestricted Access to Business FlowsSee the business-logic topic
API7SSRFSee the ssrf topic
API8Security MisconfigurationCORS, headers, environment
API9Improper Inventory ManagementKnowing what you run
API10Unsafe Consumption of Third-Party APIsTreat third-party responses as untrusted input

API3 and API9 are the two genuinely API-specific categories, and the two worth reading closely — the other eight already have their own SecLab topics. API3 because it is the most common flaw and almost never reported (nothing is "broken"). API9 because it is the flaw nobody is looking for: you cannot audit an endpoint you do not know exists.

Diagram description: A decision diagram for a request reaching an API endpoint, passing four questions in order. Question one asks who is calling, and skipping it leads to API2 Broken Authentication. Question two asks whether the caller may perform this action, and skipping it leads to API5 BFLA. Question three asks whether they may do it to this specific object, and skipping it leads to API1 BOLA. Question four asks whether each field may be read or written, and skipping it leads to API3 in two forms: returning internal fields and accepting fields the caller may not write. Only when all four are answered is the request handled.

04

Concrete example

One profile endpoint. The web page shows three fields; the API returns fourteen.

HTTP
# API3 · over-reading. This endpoint serves a page showing a name, avatar and bio.GET /api/v1/users/1042 HTTP/1.1Authorization: Bearer <an ordinary user's token> HTTP/1.1 200 OK{  "id": 1042, "displayName": "Alice", "avatarUrl": "…", "bio": "…",  "email": "alice@acme.com",              ← never shown on the page  "phone": "+84901234567",                 ← never shown on the page  "passwordChangedAt": "2026-08-01T…",    ← leaks security history  "failedLoginCount": 3,                   ← leaks brute-force progress  "internalNotes": "VIP – bypass limits",  "role": "Editor", "isDeleted": false,  "stripeCustomerId": "cus_Q3x…",          ← another system's id  "referralCode": "ALICE20"}

Nothing is "broken" here. The page works, nobody files a bug, and the endpoint leaks ten fields.

HTTP
# API3 · mass assignment. `role` appears in the response, so try writing it.PATCH /api/v1/users/1042 HTTP/1.1{"bio":"hello","role":"Admin","isDeleted":false} HTTP/1.1 200 OK{"id":1042,"role":"Admin"}      ← the model binder took a field it should not
HTTP
# API9 · surface nobody knows is running. v2 shipped six months ago.GET /api/v1/users/1042 HTTP/1.1     → 200  (v1 still lives, nobody patches it)GET /internal/users/export HTTP/1.1 → 200  (one ingress line put it on the internet)GET /api/v1/debug/config HTTP/1.1   → 200  (from a sprint last year)
HTTP
# API4 · no ceiling. One request, and it is the whole table.GET /api/v1/users?pageSize=1000000 HTTP/1.1 HTTP/1.1 200 OK   (412MB, 38 seconds, and one OOMKilled pod)
C#Three categories in one controller: API3 in both directions, and 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

What happened in the wild

Optus, September 2022 — ~9.8 million Australian customers. An unauthenticated API on a subdomain, with a sequential contactId. The records included passports, driving licences and Medicare numbers. This is API1 and API9 at once, and the API9 half is the lesson: per the ACMA proceedings, that endpoint was left behind after an infrastructure change — it kept running, nobody knew, and nobody audits something they do not know exists.

T-Mobile, January 2023 — 37 million accounts. An API allowed customer data to be read at high volume for over 40 days before detection. Not an exotic flaw: API1 (reading other people's data) combined with API4 (no ceiling, no rate-based detection). Forty days is the number to remember — it says the missing piece was not a fix but observation.

Peloton, 2021 (cited in the access-control topic) is worth repeating here for a different detail: the first fix merely required being logged in. That is answering question ① from block 3 and stopping — exactly the pattern that diagram warns about.

06

How to defend

Layer 1

Separate DTOs for IN and OUT — never serialize the entity

mandatory

This is the fix for API3 in both directions, and the most important one here because API3 is the most common flaw nobody reports.

Outbound — never serialize the entity. A UserDto lists explicitly which fields go out. What matters is not "having a DTO" but the direction of the default: when somebody adds an internalRiskScore column to the entity, it does not appear in the response. The reverse — serialize the entity and [JsonIgnore] what you do not want — means every new column is public by default, and whoever adds one has to remember. They will not.

Inbound — never bind onto the entity. An UpdateProfileRequest(string? DisplayName, string? Bio). No Role, no IsDeleted — and the crucial part is that they do not exist, not that they are "ignored": an ignored field is one refactor away from being bound again.

And different DTOs per audience. UserPublicDto (name, avatar, bio) differs from UserSelfDto (plus email, phone) differs from UserAdminDto (plus internalNotes, failedLoginCount). One DTO with an if (isAdmin) inside the mapper is where one wrong condition leaks everything.

One contract test is worth writing: serialize every DTO and assert no field name appears from a forbidden list (passwordHash, internalNotes, isCorrect…). SecLab already has exactly that test for QuizOptionDto (design/11 §4.3).

C# · Layer 1Per-audience DTOs out, a narrow DTO in, clamped ceilings, and a policy for 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);
Layer 1b

Answer all four questions, and deny by default

mandatory

The four questions in block 3 are four separate layers, and [Authorize] answers only ①.

  • Question ② (API5) — a global [Authorize] as the default, [AllowAnonymous] as an exception you type out, and admin endpoints carrying their own policy. The direction matters: a newly added controller must default to closed. See access-control layer 1c.
  • Question ③ (API1) — ownership in the query, GetByIdAsync(id, userId). See access-control layer 1.
  • Question ④ (API3) — the DTOs above.

And one API-specific point: an endpoint absent from the UI still has to answer all four. This is where BFLA lives — DELETE /api/admin/users/{id} has no button calling it, so it is in no test flow, and the OpenAPI spec tells everyone it is there.

How to enforce it: a counting test — enumerate every route from EndpointDataSource and cross-reference against authz tests by naming convention. A route with no test fails the build. Same tool as the access-control topic.

Layer 1c

A ceiling on every dimension that can grow (API4)

mandatory

With no UI there is no natural pace. Every number the client sends needs a ceiling, enforced server-side and not overridable by a parameter:

  • pageSize: Math.Min(request.PageSize, 100), not a validation that returns 400 — clamping leaves no way past it.
  • Body size: at the framework or proxy layer, not in the handler. RequestSizeLimit, client_max_body_size.
  • Query depth and complexity (GraphQL): see the graphql topic.
  • A timeout on every outbound call: a slow dependency becomes a full queue inside your app.
  • Rate limits per account and per endpoint, not only per IP. See the rate-limiting topic.

And ceilings on the dimensions nobody thinks about: array length in an input, iteration count in a job, result count in a search, string length. Each one without a ceiling is a way to exhaust memory.

Layer 2

Know what you run — API9 is the flaw nobody is looking for

mandatory

Optus was API9. And the point of API9 is: you cannot audit an endpoint you do not know exists, so none of the layer-1 controls apply to it.

Four things, and the first has the best effect-per-effort:

  1. Generate the endpoint inventory from code, every build. EndpointDataSource in ASP.NET Core lists every route actually running. Diff it against a reviewed list in the repo, and fail the build on drift. This is the only way a new endpoint cannot reach production invisibly.
  2. Deprecation with a switch-off date, written when the new version ships. v1 lives forever because nobody set a date. Add Deprecation/Sunset headers (RFC 8594) and log every v1 call to learn who still uses it.
  3. No /internal/* behind a public ingress. Check at the ingress layer with a test, not with review — one wrong annotation line is enough, and it appears in no code review.
  4. Scan from OUTSIDE, on a schedule. A code-generated list only sees what is in the code; it cannot see an old service still running on a subdomain nobody deploys to any more. That is exactly the Optus shape.
YAML · Layer 2API9 at the infrastructure layer: no /internal via ingress, and a switch-off date for 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)
Layer 2b

Treat third-party responses as untrusted input (API10)

The category most often skipped entirely, and it has one counter-intuitive point: you validate input from users but absolutely trust the response from an API you call. That API can be compromised, can be MITM'd if you skip TLS validation, or can simply change its shape.

Four things:

  • Validate the response against a schema, as you would user input. An amount field from a payment gateway goes through the same domain checks as an amount from a client.
  • Timeouts and a circuit breaker on every outbound call. Without them, one hung dependency hangs the app.
  • Do not follow redirects blindly — that is SSRF through a different door. See the ssrf topic.
  • Validate TLS certificates, and never disable it "just for testing, we will turn it back on". A ServerCertificateCustomValidationCallback returning true is the longest-lived line in any codebase.
Layer 3

Per-endpoint rate-based detection — 40 days is the number to fix

T-Mobile took over 40 days to detect. The fix for that number is not a control, it is a measurement.

Three low-noise signals, all computable from access logs you already have:

  • Distinct objects touched by one account per hour. A real user reads a few dozen profiles; a script reads ten thousand. This is the clearest signature of bulk enumeration, and it catches the case where every individual request is legitimate.
  • 404 ratio per account. A loop over id produces many 404s — see the access-control topic.
  • An endpoint suddenly receiving traffic after months of silence. That is the signature of a forgotten endpoint somebody just found — the Optus shape exactly.

And misconfiguration (API8) is checked by test, not dashboard: CORS headers, where Access-Control-Allow-Origin reflects Origin alongside credentials: true is a complete vulnerability. See the cors topic.

07

Verifying the fix

This topic is a checklist, not one check. Six of them, and the first is the one almost no codebase has.

1. A contract test: DTOs must not contain forbidden fields. Walk every DTO type by reflection, serialize an instance, and assert no field name appears from a forbidden list. This is the only check that catches API3 before production — because API3 breaks nothing. See the csharp / test tab.

2. Endpoint inventory diffed against a reviewed list (API9):

Shell
# Generated from EndpointDataSource at runtime, diffed against a file in the repo.dotnet run --project tools/EndpointInventory -- --format=txt > /tmp/actual.txtdiff -u docs/api-surface.txt /tmp/actual.txt \  || { echo "The API surface changed without review — update docs/api-surface.txt"; exit 1; }

The important part: it fails both when an endpoint is added and when one is removed. An unnoticed addition is API9; a removal with no file change means the file is stale and worth nothing.

3. BFLA tests for every endpoint absent from the UI. Enumerate routes containing admin or internal and assert an ordinary account gets 403. These are the routes in no test flow, so they need a test of their own.

4. Check the ceilings (API4) cannot be overridden by a parameter:

Shell
B=https://staging.example.com# pageSize must be CLAMPED, not 400-and-try-again-differentlyN=$(curl -s "$B/api/v1/users?pageSize=1000000" -H "Authorization: Bearer $T" | jq '.data|length')[ "$N" -le 100 ] || { echo "pageSize has no ceiling: returned $N"; exit 1; }# And an oversized body is stopped at the proxy, before the apphead -c 50M /dev/zero | curl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/v1/users" --data-binary @-# must be 413, not 500

5. Check no internal surface reaches the 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 "EXPOSED: $p → $C"done

6. Scan from outside, on a schedule, across all subdomains. Check 2 only sees what is in the code. Optus was an endpoint in no codebase at all that was still running. You need a weekly job enumerating subdomains (from CT logs) and scanning ports — and it has to be a job, not a one-off.

C#A reflection-based contract test — the only check that catches API3 before 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

Common mistakes

The "fix"Why it is wrong
[Authorize] on every controllerAnswers question ① from block 3 and skips the other three. Exactly Peloton's first fix
Serialize the entity and [JsonIgnore] unwanted fieldsThe default points the WRONG way: every new column is public until somebody remembers. They will not
One DTO with if (isAdmin) inside the mapperOne wrong condition leaks everything. Per-audience DTOs have no condition to get wrong
Validation returning 400 for an oversized pageSizeClamping is the fix. A 400 is an invitation to try a different number
Hide the endpoint from the OpenAPI specIt still accepts requests. Security by hiding documentation is security by hope
Per-IP rate limitingOne account behind one IP reading ten thousand profiles is legitimate under any per-IP limit
Keep v1 "for compatibility" with no switch-off dateWith no date it lives forever, and it is surface nobody patches
Trust a third-party API responseAPI10. It is untrusted input exactly like a user body
A hand-written endpoint inventory in a wikiStale within two weeks. It must be generated from code and fail the build on drift

This topic's biggest mistake is about existence: treating API security as a list you audit once. API9 says otherwise — the surface grows by itself between audits, and the most dangerous endpoint is the one in no audit, because it did not exist then, or had already been forgotten.

The severity mistake: rating API3 (leaked fields) as "low" because nothing is broken. Ten surplus fields in one response times ten thousand users is a database copy, obtained with a for loop in which every request returned 200.

09

References

Tier 1OWASP API Security Top 10 2023 · OWASP · API Security Top 10 · 2023
Tier 1API3:2023 Broken Object Property Level Authorization · OWASP · API Security Top 10 · 2023
Tier 1API9:2023 Improper Inventory Management · OWASP · API Security Top 10 · 2023
Tier 1RFC 8594 — The Sunset HTTP Header Field · IETF · RFC · RFC 8594
Tier 1Rate limiting middleware in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Tier 2API testing · PortSwigger · Web Security Academy
Tier 2REST Security Cheat Sheet · OWASP · Cheat Sheet Series
Tier 3ACMA commences proceedings against Optus over 2022 data breach · Australian Communications and Media Authority
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

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

Sign upSign in

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

Loading comments…