PII Leak in Response (PHP)

ID

pii_leak_in_response_php

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

Laravel, Symfony, Slim

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.

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 Laravel search route:

// routes/api.php
Route::get('/users/search', function (Request $request) {
    return User::where('username', 'like', '%' . $request->q . '%')->get();
});
// User has email, phone, date_of_birth, address — all PII

→get() returns full Eloquent models; the implicit toArray() serialises every column.

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 an API Resource collection:

// app/Http/Resources/UserSummaryResource.php
class UserSummaryResource extends JsonResource {
    public function toArray($request): array {
        return [
            'id'       => $this->id,
            'username' => $this->username,
        ];
    }
}

// routes/api.php
Route::get('/users/search', function (Request $request) {
    $users = User::where('username', 'like', '%' . $request->q . '%')
        ->select(['id', 'username'])
        ->get();
    return UserSummaryResource::collection($users);
});

select(['id', 'username']) narrows the SQL projection; UserSummaryResource declares the wire shape. PII columns never leave the database.

A separate, properly-authorised /users/{user} route returns the full record when the caller is entitled to it.

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