Excessive Data Exposure (PHP)

ID

excessive_data_exposure_php

Severity

high

Remediation Complexity

medium

Remediation Risk

high

Remediation Effort

medium

Family

API3:2023 - Broken Object Property Level Authorization

CWE

CWE-213, CWE-200

Resource

data_exposure

Language

Laravel, Symfony, Slim

Description

Walks each endpoint’s response model and classifies every field into one of three confidence tiers, based on whether the field is sensitivity-tagged (PII / PCI / PHI / credentials by the sensitivity classifier) and whether the same field name appears in the request:

  • HIGH — sensitive AND not referenced in the request. The caller never asked for the field; returning it is over-fetch.

  • MEDIUM — sensitive AND referenced in the request. Possibly a legitimate field-by-field update — surfaced for review.

  • LOW — not sensitivity-tagged AND not referenced in the request. Mild signal of over-fetching.

Only the HIGH tier fires by default. The detector is per-language because the framework idioms differ — JSON-annotation models in Java / C#, dataclass / Pydantic models in Python, plain-object / class-transformer in JS/TS, struct tags in Go, Eloquent / Symfony serializer groups in PHP.

Rationale

Excessive Data Exposure is the read side of OWASP API3:2023 — the API returns more than the client needs, because the implementation returned a persistence model directly and the persistence model has more fields than the contract. The risk is twofold:

  • Direct data leak — the extra fields contain credentials (passwordHash, apiToken), PII (email, phone, ssn), or PCI (cardLastFour, accountNumber). Anyone reading the response sees them.

  • Object-property authorization bypass — even if the endpoint is authorised to return the object, individual fields on that object should be scoped. A "GET my profile" endpoint authorised for the user should not expose the user’s isAdmin flag, lastLoginIp, or internalNotes. API3 separates these from API1 (object-level) for exactly this reason.

The pattern is endemic in single-page-app backends, where the same User / Order / Patient entity is reused across all read endpoints, with the client deciding which fields to show. The "client filters out the bad fields" defence does not work — anyone can read the network response.

A susceptible Laravel / Eloquent handler might look like this:

// app/Models/User.php
class User extends Authenticatable {
    // Fillable: username, email, password (hashed), ssn, is_admin
}

// routes/api.php
Route::get('/users/{user}', function (User $user) {
    return $user;  // returns full model as JSON
});

Eloquent’s toArray() (invoked when the route returns a model) serialises every column — email, password, ssn, is_admin — unless they are explicitly hidden.

Remediation

The fix is to return a dedicated response model per operation, narrow to the fields the operation actually needs to expose. Specific per-language patterns are in the language pages.

Beyond per-route DTOs:

  • If the sensitivity tag is wrong for your project — for example, an email-newsletter app whose User.email field is intentionally public — exclude the field via per-project sensitivity overrides rather than suppressing the finding.

  • Pair this detector with pii_leak_in_response (focuses on PII / PCI / PHI specifically, with stricter severity).

  • Server-side response filtering is the only effective control. Anything that depends on the client hiding fields is not a remediation.

Here is a revised handler with an explicit API Resource:

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

// routes/api.php
Route::get('/users/{user}', function (User $user) {
    return new PublicUserResource($user);
});

PublicUserResource::toArray() declares the wire shape explicitly. Adding a column to users no longer leaks it through the API.

Equivalent patterns:

  • Eloquent $hiddenprotected $hidden = ['password', 'remember_token', 'ssn']; on the model. Works but is one-list-for-all-endpoints; harder to vary per route.

  • Symfony Serializer groups#[Groups(['public'])] on each safe property + $serializer→serialize($user, 'json', ['groups' ⇒ ['public']]).

  • Slim: explicit json_encode(['id' ⇒ $u→id, 'username' ⇒ $u→username]) — no ORM auto-serialization.

Configuration

The detector accepts:

  • minConfidence — the minimum confidence tier that fires. Default high. Set to medium to include "sensitive AND referenced in request" findings. Set to low only for benchmark / audit runs (very noisy).

The set of sensitivity tags (PII / PCI / PHI / credentials) is configured globally on the sensitivity classifier, not per-detector.

References