Merge pull request #623 from Hexastack/feat/establish-dynamic-cors

fix: cors issue
This commit is contained in:
Med Marrouchi 2025-01-24 07:40:18 +01:00 committed by GitHub
commit 26c9470e8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 163 additions and 17 deletions

24
api/src/app.instance.ts Normal file
View File

@ -0,0 +1,24 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { INestApplication } from '@nestjs/common';
export class AppInstance {
private static app: INestApplication;
static setApp(app: INestApplication) {
this.app = app;
}
static getApp(): INestApplication {
if (!this.app) {
throw new Error('App instance has not been set yet.');
}
return this.app;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -18,10 +18,12 @@ moduleAlias.addAliases({
'@': __dirname,
});
import { AppInstance } from './app.instance';
import { HexabotModule } from './app.module';
import { config } from './config';
import { LoggerService } from './logger/logger.service';
import { seedDatabase } from './seeder';
import { SettingService } from './setting/services/setting.service';
import { swagger } from './swagger';
import { getSessionStore } from './utils/constants/session-store';
import { ObjectIdPipe } from './utils/pipes/object-id.pipe';
@ -35,6 +37,9 @@ async function bootstrap() {
bodyParser: false,
});
// Set the global app instance
AppInstance.setApp(app);
const rawBodyBuffer = (req, res, buf, encoding) => {
if (buf?.length) {
req.rawBody = buf.toString(encoding || 'utf8');
@ -43,8 +48,20 @@ async function bootstrap() {
app.use(bodyParser.urlencoded({ verify: rawBodyBuffer, extended: true }));
app.use(bodyParser.json({ verify: rawBodyBuffer }));
const settingService = app.get<SettingService>(SettingService);
app.enableCors({
origin: config.security.cors.allowOrigins,
origin: (origin, callback) => {
settingService
.getAllowedOrigins()
.then((allowedOrigins) => {
if (!origin || allowedOrigins.has(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
})
.catch(callback);
},
methods: config.security.cors.methods,
credentials: config.security.cors.allowCredentials,
allowedHeaders: config.security.cors.headers.split(','),

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -143,4 +143,62 @@ describe('SettingService', () => {
});
});
});
describe('getAllowedOrigins', () => {
it('should return a set of unique origins from allowed_domains settings', async () => {
const mockSettings = [
{
label: 'allowed_domains',
value: 'https://example.com,https://test.com',
},
{
label: 'allowed_domains',
value: 'https://example.com,https://another.com',
},
] as Setting[];
jest.spyOn(settingService, 'find').mockResolvedValue(mockSettings);
const result = await settingService.getAllowedOrigins();
expect(settingService.find).toHaveBeenCalledWith({
label: 'allowed_domains',
});
expect(result).toEqual(
new Set([
'*',
'https://example.com',
'https://test.com',
'https://another.com',
]),
);
});
it('should return the config allowed cors only if no settings are found', async () => {
jest.spyOn(settingService, 'find').mockResolvedValue([]);
const result = await settingService.getAllowedOrigins();
expect(settingService.find).toHaveBeenCalledWith({
label: 'allowed_domains',
});
expect(result).toEqual(new Set(['*']));
});
it('should handle settings with empty values', async () => {
const mockSettings = [
{ label: 'allowed_domains', value: '' },
{ label: 'allowed_domains', value: 'https://example.com' },
] as Setting[];
jest.spyOn(settingService, 'find').mockResolvedValue(mockSettings);
const result = await settingService.getAllowedOrigins();
expect(settingService.find).toHaveBeenCalledWith({
label: 'allowed_domains',
});
expect(result).toEqual(new Set(['*', 'https://example.com']));
});
});
});

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -14,13 +14,17 @@ import { Cache } from 'cache-manager';
import { config } from '@/config';
import { Config } from '@/config/types';
import { LoggerService } from '@/logger/logger.service';
import { SETTING_CACHE_KEY } from '@/utils/constants/cache';
import {
ALLOWED_ORIGINS_CACHE_KEY,
SETTING_CACHE_KEY,
} from '@/utils/constants/cache';
import { Cacheable } from '@/utils/decorators/cacheable.decorator';
import { BaseService } from '@/utils/generics/base-service';
import { SettingCreateDto } from '../dto/setting.dto';
import { SettingRepository } from '../repositories/setting.repository';
import { Setting } from '../schemas/setting.schema';
import { TextSetting } from '../schemas/types';
import { SettingSeeder } from '../seeds/setting.seed';
@Injectable()
@ -110,6 +114,7 @@ export class SettingService extends BaseService<Setting> {
*/
async clearCache() {
this.cacheManager.del(SETTING_CACHE_KEY);
this.cacheManager.del(ALLOWED_ORIGINS_CACHE_KEY);
}
/**
@ -121,6 +126,35 @@ export class SettingService extends BaseService<Setting> {
this.clearCache();
}
/**
* Retrieves a set of unique allowed origins for CORS configuration.
*
* This method combines all `allowed_domains` settings,
* splits their values (comma-separated), and removes duplicates to produce a
* whitelist of origins. The result is cached for better performance using the
* `Cacheable` decorator with the key `ALLOWED_ORIGINS_CACHE_KEY`.
*
* @returns A promise that resolves to a set of allowed origins
*/
@Cacheable(ALLOWED_ORIGINS_CACHE_KEY)
async getAllowedOrigins() {
const settings = (await this.find({
label: 'allowed_domains',
})) as TextSetting[];
const allowedDomains = settings.flatMap((setting) =>
setting.value.split(',').filter((o) => !!o),
);
const uniqueOrigins = new Set([
...config.security.cors.allowOrigins,
...config.sockets.onlyAllowOrigins,
...allowedDomains,
]);
return uniqueOrigins;
}
/**
* Retrieves settings from the cache if available, or loads them from the
* repository and caches the result.

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -16,3 +16,5 @@ export const MENU_CACHE_KEY = 'menu';
export const LANGUAGES_CACHE_KEY = 'languages';
export const DEFAULT_LANGUAGE_CACHE_KEY = 'default_language';
export const ALLOWED_ORIGINS_CACHE_KEY = 'allowed_origins';

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -10,7 +10,9 @@ import util from 'util';
import type { ServerOptions } from 'socket.io';
import { AppInstance } from '@/app.instance';
import { config } from '@/config';
import { SettingService } from '@/setting/services/setting.service';
export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
const opts: Partial<ServerOptions> = {
@ -53,16 +55,25 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
...(config.sockets.onlyAllowOrigins && {
cors: {
origin: (origin, cb) => {
if (origin && config.sockets.onlyAllowOrigins.includes(origin)) {
cb(null, true);
} else {
// eslint-disable-next-line no-console
console.log(
`A socket was rejected via the config.sockets.onlyAllowOrigins array.\n` +
`It attempted to connect with origin: ${origin}`,
);
cb(new Error('Origin not allowed'), false);
}
// Retrieve the allowed origins from the settings
const app = AppInstance.getApp();
const settingService = app.get<SettingService>(SettingService);
settingService
.getAllowedOrigins()
.then((allowedOrigins) => {
if (origin && allowedOrigins.has(origin)) {
cb(null, true);
} else {
// eslint-disable-next-line no-console
console.log(
`A socket was rejected via the config.sockets.onlyAllowOrigins array.\n` +
`It attempted to connect with origin: ${origin}`,
);
cb(new Error('Origin not allowed'), false);
}
})
.catch(cb);
},
},
}),