PII Leak in Response (Kotlin)
ID |
pii_leak_in_response_kotlin |
Severity |
high / critical (composite with |
Remediation Complexity |
medium |
Remediation Risk |
high |
Remediation Effort |
medium |
Family |
API3:2023 - Broken Object Property Level Authorization |
CWE |
CWE-359 |
Resource |
data_exposure |
Language |
Spring Boot (Kotlin), Ktor, Micronaut |
Description
Reports endpoints whose response model carries fields tagged PII (Personally Identifiable Information), PCI (payment-card data), or PHI (protected health information) by the sensitivity classifier. Distinct from excessive_data_exposure in scope:
-
excessive_data_exposure— over-fetching, any sensitivity-tagged field the client didn’t ask for (including credentials). -
pii_leak_in_response— sensitive-data sinks. Any time PII / PCI / PHI reaches the wire, regardless of whether the client asked for it.
In Python, a DRF write_only field is never serialized and so never reported, and a view that returns the authenticated caller’s own record (it serializes request.user, on a route with no path parameter) is not reported for the caller’s own PII / PHI.
CREDENTIAL and CRYPTO_MATERIAL tags are intentionally excluded — login and OAuth endpoints legitimately return tokens / cookies, and treating those as PII leaks would drown the signal.
The detector pairs with unauthenticated_endpoint via the FlawCorrelator: when the same endpoint also has no authentication requirement, a composite CRITICAL pii_leak_in_unauthenticated_endpoint finding is emitted.
Rationale
PII / PCI / PHI in a response is not automatically a defect — a user fetching their own profile needs to see their email and phone. The risk is when the response carries those fields beyond the operation’s intended scope:
-
Cross-user leak —
/users/search?name=alicereturns full PII for every match. The endpoint’s purpose is "find a username"; returning email / phone / DOB / address is over-disclosure. -
Anonymous / partner endpoint leak — a B2B partner endpoint that returns a list of customers with full PII (or worse, PCI / PHI), where the partner’s contract entitles them to aggregate numbers only.
-
Unauthenticated leak — the worst case. Any unauthenticated PII / PCI / PHI sink is a critical breach — under GDPR Art-32 ("appropriate technical measures") and PCI-DSS 3.4 ("render PAN unreadable"), there’s no defensible "well, it’s TLS-encrypted" argument.
The regulatory side compounds the technical one: a single unauthenticated PII endpoint discovered by a journalist or competitor is a public disclosure event. Treat PII / PCI / PHI in responses as a default-deny shape and justify each exposure case by case.
Kotlin’s brevity makes the mistake easy to make and hard to see: returning the entity is one line
shorter than returning a response type, and a data class serialises every property it declares.
@Entity
data class User(
@Id val id: Long,
val username: String,
val email: String, // PII
val phone: String, // PII
val nationalId: String // PII
)
@RestController
class UserController(private val repo: UserRepository) {
@GetMapping("/users/{id}")
fun get(@PathVariable id: Long): User = repo.findById(id).orElseThrow()
}
Every property reaches the wire, including the three the caller never asked for. Adding a column to the entity silently widens the response.
Remediation
Three approaches, in order of robustness:
-
Remove the field from the response DTO if the operation doesn’t genuinely require it. The fastest fix; defaults to least disclosure.
-
Mask or redact at the response boundary. Show
j***@example.cominstead ofjohn.doe@example.com, last-four-digits of a phone number, the first letter of a name. Pair with full unmasked access from a separate, audited "details" endpoint. -
Tighten authorization on the endpoint. If the field is necessary, confirm the caller is entitled to it — pair this finding with a BOLA / BFLA review. An admin reading any user’s email is acceptable; a logged-in non-admin reading any user’s email is not.
Cross-cutting:
-
If the sensitivity tag is wrong for your project — e.g., an email-newsletter app where
emailis intentionally public — override the tag rather than suppressing the finding. -
Pair this detector with
unauthenticated_endpointreview. The composite CRITICAL finding is the actionable one.
Return a response type that names what the operation exposes:
data class UserResponse(val id: Long, val username: String)
@RestController
class UserController(private val repo: UserRepository) {
@GetMapping("/users/{id}")
fun get(@PathVariable id: Long): UserResponse =
repo.findById(id).orElseThrow().let { UserResponse(it.id, it.username) }
}
The mapping is explicit, so a new entity property cannot reach the response without someone deciding it should.
Where the entity must be serialised directly, annotate the properties that must not leave:
@JsonIgnore for Jackson, @Transient for kotlinx.serialization. Prefer the response type —
an annotation is a denylist, and a denylist fails the moment somebody forgets one.
Response model coverage
These two rules compare an endpoint’s response model against its request, so they report nothing
at all on an endpoint whose response model was never established. That is not the same as a clean
result, and the report says which of the two it is: every endpoint carries a responseTypeState of
present, absent (the handler answers with a status and no body) or undetermined (nothing could
establish what it returns). An endpoint with no finding and responseTypeState: undetermined was
not examined by these rules.
Every framework whose endpoints are discovered has its response model read:
| Language | Where the response model is read from |
|---|---|
Java / Kotlin |
Spring MVC and JAX-RS (the handler’s declared return type), Micronaut (same, unwrapping
|
C# |
ASP.NET Core |
JavaScript / TypeScript |
Express, Koa, Fastify and Hono (the response DTO the route resolves); NestJS (the method’s
TypeScript return type); Next.js, Nuxt, SvelteKit and Remix (the object the handler returns, or
the one it hands to |
Python |
Django (incl. DRF), FastAPI, Flask |
Ruby |
Rails ( |
Swift |
Vapor (the route handler’s declared return type, unwrapping |
Go |
net/http, gin, echo, chi and fiber (the payload of the handler’s JSON write, resolved to a struct or read as the map literal it is) |
PHP |
Laravel and Symfony (the JSON response’s argument: an API Resource or transformer’s keys, an
Eloquent model’s |
Any |
An OpenAPI or GraphQL description of the endpoint, where the project ships one |
How often it can be established varies, and depends on how a project writes its handlers rather than on the framework:
-
a declared return type — Micronaut, Vapor, NestJS in TypeScript — resolves nearly always;
-
a response built from a named model, serializer or struct resolves when that declaration is in the scanned source, and not when it comes from a dependency;
-
a response assembled inline — a map, a hash, an object literal — is read as an object of its own keys, which is exact, and is named after the endpoint that returns it;
-
a response produced by a project-specific indirection — a serializer registry keyed by a symbol, a helper that takes the shape as an argument and wraps it — is
undetermined, and says so rather than being guessed at.
One limit applies even where the model is read: a response type declared in a dependency rather than in the scanned source has a name but no fields, so the report shows the type and these two rules have nothing to compare it against.
Configuration
The detector accepts minConfidence (default high) and inherits the sensitivity classifier’s global configuration. The set of tags considered "PII" (PII / PCI / PHI) is fixed; CREDENTIAL and CRYPTO_MATERIAL are intentionally excluded from this detector’s scope.
References
-
OWASP API Security Top 10 (2023) - API3:2023 - Broken Object Property Level Authorization.
-
OWASP API Security Top 10 (2023) - API10:2023 - Unsafe Consumption of APIs.
-
CWE-359 : Exposure of Private Personal Information to an Unauthorized Actor.
-
OWASP Cheat Sheets Series: User Privacy Protection Cheat Sheet.
-
GDPR Article 32 — Security of processing.
-
PCI-DSS v4.0 — Requirement 3.4 (render PAN unreadable wherever it is stored, processed, or transmitted).