mirror of
https://github.com/hexastack/hexabot
synced 2025-06-16 19:29:45 +00:00
Merge pull request #623 from Hexastack/feat/establish-dynamic-cors
fix: cors issue
This commit is contained in:
commit
26c9470e8c
24
api/src/app.instance.ts
Normal file
24
api/src/app.instance.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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:
|
* 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.
|
* 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,
|
'@': __dirname,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { AppInstance } from './app.instance';
|
||||||
import { HexabotModule } from './app.module';
|
import { HexabotModule } from './app.module';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { LoggerService } from './logger/logger.service';
|
import { LoggerService } from './logger/logger.service';
|
||||||
import { seedDatabase } from './seeder';
|
import { seedDatabase } from './seeder';
|
||||||
|
import { SettingService } from './setting/services/setting.service';
|
||||||
import { swagger } from './swagger';
|
import { swagger } from './swagger';
|
||||||
import { getSessionStore } from './utils/constants/session-store';
|
import { getSessionStore } from './utils/constants/session-store';
|
||||||
import { ObjectIdPipe } from './utils/pipes/object-id.pipe';
|
import { ObjectIdPipe } from './utils/pipes/object-id.pipe';
|
||||||
@ -35,6 +37,9 @@ async function bootstrap() {
|
|||||||
bodyParser: false,
|
bodyParser: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set the global app instance
|
||||||
|
AppInstance.setApp(app);
|
||||||
|
|
||||||
const rawBodyBuffer = (req, res, buf, encoding) => {
|
const rawBodyBuffer = (req, res, buf, encoding) => {
|
||||||
if (buf?.length) {
|
if (buf?.length) {
|
||||||
req.rawBody = buf.toString(encoding || 'utf8');
|
req.rawBody = buf.toString(encoding || 'utf8');
|
||||||
@ -43,8 +48,20 @@ async function bootstrap() {
|
|||||||
app.use(bodyParser.urlencoded({ verify: rawBodyBuffer, extended: true }));
|
app.use(bodyParser.urlencoded({ verify: rawBodyBuffer, extended: true }));
|
||||||
app.use(bodyParser.json({ verify: rawBodyBuffer }));
|
app.use(bodyParser.json({ verify: rawBodyBuffer }));
|
||||||
|
|
||||||
|
const settingService = app.get<SettingService>(SettingService);
|
||||||
app.enableCors({
|
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,
|
methods: config.security.cors.methods,
|
||||||
credentials: config.security.cors.allowCredentials,
|
credentials: config.security.cors.allowCredentials,
|
||||||
allowedHeaders: config.security.cors.headers.split(','),
|
allowedHeaders: config.security.cors.headers.split(','),
|
||||||
|
@ -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:
|
* 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.
|
* 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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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:
|
* 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.
|
* 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';
|
||||||
import { Config } from '@/config/types';
|
import { Config } from '@/config/types';
|
||||||
import { LoggerService } from '@/logger/logger.service';
|
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 { Cacheable } from '@/utils/decorators/cacheable.decorator';
|
||||||
import { BaseService } from '@/utils/generics/base-service';
|
import { BaseService } from '@/utils/generics/base-service';
|
||||||
|
|
||||||
import { SettingCreateDto } from '../dto/setting.dto';
|
import { SettingCreateDto } from '../dto/setting.dto';
|
||||||
import { SettingRepository } from '../repositories/setting.repository';
|
import { SettingRepository } from '../repositories/setting.repository';
|
||||||
import { Setting } from '../schemas/setting.schema';
|
import { Setting } from '../schemas/setting.schema';
|
||||||
|
import { TextSetting } from '../schemas/types';
|
||||||
import { SettingSeeder } from '../seeds/setting.seed';
|
import { SettingSeeder } from '../seeds/setting.seed';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -110,6 +114,7 @@ export class SettingService extends BaseService<Setting> {
|
|||||||
*/
|
*/
|
||||||
async clearCache() {
|
async clearCache() {
|
||||||
this.cacheManager.del(SETTING_CACHE_KEY);
|
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();
|
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
|
* Retrieves settings from the cache if available, or loads them from the
|
||||||
* repository and caches the result.
|
* repository and caches the result.
|
||||||
|
@ -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:
|
* 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.
|
* 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 LANGUAGES_CACHE_KEY = 'languages';
|
||||||
|
|
||||||
export const DEFAULT_LANGUAGE_CACHE_KEY = 'default_language';
|
export const DEFAULT_LANGUAGE_CACHE_KEY = 'default_language';
|
||||||
|
|
||||||
|
export const ALLOWED_ORIGINS_CACHE_KEY = 'allowed_origins';
|
||||||
|
@ -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:
|
* 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.
|
* 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 type { ServerOptions } from 'socket.io';
|
||||||
|
|
||||||
|
import { AppInstance } from '@/app.instance';
|
||||||
import { config } from '@/config';
|
import { config } from '@/config';
|
||||||
|
import { SettingService } from '@/setting/services/setting.service';
|
||||||
|
|
||||||
export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
|
export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
|
||||||
const opts: Partial<ServerOptions> = {
|
const opts: Partial<ServerOptions> = {
|
||||||
@ -53,7 +55,14 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
|
|||||||
...(config.sockets.onlyAllowOrigins && {
|
...(config.sockets.onlyAllowOrigins && {
|
||||||
cors: {
|
cors: {
|
||||||
origin: (origin, cb) => {
|
origin: (origin, cb) => {
|
||||||
if (origin && config.sockets.onlyAllowOrigins.includes(origin)) {
|
// 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);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@ -63,6 +72,8 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
|
|||||||
);
|
);
|
||||||
cb(new Error('Origin not allowed'), false);
|
cb(new Error('Origin not allowed'), false);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(cb);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Loading…
Reference in New Issue
Block a user