ื“ืœื’ ืœืชื•ื›ืŸ ื”ืจืืฉื™

Public Endpoint Data Exposure

How to prevent information leaks through TypeORM relation loading in public API endpoints.

warning

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โ€‹

  1. Never extend RelationsQueryDTO in public endpoints โ€” RelationsQueryDTO accepts arbitrary string arrays with no validation, which is fine for authenticated internal endpoints but dangerous for public ones.

  2. The whitelist must match the select clause โ€” every relation in the enum must have a corresponding select entry in the service that constrains which columns are returned. If a relation is in the enum but not in the select, it will return all columns.

  3. Use { each: true } on @IsEnum โ€” since relations is an array, the each: true option tells class-validator to validate each element individually.

  4. 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 } : {}),
});
caution

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โ€‹

EndpointDTOWhitelist Enum
GET /api/public/invoice/:id/:tokenPublicInvoiceQueryDTOPublicInvoiceRelationEnum
GET /api/public/employee/:idPublicEmployeeQueryDTOEmployeeRelationEnum
GET /api/public/team/:profile_link/:idPublicTeamQueryDTOPublicTeamRelationEnum
GET /api/public/organization/:profile_link/:idPublicOrganizationQueryDTOOrganizationRelationEnum
GET /api/estimate-email/validateFindEstimateEmailQueryDTOEstimateEmailRelationEnum

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 @IsEnum using { each: true } on the relations array
  • Add select constraints 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 what employee exposes and add appropriate select constraints