Skip to content

Commit 26c9470

Browse files
authored
Merge pull request #623 from Hexastack/feat/establish-dynamic-cors
fix: cors issue
2 parents 1382028 + 1452638 commit 26c9470

File tree

6 files changed

+163
-17
lines changed

6 files changed

+163
-17
lines changed

api/src/app.instance.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright © 2025 Hexastack. All rights reserved.
3+
*
4+
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
5+
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
6+
* 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).
7+
*/
8+
9+
import { INestApplication } from '@nestjs/common';
10+
11+
export class AppInstance {
12+
private static app: INestApplication;
13+
14+
static setApp(app: INestApplication) {
15+
this.app = app;
16+
}
17+
18+
static getApp(): INestApplication {
19+
if (!this.app) {
20+
throw new Error('App instance has not been set yet.');
21+
}
22+
return this.app;
23+
}
24+
}

api/src/main.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 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({
1818
'@': __dirname,
1919
});
2020

21+
import { AppInstance } from './app.instance';
2122
import { HexabotModule } from './app.module';
2223
import { config } from './config';
2324
import { LoggerService } from './logger/logger.service';
2425
import { seedDatabase } from './seeder';
26+
import { SettingService } from './setting/services/setting.service';
2527
import { swagger } from './swagger';
2628
import { getSessionStore } from './utils/constants/session-store';
2729
import { ObjectIdPipe } from './utils/pipes/object-id.pipe';
@@ -35,6 +37,9 @@ async function bootstrap() {
3537
bodyParser: false,
3638
});
3739

40+
// Set the global app instance
41+
AppInstance.setApp(app);
42+
3843
const rawBodyBuffer = (req, res, buf, encoding) => {
3944
if (buf?.length) {
4045
req.rawBody = buf.toString(encoding || 'utf8');
@@ -43,8 +48,20 @@ async function bootstrap() {
4348
app.use(bodyParser.urlencoded({ verify: rawBodyBuffer, extended: true }));
4449
app.use(bodyParser.json({ verify: rawBodyBuffer }));
4550

51+
const settingService = app.get<SettingService>(SettingService);
4652
app.enableCors({
47-
origin: config.security.cors.allowOrigins,
53+
origin: (origin, callback) => {
54+
settingService
55+
.getAllowedOrigins()
56+
.then((allowedOrigins) => {
57+
if (!origin || allowedOrigins.has(origin)) {
58+
callback(null, true);
59+
} else {
60+
callback(new Error('Not allowed by CORS'));
61+
}
62+
})
63+
.catch(callback);
64+
},
4865
methods: config.security.cors.methods,
4966
credentials: config.security.cors.allowCredentials,
5067
allowedHeaders: config.security.cors.headers.split(','),

api/src/setting/services/setting.service.spec.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 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', () => {
143143
});
144144
});
145145
});
146+
147+
describe('getAllowedOrigins', () => {
148+
it('should return a set of unique origins from allowed_domains settings', async () => {
149+
const mockSettings = [
150+
{
151+
label: 'allowed_domains',
152+
value: 'https://example.com,https://test.com',
153+
},
154+
{
155+
label: 'allowed_domains',
156+
value: 'https://example.com,https://another.com',
157+
},
158+
] as Setting[];
159+
160+
jest.spyOn(settingService, 'find').mockResolvedValue(mockSettings);
161+
162+
const result = await settingService.getAllowedOrigins();
163+
164+
expect(settingService.find).toHaveBeenCalledWith({
165+
label: 'allowed_domains',
166+
});
167+
expect(result).toEqual(
168+
new Set([
169+
'*',
170+
'https://example.com',
171+
'https://test.com',
172+
'https://another.com',
173+
]),
174+
);
175+
});
176+
177+
it('should return the config allowed cors only if no settings are found', async () => {
178+
jest.spyOn(settingService, 'find').mockResolvedValue([]);
179+
180+
const result = await settingService.getAllowedOrigins();
181+
182+
expect(settingService.find).toHaveBeenCalledWith({
183+
label: 'allowed_domains',
184+
});
185+
expect(result).toEqual(new Set(['*']));
186+
});
187+
188+
it('should handle settings with empty values', async () => {
189+
const mockSettings = [
190+
{ label: 'allowed_domains', value: '' },
191+
{ label: 'allowed_domains', value: 'https://example.com' },
192+
] as Setting[];
193+
194+
jest.spyOn(settingService, 'find').mockResolvedValue(mockSettings);
195+
196+
const result = await settingService.getAllowedOrigins();
197+
198+
expect(settingService.find).toHaveBeenCalledWith({
199+
label: 'allowed_domains',
200+
});
201+
expect(result).toEqual(new Set(['*', 'https://example.com']));
202+
});
203+
});
146204
});

api/src/setting/services/setting.service.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 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';
1414
import { config } from '@/config';
1515
import { Config } from '@/config/types';
1616
import { LoggerService } from '@/logger/logger.service';
17-
import { SETTING_CACHE_KEY } from '@/utils/constants/cache';
17+
import {
18+
ALLOWED_ORIGINS_CACHE_KEY,
19+
SETTING_CACHE_KEY,
20+
} from '@/utils/constants/cache';
1821
import { Cacheable } from '@/utils/decorators/cacheable.decorator';
1922
import { BaseService } from '@/utils/generics/base-service';
2023

2124
import { SettingCreateDto } from '../dto/setting.dto';
2225
import { SettingRepository } from '../repositories/setting.repository';
2326
import { Setting } from '../schemas/setting.schema';
27+
import { TextSetting } from '../schemas/types';
2428
import { SettingSeeder } from '../seeds/setting.seed';
2529

2630
@Injectable()
@@ -110,6 +114,7 @@ export class SettingService extends BaseService<Setting> {
110114
*/
111115
async clearCache() {
112116
this.cacheManager.del(SETTING_CACHE_KEY);
117+
this.cacheManager.del(ALLOWED_ORIGINS_CACHE_KEY);
113118
}
114119

115120
/**
@@ -121,6 +126,35 @@ export class SettingService extends BaseService<Setting> {
121126
this.clearCache();
122127
}
123128

129+
/**
130+
* Retrieves a set of unique allowed origins for CORS configuration.
131+
*
132+
* This method combines all `allowed_domains` settings,
133+
* splits their values (comma-separated), and removes duplicates to produce a
134+
* whitelist of origins. The result is cached for better performance using the
135+
* `Cacheable` decorator with the key `ALLOWED_ORIGINS_CACHE_KEY`.
136+
*
137+
* @returns A promise that resolves to a set of allowed origins
138+
*/
139+
@Cacheable(ALLOWED_ORIGINS_CACHE_KEY)
140+
async getAllowedOrigins() {
141+
const settings = (await this.find({
142+
label: 'allowed_domains',
143+
})) as TextSetting[];
144+
145+
const allowedDomains = settings.flatMap((setting) =>
146+
setting.value.split(',').filter((o) => !!o),
147+
);
148+
149+
const uniqueOrigins = new Set([
150+
...config.security.cors.allowOrigins,
151+
...config.sockets.onlyAllowOrigins,
152+
...allowedDomains,
153+
]);
154+
155+
return uniqueOrigins;
156+
}
157+
124158
/**
125159
* Retrieves settings from the cache if available, or loads them from the
126160
* repository and caches the result.

api/src/utils/constants/cache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 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';
1616
export const LANGUAGES_CACHE_KEY = 'languages';
1717

1818
export const DEFAULT_LANGUAGE_CACHE_KEY = 'default_language';
19+
20+
export const ALLOWED_ORIGINS_CACHE_KEY = 'allowed_origins';

api/src/websocket/utils/gateway-options.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 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';
1010

1111
import type { ServerOptions } from 'socket.io';
1212

13+
import { AppInstance } from '@/app.instance';
1314
import { config } from '@/config';
15+
import { SettingService } from '@/setting/services/setting.service';
1416

1517
export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
1618
const opts: Partial<ServerOptions> = {
@@ -53,16 +55,25 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
5355
...(config.sockets.onlyAllowOrigins && {
5456
cors: {
5557
origin: (origin, cb) => {
56-
if (origin && config.sockets.onlyAllowOrigins.includes(origin)) {
57-
cb(null, true);
58-
} else {
59-
// eslint-disable-next-line no-console
60-
console.log(
61-
`A socket was rejected via the config.sockets.onlyAllowOrigins array.\n` +
62-
`It attempted to connect with origin: ${origin}`,
63-
);
64-
cb(new Error('Origin not allowed'), false);
65-
}
58+
// Retrieve the allowed origins from the settings
59+
const app = AppInstance.getApp();
60+
const settingService = app.get<SettingService>(SettingService);
61+
62+
settingService
63+
.getAllowedOrigins()
64+
.then((allowedOrigins) => {
65+
if (origin && allowedOrigins.has(origin)) {
66+
cb(null, true);
67+
} else {
68+
// eslint-disable-next-line no-console
69+
console.log(
70+
`A socket was rejected via the config.sockets.onlyAllowOrigins array.\n` +
71+
`It attempted to connect with origin: ${origin}`,
72+
);
73+
cb(new Error('Origin not allowed'), false);
74+
}
75+
})
76+
.catch(cb);
6677
},
6778
},
6879
}),

0 commit comments

Comments
 (0)