fix(security): close the residual gaps in the remaining advisories + the LOCAL-provider attachment bug

A re-verification of every draft and published advisory against develop found 9 fully closed and 8
with residuals. This closes the code-fixable ones. Three patterns cover almost all of them:

1. `{ id, ...body }` — the body wins. A path id spread BEFORE the body is overridden by a body `id`,
   and save()/create() with an existing PK is an UPDATE of that row: every authorization check ran
   against the path id while the write hit the body id. On PUT /user/:id that is account takeover
   (a PROFILE_EDIT employee posts {"id":"<SUPER_ADMIN>","hash":"..."}). The id is now pinned LAST in
   all 25 controllers/handlers with that shape, UserService.updateProfile pins entity.id = id, and
   TenantAwareCrudService.create/save/createMany/saveMany refuse an entity whose id already names a
   row of ANOTHER tenant (or a tenant-less row) — the update-through-create endpoints had no other
   ownership check. (GHSA-x4mv-fhwj-g3rp, GHSA-gwpq-mmw7-vx85)

2. A client-supplied value decides an authorization branch. The register handler gated "only a
   SUPER_ADMIN may register a SUPER_ADMIN" on input.user.role.name, and POST /user had no gate at
   all. Both now resolve EVERY role identifier (the flat roleId and the role relation — the relation
   wins on persist) from the database in the caller's tenant and fail closed on an id that does not
   resolve. (GHSA-hjcg-633x-qq74, GHSA-x4mv-fhwj-g3rp)

3. Only the root row is tenant-scoped. SharedEntity turned caller-supplied shareRules.relations
   straight into TypeORM relations on a @Public() token route, so a share of an OWNED Organization
   could pivot featureOrganizations -> feature -> featureOrganizations -> tenant -> organizations ->
   employees -> user into every tenant. Relations are now validated against entity metadata (each hop
   must exist AND target a tenant-scoped entity), depth-bounded, joined rows are scope-filtered,
   tenant-less roots are refused, and create/update bodies are whitelisted. (GHSA-cx2q-xmh2-pc38,
   GHSA-gpg5-qwjc-8hqh)

Also:
- /invite/accept mass-assignment: AuthService.register strips id/hash/emailVerifiedAt/emailToken/
  code/codeExpireAt/refreshToken from input.user, honours createdByUserId only for the authenticated
  caller, pins user.tenantId to the trusted tenant; invite accept pins the invited email.
  (GHSA-929w-5p4w-cxjp)
- TimeOffStatusHandler used raw repositories with no tenant scope (an admin of tenant A could
  approve/deny tenant B's requests); equipment-sharing deleted the request_approval row unscoped, and
  its status change went through an update() that deletes and re-inserts — a { status }-only body
  replaced the record with a stub. (GHSA-gwpq-mmw7-vx85)
- Hubstaff /refresh-token no longer returns the refresh token. (GHSA-3rqg-gpm9-gx84)
- Upload filters on the endpoints that had none: POST /import (archive allowlist), POST
  /ai-chat/attachments and the 5 registry upload routes (script-capable-extension denylist).
  (GHSA-p334-cm7f-php5)
- docker-compose defaults NODE_ENV to production so the insecure-secret guard actually fires (compose
  `environment:` overrode .env.compose and the image ENV); render blueprints generate their secrets
  instead of shipping secretKey/refreshSecretKey/gauzy, and the CORP policy is overridable
  (CORP_POLICY) because API and webapp live on two different *.onrender.com sites.
  (GHSA-chm8-2ggf-pgjq)

And a functional bug found on the way: POST /ai-chat/attachments was broken on the default LOCAL file
provider. It used Nest's @UploadedFile(), which hands over multer's diskStorage object — no `key` (only
core's @UploadedFileStorage() maps it through provider.mapUploadedFile, where LOCAL derives key from
path) — so the service threw 400 AFTER the bytes were written, leaving an orphan. It now uses the core
decorator, never puts a browser-renderable extension on the stored object name, and deletes the stored
object when the upload is rejected (service) or when sniffFile rejects it (docs chat-capture).

PUT /product-types/:id was likewise a silent 400 for every caller (a DTO instance was passed to
EntityManager.save, which resolves metadata from the constructor); it now saves with an explicit
entity target under the verified id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ruslan Konviser
2026-08-19 18:24:20 +02:00
co-authored by Claude Fable 5
parent ad7b675c8b
commit 84032fd11c
63 changed files with 792 additions and 208 deletions
+5
View File
@@ -99,6 +99,11 @@ REDIS_URL=redis://redis:6379
# The API refuses to start in production (NODE_ENV=production and DEMO != true)
# while any of these is empty or left at a well-known default value, because
# shared/default secrets let anyone forge authentication tokens and sessions.
#
# docker-compose now runs the API with NODE_ENV=production by default, so a stack
# started with these left blank will STOP with an "INSECURE SECRETS" error instead
# of silently serving on the publicly known defaults. Fill them in (or, for a
# throwaway local stack only, export NODE_ENV=development or DEMO=true).
# ============================================================================
JWT_SECRET=
EXPRESS_SESSION_SECRET=
+5 -3
View File
@@ -29,11 +29,13 @@ services:
- key: CLIENT_BASE_URL
value: https://ever-gauzy-webapp.onrender.com
- key: EXPRESS_SESSION_SECRET
value: gauzy
generateValue: true
- key: JWT_SECRET
value: secretKey
generateValue: true
- key: JWT_REFRESH_TOKEN_SECRET
value: refreshSecretKey
generateValue: true
- key: JWT_VERIFICATION_TOKEN_SECRET
generateValue: true
- key: JWT_REFRESH_TOKEN_EXPIRATION_TIME
value: 86400
healthCheckPath: /api/health
+1 -1
View File
@@ -203,7 +203,7 @@ Please refer to our official [Platform Documentation](https://docs.gauzy.co) and
#### Production
- Edit `.env.compose` (if needed) to use your custom settings, e.g. DB type.
- Edit `.env.compose`: you **must** set `JWT_SECRET`, `JWT_REFRESH_TOKEN_SECRET`, `JWT_VERIFICATION_TOKEN_SECRET` and `EXPRESS_SESSION_SECRET` to strong, unique values (e.g. `openssl rand -hex 64`). The API refuses to start on the shipped blank/default secrets — shared defaults let anyone forge authentication tokens and sessions. Adjust any other settings (e.g. DB type) there too.
- Run `docker-compose up -d`, if you want to run the platform in minimal production configuration using our prebuilt Docker images. _(Note: Docker Compose will use latest images pre-build automatically from head of `master` branch using GitHub CI/CD.)_
Note: we recommend using Kubernetes for production workloads instead of Docker Compose!
+1 -1
View File
@@ -17,7 +17,7 @@ services:
environment:
API_HOST: ${API_HOST:-api}
API_PORT: ${API_PORT:-3000}
NODE_ENV: ${NODE_ENV:-development}
NODE_ENV: ${NODE_ENV:-production}
DB_HOST: db
API_BASE_URL: ${API_BASE_URL:-http://localhost:3000}
CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200}
+2 -2
View File
@@ -8,7 +8,7 @@ services:
environment:
API_HOST: ${API_HOST:-api}
API_PORT: ${API_PORT:-3000}
NODE_ENV: ${NODE_ENV:-development}
NODE_ENV: ${NODE_ENV:-production}
DB_HOST: db
API_BASE_URL: ${API_BASE_URL:-http://localhost:3000}
CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200}
@@ -88,7 +88,7 @@ services:
environment:
WEB_HOST: ${WEB_HOST:-webapp}
WEB_PORT: ${WEB_PORT:-4200}
NODE_ENV: ${NODE_ENV:-development}
NODE_ENV: ${NODE_ENV:-production}
API_BASE_URL: ${API_BASE_URL:-http://localhost:3000}
CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200}
SENTRY_DSN: ${SENTRY_DSN:-}
+5
View File
@@ -4,6 +4,8 @@
export { bootstrap, registerPluginConfig } from './lib/bootstrap';
export * from './lib/core';
export {
ALLOWED_ARCHIVE_EXTENSIONS,
ALLOWED_ARCHIVE_MIME_TYPES,
ALLOWED_AUDIO_EXTENSIONS,
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_EXTENSIONS,
@@ -11,13 +13,16 @@ export {
ALLOWED_VIDEO_EXTENSIONS,
ALLOWED_VIDEO_MIME_TYPES,
BLOCKED_UPLOAD_EXTENSIONS,
SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS,
FileStorage,
FileStorageFactory,
MARKUP_SCAN_MAX_BYTES,
UploadedFileStorage,
archiveUploadFileFilter,
assertNotMarkupContent,
audioUploadFileFilter,
createUploadFileFilter,
documentUploadFileFilter,
imageUploadFileFilter,
isMarkupContent,
shouldScanForMarkup,
@@ -171,10 +171,7 @@ export class AccountingTemplateController extends CrudController<AccountingTempl
@Body() input: IAccountingTemplateUpdateInput
): Promise<IAccountingTemplate> {
try {
await this.accountingTemplateService.create({
id,
...input
});
await this.accountingTemplateService.create({ ...input, id });
return await this.findById(id);
} catch (error) {
throw new BadRequestException();
+30 -1
View File
@@ -1204,6 +1204,25 @@ export class AuthService extends SocialAuthService {
let tenant = input.user.tenant;
const { organizationId } = input;
// -1. This is a CREATION sink reachable from public routes (/auth/register, /invite/accept,
// /invite/contact) whose bodies are attacker-controlled. Strip everything that identifies or
// privileges an EXISTING account before the payload is spread into create()/save():
// a body `user.id` would turn the save into an UPDATE of that row (account takeover), and
// hash / verification / token columns must only ever be derived server-side.
if (input.user) {
const {
id: _id,
hash: _hash,
emailVerifiedAt: _emailVerifiedAt,
emailToken: _emailToken,
code: _code,
codeExpireAt: _codeExpireAt,
refreshToken: _refreshToken,
...safeUser
} = input.user as any;
input.user = safeUser;
}
// 0. Validate the terms acceptance BEFORE anything irreversible happens.
//
// The register form gates its submit button on a hard-required terms
@@ -1230,7 +1249,13 @@ export class AuthService extends SocialAuthService {
input.user.roleId = input.user.role.id;
}
// 1. If createdByUserId is provided, get the creating user and use their tenant
// 1. If createdByUserId is provided, get the creating user and use their tenant — but only when
// it names the AUTHENTICATED caller. On the public invite routes the field is attacker-controlled
// and used to override the invite's tenant with any user's tenant (cross-tenant registration).
const authenticatedUserId = RequestContext.currentUserId();
if (input.createdByUserId && (!authenticatedUserId || String(input.createdByUserId) !== String(authenticatedUserId))) {
delete input.createdByUserId;
}
if (input.createdByUserId) {
const creatingUser = await this.userService.findOneByIdString(input.createdByUserId, {
relations: {
@@ -1239,6 +1264,10 @@ export class AuthService extends SocialAuthService {
});
tenant = creatingUser.tenant;
}
// Keep the flat FK consistent with the trusted tenant relation (mirrors the roleId pin above).
if (tenant?.id && input.user) {
input.user.tenantId = tenant.id;
}
// 2. Register new user
let user: User;
@@ -30,14 +30,27 @@ export class AuthRegisterHandler implements ICommandHandler<AuthRegisterCommand>
const { input, languageCode } = command;
let targetRoleName: string | null = null;
if (input.user?.roleId) {
// The target role may arrive as `roleId` or as a `role` object; resolve BOTH from the DATABASE.
// A client-supplied `role.name` must never feed the SUPER_ADMIN gate below (a role object with
// only an id, or a spoofed name, used to skip it), and checking only the first of the two is not
// enough either: the `role` RELATION wins over the flat `roleId` when the row is persisted
// (AuthService.register pins roleId = role.id), so a body pairing a harmless `roleId` with a
// privileged `role: { id }` would be validated as the harmless one and registered as the
// privileged one.
const targetRoleIds = [input.user?.roleId, input.user?.role?.id].filter((roleId) => !!roleId);
if (input.user?.role && !targetRoleIds.length) {
throw new BadRequestException('The specified role does not reference a valid role.');
}
if (targetRoleIds.length) {
// Get tenant id from request context
const tenantId = RequestContext.currentTenantId();
for (const targetRoleId of targetRoleIds) {
// Resolve role entity to get the name
try {
const whereCondition = {
id: input.user.roleId,
id: targetRoleId,
...(tenantId ? { tenantId } : {})
};
@@ -46,13 +59,15 @@ export class AuthRegisterHandler implements ICommandHandler<AuthRegisterCommand>
? await this.mikroOrmRoleRepository.findOneOrFail(whereCondition)
: await this.typeOrmRoleRepository.findOneByOrFail(whereCondition);
// The strictest of the candidates decides: if ANY of them is SUPER_ADMIN, the creator
// check below must run.
if (role.name === RolesEnum.SUPER_ADMIN || !targetRoleName) {
targetRoleName = role.name;
}
} catch {
throw new BadRequestException('The specified roleId does not reference a valid role.');
}
} else if (input.user?.role?.name) {
// Role name provided directly via role object
targetRoleName = input.user.role.name;
}
}
// Check if the target role is SUPER_ADMIN and require 'createdByUserId' for verification
@@ -110,9 +110,6 @@ export class AvailabilitySlotsController extends CrudController<AvailabilitySlot
@Param('id', UUIDValidationPipe) id: ID,
@Body() entity: IAvailabilitySlot
): Promise<IAvailabilitySlot> {
return this.availabilitySlotsService.create({
id,
...entity
});
return this.availabilitySlotsService.create({ ...entity, id });
}
}
+10 -3
View File
@@ -190,9 +190,16 @@ export async function bootstrap(pluginConfig?: Partial<ApplicationPluginConfig>)
contentSecurityPolicy: isProduction
? undefined // use Helmet's strict default CSP in production
: false, // disable CSP in dev/stage so Swagger/Scalar inline scripts work
crossOriginResourcePolicy: isProduction
? { policy: 'same-site' } // Prod: same-site lets *.gauzy.co (e.g. app.gauzy.co) embed API-served assets like /public icons, while still blocking third-party origins
: { policy: 'cross-origin' }, // Relaxed in dev/stage — resources served from API
crossOriginResourcePolicy: {
// Prod default: same-site lets *.gauzy.co (e.g. app.gauzy.co) embed API-served assets like
// /public icons, while still blocking third-party origins; relaxed in dev/stage — resources
// served from API. Overridable because a deployment may legitimately serve its API and web
// app from two DIFFERENT sites (e.g. *.onrender.com, a public suffix), where 'same-site'
// would block every API-served image.
policy:
(process.env.CORP_POLICY as 'same-site' | 'cross-origin' | 'same-origin') ??
(isProduction ? 'same-site' : 'cross-origin')
},
crossOriginEmbedderPolicy: isProduction // strict in production, relaxed in dev/stage for Electron/desktop
})
);
@@ -34,9 +34,6 @@ export class FeedbackUpdateHandler implements ICommandHandler<FeedbackUpdateComm
}
public async update(id: string, entity: ICandidateFeedbackCreateInput): Promise<ICandidateFeedback> {
return this.candidateFeedbackService.create({
id,
...entity
});
return this.candidateFeedbackService.create({ ...entity, id });
}
}
@@ -208,7 +208,7 @@ export class CandidateController extends CrudController<Candidate> {
@Put('/:id')
@UseValidationPipe({ transform: true })
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateCandidateDTO): Promise<ICandidate> {
return await this.commandBus.execute(new CandidateUpdateCommand({ id, ...entity }));
return await this.commandBus.execute(new CandidateUpdateCommand({ ...entity, id }));
}
/**
@@ -18,10 +18,7 @@ export class CandidateUpdateHandler
try {
//We are using create here because create calls the method save()
//We need save() to save ManyToMany relations
return await this.candidateService.create({
id,
...input
});
return await this.candidateService.create({ ...input, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -1,9 +1,9 @@
import { NotFoundException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { DeleteResult, FindOptionsWhere, In, Repository, UpdateResult } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ID, IPagination, IUser, PermissionsEnum } from '@gauzy/contracts';
import { isNotEmpty } from '@gauzy/utils';
import { LegacyFindManyOptions, LegacyFindOneOptions } from '../utils';
import { LegacyFindManyOptions, LegacyFindOneOptions, MultiORMEnum } from '../utils';
import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository';
import { RequestContext } from '../context';
import { TenantBaseEntity } from '../entities/internal';
@@ -367,6 +367,77 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
});
}
/**
* Refuses to persist an entity whose id already names a row of ANOTHER tenant.
*
* create()/save() with an id are upserts: TypeORM's save() looks the row up by primary key only and
* then UPDATEs it, while this service merely stamps the caller's tenantId onto the payload. A body
* that smuggled a foreign id in (`{ id, ...body }` spreads, un-whitelisted update DTOs) therefore
* overwrote — and re-tenanted — another tenant's row (GHSA-gwpq-mmw7-vx85 / GHSA-x4mv-fhwj-g3rp
* class). Rows the caller's tenant owns, and ids that do not exist yet, are untouched.
*
* @param entity - The payload about to be persisted.
* @param tenantId - The caller's tenant.
*/
protected async assertNotForeignRow(entity: IPartialEntity<T>, tenantId: ID | null): Promise<void> {
const id = (entity as any)?.id;
if (!id || !tenantId || !this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId')) {
return;
}
let existing: unknown;
let existingTenantId: ID | null | undefined;
switch (this.ormType) {
case MultiORMEnum.MikroORM: {
existing = await this.mikroOrmRepository.findOne({ id } as any, { fields: ['id', 'tenantId'] as any });
existingTenantId = (existing as any)?.tenantId;
break;
}
case MultiORMEnum.TypeORM:
default: {
existing = await this.typeOrmRepository.findOne({
where: { id } as FindOptionsWhere<T>,
select: { id: true, tenantId: true } as any,
withDeleted: true
});
existingTenantId = (existing as any)?.tenantId;
break;
}
}
// Fail CLOSED on a tenant-less row too: on the update-through-create endpoints this guard is the
// only ownership check, so a row with a NULL tenantId (legacy / global / written without a
// request context) must not be overwritable — and re-tenantable — by a tenant user.
if (existing && String(existingTenantId ?? '') !== String(tenantId)) {
throw new ForbiddenException('The record belongs to another tenant');
}
}
/**
* Batch form of {@link assertNotForeignRow} for createMany()/saveMany() (one lookup for all ids).
*/
protected async assertNotForeignRows(entities: IPartialEntity<T>[], tenantId: ID | null): Promise<void> {
const ids = (entities ?? []).map((entity) => (entity as any)?.id).filter((id) => !!id);
if (!ids.length || !tenantId || !this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId')) {
return;
}
let existing: any[];
switch (this.ormType) {
case MultiORMEnum.MikroORM:
existing = await this.mikroOrmRepository.find({ id: { $in: ids } } as any, { fields: ['id', 'tenantId'] as any });
break;
case MultiORMEnum.TypeORM:
default:
existing = await this.typeOrmRepository.find({
where: { id: In(ids) } as FindOptionsWhere<T>,
select: { id: true, tenantId: true } as any,
withDeleted: true
});
break;
}
if (existing.some((row) => String(row?.tenantId ?? '') !== String(tenantId))) {
throw new ForbiddenException('One of the records belongs to another tenant');
}
}
/**
* Creates a new entity instance and copies all entity properties from this object into a new entity.
* Note that it copies only properties that are present in entity schema.
@@ -377,6 +448,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
public async create(entity: IPartialEntity<T>): Promise<T> {
const tenantId = RequestContext.currentTenantId();
const employeeId = RequestContext.currentEmployeeId();
await this.assertNotForeignRow(entity, tenantId);
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
const hasEmployeeColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('employeeId');
@@ -408,6 +480,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
*/
public async createMany(entities: IPartialEntity<T>[]): Promise<T[]> {
const tenantId = RequestContext.currentTenantId();
await this.assertNotForeignRows(entities, tenantId);
const employeeId = RequestContext.currentEmployeeId();
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
@@ -435,6 +508,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
public async save(entity: IPartialEntity<T>): Promise<T> {
const tenantId = RequestContext.currentTenantId();
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
await this.assertNotForeignRow(entity, tenantId);
return await super.save({
...entity,
@@ -470,6 +544,7 @@ export abstract class TenantAwareCrudService<T extends TenantBaseEntity>
*/
public async saveMany(entities: IPartialEntity<T>[]): Promise<T[]> {
const tenantId = RequestContext.currentTenantId();
await this.assertNotForeignRows(entities, tenantId);
const hasTenantColumn = this.typeOrmRepository.metadata?.hasColumnWithPropertyPath('tenantId');
const enriched = entities.map((entity) => ({
@@ -94,6 +94,59 @@ export const videoUploadFileFilter = createUploadFileFilter(ALLOWED_VIDEO_MIME_T
/** Multer `fileFilter` accepting only audio files. */
export const audioUploadFileFilter = createUploadFileFilter(ALLOWED_AUDIO_MIME_TYPES, ALLOWED_AUDIO_EXTENSIONS);
/** MIME types browsers/clients send for a ZIP archive. */
export const ALLOWED_ARCHIVE_MIME_TYPES = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'multipart/x-zip',
'application/octet-stream'
] as const;
/** Extensions accepted by the archive (import) endpoints. */
export const ALLOWED_ARCHIVE_EXTENSIONS = ['.zip'] as const;
/** Multer `fileFilter` accepting only ZIP archives (the data-import format). */
export const archiveUploadFileFilter = createUploadFileFilter(ALLOWED_ARCHIVE_MIME_TYPES, ALLOWED_ARCHIVE_EXTENSIONS);
/**
* Extensions that are script-capable when served by extension yet have no legitimate use on the
* open-ended DOCUMENT upload endpoints (chat attachments, knowledge ingestion): those endpoints must
* accept `.html`/`.xml` (they are ingested), so an allowlist is impossible there and this is the
* backstop for the rest. `/public` additionally serves every asset with `nosniff` + a `sandbox` CSP.
*/
export const SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS = [
'.svg',
'.svgz',
'.xhtml',
'.xht',
'.mhtml',
'.mht',
'.xsl',
'.xslt',
'.shtml',
'.shtm',
'.hta',
'.js',
'.mjs',
'.vbs',
'.wsf'
] as const;
/**
* Multer `fileFilter` for open-ended document uploads: refuses {@link SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS}
* and accepts everything else. Callers still store the bytes behind `/public`'s `sandbox` CSP.
*/
export function documentUploadFileFilter(_req: unknown, file: { originalname?: string }, callback: MulterFileFilterCallback): void {
const name = String(file?.originalname ?? '').toLowerCase();
const dot = name.lastIndexOf('.');
const extension = dot >= 0 ? name.slice(dot) : '';
if ((SCRIPT_CAPABLE_NON_DOCUMENT_EXTENSIONS as readonly string[]).includes(extension)) {
return callback(new BadRequestException(`Files of type "${extension}" are not allowed`), false);
}
return callback(null, true);
}
/**
* Detects whether the given file content is markup (SVG / XML / HTML / XHTML).
*
@@ -33,10 +33,7 @@ export class EmployeeLevelController extends CrudController<EmployeeLevel> {
...options: any[]
): Promise<IEmployeeLevel> {
try {
return this.employeeLevelService.create({
id,
...entity
});
return this.employeeLevelService.create({ ...entity, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -39,7 +39,9 @@ export class EquipmentSharingStatusHandler implements ICommandHandler<EquipmentS
// If a corresponding request approval exists, update its status as well.
await this._requestApprovalService.update({ requestId: id }, { status });
// Persist and return the updated equipment sharing record.
return await this._equipmentSharingService.update(id, { status });
// Persist and return the updated equipment sharing record. NOT update(): that one deletes the row
// and re-inserts the payload, so approving/refusing (a { status }-only body) replaced the record
// with a stub. updateStatusEquipmentSharingByAdmin loads the tenant-scoped row and saves it back.
return await this._equipmentSharingService.updateStatusEquipmentSharingByAdmin(id, status);
}
}
@@ -27,8 +27,9 @@ export class EquipmentSharingUpdateHandler implements ICommandHandler<EquipmentS
this._requestApprovalService.delete({ requestId: id })
]);
// Save the updated Equipment Sharing record.
const equipmentSharing = await this._equipmentSharingService.create(input);
// Save the updated Equipment Sharing record under the path id (a body-supplied id must not
// retarget the write; the raw body is not DTO-validated).
const equipmentSharing = await this._equipmentSharingService.create({ ...input, id });
// Create a new request approval record for the updated equipment sharing.
await this._requestApprovalService.create({
@@ -167,8 +167,10 @@ export class EquipmentSharingService extends TenantAwareCrudService<EquipmentSha
// Use parent's tenant-scoped delete instead of direct repository access
await super.delete(id);
// Save the new equipment sharing data with tenant scoping
const equipmentSharing = await this.save(input);
// Save the new equipment sharing data with tenant scoping, under the SAME id: the body is not
// guaranteed to carry one, and a delete-then-insert without it replaced the record with a stub
// (approve/refuse goes through here).
const equipmentSharing = await this.save({ ...input, id });
// Return the newly saved record
return equipmentSharing;
@@ -189,12 +191,17 @@ export class EquipmentSharingService extends TenantAwareCrudService<EquipmentSha
*/
async delete(id: ID): Promise<DeleteResult> {
try {
// Execute both deletion operations concurrently.
// Use parent's tenant-scoped delete instead of direct repository access
const [equipmentSharing] = await Promise.all([
super.delete(id),
this.typeOrmRequestApprovalRepository.delete({ requestId: id })
]);
// The equipment-sharing delete is tenant-scoped (parent); the approval-row delete runs on a RAW
// repository, so it must be tenant-scoped explicitly and only run once the sharing row was
// really ours — otherwise a foreign UUID deleted another tenant's request_approval row.
const tenantId = RequestContext.currentTenantId();
const equipmentSharing = await super.delete(id);
if (equipmentSharing?.affected) {
await this.typeOrmRequestApprovalRepository.delete({
requestId: id,
...(tenantId ? { tenantId } : {})
});
}
// Return the result from the equipment sharing deletion.
return equipmentSharing;
@@ -92,9 +92,6 @@ export class EquipmentController extends CrudController<Equipment> {
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: UpdateEquipmentDTO): Promise<IEquipment> {
//We are using create here because create calls the method save()
//We need save() to save ManyToMany relations
return await this.equipmentService.create({
id,
...entity
});
return await this.equipmentService.create({ ...entity, id });
}
}
@@ -134,9 +134,6 @@ export class EventTypeController extends CrudController<EventType> {
@HttpCode(HttpStatus.ACCEPTED)
@Put(':id')
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: EventType): Promise<IEventType> {
return this.eventTypeService.create({
id,
...entity
});
return this.eventTypeService.create({ ...entity, id });
}
}
@@ -16,10 +16,7 @@ export class ExpenseCategoryUpdateHandler
) {
const { id, input } = command;
try {
return await this._expenseCategoryService.create({
id,
...input
});
return await this._expenseCategoryService.create({ ...input, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -19,10 +19,7 @@ export class ExpenseUpdateHandler implements ICommandHandler<ExpenseUpdateComman
let { id, entity } = command;
try {
await this.expenseService.findOneByIdString(id);
const expense = await this.expenseService.create({
id,
...entity
});
const expense = await this.expenseService.create({ ...entity, id });
let averageExpense = 0;
if (isNotEmpty(expense.employeeId)) {
const { employeeId } = expense;
@@ -4,7 +4,7 @@ import { CommandBus } from '@nestjs/cqrs';
import { ImportStatusEnum, ImportTypeEnum, PermissionsEnum, UploadedFile } from '@gauzy/contracts';
import { ImportService } from './import.service';
import { RequestContext } from '../../core/context';
import { FileStorage, UploadedFileStorage } from '../../core/file-storage';
import { archiveUploadFileFilter, FileStorage, UploadedFileStorage } from '../../core/file-storage';
import { PermissionGuard, TenantPermissionGuard } from '../../shared/guards';
import { Permissions } from '../../shared/decorators';
import { ImportHistoryCreateCommand } from '../import-history';
@@ -29,7 +29,10 @@ export class ImportController {
storage: new FileStorage().storage({
dest: path.join('import'),
prefix: 'import'
})
}),
// The import format is a ZIP of CSVs; the local provider keeps the client's extension and
// the file lands under /public, so anything else is refused before it is stored.
fileFilter: archiveUploadFileFilter
})
)
@ApiOperation({ summary: 'Imports templates records.' })
@@ -75,10 +75,7 @@ export class GoalGeneralSettingController extends CrudController<GoalGeneralSett
try {
//We are using create here because create calls the method save()
//We need save() to save ManyToMany relations
return await this.goalGeneralSettingService.create({
id,
...entity
});
return await this.goalGeneralSettingService.create({ ...entity, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -74,10 +74,7 @@ export class GoalKpiController extends CrudController<GoalKPI> {
@Put(':id')
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: GoalKPI): Promise<IKPI> {
try {
return await this.goalKpiService.create({
id,
...entity
});
return await this.goalKpiService.create({ ...entity, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -93,10 +93,7 @@ export class GoalTimeFrameController extends CrudController<GoalTimeFrame> {
@Body() entity: UpdateGoalTimeFrameDTO
): Promise<IGoalTimeFrame> {
try {
return await this.goalTimeFrameService.create({
id,
...entity
});
return await this.goalTimeFrameService.create({ ...entity, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -75,10 +75,7 @@ export class GoalController extends CrudController<Goal> {
try {
//We are using create here because create calls the method save()
//We need save() to save ManyToMany relations
return await this.goalService.create({
id,
...entity
});
return await this.goalService.create({ ...entity, id });
} catch (error) {
console.log(error);
return;
@@ -19,10 +19,7 @@ export class IncomeUpdateHandler implements ICommandHandler<IncomeUpdateCommand>
const { id, entity } = command;
try {
await this.incomeService.findOneByIdString(id);
const income = await this.incomeService.create({
id,
...entity
});
const income = await this.incomeService.create({ ...entity, id });
let averageIncome = 0;
let averageBonus = 0;
@@ -56,6 +56,9 @@ export class InviteAcceptHandler implements ICommandHandler<InviteAcceptCommand>
// would let an invitee accept with `user.roleId` of any role (e.g. SUPER_ADMIN).
input['user']['role'] = role;
input['user']['roleId'] = role.id;
// The account is created for the INVITED address (the one the token/code was validated
// against) — never for a different, auto-verified address supplied in the body.
input['user']['email'] = invite.email;
input['inviteId'] = inviteId;
// Invite accept for employee, candidate & user
@@ -167,7 +167,7 @@ export class InvoiceController extends CrudController<Invoice> {
@Param('id', UUIDValidationPipe) id: IInvoice['id'],
@Body() entity: UpdateInvoiceDTO
): Promise<Invoice> {
return await this.commandBus.execute(new InvoiceUpdateCommand({ id, ...entity }));
return await this.commandBus.execute(new InvoiceUpdateCommand({ ...entity, id }));
}
/**
@@ -197,7 +197,7 @@ export class InvoiceController extends CrudController<Invoice> {
@Param('id', UUIDValidationPipe) id: IInvoice['id'],
@Body() entity: UpdateEstimateInvoiceDTO
) {
return await this.commandBus.execute(new InvoiceUpdateCommand({ id, ...entity }));
return await this.commandBus.execute(new InvoiceUpdateCommand({ ...entity, id }));
}
/**
@@ -224,7 +224,7 @@ export class InvoiceController extends CrudController<Invoice> {
@Put('/:id/action')
@UseValidationPipe({ transform: true, whitelist: true })
async updateAction(@Param('id', UUIDValidationPipe) id: IInvoice['id'], @Body() entity: UpdateInvoiceActionDTO) {
return await this.commandBus.execute(new InvoiceUpdateCommand({ id, ...entity }));
return await this.commandBus.execute(new InvoiceUpdateCommand({ ...entity, id }));
}
/**
@@ -90,10 +90,7 @@ export class KeyResultUpdateController extends CrudController<KeyResultUpdate> {
//We are using create here because create calls the method save()
//We need save() to save ManyToMany relations
try {
return await this.keyResultUpdateService.create({
id,
...entity
});
return await this.keyResultUpdateService.create({ ...entity, id });
} catch (error) {
console.log(error);
return;
@@ -99,10 +99,7 @@ export class KeyResultController extends CrudController<KeyResult> {
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateKeyResultDTO): Promise<IKeyResult> {
//We are using create here because create calls the method save()
//We need save() to save ManyToMany relations
return await this.keyResultService.create({
id,
...entity
});
return await this.keyResultService.create({ ...entity, id });
}
@HttpCode(HttpStatus.ACCEPTED)
@@ -16,9 +16,6 @@ export class OrganizationDepartmentUpdateHandler implements ICommandHandler<Orga
const { id, input } = command;
//This will call save() with the id so that members[] also get saved accordingly
return this.organizationDepartmentService.create({
id,
...input
});
return this.organizationDepartmentService.create({ ...input, id });
}
}
@@ -55,10 +55,7 @@ export class OrganizationEmploymentTypeController extends CrudController<Organiz
@Body() entity: OrganizationEmploymentType
): Promise<IOrganizationEmploymentType> {
try {
return this.organizationEmploymentTypeService.create({
id,
...entity
});
return this.organizationEmploymentTypeService.create({ ...entity, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -57,10 +57,7 @@ export class OrganizationPositionController extends CrudController<OrganizationP
@Body() body: UpdateOrganizationPositionDTO
): Promise<IOrganizationPosition> {
try {
return this.organizationPositionService.create({
id,
...body
});
return this.organizationPositionService.create({ ...body, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -61,10 +61,7 @@ export class OrganizationVendorController extends CrudController<OrganizationVen
@Param('id', UUIDValidationPipe) id: string,
@Body() body: OrganizationVendor
): Promise<IOrganizationVendor> {
return this.organizationVendorService.create({
id,
...body
});
return this.organizationVendorService.create({ ...body, id });
}
/**
@@ -169,10 +169,7 @@ export class PaymentController extends CrudController<Payment> {
@UseValidationPipe({ transform: true, whitelist: true })
async update(@Param('id', UUIDValidationPipe) id: string, @Body() entity: UpdatePaymentDTO): Promise<IPayment> {
try {
return await this.paymentService.create({
id,
...entity
});
return await this.paymentService.create({ ...entity, id });
} catch (error) {
throw new BadRequestException(error);
}
@@ -38,7 +38,9 @@ export class ProductCategoryService extends TenantAwareCrudService<ProductCatego
async updateProductCategory(id: ID, entity: ProductCategory): Promise<ProductCategory> {
try {
await super.delete(id);
return this.save(entity);
// Persist under the verified path id, never a body-supplied one (save() with an existing PK
// updates THAT row).
return this.save({ ...entity, id });
} catch (err) {
throw new BadRequestException(err);
}
@@ -41,8 +41,14 @@ export class ProductTypeService extends TenantAwareCrudService<ProductType> {
async updateProductType(id: ID, entity: ProductType): Promise<ProductType> {
try {
const tenantId = RequestContext.currentTenantId();
// Persist under the verified path id, never a body-supplied one (save() with an existing PK
// updates THAT row — with a foreign id, another tenant's).
entity.id = id;
if (this.ormType === MultiORMEnum.TypeORM) {
// The transactional manager below is raw: TenantAwareCrudService's create/save guards do not
// run, so the ownership check has to be explicit here.
await this.assertNotForeignRow({ id } as any, tenantId);
return await this.typeOrmRepository.manager.transaction(async (transactionalEntityManager) => {
// 1. Ensure delete is scoped to the current tenant
await transactionalEntityManager.delete(ProductType, {
@@ -50,16 +56,19 @@ export class ProductTypeService extends TenantAwareCrudService<ProductType> {
...(isNotEmpty(tenantId) ? { tenantId } : {})
});
// 2. Ensure the entity injected has the correct tenantId before reproduction
if (isNotEmpty(tenantId)) {
entity.tenantId = tenantId;
}
return await transactionalEntityManager.save(entity);
// 2. Save with an EXPLICIT entity target and a plain payload. The route validates with
// `transform: true`, so `entity` is a ProductTypeDTO instance; EntityManager.save()
// resolves metadata from the constructor and threw EntityMetadataNotFoundError for it —
// rolling the transaction back and turning every update into a 400.
return await transactionalEntityManager.save(ProductType, {
...entity,
id,
...(isNotEmpty(tenantId) ? { tenantId } : {})
});
});
}
await super.delete(id);
return await this.save(entity);
return await this.save({ ...entity, id });
} catch (err) {
throw new BadRequestException(err);
}
@@ -75,7 +75,7 @@ export class SharedEntityController extends CrudController<SharedEntity> {
})
@HttpCode(HttpStatus.CREATED)
@Post()
@UseValidationPipe()
@UseValidationPipe({ whitelist: true })
async create(@Body() entity: CreateSharedEntityDTO): Promise<SharedEntity> {
return await this.commandBus.execute(new SharedEntityCreateCommand(entity));
}
@@ -94,7 +94,7 @@ export class SharedEntityController extends CrudController<SharedEntity> {
})
@HttpCode(HttpStatus.OK)
@Put(':id')
@UseValidationPipe()
@UseValidationPipe({ whitelist: true })
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateSharedEntityDTO): Promise<SharedEntity> {
return await this.commandBus.execute(new SharedEntityUpdateCommand(id, entity));
}
@@ -1,7 +1,8 @@
import { randomBytes } from "crypto";
import { FindOptionsRelations, FindOptionsSelect } from "typeorm";
import { BadRequestException, ForbiddenException } from "@nestjs/common";
import { EntityMetadata, FindOptionsRelations, FindOptionsSelect } from "typeorm";
import { isEmpty, isNotEmpty } from "@gauzy/utils";
import { IShareRule } from "@gauzy/contracts";
import { ID, IShareRule } from "@gauzy/contracts";
/**
* Field names that must never be exposed through a shared-entity link, regardless of the
@@ -23,18 +24,83 @@ export const SHARED_ENTITY_FORBIDDEN_FIELDS: ReadonlySet<string> = new Set([
'twoFactorRecoveryCode'
]);
/** How many relation hops a share may traverse from the root entity. */
export const SHARED_ENTITY_MAX_RELATION_DEPTH = 2;
/**
* The tenant/organization scope a share was created in; joined rows outside it are dropped.
*/
export interface ISharedEntityScope {
tenantId: ID;
organizationId?: ID;
}
/**
* Validates `shareRules.relations` against the entity metadata: every hop must be a real relation,
* every hop's TARGET must be tenant-scoped (carry a `tenantId` column), and the depth is bounded.
*
* Why: the token route is @Public() and only the ROOT row is tenant-scoped, so a share of an owned
* Organization could pivot through a GLOBAL entity's inverse relations into every other tenant
* (`Organization.featureOrganizations -> feature -> featureOrganizations -> tenant -> ...`,
* `languages -> language -> organizationLanguages -> organization`, `reportOrganizations -> report
* -> reportOrganizations`) — GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38.
*
* @param metadata - Metadata of the entity the rules apply to (root, then each hop's target).
* @param rules - The share rules to validate.
* @param depth - Current depth (internal).
*/
export function assertShareRulesAreSafe(metadata: EntityMetadata, rules: IShareRule, depth = 0): void {
if (!rules || typeof rules !== 'object') {
throw new BadRequestException('shareRules must be an object');
}
if (!Array.isArray(rules.fields)) {
throw new BadRequestException('shareRules.fields must be an array');
}
if (isEmpty(rules.relations)) {
return;
}
if (typeof rules.relations !== 'object') {
throw new BadRequestException('shareRules.relations must be an object');
}
if (depth >= SHARED_ENTITY_MAX_RELATION_DEPTH) {
throw new BadRequestException(
`shareRules.relations may not be nested deeper than ${SHARED_ENTITY_MAX_RELATION_DEPTH} levels`
);
}
for (const [relationName, subRules] of Object.entries(rules.relations)) {
const relation = metadata.findRelationWithPropertyPath(relationName);
if (!relation) {
throw new BadRequestException(`Unknown relation "${relationName}" on ${metadata.name}`);
}
const target = relation.inverseEntityMetadata;
// A hop into a global (tenant-less) entity — Tenant, Language, Feature, Report, Currency,
// Country, Integration, ... — cannot be tenant-filtered and is where cross-tenant pivots start.
if (!target.findColumnWithPropertyPath('tenantId')) {
throw new ForbiddenException(
`Relation "${relationName}" (${target.name}) is not tenant-scoped and cannot be shared`
);
}
assertShareRulesAreSafe(target, (subRules ?? { fields: [] }) as IShareRule, depth + 1);
}
}
/**
* Builds the select for the shared entity.
*
* @param rules - The share rules for the shared entity.
* @param metadata - Metadata of the entity the rules apply to; when given, the tenant/organization
* scope columns are always selected (never returned unless requested) so joined rows can be
* filtered to the share's scope after the query — see {@link filterSharedEntity}.
* @returns The select for the shared entity.
*/
export function buildSharedEntitySelect(rules: IShareRule): FindOptionsSelect<any> {
export function buildSharedEntitySelect(rules: IShareRule, metadata?: EntityMetadata): FindOptionsSelect<any> {
const select: FindOptionsSelect<any> = {
id: true // Always include the primary key for TypeORM to work correctly
};
if (metadata?.findColumnWithPropertyPath('tenantId')) select['tenantId'] = true;
if (metadata?.findColumnWithPropertyPath('organizationId')) select['organizationId'] = true;
// Add the fields to the select (never expose forbidden/sensitive columns)
for (const field of rules.fields) {
if (SHARED_ENTITY_FORBIDDEN_FIELDS.has(field)) continue;
@@ -44,7 +110,8 @@ export function buildSharedEntitySelect(rules: IShareRule): FindOptionsSelect<an
// Add the relations to the select
if (isNotEmpty(rules.relations)) {
for (const [relation, subRules] of Object.entries(rules.relations)) {
select[relation] = buildSharedEntitySelect(subRules as IShareRule);
const target = metadata?.findRelationWithPropertyPath(relation)?.inverseEntityMetadata;
select[relation] = buildSharedEntitySelect(subRules as IShareRule, target);
}
}
@@ -72,14 +139,35 @@ export function buildSharedEntityRelations(rules: IShareRule): FindOptionsRelati
return relations;
}
/**
* Whether a joined row belongs to the share's scope. Rows that carry a tenantId must match the
* share's tenant; rows that carry an organizationId must match the share's organization when the
* share has one. Rows without those columns cannot be judged and are kept (the relation validator
* already refuses hops into tenant-less entities).
*/
function isWithinScope(row: any, scope?: ISharedEntityScope): boolean {
if (!scope || !row || typeof row !== 'object') return true;
if ('tenantId' in row && row.tenantId && String(row.tenantId) !== String(scope.tenantId)) return false;
if (
scope.organizationId &&
'organizationId' in row &&
row.organizationId &&
String(row.organizationId) !== String(scope.organizationId)
) {
return false;
}
return true;
}
/**
* Filters the entity based on the share rules.
*
* @param entity - The entity to filter.
* @param rules - The share rules for the shared entity.
* @param scope - The share's tenant/organization; joined rows outside it are dropped.
* @returns The filtered entity.
*/
export function filterSharedEntity(entity: any, rules: IShareRule): any {
export function filterSharedEntity(entity: any, rules: IShareRule, scope?: ISharedEntityScope): any {
const result: any = {};
for (const field of rules.fields) {
@@ -92,11 +180,11 @@ export function filterSharedEntity(entity: any, rules: IShareRule): any {
if (!entity[relation]) continue;
if (Array.isArray(entity[relation])) {
result[relation] = entity[relation].map(item =>
filterSharedEntity(item, subRules as IShareRule)
);
} else {
result[relation] = filterSharedEntity(entity[relation], subRules as IShareRule);
result[relation] = entity[relation]
.filter((item: any) => isWithinScope(item, scope))
.map((item: any) => filterSharedEntity(item, subRules as IShareRule, scope));
} else if (isWithinScope(entity[relation], scope)) {
result[relation] = filterSharedEntity(entity[relation], subRules as IShareRule, scope);
}
}
}
@@ -1,6 +1,6 @@
import { BadRequestException, ForbiddenException, HttpException, Injectable, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, FindOneOptions, Repository } from "typeorm";
import { DataSource, FindOneOptions, Repository, UpdateResult } from "typeorm";
import { BaseEntityEnum, ID, ISharedEntityCreateInput, IShareRule } from "@gauzy/contracts";
import { RequestContext } from "../core/context";
@@ -8,7 +8,13 @@ import { TenantAwareCrudService } from "../core/crud";
import { MikroOrmSharedEntityRepository } from "./repository/mikro-orm-shared-entity.repository";
import { TypeOrmSharedEntityRepository } from "./repository/type-orm-shared-entity.repository";
import { SharedEntity } from "./shared-entity.entity";
import { buildSharedEntityRelations, buildSharedEntitySelect, filterSharedEntity, generateSharedEntityToken } from "./shared-entity.helper";
import {
assertShareRulesAreSafe,
buildSharedEntityRelations,
buildSharedEntitySelect,
filterSharedEntity,
generateSharedEntityToken
} from "./shared-entity.helper";
@Injectable()
export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
@@ -38,6 +44,8 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
// share-link pointing at another tenant's record by its id (cross-tenant IDOR -
// GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38).
const targetRepository = this.resolveRepository(input.entity);
// Only real, tenant-scoped relations, to a bounded depth (see assertShareRulesAreSafe).
assertShareRulesAreSafe(targetRepository.metadata, this.parseShareRules(input.shareRules));
const owned = await targetRepository.findOne({
where: this.buildScopedWhere(targetRepository, input.entityId, tenantId, organizationId)
});
@@ -84,10 +92,13 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
throw new NotFoundException('Shared entity not found');
}
const shareRules = sharedEntity.shareRules as IShareRule;
const shareRules = this.parseShareRules(sharedEntity.shareRules);
// Get the repository for the shared entity
const repository = this.resolveRepository(sharedEntity.entity);
// Re-validated at resolution time too: rules stored before this guard existed (or edited
// through another path) must not be able to traverse out of the tenant.
assertShareRulesAreSafe(repository.metadata, shareRules);
// Construct the find options for the shared entity.
// Scope the lookup to the share's OWN tenant/organization so a token issued by tenant A can
@@ -108,8 +119,11 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
throw new NotFoundException('Entity not found');
}
// Return the entity
return filterSharedEntity(entity, shareRules);
// Return the entity, dropping any joined row outside the share's own scope
return filterSharedEntity(entity, shareRules, {
tenantId: sharedEntity.tenantId,
organizationId: sharedEntity.organizationId
});
} catch (error) {
throw new NotFoundException(`Failed to get shared entity by token: ${error?.message || error}`);
}
@@ -134,7 +148,7 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
): FindOneOptions<any> {
return {
where: this.buildScopedWhere(repository, entityId, tenantId, organizationId),
select: buildSharedEntitySelect(rules),
select: buildSharedEntitySelect(rules, repository.metadata),
relations: buildSharedEntityRelations(rules)
}
}
@@ -161,21 +175,68 @@ export class SharedEntityService extends TenantAwareCrudService<SharedEntity> {
): Record<string, any> {
const where: Record<string, any> = { id: entityId };
const hasTenantColumn = !!repository.metadata.findColumnWithPropertyName('tenantId');
// Fail closed: if the target entity IS tenant-scoped (has a tenantId column) but we have no
// tenantId to scope by, refuse rather than fall back to an `id`-only lookup that would bypass
// tenant isolation on the public token route (GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38).
if (hasTenantColumn) {
// Global (tenant-less) entity types — Tenant, Language, Currency, ... — are NOT shareable:
// there is no tenant column to scope them by, so the lookup would be id-only and their inverse
// relations reach into every tenant (GHSA-gpg5-qwjc-8hqh / GHSA-cx2q-xmh2-pc38).
if (!hasTenantColumn) {
throw new ForbiddenException(`Entity "${repository.metadata.name}" is not tenant-scoped and cannot be shared`);
}
// Fail closed: a tenant-scoped entity without a tenant to scope by must not fall back to an
// `id`-only lookup on the public token route.
if (!tenantId) {
throw new ForbiddenException('Cannot resolve a tenant-scoped entity without a tenant context');
}
where['tenantId'] = tenantId;
}
if (organizationId && repository.metadata.findColumnWithPropertyName('organizationId')) {
where['organizationId'] = organizationId;
}
return where;
}
/**
* Updates a share. The target (entity/entityId) and the token are pinned at creation and never
* client-editable; when the rules change they are re-validated against the target's metadata.
*
* @param id - The shared entity id.
* @param input - The update payload.
* @returns The updated shared entity.
*/
public async update(id: string, input: Partial<SharedEntity>): Promise<SharedEntity | UpdateResult> {
const {
id: _id,
entity: _entity,
entityId: _entityId,
token: _token,
tenant: _tenant,
tenantId: _tenantId,
organization: _organization,
organizationId: _organizationId,
...safeInput
} = (input ?? {}) as any;
if (safeInput.shareRules !== undefined) {
const existing = await this.findOneByIdString(id);
const targetRepository = this.resolveRepository(existing.entity);
assertShareRulesAreSafe(targetRepository.metadata, this.parseShareRules(safeInput.shareRules));
}
return await super.update(id, safeInput);
}
/**
* shareRules is stored as JSON(B) on Postgres/MySQL and as text on SQLite; normalise to an object.
*
* @param shareRules - The stored or submitted rules.
*/
private parseShareRules(shareRules: IShareRule | string): IShareRule {
if (typeof shareRules === 'string') {
try {
return JSON.parse(shareRules) as IShareRule;
} catch {
throw new BadRequestException('shareRules must be valid JSON');
}
}
return shareRules as IShareRule;
}
/**
* Resolves the repository for the given entity name.
*
@@ -1,6 +1,7 @@
import { ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@nestjs/common';
import { StatusTypesMapRequestApprovalEnum } from '@gauzy/contracts';
import { RequestContext } from '../../../core/context';
import { TimeOffStatusCommand } from '../time-off.status.command';
import { TimeOffRequest } from '../../time-off-request.entity';
import { TypeOrmTimeOffRequestRepository } from '../../repository/type-orm-time-off-request.repository';
@@ -15,12 +16,17 @@ export class TimeOffStatusHandler implements ICommandHandler<TimeOffStatusComman
public async execute(command?: TimeOffStatusCommand): Promise<TimeOffRequest> {
const { id, status } = command;
// Both repositories are RAW (not tenant-aware): the request and its approval row must be
// resolved inside the caller's tenant, or an admin of tenant A could approve / deny any tenant
// B request by UUID (GHSA-gwpq-mmw7-vx85 class).
const tenantId = RequestContext.currentTenantId();
if (!id || !tenantId) {
throw new NotFoundException('Request time off not found');
}
const [timeOffRequest, requestApproval] = await Promise.all([
await this.typeOrmTimeOffRequestRepository.findOneBy({ id }),
await this.typeOrmRequestApprovalRepository.findOneBy({
requestId: id
})
this.typeOrmTimeOffRequestRepository.findOneBy({ id, tenantId }),
this.typeOrmRequestApprovalRepository.findOneBy({ requestId: id, tenantId })
]);
if (!timeOffRequest) {
@@ -130,11 +130,13 @@ export class TimeOffRequestService extends TenantAwareCrudService<TimeOffRequest
async updateTimeOffByAdmin(id: string, timeOffRequest: ITimeOffCreateInput) {
try {
// Verify the record belongs to the current tenant before updating
// Verify the record belongs to the current tenant before updating, and make the verified id
// the one that is saved: the body is not DTO-validated, so a body id spread after the path id
// used to retarget the save (TypeORM save() with an existing PK is an UPDATE of that row).
await this.findOneByIdString(id);
return await this.save({
id,
...timeOffRequest
...timeOffRequest,
id
});
} catch (error) {
throw new BadRequestException(error);
@@ -1,5 +1,7 @@
import { ForbiddenException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { IUser } from '@gauzy/contracts';
import { IUser, PermissionsEnum, RolesEnum } from '@gauzy/contracts';
import { RequestContext } from '../../../core/context';
import { UserCreateCommand } from '../user.create.command';
import { UserService } from '../../user.service';
@@ -15,6 +17,13 @@ export class UserCreateHandler implements ICommandHandler<UserCreateCommand> {
*/
public async execute(command: UserCreateCommand): Promise<IUser> {
const { input } = command;
// Creating a SUPER_ADMIN is reserved to callers who may edit super admins — the same boundary
// the register handler and invite creation enforce. Both the flat `roleId` and the `role`
// relation are resolved from the database (the relation wins on persist), and an id that does
// not belong to the caller's tenant is refused rather than ignored.
await this.userService.assertCanAssignRoles([input?.roleId, input?.role?.id]);
return await this.userService.create(input);
}
}
@@ -1,5 +1,6 @@
import { ApiPropertyOptional, IntersectionType, PartialType, PickType } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IUserUpdateInput } from '@gauzy/contracts';
import { IntersectionType, PartialType, PickType } from '@nestjs/swagger';
import { User } from '../user.entity';
import { CreateUserDTO } from './create-user.dto';
@@ -14,9 +15,26 @@ class UpdateUserBaseDTO extends PickType(User, [
'isActive'
] as const) {}
/**
* The credential half of a profile update.
*
* `hash` carries the NEW PASSWORD in clear text (`UserService.updateProfile` hashes it before the
* write) — that is the long-standing contract the profile form uses. It is declared explicitly so the
* route can validate with `whitelist: true`: every property the DTO does not declare is stripped, which
* is what keeps identity/verification columns (`id`, `emailVerifiedAt`, `emailToken`, `refreshToken`, …)
* out of a body that is otherwise spread straight onto the entity.
*/
class UpdateUserCredentialsDTO {
@ApiPropertyOptional({ type: () => String })
@IsOptional()
@IsString()
@IsNotEmpty()
readonly hash?: string;
}
/**
* Update User Data Transfer Object (DTO) validation.
*/
export class UpdateUserDTO
extends IntersectionType(PartialType(CreateUserDTO), UpdateUserBaseDTO)
extends IntersectionType(PartialType(CreateUserDTO), IntersectionType(UpdateUserBaseDTO, UpdateUserCredentialsDTO))
implements IUserUpdateInput {}
@@ -0,0 +1,42 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { RolesEnum } from '@gauzy/contracts';
import { assertRoleAssignmentAllowed } from './role-assignment.helper';
/**
* Regression suite for the SUPER_ADMIN assignment boundary (GHSA-hjcg-633x-qq74 / GHSA-x4mv-fhwj-g3rp
* residuals).
*
* Two ways the previous gates leaked:
* - they read the role name from the CLIENT (`input.user.role.name`), so a role object carrying only
* an id — or a spoofed name — skipped the check while the id was still persisted;
* - they resolved only ONE identifier (`roleId ?? role.id`), while the `role` RELATION wins on
* persist, so a harmless `roleId` next to a privileged `role: { id }` validated the harmless one;
* - and an id that did not resolve inside the caller's tenant was treated as "not a super admin",
* which let an attacker who also owns a second tenant pass that tenant's SUPER_ADMIN role id.
*/
describe('assertRoleAssignmentAllowed', () => {
it('allows an ordinary role for any caller', () => {
expect(() => assertRoleAssignmentAllowed(RolesEnum.EMPLOYEE, false)).not.toThrow();
expect(() => assertRoleAssignmentAllowed(RolesEnum.ADMIN, false)).not.toThrow();
});
it('allows SUPER_ADMIN only for a caller holding SUPER_ADMIN_EDIT', () => {
expect(() => assertRoleAssignmentAllowed(RolesEnum.SUPER_ADMIN, true)).not.toThrow();
expect(() => assertRoleAssignmentAllowed(RolesEnum.SUPER_ADMIN, false)).toThrow(ForbiddenException);
});
it.each([[undefined], ['']])('refuses an id that did not resolve in the tenant (%p) — fail closed', (roleName) => {
// A cross-tenant SUPER_ADMIN role id resolves to undefined here; it must NOT read as "harmless".
expect(() => assertRoleAssignmentAllowed(roleName as any, false)).toThrow(BadRequestException);
expect(() => assertRoleAssignmentAllowed(roleName as any, true)).toThrow(BadRequestException);
});
it('CONTROL: the pre-fix shape (only the first identifier checked) would have admitted the escalation', () => {
// Body: { roleId: <EMPLOYEE>, role: { id: <SUPER_ADMIN> } } — the relation is what gets persisted.
const resolved = { roleId: RolesEnum.EMPLOYEE, relation: RolesEnum.SUPER_ADMIN };
const preFix = () => assertRoleAssignmentAllowed(resolved.roleId, false); // `roleId ?? role.id`
const fixed = () => [resolved.roleId, resolved.relation].forEach((name) => assertRoleAssignmentAllowed(name, false));
expect(preFix).not.toThrow();
expect(fixed).toThrow(ForbiddenException);
});
});
@@ -0,0 +1,31 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { RolesEnum } from '@gauzy/contracts';
/**
* Decides whether a caller may assign a role, given the role's name **as resolved from the database**
* and whether the caller holds `SUPER_ADMIN_EDIT`.
*
* Two rules, both fail-closed:
*
* 1. An unresolvable role id is refused. `UserService.resolveRoleName()` scopes its lookup to the
* caller's tenant, so a role id borrowed from another tenant resolves to `undefined` — treating
* that as "not a super admin" would let an attacker who owns a second tenant on the same
* deployment pass their own tenant's SUPER_ADMIN role id through the gate.
* 2. Granting SUPER_ADMIN requires `SUPER_ADMIN_EDIT` — the same boundary the register handler and
* invite creation enforce (GHSA-hjcg-633x-qq74 / GHSA-x4mv-fhwj-g3rp).
*
* Callers must apply this to EVERY role identifier in the payload (both the flat `roleId` and the
* `role` relation): the relation wins when the row is persisted, so validating only one of them lets
* a body pair a harmless `roleId` with a privileged `role: { id }`.
*
* @param roleName - The role name resolved from the database, or undefined when it did not resolve.
* @param canEditSuperAdmin - Whether the caller holds `PermissionsEnum.SUPER_ADMIN_EDIT`.
*/
export function assertRoleAssignmentAllowed(roleName: string | undefined, canEditSuperAdmin: boolean): void {
if (!roleName) {
throw new BadRequestException('The specified role does not exist in this tenant.');
}
if (roleName === RolesEnum.SUPER_ADMIN && !canEditSuperAdmin) {
throw new ForbiddenException('Only a super admin may assign the super admin role.');
}
}
@@ -247,7 +247,7 @@ export class UserController extends CrudController<User> {
@Permissions(PermissionsEnum.ORG_USERS_EDIT)
@HttpCode(HttpStatus.CREATED)
@Post('/')
@UseValidationPipe()
@UseValidationPipe({ whitelist: true })
async create(@Body() entity: CreateUserDTO): Promise<IUser> {
return await this._commandBus.execute(new UserCreateCommand(entity));
}
@@ -263,12 +263,9 @@ export class UserController extends CrudController<User> {
@UseGuards(TenantPermissionGuard, PermissionGuard)
@Permissions(PermissionsEnum.ORG_USERS_EDIT, PermissionsEnum.PROFILE_EDIT)
@Put('/:id')
@UseValidationPipe({ transform: true })
@UseValidationPipe({ transform: true, whitelist: true })
async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateUserDTO): Promise<IUser> {
return await this._userService.updateProfile(id, {
id,
...entity
});
return await this._userService.updateProfile(id, { ...entity, id });
}
/**
@@ -49,6 +49,7 @@ import { User } from './user.entity';
import { validateUserDeletion } from './default-protected-users';
import { assertUiPreferencesSize, mergeUiPreferences, sanitizeUiPreferencesPatch } from './ui-preferences.util';
import { PasswordHashService } from '../password-hash/password-hash.service';
import { assertRoleAssignmentAllowed } from './role-assignment.helper';
import {
emailVerificationClaimWhere,
emailVerificationClaimWhereMikroOrm,
@@ -382,6 +383,11 @@ export class UserService extends TenantAwareCrudService<User> {
* @throws ForbiddenException if the user lacks the required permissions or attempts unauthorized updates.
*/
async updateProfile(id: ID | number, entity: User): Promise<IUser> {
// The path id is authoritative. Every check below authorizes THIS id, and save() persists the
// entity's id — a body `id` (the update DTO is not whitelisted) must never retarget the write to
// another user (e.g. overwrite the SUPER_ADMIN's password hash from a PROFILE_EDIT account).
entity.id = id as ID;
// Retrieve the current user's role ID from the RequestContext
const currentRoleId = RequestContext.currentRoleId();
const currentUserId = RequestContext.currentUserId();
@@ -432,6 +438,11 @@ export class UserService extends TenantAwareCrudService<User> {
if (requestedRoleIds.some((roleId) => String(roleId) !== String(currentRoleId))) {
throw new ForbiddenException();
}
} else {
// Updating SOMEONE ELSE: granting SUPER_ADMIN is reserved to callers who may edit super
// admins (the same boundary the register handler and invite creation enforce). The role is
// resolved from the database — never from a client-supplied role name.
await this.assertCanAssignRoles([entity.role?.id, entity.roleId]);
}
// Update password hash if provided
@@ -810,6 +821,52 @@ export class UserService extends TenantAwareCrudService<User> {
return this._passwordHashService.hash(password);
}
/**
* Refuses a payload that assigns a role the caller may not grant.
*
* @param roleIds Every role identifier in the payload — both the flat `roleId` and `role.id`.
* @throws BadRequestException When an id does not resolve inside the caller's tenant.
* @throws ForbiddenException When SUPER_ADMIN is requested without `SUPER_ADMIN_EDIT`.
*/
public async assertCanAssignRoles(roleIds: Array<ID | undefined>): Promise<void> {
// EVERY candidate is checked, not just the first: the entity carries both a `role` relation and a
// flat `roleId` column, and the RELATION wins when the row is persisted — so a body sending a
// harmless `roleId` next to a privileged `role: { id }` must not validate the harmless one.
const candidates = roleIds.filter((roleId) => isNotEmpty(roleId)) as ID[];
const canEditSuperAdmin = RequestContext.hasPermission(PermissionsEnum.SUPER_ADMIN_EDIT);
for (const roleId of candidates) {
assertRoleAssignmentAllowed(await this.resolveRoleName(roleId), canEditSuperAdmin);
}
}
/**
* Resolves the name of a role of the caller's tenant from the database (by entity name, to avoid
* a role -> user -> role import cycle). Returns undefined for an unknown / foreign role.
*
* @param roleId The role id to resolve.
*/
public async resolveRoleName(roleId: ID): Promise<string | undefined> {
if (!roleId) {
return undefined;
}
const tenantId = RequestContext.currentTenantId();
switch (this.ormType) {
case MultiORMEnum.MikroORM: {
const role = await this.mikroOrmRepository
.getEntityManager()
.findOne('Role', { id: roleId, ...(tenantId ? { tenantId } : {}) } as any);
return (role as any)?.name;
}
case MultiORMEnum.TypeORM:
default: {
const role = await this.typeOrmRepository.manager.findOne('Role', {
where: { id: roleId, ...(tenantId ? { tenantId } : {}) } as any
});
return (role as any)?.name;
}
}
}
/**
* To permanently delete your account from your Gauzy app:
*
@@ -30,7 +30,9 @@ import {
PermissionGuard,
Permissions,
RequestContext,
TenantPermissionGuard
TenantPermissionGuard,
UploadedFileStorage,
documentUploadFileFilter
} from '@gauzy/core';
import { AiChatService, MAX_AUDIO_BYTES } from './ai-chat.service';
import {
@@ -39,6 +41,13 @@ import {
MAX_ATTACHMENT_BYTES
} from './attachments/ai-chat-attachment.service';
/**
* Object-name extensions a static file server would render in the browser (mirrors the Documents
* upload endpoint). The stored object carries a neutral extension instead; the client keeps the
* real type via `mimeType`.
*/
const RENDERABLE_KEY_EXTENSIONS = new Set(['html', 'htm', 'xhtml', 'xml', 'svg', 'svgz', 'js', 'mjs', 'css']);
/**
* Per-request storage engine of the attachment endpoint.
*
@@ -60,7 +69,14 @@ const attachmentsStorage = (ctx: ExecutionContext) => {
const safeExtension = String(extension ?? '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
return safeExtension ? `${randomUUID()}.${safeExtension}` : `${randomUUID()}`;
if (!safeExtension) {
return `${randomUUID()}`;
}
// Never let a browser-renderable extension onto the stored object name — same rule as the
// Documents upload endpoint (the LOCAL provider serves /public/<key> with a Content-Type
// derived from the extension; the canonical type travels in the attachment's mimeType).
const storedExtension = RENDERABLE_KEY_EXTENSIONS.has(safeExtension) ? 'bin' : safeExtension;
return `${randomUUID()}.${storedExtension}`;
}
});
};
@@ -210,13 +226,21 @@ export class AiChatController {
@UseInterceptors(
LazyFileInterceptor('file', {
storage: (ctx: ExecutionContext) => attachmentsStorage(ctx),
// Documents of (almost) any type are ingested here, so no allowlist — but script-capable
// non-document types (.svg, .xhtml, .mhtml, .hta, .js, ...) have no business being stored
// under /public with the client's extension.
fileFilter: documentUploadFileFilter,
// The same constant the service's cap derives from, declared here so an oversized
// upload is rejected by multer BEFORE the provider stores any of it.
limits: { fileSize: MAX_ATTACHMENT_BYTES }
})
)
async attach(
@UploadedFile() file: IUploadedFile,
// Core's decorator maps the multer object through the ACTIVE provider (mapUploadedFile), which
// is what fills `key` (LOCAL derives it from `path`; S3-family providers set it themselves).
// Nest's plain @UploadedFile() hands the raw diskStorage object over, which has no `key` — so
// on the default LOCAL provider every attachment answered 400 after the bytes were written.
@UploadedFileStorage() file: IUploadedFile,
@Body() body: { conversationId?: string }
): Promise<IAiChatAttachmentResult> {
return this.attachmentService.save(file, body?.conversationId);
@@ -1,12 +1,15 @@
// `@gauzy/core` pulls the whole bootstrap graph; only `RequestContext` is on the path under
// test, so it is stubbed AT THE MODULE BOUNDARY — hoisted above the imports.
const deleteFile = jest.fn(async () => undefined);
jest.mock('@gauzy/core', () => ({
RequestContext: {
currentTenantId: jest.fn(() => 'tenant-1'),
currentOrganizationId: jest.fn(() => 'org-1'),
currentUserId: jest.fn(() => 'user-1'),
currentRequest: jest.fn(() => ({ headers: {} }))
}
},
// The service discards a rejected upload through the active provider.
FileStorage: jest.fn().mockImplementation(() => ({ getProvider: () => ({ deleteFile }) }))
}));
import { BadRequestException } from '@nestjs/common';
@@ -97,12 +100,36 @@ describe('AiChatAttachmentService', () => {
* Without a scope the consumer would have to GUESS which organization the file belongs to,
* which is how an attachment lands in the wrong workspace. Refusing is the only safe answer.
*/
it('refuses an attachment it cannot attribute to an organization', async () => {
it('refuses an attachment it cannot attribute to an organization — and removes the stored bytes', async () => {
requestContext.currentOrganizationId.mockReturnValue(null);
requestContext.currentRequest.mockReturnValue({ headers: {} });
deleteFile.mockClear();
await expect(service.save(uploaded())).rejects.toBeInstanceOf(BadRequestException);
expect(publish).not.toHaveBeenCalled();
// multer already wrote the object before the service ran: it must not be left orphaned.
expect(deleteFile).toHaveBeenCalledWith('ai-chat/tenant-1/org-1/abc.pdf');
});
it('still rejects (with the scope error) when the orphan cleanup itself fails', async () => {
requestContext.currentOrganizationId.mockReturnValue(null);
requestContext.currentRequest.mockReturnValue({ headers: {} });
deleteFile.mockRejectedValueOnce(new Error('disk gone'));
await expect(service.save(uploaded())).rejects.toBeInstanceOf(BadRequestException);
});
/**
* On the default LOCAL provider Nest's plain @UploadedFile() hands over multer's diskStorage
* object, which has no `key` (only core's @UploadedFileStorage() maps it through the provider,
* which derives `key` from `path`). The controller now uses that decorator; this pins the
* contract the service relies on: a mapped file HAS a key and is accepted.
*/
it('accepts a provider-mapped LOCAL file (key derived from path)', async () => {
const result = await service.save(
uploaded({ path: '/srv/public/ai-chat/tenant-1/org-1/abc.pdf', key: 'ai-chat/tenant-1/org-1/abc.pdf' })
);
expect(result.key).toBe('ai-chat/tenant-1/org-1/abc.pdf');
});
it('rejects a request with no uploaded file', async () => {
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ID, UploadedFile } from '@gauzy/contracts';
import { EventBus, RequestContext } from '@gauzy/core';
import { EventBus, FileStorage, RequestContext } from '@gauzy/core';
import { AiChatAttachmentSavedEvent } from './ai-chat-attachment.event';
/**
@@ -61,7 +61,9 @@ export class AiChatAttachmentService {
const organizationId = this.resolveOrganizationId();
if (!tenantId || !organizationId) {
// Without a scope the attachment cannot be attributed to anything, and a consumer
// would have to guess — which is how a file ends up in the wrong organization.
// would have to guess — which is how a file ends up in the wrong organization. The bytes
// are already in storage (multer ran first): remove them rather than leave an orphan.
await this.discardStoredFile(file.key);
throw new BadRequestException(
'An organization is required to attach a file — send the `Organization-Id` header.'
);
@@ -100,6 +102,22 @@ export class AiChatAttachmentService {
};
}
/**
* Removes a stored object that will not be recorded (rejected upload). Best effort: a failure
* to delete must not mask the rejection the caller is about to see.
*
* @param key The storage key of the object.
*/
private async discardStoredFile(key: string): Promise<void> {
try {
await new FileStorage().getProvider().deleteFile(key);
} catch (error) {
this.logger.warn(
`Could not remove rejected attachment '${key}': ${error instanceof Error ? error.message : error}`
);
}
}
/**
* The requesting organization: the request context first, then the `Organization-Id` header
* the web client sends on every call (the JWT itself carries no organization).
@@ -11,6 +11,6 @@ export class ChangelogUpdateHandler
public async execute(command: ChangelogUpdateCommand): Promise<IChangelog> {
const { input } = command;
const { id } = input;
return this.changelogService.create({ id, ...input });
return this.changelogService.create({ ...input, id });
}
}
@@ -140,6 +140,17 @@ export class ChatCaptureSubscriber implements OnModuleInit, OnModuleDestroy {
const sniff = sniffFile(buffer, fileName, payload.file.mimetype);
if (!sniff.ok) {
this.logger.warn(`Chat attachment '${fileName.slice(0, 40)}' rejected: ${sniff.code}`);
// Nothing will ever reference a REJECTED object: remove it instead of leaving an orphan
// (best effort — the rejection stands either way). Only when the bytes were actually read:
// LocalProvider.getFile swallows fs errors and returns undefined, and a transient read
// failure must not destroy a valid attachment.
if (buffer?.length) {
try {
await provider.deleteFile(payload.file.key);
} catch (error) {
this.logger.warn(`Could not remove rejected chat attachment '${payload.file.key}': ${error?.message ?? error}`);
}
}
return null;
}
@@ -11,7 +11,7 @@ import {
ID
} from '@gauzy/contracts';
import { PermissionGuard, Permissions, TenantPermissionGuard, UUIDValidationPipe } from '@gauzy/core';
import { HubstaffService } from './hubstaff.service';
import { HubstaffService, IHubstaffAccessTokenResponse } from './hubstaff.service';
@ApiTags('Hubstaff Integrations')
@UseGuards(TenantPermissionGuard, PermissionGuard)
@@ -37,12 +37,12 @@ export class HubstaffController {
* Refresh Hubstaff token by integration ID
*
* @param integrationId The ID of the integration
* @returns The refreshed Hubstaff token
* @returns The refreshed Hubstaff access token (the refresh token itself stays server-side)
*/
@Get('/refresh-token/:integrationId')
async refreshHubstaffTokenByIntegration(
@Param('integrationId', UUIDValidationPipe) integrationId: ID
): Promise<string> {
): Promise<IHubstaffAccessTokenResponse> {
return await this._hubstaffService.refreshToken(integrationId);
}
@@ -64,6 +64,17 @@ import {
} from '@gauzy/core';
import { HUBSTAFF_AUTHORIZATION_URL } from './hubstaff.config';
/**
* What the refresh endpoint returns to the client: the short-lived access token only. The refresh
* token is a long-lived credential and never leaves the server (GHSA-3rqg-gpm9-gx84 class).
*/
export interface IHubstaffAccessTokenResponse {
access_token: string;
token_type?: string;
expires_in?: number;
scope?: string;
}
@Injectable()
export class HubstaffService {
constructor(
@@ -109,7 +120,7 @@ export class HubstaffService {
* @param integrationId The ID of the integration.
* @returns The new tokens.
*/
async refreshToken(integrationId: ID) {
async refreshToken(integrationId: ID): Promise<IHubstaffAccessTokenResponse> {
const settings = await this._integrationSettingService.find({
where: {
integration: { id: integrationId },
@@ -163,7 +174,10 @@ export class HubstaffService {
}) as DeepPartial<IIntegrationSetting>;
await this._integrationSettingService.create(settingsDto);
return tokens;
// The client only needs the (short-lived) access token to keep calling the API; the refresh
// token is a long-lived credential and stays server-side (GHSA-3rqg-gpm9-gx84 class).
const { access_token, token_type, expires_in, scope } = tokens ?? {};
return { access_token, token_type, expires_in, scope };
} catch (error) {
throw new BadRequestException(error);
}
@@ -7,7 +7,8 @@ import {
RequestContext,
TenantPermissionGuard,
UseValidationPipe,
UUIDValidationPipe
UUIDValidationPipe,
documentUploadFileFilter
} from '@gauzy/core';
import {
BadRequestException,
@@ -84,7 +85,10 @@ export class PluginManagementController {
})
@UseInterceptors(
LazyAnyFileInterceptor({
storage: () => FileStorageFactory.create('plugins')
storage: () => FileStorageFactory.create('plugins'),
// Plugin archives land under /public with the client extension: refuse script-capable
// non-document types (GHSA-p334-cm7f-php5 class).
fileFilter: documentUploadFileFilter
})
)
@Permissions(PermissionsEnum.PLUGIN_CONFIGURE)
@@ -188,7 +192,10 @@ export class PluginManagementController {
})
@UseInterceptors(
LazyAnyFileInterceptor({
storage: () => FileStorageFactory.create('plugins')
storage: () => FileStorageFactory.create('plugins'),
// Plugin archives land under /public with the client extension: refuse script-capable
// non-document types (GHSA-p334-cm7f-php5 class).
fileFilter: documentUploadFileFilter
})
)
@Permissions(PermissionsEnum.PLUGIN_UPDATE)
@@ -6,7 +6,8 @@ import {
PermissionGuard,
TenantPermissionGuard,
UseValidationPipe,
UUIDValidationPipe
UUIDValidationPipe,
documentUploadFileFilter
} from '@gauzy/core';
import {
BadRequestException,
@@ -80,7 +81,10 @@ export class PluginSourceController {
@UseValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })
@UseInterceptors(
LazyAnyFileInterceptor({
storage: () => FileStorageFactory.create('plugins')
storage: () => FileStorageFactory.create('plugins'),
// Plugin archives land under /public with the client extension: refuse script-capable
// non-document types (GHSA-p334-cm7f-php5 class).
fileFilter: documentUploadFileFilter
})
)
@UseGuards(PluginOwnerGuard, TenantPermissionGuard, PermissionGuard)
@@ -6,7 +6,8 @@ import {
PermissionGuard,
TenantPermissionGuard,
UseValidationPipe,
UUIDValidationPipe
UUIDValidationPipe,
documentUploadFileFilter
} from '@gauzy/core';
import {
BadRequestException,
@@ -76,7 +77,10 @@ export class PluginVersionController {
@UseValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })
@UseInterceptors(
LazyAnyFileInterceptor({
storage: () => FileStorageFactory.create('plugins')
storage: () => FileStorageFactory.create('plugins'),
// Plugin archives land under /public with the client extension: refuse script-capable
// non-document types (GHSA-p334-cm7f-php5 class).
fileFilter: documentUploadFileFilter
})
)
@UseGuards(PluginOwnerGuard, TenantPermissionGuard, PermissionGuard)
@@ -177,7 +181,10 @@ export class PluginVersionController {
})
@UseInterceptors(
LazyAnyFileInterceptor({
storage: () => FileStorageFactory.create('plugins')
storage: () => FileStorageFactory.create('plugins'),
// Plugin archives land under /public with the client extension: refuse script-capable
// non-document types (GHSA-p334-cm7f-php5 class).
fileFilter: documentUploadFileFilter
})
)
@UseGuards(PluginOwnerGuard, TenantPermissionGuard, PermissionGuard)
+10 -4
View File
@@ -13,7 +13,11 @@ services:
- key: DEMO
value: false
- key: NODE_ENV
value: development
value: production
# API and webapp live on two different *.onrender.com sites (public suffix), so the
# production default of CORP: same-site would block every API-served image.
- key: CORP_POLICY
value: cross-origin
- key: ADMIN_PASSWORD_RESET
value: true
- key: LOG_LEVEL
@@ -29,11 +33,13 @@ services:
- key: CLIENT_BASE_URL
value: https://ever-gauzy-webapp.onrender.com
- key: EXPRESS_SESSION_SECRET
value: gauzy
generateValue: true
- key: JWT_SECRET
value: secretKey
generateValue: true
- key: JWT_REFRESH_TOKEN_SECRET
value: refreshSecretKey
generateValue: true
- key: JWT_VERIFICATION_TOKEN_SECRET
generateValue: true
- key: JWT_REFRESH_TOKEN_EXPIRATION_TIME
value: 86400
- key: ALLOWED_ORIGINS