mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: add CORS instance settings (#1957)
* feat: add CORS instance settings * refactor: disallow arbitrary asterisks in CORS origins
This commit is contained in:
		
							parent
							
								
									f3e8f723a2
								
							
						
					
					
						commit
						42d64c8803
					
				| @ -83,7 +83,9 @@ Object { | ||||
|       "isEnabled": [Function], | ||||
|     }, | ||||
|   }, | ||||
|   "frontendApiOrigins": Array [], | ||||
|   "frontendApiOrigins": Array [ | ||||
|     "*", | ||||
|   ], | ||||
|   "getLogger": [Function], | ||||
|   "import": Object { | ||||
|     "dropBeforeImport": false, | ||||
|  | ||||
| @ -76,10 +76,7 @@ export default async function getApp( | ||||
|         // Support CORS preflight requests for the frontend endpoints.
 | ||||
|         // Preflight requests should not have Authorization headers,
 | ||||
|         // so this must be handled before the API token middleware.
 | ||||
|         app.options( | ||||
|             '/api/frontend*', | ||||
|             corsOriginMiddleware(config.frontendApiOrigins), | ||||
|         ); | ||||
|         app.options('/api/frontend*', corsOriginMiddleware(services)); | ||||
|     } | ||||
| 
 | ||||
|     switch (config.authentication.type) { | ||||
|  | ||||
| @ -403,3 +403,28 @@ test('Environment variables for client features caching takes priority over opti | ||||
|     expect(config.clientFeatureCaching.enabled).toBe(true); | ||||
|     expect(config.clientFeatureCaching.maxAge).toBe(120); | ||||
| }); | ||||
| 
 | ||||
| test('Environment variables for frontend CORS origins takes priority over options', async () => { | ||||
|     const create = (frontendApiOrigins?): string[] => { | ||||
|         return createConfig({ | ||||
|             frontendApiOrigins, | ||||
|         }).frontendApiOrigins; | ||||
|     }; | ||||
| 
 | ||||
|     expect(create()).toEqual(['*']); | ||||
|     expect(create([])).toEqual([]); | ||||
|     expect(create(['*'])).toEqual(['*']); | ||||
|     expect(create(['https://example.com'])).toEqual(['https://example.com']); | ||||
|     expect(() => create(['a'])).toThrow('Invalid origin: a'); | ||||
| 
 | ||||
|     process.env.UNLEASH_FRONTEND_API_ORIGINS = ''; | ||||
|     expect(create()).toEqual([]); | ||||
|     process.env.UNLEASH_FRONTEND_API_ORIGINS = '*'; | ||||
|     expect(create()).toEqual(['*']); | ||||
|     process.env.UNLEASH_FRONTEND_API_ORIGINS = 'https://example.com, *'; | ||||
|     expect(create()).toEqual(['https://example.com', '*']); | ||||
|     process.env.UNLEASH_FRONTEND_API_ORIGINS = 'b'; | ||||
|     expect(() => create(['a'])).toThrow('Invalid origin: b'); | ||||
|     delete process.env.UNLEASH_FRONTEND_API_ORIGINS; | ||||
|     expect(create()).toEqual(['*']); | ||||
| }); | ||||
|  | ||||
| @ -43,6 +43,7 @@ import { | ||||
|     DEFAULT_STRATEGY_SEGMENTS_LIMIT, | ||||
| } from './util/segments'; | ||||
| import FlagResolver from './util/flag-resolver'; | ||||
| import { validateOrigins } from './util/validateOrigin'; | ||||
| 
 | ||||
| const safeToUpper = (s: string) => (s ? s.toUpperCase() : s); | ||||
| 
 | ||||
| @ -311,6 +312,20 @@ const parseCspEnvironmentVariables = (): ICspDomainConfig => { | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const parseFrontendApiOrigins = (options: IUnleashOptions): string[] => { | ||||
|     const frontendApiOrigins = parseEnvVarStrings( | ||||
|         process.env.UNLEASH_FRONTEND_API_ORIGINS, | ||||
|         options.frontendApiOrigins || ['*'], | ||||
|     ); | ||||
| 
 | ||||
|     const error = validateOrigins(frontendApiOrigins); | ||||
|     if (error) { | ||||
|         throw new Error(error); | ||||
|     } | ||||
| 
 | ||||
|     return frontendApiOrigins; | ||||
| }; | ||||
| 
 | ||||
| export function createConfig(options: IUnleashOptions): IUnleashConfig { | ||||
|     let extraDbOptions = {}; | ||||
| 
 | ||||
| @ -420,10 +435,6 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { | ||||
|         DEFAULT_STRATEGY_SEGMENTS_LIMIT, | ||||
|     ); | ||||
| 
 | ||||
|     const frontendApiOrigins = | ||||
|         options.frontendApiOrigins || | ||||
|         parseEnvVarStrings(process.env.UNLEASH_FRONTEND_API_ORIGINS, []); | ||||
| 
 | ||||
|     const clientFeatureCaching = loadClientCachingOptions(options); | ||||
| 
 | ||||
|     return { | ||||
| @ -449,7 +460,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { | ||||
|         eventBus: new EventEmitter(), | ||||
|         environmentEnableOverrides, | ||||
|         additionalCspAllowedDomains, | ||||
|         frontendApiOrigins, | ||||
|         frontendApiOrigins: parseFrontendApiOrigins(options), | ||||
|         inlineSegmentConstraints, | ||||
|         segmentValuesLimit, | ||||
|         strategySegmentsLimit, | ||||
|  | ||||
| @ -1,4 +1,21 @@ | ||||
| import { allowRequestOrigin } from './cors-origin-middleware'; | ||||
| import FakeSettingStore from '../../test/fixtures/fake-setting-store'; | ||||
| import SettingService from '../services/setting-service'; | ||||
| import { createTestConfig } from '../../test/config/test-config'; | ||||
| import FakeEventStore from '../../test/fixtures/fake-event-store'; | ||||
| import { randomId } from '../util/random-id'; | ||||
| import { frontendSettingsKey } from '../types/settings/frontend-settings'; | ||||
| 
 | ||||
| const createSettingService = (frontendApiOrigins: string[]): SettingService => { | ||||
|     const config = createTestConfig({ frontendApiOrigins }); | ||||
| 
 | ||||
|     const stores = { | ||||
|         settingStore: new FakeSettingStore(), | ||||
|         eventStore: new FakeEventStore(), | ||||
|     }; | ||||
| 
 | ||||
|     return new SettingService(stores, config); | ||||
| }; | ||||
| 
 | ||||
| test('allowRequestOrigin', () => { | ||||
|     const dotCom = 'https://example.com'; | ||||
| @ -16,3 +33,54 @@ test('allowRequestOrigin', () => { | ||||
|     expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true); | ||||
|     expect(allowRequestOrigin(dotCom, [dotCom, dotOrg, '*'])).toEqual(true); | ||||
| }); | ||||
| 
 | ||||
| test('corsOriginMiddleware origin validation', async () => { | ||||
|     const service = createSettingService([]); | ||||
|     const userName = randomId(); | ||||
|     await expect(() => | ||||
|         service.setFrontendSettings({ frontendApiOrigins: ['a'] }, userName), | ||||
|     ).rejects.toThrow('Invalid origin: a'); | ||||
| }); | ||||
| 
 | ||||
| test('corsOriginMiddleware without config', async () => { | ||||
|     const service = createSettingService([]); | ||||
|     const userName = randomId(); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: [], | ||||
|     }); | ||||
|     await service.setFrontendSettings({ frontendApiOrigins: [] }, userName); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: [], | ||||
|     }); | ||||
|     await service.setFrontendSettings({ frontendApiOrigins: ['*'] }, userName); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: ['*'], | ||||
|     }); | ||||
|     await service.delete(frontendSettingsKey, userName); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: [], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| test('corsOriginMiddleware with config', async () => { | ||||
|     const service = createSettingService(['*']); | ||||
|     const userName = randomId(); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: ['*'], | ||||
|     }); | ||||
|     await service.setFrontendSettings({ frontendApiOrigins: [] }, userName); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: [], | ||||
|     }); | ||||
|     await service.setFrontendSettings( | ||||
|         { frontendApiOrigins: ['https://example.com', 'https://example.org'] }, | ||||
|         userName, | ||||
|     ); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: ['https://example.com', 'https://example.org'], | ||||
|     }); | ||||
|     await service.delete(frontendSettingsKey, userName); | ||||
|     expect(await service.getFrontendSettings()).toEqual({ | ||||
|         frontendApiOrigins: ['*'], | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @ -1,25 +1,33 @@ | ||||
| import { RequestHandler } from 'express'; | ||||
| import cors from 'cors'; | ||||
| 
 | ||||
| const ANY_ORIGIN = '*'; | ||||
| import { IUnleashServices } from '../types'; | ||||
| 
 | ||||
| export const allowRequestOrigin = ( | ||||
|     requestOrigin: string, | ||||
|     allowedOrigins: string[], | ||||
| ): boolean => { | ||||
|     return allowedOrigins.some((allowedOrigin) => { | ||||
|         return allowedOrigin === requestOrigin || allowedOrigin === ANY_ORIGIN; | ||||
|         return allowedOrigin === requestOrigin || allowedOrigin === '*'; | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| // Check the request's Origin header against a list of allowed origins.
 | ||||
| // The list may include '*', which `cors` does not support natively.
 | ||||
| export const corsOriginMiddleware = ( | ||||
|     allowedOrigins: string[], | ||||
| ): RequestHandler => { | ||||
|     return cors((req, callback) => { | ||||
|         callback(null, { | ||||
|             origin: allowRequestOrigin(req.header('Origin'), allowedOrigins), | ||||
|         }); | ||||
| export const corsOriginMiddleware = ({ | ||||
|     settingService, | ||||
| }: Pick<IUnleashServices, 'settingService'>): RequestHandler => { | ||||
|     return cors(async (req, callback) => { | ||||
|         try { | ||||
|             const { frontendApiOrigins = [] } = | ||||
|                 await settingService.getFrontendSettings(); | ||||
|             callback(null, { | ||||
|                 origin: allowRequestOrigin( | ||||
|                     req.header('Origin'), | ||||
|                     frontendApiOrigins, | ||||
|                 ), | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             callback(error); | ||||
|         } | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| @ -110,6 +110,7 @@ import { proxyFeaturesSchema } from './spec/proxy-features-schema'; | ||||
| import { proxyFeatureSchema } from './spec/proxy-feature-schema'; | ||||
| import { proxyClientSchema } from './spec/proxy-client-schema'; | ||||
| import { proxyMetricsSchema } from './spec/proxy-metrics-schema'; | ||||
| import { setUiConfigSchema } from './spec/set-ui-config-schema'; | ||||
| 
 | ||||
| // All schemas in `openapi/spec` should be listed here.
 | ||||
| export const schemas = { | ||||
| @ -187,6 +188,7 @@ export const schemas = { | ||||
|     searchEventsSchema, | ||||
|     segmentSchema, | ||||
|     setStrategySortOrderSchema, | ||||
|     setUiConfigSchema, | ||||
|     sortOrderSchema, | ||||
|     splashSchema, | ||||
|     stateSchema, | ||||
|  | ||||
							
								
								
									
										24
									
								
								src/lib/openapi/spec/set-ui-config-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/lib/openapi/spec/set-ui-config-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| import { FromSchema } from 'json-schema-to-ts'; | ||||
| 
 | ||||
| export const setUiConfigSchema = { | ||||
|     $id: '#/components/schemas/setUiConfigSchema', | ||||
|     type: 'object', | ||||
|     additionalProperties: false, | ||||
|     properties: { | ||||
|         frontendSettings: { | ||||
|             type: 'object', | ||||
|             additionalProperties: false, | ||||
|             required: ['frontendApiOrigins'], | ||||
|             properties: { | ||||
|                 frontendApiOrigins: { | ||||
|                     type: 'array', | ||||
|                     additionalProperties: false, | ||||
|                     items: { type: 'string' }, | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|     }, | ||||
|     components: {}, | ||||
| } as const; | ||||
| 
 | ||||
| export type SetUiConfigSchema = FromSchema<typeof setUiConfigSchema>; | ||||
| @ -37,6 +37,12 @@ export const uiConfigSchema = { | ||||
|         strategySegmentsLimit: { | ||||
|             type: 'number', | ||||
|         }, | ||||
|         frontendApiOrigins: { | ||||
|             type: 'array', | ||||
|             items: { | ||||
|                 type: 'string', | ||||
|             }, | ||||
|         }, | ||||
|         flags: { | ||||
|             type: 'object', | ||||
|             additionalProperties: { | ||||
|  | ||||
| @ -7,10 +7,10 @@ import Controller from '../controller'; | ||||
| import VersionService from '../../services/version-service'; | ||||
| import SettingService from '../../services/setting-service'; | ||||
| import { | ||||
|     simpleAuthKey, | ||||
|     simpleAuthSettingsKey, | ||||
|     SimpleAuthSettings, | ||||
| } from '../../types/settings/simple-auth-settings'; | ||||
| import { NONE } from '../../types/permissions'; | ||||
| import { ADMIN, NONE } from '../../types/permissions'; | ||||
| import { createResponseSchema } from '../../openapi/util/create-response-schema'; | ||||
| import { | ||||
|     uiConfigSchema, | ||||
| @ -18,6 +18,12 @@ import { | ||||
| } from '../../openapi/spec/ui-config-schema'; | ||||
| import { OpenApiService } from '../../services/openapi-service'; | ||||
| import { EmailService } from '../../services/email-service'; | ||||
| import { emptyResponse } from '../../openapi/util/standard-responses'; | ||||
| import { IAuthRequest } from '../unleash-types'; | ||||
| import { extractUsername } from '../../util/extract-user'; | ||||
| import NotFoundError from '../../error/notfound-error'; | ||||
| import { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema'; | ||||
| import { createRequestSchema } from '../../openapi/util/create-request-schema'; | ||||
| 
 | ||||
| class ConfigController extends Controller { | ||||
|     private versionService: VersionService; | ||||
| @ -52,26 +58,43 @@ class ConfigController extends Controller { | ||||
|         this.route({ | ||||
|             method: 'get', | ||||
|             path: '', | ||||
|             handler: this.getUIConfig, | ||||
|             handler: this.getUiConfig, | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Admin UI'], | ||||
|                     operationId: 'getUIConfig', | ||||
|                     operationId: 'getUiConfig', | ||||
|                     responses: { | ||||
|                         200: createResponseSchema('uiConfigSchema'), | ||||
|                     }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         this.route({ | ||||
|             method: 'post', | ||||
|             path: '', | ||||
|             handler: this.setUiConfig, | ||||
|             permission: ADMIN, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Admin UI'], | ||||
|                     operationId: 'setUiConfig', | ||||
|                     requestBody: createRequestSchema('setUiConfigSchema'), | ||||
|                     responses: { 200: emptyResponse }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     async getUIConfig( | ||||
|     async getUiConfig( | ||||
|         req: AuthedRequest, | ||||
|         res: Response<UiConfigSchema>, | ||||
|     ): Promise<void> { | ||||
|         const simpleAuthSettings = | ||||
|             await this.settingService.get<SimpleAuthSettings>(simpleAuthKey); | ||||
|         const [frontendSettings, simpleAuthSettings] = await Promise.all([ | ||||
|             this.settingService.getFrontendSettings(), | ||||
|             this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey), | ||||
|         ]); | ||||
| 
 | ||||
|         const disablePasswordAuth = | ||||
|             simpleAuthSettings?.disabled || | ||||
| @ -92,6 +115,7 @@ class ConfigController extends Controller { | ||||
|             authenticationType: this.config.authentication?.type, | ||||
|             segmentValuesLimit: this.config.segmentValuesLimit, | ||||
|             strategySegmentsLimit: this.config.strategySegmentsLimit, | ||||
|             frontendApiOrigins: frontendSettings.frontendApiOrigins, | ||||
|             versionInfo: this.versionService.getVersionInfo(), | ||||
|             disablePasswordAuth, | ||||
|             embedProxy: this.config.experimental.flags.embedProxy, | ||||
| @ -104,5 +128,22 @@ class ConfigController extends Controller { | ||||
|             response, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async setUiConfig( | ||||
|         req: IAuthRequest<void, void, SetUiConfigSchema>, | ||||
|         res: Response<string>, | ||||
|     ): Promise<void> { | ||||
|         if (req.body.frontendSettings) { | ||||
|             await this.settingService.setFrontendSettings( | ||||
|                 req.body.frontendSettings, | ||||
|                 extractUsername(req), | ||||
|             ); | ||||
|             res.sendStatus(204); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         throw new NotFoundError(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export default ConfigController; | ||||
|  | ||||
| @ -10,7 +10,7 @@ import ResetTokenService from '../../services/reset-token-service'; | ||||
| import { IAuthRequest } from '../unleash-types'; | ||||
| import SettingService from '../../services/setting-service'; | ||||
| import { IUser, SimpleAuthSettings } from '../../server-impl'; | ||||
| import { simpleAuthKey } from '../../types/settings/simple-auth-settings'; | ||||
| import { simpleAuthSettingsKey } from '../../types/settings/simple-auth-settings'; | ||||
| import { anonymise } from '../../util/anonymise'; | ||||
| import { OpenApiService } from '../../services/openapi-service'; | ||||
| import { createRequestSchema } from '../../openapi/util/create-request-schema'; | ||||
| @ -369,7 +369,9 @@ export default class UserAdminController extends Controller { | ||||
|         ); | ||||
| 
 | ||||
|         const passwordAuthSettings = | ||||
|             await this.settingService.get<SimpleAuthSettings>(simpleAuthKey); | ||||
|             await this.settingService.get<SimpleAuthSettings>( | ||||
|                 simpleAuthSettingsKey, | ||||
|             ); | ||||
| 
 | ||||
|         let inviteLink: string; | ||||
|         if (!passwordAuthSettings?.disabled) { | ||||
|  | ||||
| @ -2,9 +2,7 @@ import { Response, Request } from 'express'; | ||||
| import Controller from '../controller'; | ||||
| import { IUnleashConfig, IUnleashServices } from '../../types'; | ||||
| import { Logger } from '../../logger'; | ||||
| import { OpenApiService } from '../../services/openapi-service'; | ||||
| import { NONE } from '../../types/permissions'; | ||||
| import { ProxyService } from '../../services/proxy-service'; | ||||
| import ApiUser from '../../types/api-user'; | ||||
| import { | ||||
|     proxyFeaturesSchema, | ||||
| @ -28,29 +26,25 @@ interface ApiUserRequest< | ||||
|     user: ApiUser; | ||||
| } | ||||
| 
 | ||||
| type Services = Pick< | ||||
|     IUnleashServices, | ||||
|     'settingService' | 'proxyService' | 'openApiService' | ||||
| >; | ||||
| 
 | ||||
| export default class ProxyController extends Controller { | ||||
|     private readonly logger: Logger; | ||||
| 
 | ||||
|     private proxyService: ProxyService; | ||||
|     private services: Services; | ||||
| 
 | ||||
|     private openApiService: OpenApiService; | ||||
| 
 | ||||
|     constructor( | ||||
|         config: IUnleashConfig, | ||||
|         { | ||||
|             proxyService, | ||||
|             openApiService, | ||||
|         }: Pick<IUnleashServices, 'proxyService' | 'openApiService'>, | ||||
|     ) { | ||||
|     constructor(config: IUnleashConfig, services: Services) { | ||||
|         super(config); | ||||
|         this.logger = config.getLogger('client-api/feature.js'); | ||||
|         this.proxyService = proxyService; | ||||
|         this.openApiService = openApiService; | ||||
|         this.services = services; | ||||
| 
 | ||||
|         if (config.frontendApiOrigins.length > 0) { | ||||
|             // Support CORS requests for the frontend endpoints.
 | ||||
|             // Preflight requests are handled in `app.ts`.
 | ||||
|             this.app.use(corsOriginMiddleware(config.frontendApiOrigins)); | ||||
|             this.app.use(corsOriginMiddleware(services)); | ||||
|         } | ||||
| 
 | ||||
|         this.route({ | ||||
| @ -59,7 +53,7 @@ export default class ProxyController extends Controller { | ||||
|             handler: this.getProxyFeatures, | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 this.openApiService.validPath({ | ||||
|                 this.services.openApiService.validPath({ | ||||
|                     tags: ['Unstable'], | ||||
|                     operationId: 'getFrontendFeatures', | ||||
|                     responses: { | ||||
| @ -89,7 +83,7 @@ export default class ProxyController extends Controller { | ||||
|             handler: this.registerProxyMetrics, | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 this.openApiService.validPath({ | ||||
|                 this.services.openApiService.validPath({ | ||||
|                     tags: ['Unstable'], | ||||
|                     operationId: 'registerFrontendMetrics', | ||||
|                     requestBody: createRequestSchema('proxyMetricsSchema'), | ||||
| @ -104,7 +98,7 @@ export default class ProxyController extends Controller { | ||||
|             handler: ProxyController.registerProxyClient, | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 this.openApiService.validPath({ | ||||
|                 this.services.openApiService.validPath({ | ||||
|                     tags: ['Unstable'], | ||||
|                     operationId: 'registerFrontendClient', | ||||
|                     requestBody: createRequestSchema('proxyClientSchema'), | ||||
| @ -141,11 +135,11 @@ export default class ProxyController extends Controller { | ||||
|         req: ApiUserRequest, | ||||
|         res: Response<ProxyFeaturesSchema>, | ||||
|     ) { | ||||
|         const toggles = await this.proxyService.getProxyFeatures( | ||||
|         const toggles = await this.services.proxyService.getProxyFeatures( | ||||
|             req.user, | ||||
|             ProxyController.createContext(req), | ||||
|         ); | ||||
|         this.openApiService.respondWithValidation( | ||||
|         this.services.openApiService.respondWithValidation( | ||||
|             200, | ||||
|             res, | ||||
|             proxyFeaturesSchema.$id, | ||||
| @ -157,7 +151,7 @@ export default class ProxyController extends Controller { | ||||
|         req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>, | ||||
|         res: Response, | ||||
|     ) { | ||||
|         await this.proxyService.registerProxyMetrics( | ||||
|         await this.services.proxyService.registerProxyMetrics( | ||||
|             req.user, | ||||
|             req.body, | ||||
|             req.ip, | ||||
|  | ||||
| @ -8,31 +8,48 @@ import { | ||||
|     SettingDeletedEvent, | ||||
|     SettingUpdatedEvent, | ||||
| } from '../types/events'; | ||||
| import { validateOrigins } from '../util/validateOrigin'; | ||||
| import { | ||||
|     FrontendSettings, | ||||
|     frontendSettingsKey, | ||||
| } from '../types/settings/frontend-settings'; | ||||
| import BadDataError from '../error/bad-data-error'; | ||||
| 
 | ||||
| export default class SettingService { | ||||
|     private config: IUnleashConfig; | ||||
| 
 | ||||
|     private logger: Logger; | ||||
| 
 | ||||
|     private settingStore: ISettingStore; | ||||
| 
 | ||||
|     private eventStore: IEventStore; | ||||
| 
 | ||||
|     // SettingService.getFrontendSettings is called on every request to the
 | ||||
|     // frontend API. Keep fetched settings in a cache for fewer DB queries.
 | ||||
|     private cache = new Map<string, unknown>(); | ||||
| 
 | ||||
|     constructor( | ||||
|         { | ||||
|             settingStore, | ||||
|             eventStore, | ||||
|         }: Pick<IUnleashStores, 'settingStore' | 'eventStore'>, | ||||
|         { getLogger }: Pick<IUnleashConfig, 'getLogger'>, | ||||
|         config: IUnleashConfig, | ||||
|     ) { | ||||
|         this.logger = getLogger('services/setting-service.ts'); | ||||
|         this.config = config; | ||||
|         this.logger = config.getLogger('services/setting-service.ts'); | ||||
|         this.settingStore = settingStore; | ||||
|         this.eventStore = eventStore; | ||||
|     } | ||||
| 
 | ||||
|     async get<T>(id: string): Promise<T> { | ||||
|         return this.settingStore.get(id); | ||||
|     async get<T>(id: string, defaultValue?: T): Promise<T> { | ||||
|         if (!this.cache.has(id)) { | ||||
|             this.cache.set(id, await this.settingStore.get(id)); | ||||
|         } | ||||
|         return (this.cache.get(id) as T) || defaultValue; | ||||
|     } | ||||
| 
 | ||||
|     async insert(id: string, value: object, createdBy: string): Promise<void> { | ||||
|         this.cache.delete(id); | ||||
|         const exists = await this.settingStore.exists(id); | ||||
|         if (exists) { | ||||
|             await this.settingStore.updateRow(id, value); | ||||
| @ -54,6 +71,7 @@ export default class SettingService { | ||||
|     } | ||||
| 
 | ||||
|     async delete(id: string, createdBy: string): Promise<void> { | ||||
|         this.cache.delete(id); | ||||
|         await this.settingStore.delete(id); | ||||
|         await this.eventStore.store( | ||||
|             new SettingDeletedEvent({ | ||||
| @ -64,6 +82,28 @@ export default class SettingService { | ||||
|             }), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async deleteAll(): Promise<void> { | ||||
|         this.cache.clear(); | ||||
|         await this.settingStore.deleteAll(); | ||||
|     } | ||||
| 
 | ||||
|     async setFrontendSettings( | ||||
|         value: FrontendSettings, | ||||
|         createdBy: string, | ||||
|     ): Promise<void> { | ||||
|         const error = validateOrigins(value.frontendApiOrigins); | ||||
|         if (error) { | ||||
|             throw new BadDataError(error); | ||||
|         } | ||||
|         await this.insert(frontendSettingsKey, value, createdBy); | ||||
|     } | ||||
| 
 | ||||
|     async getFrontendSettings(): Promise<FrontendSettings> { | ||||
|         return this.get(frontendSettingsKey, { | ||||
|             frontendApiOrigins: this.config.frontendApiOrigins, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = SettingService; | ||||
|  | ||||
| @ -22,7 +22,7 @@ import { IUserStore } from '../types/stores/user-store'; | ||||
| import { RoleName } from '../types/model'; | ||||
| import SettingService from './setting-service'; | ||||
| import { SimpleAuthSettings } from '../server-impl'; | ||||
| import { simpleAuthKey } from '../types/settings/simple-auth-settings'; | ||||
| import { simpleAuthSettingsKey } from '../types/settings/simple-auth-settings'; | ||||
| import DisabledError from '../error/disabled-error'; | ||||
| import PasswordMismatch from '../error/password-mismatch'; | ||||
| import BadDataError from '../error/bad-data-error'; | ||||
| @ -276,7 +276,7 @@ class UserService { | ||||
| 
 | ||||
|     async loginUser(usernameOrEmail: string, password: string): Promise<IUser> { | ||||
|         const settings = await this.settingService.get<SimpleAuthSettings>( | ||||
|             simpleAuthKey, | ||||
|             simpleAuthSettingsKey, | ||||
|         ); | ||||
| 
 | ||||
|         if (settings?.disabled) { | ||||
|  | ||||
							
								
								
									
										5
									
								
								src/lib/types/settings/frontend-settings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/lib/types/settings/frontend-settings.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| import { IUnleashConfig } from '../option'; | ||||
| 
 | ||||
| export const frontendSettingsKey = 'unleash.frontend'; | ||||
| 
 | ||||
| export type FrontendSettings = Pick<IUnleashConfig, 'frontendApiOrigins'>; | ||||
| @ -1,4 +1,5 @@ | ||||
| export const simpleAuthKey = 'unleash.auth.simple'; | ||||
| export const simpleAuthSettingsKey = 'unleash.auth.simple'; | ||||
| 
 | ||||
| export interface SimpleAuthSettings { | ||||
|     disabled: boolean; | ||||
| } | ||||
|  | ||||
| @ -29,9 +29,11 @@ test('parseEnvVarBoolean', () => { | ||||
| }); | ||||
| 
 | ||||
| test('parseEnvVarStringList', () => { | ||||
|     expect(parseEnvVarStrings(undefined, [])).toEqual([]); | ||||
|     expect(parseEnvVarStrings(undefined, ['a'])).toEqual(['a']); | ||||
|     expect(parseEnvVarStrings('', ['a'])).toEqual([]); | ||||
|     expect(parseEnvVarStrings('', [])).toEqual([]); | ||||
|     expect(parseEnvVarStrings('  ', [])).toEqual([]); | ||||
|     expect(parseEnvVarStrings('', ['*'])).toEqual(['*']); | ||||
|     expect(parseEnvVarStrings('a', ['*'])).toEqual(['a']); | ||||
|     expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']); | ||||
|     expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']); | ||||
|  | ||||
| @ -20,10 +20,10 @@ export function parseEnvVarBoolean( | ||||
| } | ||||
| 
 | ||||
| export function parseEnvVarStrings( | ||||
|     envVar: string, | ||||
|     envVar: string | undefined, | ||||
|     defaultVal: string[], | ||||
| ): string[] { | ||||
|     if (envVar) { | ||||
|     if (typeof envVar === 'string') { | ||||
|         return envVar | ||||
|             .split(',') | ||||
|             .map((item) => item.trim()) | ||||
|  | ||||
							
								
								
									
										24
									
								
								src/lib/util/validateOrigin.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/lib/util/validateOrigin.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| import { validateOrigin } from './validateOrigin'; | ||||
| 
 | ||||
| test('validateOrigin', () => { | ||||
|     expect(validateOrigin(undefined)).toEqual(false); | ||||
|     expect(validateOrigin('')).toEqual(false); | ||||
|     expect(validateOrigin(' ')).toEqual(false); | ||||
|     expect(validateOrigin('a')).toEqual(false); | ||||
|     expect(validateOrigin('**')).toEqual(false); | ||||
|     expect(validateOrigin('localhost')).toEqual(false); | ||||
|     expect(validateOrigin('localhost:8080')).toEqual(false); | ||||
|     expect(validateOrigin('//localhost:8080')).toEqual(false); | ||||
|     expect(validateOrigin('http://localhost/')).toEqual(false); | ||||
|     expect(validateOrigin('http://localhost/a')).toEqual(false); | ||||
|     expect(validateOrigin('https://example.com/a')).toEqual(false); | ||||
|     expect(validateOrigin('https://example.com ')).toEqual(false); | ||||
|     expect(validateOrigin('https://*.example.com ')).toEqual(false); | ||||
|     expect(validateOrigin('*.example.com')).toEqual(false); | ||||
| 
 | ||||
|     expect(validateOrigin('*')).toEqual(true); | ||||
|     expect(validateOrigin('http://localhost')).toEqual(true); | ||||
|     expect(validateOrigin('http://localhost:8080')).toEqual(true); | ||||
|     expect(validateOrigin('https://example.com')).toEqual(true); | ||||
|     expect(validateOrigin('https://example.com:8080')).toEqual(true); | ||||
| }); | ||||
							
								
								
									
										24
									
								
								src/lib/util/validateOrigin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/lib/util/validateOrigin.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| export const validateOrigin = (origin: string): boolean => { | ||||
|     if (origin === '*') { | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     if (origin?.includes('*')) { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|         const parsed = new URL(origin); | ||||
|         return parsed.origin && parsed.origin === origin; | ||||
|     } catch { | ||||
|         return false; | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| export const validateOrigins = (origins: string[]): string | undefined => { | ||||
|     for (const origin of origins) { | ||||
|         if (!validateOrigin(origin)) { | ||||
|             return `Invalid origin: ${origin}`; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| @ -1,10 +1,11 @@ | ||||
| import dbInit, { ITestDb } from '../../helpers/database-init'; | ||||
| import { setupApp } from '../../helpers/test-helper'; | ||||
| import { IUnleashTest, setupApp } from '../../helpers/test-helper'; | ||||
| import getLogger from '../../../fixtures/no-logger'; | ||||
| import { simpleAuthKey } from '../../../../lib/types/settings/simple-auth-settings'; | ||||
| import { simpleAuthSettingsKey } from '../../../../lib/types/settings/simple-auth-settings'; | ||||
| import { randomId } from '../../../../lib/util/random-id'; | ||||
| 
 | ||||
| let db: ITestDb; | ||||
| let app; | ||||
| let app: IUnleashTest; | ||||
| 
 | ||||
| beforeAll(async () => { | ||||
|     db = await dbInit('config_api_serial', getLogger); | ||||
| @ -16,24 +17,71 @@ afterAll(async () => { | ||||
|     await db.destroy(); | ||||
| }); | ||||
| 
 | ||||
| beforeEach(async () => { | ||||
|     await app.services.settingService.deleteAll(); | ||||
| }); | ||||
| 
 | ||||
| test('gets ui config fields', async () => { | ||||
|     const { body } = await app.request | ||||
|         .get('/api/admin/ui-config') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     expect(body.unleashUrl).toBe('http://localhost:4242'); | ||||
|     expect(body.version).toBeDefined(); | ||||
|     expect(body.emailEnabled).toBe(false); | ||||
| }); | ||||
| 
 | ||||
| test('gets ui config with disablePasswordAuth', async () => { | ||||
|     await db.stores.settingStore.insert(simpleAuthKey, { disabled: true }); | ||||
| 
 | ||||
|     await db.stores.settingStore.insert(simpleAuthSettingsKey, { | ||||
|         disabled: true, | ||||
|     }); | ||||
|     const { body } = await app.request | ||||
|         .get('/api/admin/ui-config') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     expect(body.disablePasswordAuth).toBe(true); | ||||
| }); | ||||
| 
 | ||||
| test('gets ui config with frontendSettings', async () => { | ||||
|     const frontendApiOrigins = ['https://example.net']; | ||||
|     await app.services.settingService.setFrontendSettings( | ||||
|         { frontendApiOrigins }, | ||||
|         randomId(), | ||||
|     ); | ||||
|     await app.request | ||||
|         .get('/api/admin/ui-config') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect((res) => | ||||
|             expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins), | ||||
|         ); | ||||
| }); | ||||
| 
 | ||||
| test('sets ui config with frontendSettings', async () => { | ||||
|     const frontendApiOrigins = ['https://example.org']; | ||||
|     await app.request | ||||
|         .get('/api/admin/ui-config') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect((res) => expect(res.body.frontendApiOrigins).toEqual(['*'])); | ||||
|     await app.request | ||||
|         .post('/api/admin/ui-config') | ||||
|         .send({ frontendSettings: { frontendApiOrigins: [] } }) | ||||
|         .expect(204); | ||||
|     await app.request | ||||
|         .get('/api/admin/ui-config') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect((res) => expect(res.body.frontendApiOrigins).toEqual([])); | ||||
|     await app.request | ||||
|         .post('/api/admin/ui-config') | ||||
|         .send({ frontendSettings: { frontendApiOrigins } }) | ||||
|         .expect(204); | ||||
|     await app.request | ||||
|         .get('/api/admin/ui-config') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect((res) => | ||||
|             expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins), | ||||
|         ); | ||||
| }); | ||||
|  | ||||
| @ -2498,6 +2498,28 @@ Object { | ||||
|         }, | ||||
|         "type": "array", | ||||
|       }, | ||||
|       "setUiConfigSchema": Object { | ||||
|         "additionalProperties": false, | ||||
|         "properties": Object { | ||||
|           "frontendSettings": Object { | ||||
|             "additionalProperties": false, | ||||
|             "properties": Object { | ||||
|               "frontendApiOrigins": Object { | ||||
|                 "additionalProperties": false, | ||||
|                 "items": Object { | ||||
|                   "type": "string", | ||||
|                 }, | ||||
|                 "type": "array", | ||||
|               }, | ||||
|             }, | ||||
|             "required": Array [ | ||||
|               "frontendApiOrigins", | ||||
|             ], | ||||
|             "type": "object", | ||||
|           }, | ||||
|         }, | ||||
|         "type": "object", | ||||
|       }, | ||||
|       "sortOrderSchema": Object { | ||||
|         "additionalProperties": Object { | ||||
|           "type": "number", | ||||
| @ -2829,6 +2851,12 @@ Object { | ||||
|             }, | ||||
|             "type": "object", | ||||
|           }, | ||||
|           "frontendApiOrigins": Object { | ||||
|             "items": Object { | ||||
|               "type": "string", | ||||
|             }, | ||||
|             "type": "array", | ||||
|           }, | ||||
|           "links": Object { | ||||
|             "items": Object { | ||||
|               "type": "object", | ||||
| @ -6172,7 +6200,7 @@ If the provided project does not exist, the list of events will be empty.", | ||||
|     }, | ||||
|     "/api/admin/ui-config": Object { | ||||
|       "get": Object { | ||||
|         "operationId": "getUIConfig", | ||||
|         "operationId": "getUiConfig", | ||||
|         "responses": Object { | ||||
|           "200": Object { | ||||
|             "content": Object { | ||||
| @ -6189,6 +6217,28 @@ If the provided project does not exist, the list of events will be empty.", | ||||
|           "Admin UI", | ||||
|         ], | ||||
|       }, | ||||
|       "post": Object { | ||||
|         "operationId": "setUiConfig", | ||||
|         "requestBody": Object { | ||||
|           "content": Object { | ||||
|             "application/json": Object { | ||||
|               "schema": Object { | ||||
|                 "$ref": "#/components/schemas/setUiConfigSchema", | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|           "description": "setUiConfigSchema", | ||||
|           "required": true, | ||||
|         }, | ||||
|         "responses": Object { | ||||
|           "200": Object { | ||||
|             "description": "This response has no body.", | ||||
|           }, | ||||
|         }, | ||||
|         "tags": Array [ | ||||
|           "Admin UI", | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|     "/api/admin/user": Object { | ||||
|       "get": Object { | ||||
|  | ||||
| @ -11,9 +11,10 @@ import NotFoundError from '../../../lib/error/notfound-error'; | ||||
| import { IRole } from '../../../lib/types/stores/access-store'; | ||||
| import { RoleName } from '../../../lib/types/model'; | ||||
| import SettingService from '../../../lib/services/setting-service'; | ||||
| import { simpleAuthKey } from '../../../lib/types/settings/simple-auth-settings'; | ||||
| import { simpleAuthSettingsKey } from '../../../lib/types/settings/simple-auth-settings'; | ||||
| import { addDays, minutesToMilliseconds } from 'date-fns'; | ||||
| import { GroupService } from '../../../lib/services/group-service'; | ||||
| import { randomId } from '../../../lib/util/random-id'; | ||||
| 
 | ||||
| let db; | ||||
| let stores; | ||||
| @ -22,6 +23,7 @@ let userStore: UserStore; | ||||
| let adminRole: IRole; | ||||
| let viewerRole: IRole; | ||||
| let sessionService: SessionService; | ||||
| let settingService: SettingService; | ||||
| 
 | ||||
| beforeAll(async () => { | ||||
|     db = await dbInit('user_service_serial', getLogger); | ||||
| @ -32,7 +34,7 @@ beforeAll(async () => { | ||||
|     const resetTokenService = new ResetTokenService(stores, config); | ||||
|     const emailService = new EmailService(undefined, config.getLogger); | ||||
|     sessionService = new SessionService(stores, config); | ||||
|     const settingService = new SettingService(stores, config); | ||||
|     settingService = new SettingService(stores, config); | ||||
| 
 | ||||
|     userService = new UserService(stores, config, { | ||||
|         accessService, | ||||
| @ -101,7 +103,11 @@ test('should create user with password', async () => { | ||||
| }); | ||||
| 
 | ||||
| test('should not login user if simple auth is disabled', async () => { | ||||
|     await db.stores.settingStore.insert(simpleAuthKey, { disabled: true }); | ||||
|     await settingService.insert( | ||||
|         simpleAuthSettingsKey, | ||||
|         { disabled: true }, | ||||
|         randomId(), | ||||
|     ); | ||||
| 
 | ||||
|     await userService.createUser({ | ||||
|         username: 'test_no_pass', | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user