Excessive Data Exposure (Go)

ID

excessive_data_exposure_go

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

net/http, Gin, Echo, Chi, Fiber

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.

Two kinds of field are never counted. In Java and Python, when the handler returns the authenticated caller’s own record — a "my profile" view that serializes request.user, or looks the user up by getUserPrincipal().getName(), on a route with no path parameter — the caller’s own PII / PHI is not over-exposure (credentials and card data still are). In Python, a DRF write_only field is bound from the request and never serialized, so it is not response content.

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 Gin / GORM handler might look like this:

type User struct {
    ID           int64
    Username     string
    Email        string  // PII
    PasswordHash string  // CREDENTIAL
    SSN          string  // PII
    IsAdmin      bool    // PRIVILEGE
}

func GetUser(c *gin.Context) {
    var u User
    db.First(&u, c.Param("id"))
    c.JSON(http.StatusOK, u)
}

encoding/json marshals every exported field — including PasswordHash, SSN, and IsAdmin.

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 a dedicated response struct:

type PublicUser struct {
    ID       int64  `json:"id"`
    Username string `json:"username"`
}

func GetUser(c *gin.Context) {
    var u User
    if err := db.Select("id", "username").First(&u, c.Param("id")).Error; err != nil {
        c.AbortWithStatus(http.StatusNotFound)
        return
    }
    c.JSON(http.StatusOK, PublicUser{ID: u.ID, Username: u.Username})
}

PublicUser declares the wire shape; the database Select("id", "username") mirrors that shape on the query side so the sensitive columns never leave the database. Adding a sensitive field to User no longer leaks it.

A lighter alternative — json:"-" on every sensitive field of User — works but is fragile: every new sensitive field has to be annotated, and a future ORM-generated model resets the tags.

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 — 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

© 2026 Xygeni. All rights reserved