diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index e9783fec3a..c376183173 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -83,7 +83,9 @@ Object { "isEnabled": [Function], }, }, - "frontendApiOrigins": Array [], + "frontendApiOrigins": Array [ + "*", + ], "getLogger": [Function], "import": Object { "dropBeforeImport": false, diff --git a/src/lib/app.ts b/src/lib/app.ts index e85b21cb81..bc67591ebb 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -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) { diff --git a/src/lib/create-config.test.ts b/src/lib/create-config.test.ts index 67a7b196d1..89cf169e2e 100644 --- a/src/lib/create-config.test.ts +++ b/src/lib/create-config.test.ts @@ -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(['*']); +}); diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index cc3a5cdb88..f5abb14047 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -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, diff --git a/src/lib/middleware/cors-origin-middleware.test.ts b/src/lib/middleware/cors-origin-middleware.test.ts index 82ffc43df4..2696c01bc8 100644 --- a/src/lib/middleware/cors-origin-middleware.test.ts +++ b/src/lib/middleware/cors-origin-middleware.test.ts @@ -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: ['*'], + }); +}); diff --git a/src/lib/middleware/cors-origin-middleware.ts b/src/lib/middleware/cors-origin-middleware.ts index 5e04abb98f..d9b952b823 100644 --- a/src/lib/middleware/cors-origin-middleware.ts +++ b/src/lib/middleware/cors-origin-middleware.ts @@ -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): RequestHandler => { + return cors(async (req, callback) => { + try { + const { frontendApiOrigins = [] } = + await settingService.getFrontendSettings(); + callback(null, { + origin: allowRequestOrigin( + req.header('Origin'), + frontendApiOrigins, + ), + }); + } catch (error) { + callback(error); + } }); }; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 22a7d2eb59..86d04fca28 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/set-ui-config-schema.ts b/src/lib/openapi/spec/set-ui-config-schema.ts new file mode 100644 index 0000000000..27f7153a2d --- /dev/null +++ b/src/lib/openapi/spec/set-ui-config-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/ui-config-schema.ts b/src/lib/openapi/spec/ui-config-schema.ts index dde89ef689..1c82027bab 100644 --- a/src/lib/openapi/spec/ui-config-schema.ts +++ b/src/lib/openapi/spec/ui-config-schema.ts @@ -37,6 +37,12 @@ export const uiConfigSchema = { strategySegmentsLimit: { type: 'number', }, + frontendApiOrigins: { + type: 'array', + items: { + type: 'string', + }, + }, flags: { type: 'object', additionalProperties: { diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts index ada7724331..e0037c9a8a 100644 --- a/src/lib/routes/admin-api/config.ts +++ b/src/lib/routes/admin-api/config.ts @@ -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, ): Promise { - const simpleAuthSettings = - await this.settingService.get(simpleAuthKey); + const [frontendSettings, simpleAuthSettings] = await Promise.all([ + this.settingService.getFrontendSettings(), + this.settingService.get(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, + res: Response, + ): Promise { + if (req.body.frontendSettings) { + await this.settingService.setFrontendSettings( + req.body.frontendSettings, + extractUsername(req), + ); + res.sendStatus(204); + return; + } + + throw new NotFoundError(); + } } + export default ConfigController; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index b7a21cc68c..acef4f0b29 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -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(simpleAuthKey); + await this.settingService.get( + simpleAuthSettingsKey, + ); let inviteLink: string; if (!passwordAuthSettings?.disabled) { diff --git a/src/lib/routes/proxy-api/index.ts b/src/lib/routes/proxy-api/index.ts index d0fc73429b..2443a47957 100644 --- a/src/lib/routes/proxy-api/index.ts +++ b/src/lib/routes/proxy-api/index.ts @@ -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, - ) { + 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, ) { - 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, res: Response, ) { - await this.proxyService.registerProxyMetrics( + await this.services.proxyService.registerProxyMetrics( req.user, req.body, req.ip, diff --git a/src/lib/services/setting-service.ts b/src/lib/services/setting-service.ts index 0b2cf3c030..77ad7c54a2 100644 --- a/src/lib/services/setting-service.ts +++ b/src/lib/services/setting-service.ts @@ -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(); + constructor( { settingStore, eventStore, }: Pick, - { getLogger }: Pick, + 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(id: string): Promise { - return this.settingStore.get(id); + async get(id: string, defaultValue?: T): Promise { + 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 { + 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 { + 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 { + this.cache.clear(); + await this.settingStore.deleteAll(); + } + + async setFrontendSettings( + value: FrontendSettings, + createdBy: string, + ): Promise { + const error = validateOrigins(value.frontendApiOrigins); + if (error) { + throw new BadDataError(error); + } + await this.insert(frontendSettingsKey, value, createdBy); + } + + async getFrontendSettings(): Promise { + return this.get(frontendSettingsKey, { + frontendApiOrigins: this.config.frontendApiOrigins, + }); + } } module.exports = SettingService; diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index a983abb546..8534099d4b 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -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 { const settings = await this.settingService.get( - simpleAuthKey, + simpleAuthSettingsKey, ); if (settings?.disabled) { diff --git a/src/lib/types/settings/frontend-settings.ts b/src/lib/types/settings/frontend-settings.ts new file mode 100644 index 0000000000..909797fac0 --- /dev/null +++ b/src/lib/types/settings/frontend-settings.ts @@ -0,0 +1,5 @@ +import { IUnleashConfig } from '../option'; + +export const frontendSettingsKey = 'unleash.frontend'; + +export type FrontendSettings = Pick; diff --git a/src/lib/types/settings/simple-auth-settings.ts b/src/lib/types/settings/simple-auth-settings.ts index eb63658910..b343e7288b 100644 --- a/src/lib/types/settings/simple-auth-settings.ts +++ b/src/lib/types/settings/simple-auth-settings.ts @@ -1,4 +1,5 @@ -export const simpleAuthKey = 'unleash.auth.simple'; +export const simpleAuthSettingsKey = 'unleash.auth.simple'; + export interface SimpleAuthSettings { disabled: boolean; } diff --git a/src/lib/util/parseEnvVar.test.ts b/src/lib/util/parseEnvVar.test.ts index a147e6cca9..c70df3551e 100644 --- a/src/lib/util/parseEnvVar.test.ts +++ b/src/lib/util/parseEnvVar.test.ts @@ -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']); diff --git a/src/lib/util/parseEnvVar.ts b/src/lib/util/parseEnvVar.ts index a52ada078e..7bc5d1b50e 100644 --- a/src/lib/util/parseEnvVar.ts +++ b/src/lib/util/parseEnvVar.ts @@ -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()) diff --git a/src/lib/util/validateOrigin.test.ts b/src/lib/util/validateOrigin.test.ts new file mode 100644 index 0000000000..fefb4eba60 --- /dev/null +++ b/src/lib/util/validateOrigin.test.ts @@ -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); +}); diff --git a/src/lib/util/validateOrigin.ts b/src/lib/util/validateOrigin.ts new file mode 100644 index 0000000000..e9ad69705f --- /dev/null +++ b/src/lib/util/validateOrigin.ts @@ -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}`; + } + } +}; diff --git a/src/test/e2e/api/admin/config.e2e.test.ts b/src/test/e2e/api/admin/config.e2e.test.ts index b4812fa2d5..4bf36e8812 100644 --- a/src/test/e2e/api/admin/config.e2e.test.ts +++ b/src/test/e2e/api/admin/config.e2e.test.ts @@ -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), + ); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index ae38379ffc..0f250958cb 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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 { diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts index 04f56966b9..62ae96b4e9 100644 --- a/src/test/e2e/services/user-service.e2e.test.ts +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -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',