PII Leak in Response (JavaScript / TypeScript)

ID

pii_leak_in_response_jsts

Severity

high / critical (composite with unauthenticated_endpoint)

Remediation Complexity

medium

Remediation Risk

high

Remediation Effort

medium

Family

API3:2023 - Broken Object Property Level Authorization

CWE

CWE-359

Resource

data_exposure

Language

Express, NestJS, Koa, Fastify, Hono

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=alice returns 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.

A susceptible Express search route:

app.get('/users/search', async (req, res) => {
  const users = await User.find({
    username: { $regex: req.query.q, $options: 'i' },
  });
  res.json(users);   // each user has email, phone, dob, address — PII
});

User.find(…​) returns the full Mongoose documents; res.json serialises every field.

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.com instead of john.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 email is intentionally public — override the tag rather than suppressing the finding.

  • Pair this detector with unauthenticated_endpoint review. The composite CRITICAL finding is the actionable one.

A revised route with explicit projection and shaping:

const toSummary = (u) => ({ id: u.id, username: u.username });

app.get('/users/search', async (req, res) => {
  const users = await User
    .find({ username: { $regex: req.query.q, $options: 'i' } }, 'username')
    .lean();
  res.json(users.map(toSummary));
});

The Mongoose projection 'username' selects only the needed column at the database layer, and toSummary is the single place where the wire shape is defined.

NestJS equivalent: a dedicated UserSummaryDto with @Expose() annotations, returned via ClassSerializerInterceptor with strategy: 'excludeAll'.

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 HttpResponse / Mono / Single), Ktor (the call.respond… argument, through the receiver’s declared type and the callee’s declared return type)

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 json(…))

Python

Django (incl. DRF), FastAPI, Flask

Ruby

Rails (render json: → serializer, else the model’s columns in db/schema.rb), Sinatra (the block’s value, including a hash it builds before serialising it)

Swift

Vapor (the route handler’s declared return type, unwrapping EventLoopFuture and arrays)

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 $fillable minus its $hidden, or the array literal returned)

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

© 2026 Xygeni. All rights reserved