Public Endpoint Data Exposure
How to prevent information leaks through TypeORM relation loading in public API endpoints.
Public endpoints (decorated with @Public()) bypass authentication entirely. Any data exposed through these endpoints is accessible to anyone with a valid shareable link. Careful control of TypeORM relations is essential to prevent unintended data exposure.
The Problemโ
TypeORM allows callers to specify which entity relations to load via query parameters. For example, an endpoint for a public invoice might accept:
GET /api/public/invoice/{id}/{token}?relations[]=invoiceItems&relations[]=toContact
If the relations parameter accepts arbitrary strings, a caller can request deeply nested relations that the endpoint was never designed to expose:
GET /api/public/invoice/{id}/{token}?relations[]=payments&relations[]=payments.employee&relations[]=payments.employee.user
Even if the endpoint uses a select clause to restrict columns on intended relations, any additional relations not covered by the select clause will return all their columns โ including sensitive internal data like employee personal information, payment details, or internal notes.
The Solution: Enum-Based Relation Whitelistsโ
Every public endpoint DTO that accepts a relations parameter must use an enum-based whitelist validated with class-validator. This ensures that only explicitly approved relations can be loaded.
Patternโ
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, TransformFnParams } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
// 1. Define an enum with ONLY the safe relations
export enum PublicInvoiceRelationEnum {
"tenant" = "tenant",
"organization" = "organization",
"fromOrganization" = "fromOrganization",
"toContact" = "toContact",
"invoiceItems" = "invoiceItems",
"invoiceItems.employee" = "invoiceItems.employee",
"invoiceItems.employee.user" = "invoiceItems.employee.user",
"invoiceItems.project" = "invoiceItems.project",
"invoiceItems.product" = "invoiceItems.product",
"invoiceItems.expense" = "invoiceItems.expense",
"invoiceItems.task" = "invoiceItems.task",
}
// 2. Use the enum in the DTO with @IsEnum validation
export class PublicInvoiceQueryDTO {
@ApiPropertyOptional({ type: () => String, enum: PublicInvoiceRelationEnum })
@IsOptional()
@Transform(({ value }: TransformFnParams) =>
value ? value.map((element: string) => element.trim()) : [],
)
@IsEnum(PublicInvoiceRelationEnum, { each: true })
readonly relations: string[] = [];
}
Key Rulesโ
-
Never extend
RelationsQueryDTOin public endpoints โRelationsQueryDTOaccepts arbitrary string arrays with no validation, which is fine for authenticated internal endpoints but dangerous for public ones. -
The whitelist must match the
selectclause โ every relation in the enum must have a correspondingselectentry in the service that constrains which columns are returned. If a relation is in the enum but not in theselect, it will return all columns. -
Use
{ each: true }on@IsEnumโ sincerelationsis an array, theeach: trueoption tells class-validator to validate each element individually. -
Always pair with
@UseValidationPipe({ whitelist: true })โ this strips unknown properties from the request object, providing defense-in-depth.
The select Clauseโ
Along with the relation whitelist, the service must define a select clause that constrains which columns are returned for each relation:
return await this.repository.findOneOrFail({
select: {
// Only return safe columns for each relation
tenant: {
name: true,
logo: true,
},
organization: {
name: true,
officialName: true,
brandColor: true,
},
invoiceItems: {
id: true,
description: true,
quantity: true,
price: true,
employee: {
id: true,
user: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
where: {
/* ... */
},
...(relations ? { relations } : {}),
});
If you add a new relation to the enum whitelist, you must also add corresponding select constraints in the service. A whitelisted relation without select constraints will expose all columns of that entity.
Current Public Endpointsโ
| Endpoint | DTO | Whitelist Enum |
|---|---|---|
GET /api/public/invoice/:id/:token | PublicInvoiceQueryDTO | PublicInvoiceRelationEnum |
GET /api/public/employee/:id | PublicEmployeeQueryDTO | EmployeeRelationEnum |
GET /api/public/team/:profile_link/:id | PublicTeamQueryDTO | PublicTeamRelationEnum |
GET /api/public/organization/:profile_link/:id | PublicOrganizationQueryDTO | OrganizationRelationEnum |
GET /api/estimate-email/validate | FindEstimateEmailQueryDTO | EstimateEmailRelationEnum |
Checklist for New Public Endpointsโ
When creating a new public endpoint that loads entity relations:
- Define an enum listing only the relations safe for public exposure
- Validate with
@IsEnumusing{ each: true }on therelationsarray - Add
selectconstraints in the service for every whitelisted relation - Do not extend
RelationsQueryDTOโ create a standalone DTO - Apply
@UseValidationPipe({ whitelist: true })on the controller method - Review nested relations โ if you whitelist
invoiceItems.employee, also check whatemployeeexposes and add appropriateselectconstraints
Related Pagesโ
- Security Overview โ architecture and guards
- Data Protection โ classification and handling
- API Overview โ REST API conventions