Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion packages/api/src/utils/generics/base-orm.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { randomUUID } from 'crypto';

import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TestingModule } from '@nestjs/testing';
import { In, InsertEvent, RemoveEvent, Repository, UpdateEvent } from 'typeorm';
Expand Down Expand Up @@ -274,6 +274,94 @@ describe('BaseOrmRepository', () => {
});
});

describe('prototype pollution guard', () => {
it('should reject create with __proto__ key', async () => {
const payload = JSON.parse('{"__proto__":{"polluted":true}}');
await expect(dummyRepository.create(payload)).rejects.toBeInstanceOf(
BadRequestException,
);
});

it('should reject create with nested forbidden key', async () => {
await expect(
dummyRepository.create({
dummy: 'safe',
dynamicField: { constructor: { polluted: true } },
} as any),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should reject createMany with forbidden key', async () => {
await expect(
dummyRepository.createMany([
{ dummy: 'safe' },
{ prototype: { polluted: true } } as any,
]),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should reject updateOne with forbidden key', async () => {
const target = baselineEntities[0];
const payload = JSON.parse('{"__proto__":{"polluted":true}}');
await expect(
dummyRepository.updateOne(target.id, payload),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should reject updateOne with nested forbidden key in flatten mode', async () => {
const target = baselineEntities[0];
const payload = JSON.parse(
'{"dynamicField":{"__proto__":{"polluted":true}}}',
);
await expect(
dummyRepository.updateOne(target.id, payload, {
shouldFlatten: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should reject updateMany with forbidden key', async () => {
await expect(
dummyRepository.updateMany({}, {
constructor: { polluted: true },
} as any),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should reject create with forbidden key inside array value', async () => {
const payload = {
dummy: 'safe',
dynamicField: {
items: JSON.parse('[{"__proto__":{"polluted":true}}]'),
},
};
await expect(
dummyRepository.create(payload as any),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should reject createMany with forbidden key nested in array', async () => {
await expect(
dummyRepository.createMany([
{
dummy: 'safe',
dynamicField: {
items: JSON.parse('[{"constructor":{"polluted":true}}]'),
},
} as any,
]),
).rejects.toBeInstanceOf(BadRequestException);
});

it('should allow safe payloads through', async () => {
const result = await dummyRepository.create({
dummy: 'safe payload',
dynamicField: { constructorName: 'safe', nested: { value: true } },
});
expect(result.dummy).toBe('safe payload');
});
});

describe('delete operations', () => {
it('should delete one entity by id', async () => {
const target = baselineEntities[0];
Expand Down
31 changes: 30 additions & 1 deletion packages/api/src/utils/generics/base-orm.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Full terms: see LICENSE.md.
*/

import { Inject, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { instanceToPlain, plainToInstance } from 'class-transformer';
import camelCase from 'lodash/camelCase';
Expand All @@ -31,6 +31,7 @@ import {
import { BaseOrmEntity } from '@/database/entities/base.entity';
import { LoggerService } from '@/logger/logger.service';
import { flatten } from '@/utils/helpers/flatten';
import { hasForbiddenSegment } from '@/utils/helpers/safe-property-path';

import {
DtoAction,
Expand Down Expand Up @@ -140,6 +141,30 @@ export abstract class BaseOrmRepository<
return result;
}

private assertSafePayload(data: unknown): void {
if (!data || typeof data !== 'object') return;

if (Array.isArray(data)) {
data.forEach((item) => this.assertSafePayload(item));

return;
}

const flat = flatten(data as Record<string, unknown>) as Record<
string,
unknown
>;

for (const [key, value] of Object.entries(flat)) {
if (hasForbiddenSegment(key)) {
throw new BadRequestException(`Forbidden property path: "${key}"`);
}
if (Array.isArray(value)) {
value.forEach((item) => this.assertSafePayload(item));
}
}
}

public actionDtoToEntity<Action extends DtoAction>(
data: InferEntityDto<Action, Entity>,
): DeepPartial<Entity> {
Expand Down Expand Up @@ -269,6 +294,7 @@ export abstract class BaseOrmRepository<
}

async create(payload: InferCreateDto<Entity>): Promise<InferPlain<Entity>> {
this.assertSafePayload(payload);
const entity = this.repository.create(this.actionDtoToEntity(payload));
await this.emitEvent<EHook.preCreate>({
action: EHook.preCreate,
Expand All @@ -290,6 +316,7 @@ export abstract class BaseOrmRepository<
async createMany(
payloads: InferCreateDto<Entity>[],
): Promise<InferPlain<Entity>[]> {
payloads.forEach((payload) => this.assertSafePayload(payload));
const entities = this.repository.create(
payloads.map((payload) => this.actionDtoToEntity(payload)),
);
Expand Down Expand Up @@ -320,6 +347,7 @@ export abstract class BaseOrmRepository<
payload: InferUpdateDto<Entity>,
options?: UpdateOneOptions,
): Promise<InferPlain<Entity>> {
this.assertSafePayload(payload);
const entity = await this.findOneEntity(idOrOptions);
const databaseEntity = await this.findOneEntity(idOrOptions);
if (entity && databaseEntity) {
Expand Down Expand Up @@ -383,6 +411,7 @@ export abstract class BaseOrmRepository<
options: FindManyOptions<Entity> = {} as FindManyOptions<Entity>,
payload: InferUpdateDto<Entity>,
): Promise<InferPlain<Entity>[]> {
this.assertSafePayload(payload);
const entities = await this.findEntities(options);

if (!entities.length) {
Expand Down
Loading