1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

feat: add CORS instance settings (#1957)

* feat: add CORS instance settings

* refactor: disallow arbitrary asterisks in CORS origins
This commit is contained in:
olav 2022-08-26 09:09:48 +02:00 committed by GitHub
parent f3e8f723a2
commit 42d64c8803
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 451 additions and 71 deletions

View File

@ -83,7 +83,9 @@ Object {
"isEnabled": [Function], "isEnabled": [Function],
}, },
}, },
"frontendApiOrigins": Array [], "frontendApiOrigins": Array [
"*",
],
"getLogger": [Function], "getLogger": [Function],
"import": Object { "import": Object {
"dropBeforeImport": false, "dropBeforeImport": false,

View File

@ -76,10 +76,7 @@ export default async function getApp(
// Support CORS preflight requests for the frontend endpoints. // Support CORS preflight requests for the frontend endpoints.
// Preflight requests should not have Authorization headers, // Preflight requests should not have Authorization headers,
// so this must be handled before the API token middleware. // so this must be handled before the API token middleware.
app.options( app.options('/api/frontend*', corsOriginMiddleware(services));
'/api/frontend*',
corsOriginMiddleware(config.frontendApiOrigins),
);
} }
switch (config.authentication.type) { switch (config.authentication.type) {

View File

@ -403,3 +403,28 @@ test('Environment variables for client features caching takes priority over opti
expect(config.clientFeatureCaching.enabled).toBe(true); expect(config.clientFeatureCaching.enabled).toBe(true);
expect(config.clientFeatureCaching.maxAge).toBe(120); 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(['*']);
});

View File

@ -43,6 +43,7 @@ import {
DEFAULT_STRATEGY_SEGMENTS_LIMIT, DEFAULT_STRATEGY_SEGMENTS_LIMIT,
} from './util/segments'; } from './util/segments';
import FlagResolver from './util/flag-resolver'; import FlagResolver from './util/flag-resolver';
import { validateOrigins } from './util/validateOrigin';
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s); 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 { export function createConfig(options: IUnleashOptions): IUnleashConfig {
let extraDbOptions = {}; let extraDbOptions = {};
@ -420,10 +435,6 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
DEFAULT_STRATEGY_SEGMENTS_LIMIT, DEFAULT_STRATEGY_SEGMENTS_LIMIT,
); );
const frontendApiOrigins =
options.frontendApiOrigins ||
parseEnvVarStrings(process.env.UNLEASH_FRONTEND_API_ORIGINS, []);
const clientFeatureCaching = loadClientCachingOptions(options); const clientFeatureCaching = loadClientCachingOptions(options);
return { return {
@ -449,7 +460,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
eventBus: new EventEmitter(), eventBus: new EventEmitter(),
environmentEnableOverrides, environmentEnableOverrides,
additionalCspAllowedDomains, additionalCspAllowedDomains,
frontendApiOrigins, frontendApiOrigins: parseFrontendApiOrigins(options),
inlineSegmentConstraints, inlineSegmentConstraints,
segmentValuesLimit, segmentValuesLimit,
strategySegmentsLimit, strategySegmentsLimit,

View File

@ -1,4 +1,21 @@
import { allowRequestOrigin } from './cors-origin-middleware'; 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', () => { test('allowRequestOrigin', () => {
const dotCom = 'https://example.com'; const dotCom = 'https://example.com';
@ -16,3 +33,54 @@ test('allowRequestOrigin', () => {
expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true); expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true);
expect(allowRequestOrigin(dotCom, [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: ['*'],
});
});

View File

@ -1,25 +1,33 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import cors from 'cors'; import cors from 'cors';
import { IUnleashServices } from '../types';
const ANY_ORIGIN = '*';
export const allowRequestOrigin = ( export const allowRequestOrigin = (
requestOrigin: string, requestOrigin: string,
allowedOrigins: string[], allowedOrigins: string[],
): boolean => { ): boolean => {
return allowedOrigins.some((allowedOrigin) => { 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. // Check the request's Origin header against a list of allowed origins.
// The list may include '*', which `cors` does not support natively. // The list may include '*', which `cors` does not support natively.
export const corsOriginMiddleware = ( export const corsOriginMiddleware = ({
allowedOrigins: string[], settingService,
): RequestHandler => { }: Pick<IUnleashServices, 'settingService'>): RequestHandler => {
return cors((req, callback) => { return cors(async (req, callback) => {
try {
const { frontendApiOrigins = [] } =
await settingService.getFrontendSettings();
callback(null, { callback(null, {
origin: allowRequestOrigin(req.header('Origin'), allowedOrigins), origin: allowRequestOrigin(
req.header('Origin'),
frontendApiOrigins,
),
}); });
} catch (error) {
callback(error);
}
}); });
}; };

View File

@ -110,6 +110,7 @@ import { proxyFeaturesSchema } from './spec/proxy-features-schema';
import { proxyFeatureSchema } from './spec/proxy-feature-schema'; import { proxyFeatureSchema } from './spec/proxy-feature-schema';
import { proxyClientSchema } from './spec/proxy-client-schema'; import { proxyClientSchema } from './spec/proxy-client-schema';
import { proxyMetricsSchema } from './spec/proxy-metrics-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. // All schemas in `openapi/spec` should be listed here.
export const schemas = { export const schemas = {
@ -187,6 +188,7 @@ export const schemas = {
searchEventsSchema, searchEventsSchema,
segmentSchema, segmentSchema,
setStrategySortOrderSchema, setStrategySortOrderSchema,
setUiConfigSchema,
sortOrderSchema, sortOrderSchema,
splashSchema, splashSchema,
stateSchema, stateSchema,

View 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>;

View File

@ -37,6 +37,12 @@ export const uiConfigSchema = {
strategySegmentsLimit: { strategySegmentsLimit: {
type: 'number', type: 'number',
}, },
frontendApiOrigins: {
type: 'array',
items: {
type: 'string',
},
},
flags: { flags: {
type: 'object', type: 'object',
additionalProperties: { additionalProperties: {

View File

@ -7,10 +7,10 @@ import Controller from '../controller';
import VersionService from '../../services/version-service'; import VersionService from '../../services/version-service';
import SettingService from '../../services/setting-service'; import SettingService from '../../services/setting-service';
import { import {
simpleAuthKey, simpleAuthSettingsKey,
SimpleAuthSettings, SimpleAuthSettings,
} from '../../types/settings/simple-auth-settings'; } 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 { createResponseSchema } from '../../openapi/util/create-response-schema';
import { import {
uiConfigSchema, uiConfigSchema,
@ -18,6 +18,12 @@ import {
} from '../../openapi/spec/ui-config-schema'; } from '../../openapi/spec/ui-config-schema';
import { OpenApiService } from '../../services/openapi-service'; import { OpenApiService } from '../../services/openapi-service';
import { EmailService } from '../../services/email-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 { class ConfigController extends Controller {
private versionService: VersionService; private versionService: VersionService;
@ -52,26 +58,43 @@ class ConfigController extends Controller {
this.route({ this.route({
method: 'get', method: 'get',
path: '', path: '',
handler: this.getUIConfig, handler: this.getUiConfig,
permission: NONE, permission: NONE,
middleware: [ middleware: [
openApiService.validPath({ openApiService.validPath({
tags: ['Admin UI'], tags: ['Admin UI'],
operationId: 'getUIConfig', operationId: 'getUiConfig',
responses: { responses: {
200: createResponseSchema('uiConfigSchema'), 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, req: AuthedRequest,
res: Response<UiConfigSchema>, res: Response<UiConfigSchema>,
): Promise<void> { ): Promise<void> {
const simpleAuthSettings = const [frontendSettings, simpleAuthSettings] = await Promise.all([
await this.settingService.get<SimpleAuthSettings>(simpleAuthKey); this.settingService.getFrontendSettings(),
this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
]);
const disablePasswordAuth = const disablePasswordAuth =
simpleAuthSettings?.disabled || simpleAuthSettings?.disabled ||
@ -92,6 +115,7 @@ class ConfigController extends Controller {
authenticationType: this.config.authentication?.type, authenticationType: this.config.authentication?.type,
segmentValuesLimit: this.config.segmentValuesLimit, segmentValuesLimit: this.config.segmentValuesLimit,
strategySegmentsLimit: this.config.strategySegmentsLimit, strategySegmentsLimit: this.config.strategySegmentsLimit,
frontendApiOrigins: frontendSettings.frontendApiOrigins,
versionInfo: this.versionService.getVersionInfo(), versionInfo: this.versionService.getVersionInfo(),
disablePasswordAuth, disablePasswordAuth,
embedProxy: this.config.experimental.flags.embedProxy, embedProxy: this.config.experimental.flags.embedProxy,
@ -104,5 +128,22 @@ class ConfigController extends Controller {
response, 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; export default ConfigController;

View File

@ -10,7 +10,7 @@ import ResetTokenService from '../../services/reset-token-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import SettingService from '../../services/setting-service'; import SettingService from '../../services/setting-service';
import { IUser, SimpleAuthSettings } from '../../server-impl'; 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 { anonymise } from '../../util/anonymise';
import { OpenApiService } from '../../services/openapi-service'; import { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema';
@ -369,7 +369,9 @@ export default class UserAdminController extends Controller {
); );
const passwordAuthSettings = const passwordAuthSettings =
await this.settingService.get<SimpleAuthSettings>(simpleAuthKey); await this.settingService.get<SimpleAuthSettings>(
simpleAuthSettingsKey,
);
let inviteLink: string; let inviteLink: string;
if (!passwordAuthSettings?.disabled) { if (!passwordAuthSettings?.disabled) {

View File

@ -2,9 +2,7 @@ import { Response, Request } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { IUnleashConfig, IUnleashServices } from '../../types'; import { IUnleashConfig, IUnleashServices } from '../../types';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions'; import { NONE } from '../../types/permissions';
import { ProxyService } from '../../services/proxy-service';
import ApiUser from '../../types/api-user'; import ApiUser from '../../types/api-user';
import { import {
proxyFeaturesSchema, proxyFeaturesSchema,
@ -28,29 +26,25 @@ interface ApiUserRequest<
user: ApiUser; user: ApiUser;
} }
type Services = Pick<
IUnleashServices,
'settingService' | 'proxyService' | 'openApiService'
>;
export default class ProxyController extends Controller { export default class ProxyController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
private proxyService: ProxyService; private services: Services;
private openApiService: OpenApiService; constructor(config: IUnleashConfig, services: Services) {
constructor(
config: IUnleashConfig,
{
proxyService,
openApiService,
}: Pick<IUnleashServices, 'proxyService' | 'openApiService'>,
) {
super(config); super(config);
this.logger = config.getLogger('client-api/feature.js'); this.logger = config.getLogger('client-api/feature.js');
this.proxyService = proxyService; this.services = services;
this.openApiService = openApiService;
if (config.frontendApiOrigins.length > 0) { if (config.frontendApiOrigins.length > 0) {
// Support CORS requests for the frontend endpoints. // Support CORS requests for the frontend endpoints.
// Preflight requests are handled in `app.ts`. // Preflight requests are handled in `app.ts`.
this.app.use(corsOriginMiddleware(config.frontendApiOrigins)); this.app.use(corsOriginMiddleware(services));
} }
this.route({ this.route({
@ -59,7 +53,7 @@ export default class ProxyController extends Controller {
handler: this.getProxyFeatures, handler: this.getProxyFeatures,
permission: NONE, permission: NONE,
middleware: [ middleware: [
this.openApiService.validPath({ this.services.openApiService.validPath({
tags: ['Unstable'], tags: ['Unstable'],
operationId: 'getFrontendFeatures', operationId: 'getFrontendFeatures',
responses: { responses: {
@ -89,7 +83,7 @@ export default class ProxyController extends Controller {
handler: this.registerProxyMetrics, handler: this.registerProxyMetrics,
permission: NONE, permission: NONE,
middleware: [ middleware: [
this.openApiService.validPath({ this.services.openApiService.validPath({
tags: ['Unstable'], tags: ['Unstable'],
operationId: 'registerFrontendMetrics', operationId: 'registerFrontendMetrics',
requestBody: createRequestSchema('proxyMetricsSchema'), requestBody: createRequestSchema('proxyMetricsSchema'),
@ -104,7 +98,7 @@ export default class ProxyController extends Controller {
handler: ProxyController.registerProxyClient, handler: ProxyController.registerProxyClient,
permission: NONE, permission: NONE,
middleware: [ middleware: [
this.openApiService.validPath({ this.services.openApiService.validPath({
tags: ['Unstable'], tags: ['Unstable'],
operationId: 'registerFrontendClient', operationId: 'registerFrontendClient',
requestBody: createRequestSchema('proxyClientSchema'), requestBody: createRequestSchema('proxyClientSchema'),
@ -141,11 +135,11 @@ export default class ProxyController extends Controller {
req: ApiUserRequest, req: ApiUserRequest,
res: Response<ProxyFeaturesSchema>, res: Response<ProxyFeaturesSchema>,
) { ) {
const toggles = await this.proxyService.getProxyFeatures( const toggles = await this.services.proxyService.getProxyFeatures(
req.user, req.user,
ProxyController.createContext(req), ProxyController.createContext(req),
); );
this.openApiService.respondWithValidation( this.services.openApiService.respondWithValidation(
200, 200,
res, res,
proxyFeaturesSchema.$id, proxyFeaturesSchema.$id,
@ -157,7 +151,7 @@ export default class ProxyController extends Controller {
req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>, req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>,
res: Response, res: Response,
) { ) {
await this.proxyService.registerProxyMetrics( await this.services.proxyService.registerProxyMetrics(
req.user, req.user,
req.body, req.body,
req.ip, req.ip,

View File

@ -8,31 +8,48 @@ import {
SettingDeletedEvent, SettingDeletedEvent,
SettingUpdatedEvent, SettingUpdatedEvent,
} from '../types/events'; } 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 { export default class SettingService {
private config: IUnleashConfig;
private logger: Logger; private logger: Logger;
private settingStore: ISettingStore; private settingStore: ISettingStore;
private eventStore: IEventStore; 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( constructor(
{ {
settingStore, settingStore,
eventStore, eventStore,
}: Pick<IUnleashStores, '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.settingStore = settingStore;
this.eventStore = eventStore; this.eventStore = eventStore;
} }
async get<T>(id: string): Promise<T> { async get<T>(id: string, defaultValue?: T): Promise<T> {
return this.settingStore.get(id); 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> { async insert(id: string, value: object, createdBy: string): Promise<void> {
this.cache.delete(id);
const exists = await this.settingStore.exists(id); const exists = await this.settingStore.exists(id);
if (exists) { if (exists) {
await this.settingStore.updateRow(id, value); await this.settingStore.updateRow(id, value);
@ -54,6 +71,7 @@ export default class SettingService {
} }
async delete(id: string, createdBy: string): Promise<void> { async delete(id: string, createdBy: string): Promise<void> {
this.cache.delete(id);
await this.settingStore.delete(id); await this.settingStore.delete(id);
await this.eventStore.store( await this.eventStore.store(
new SettingDeletedEvent({ 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; module.exports = SettingService;

View File

@ -22,7 +22,7 @@ import { IUserStore } from '../types/stores/user-store';
import { RoleName } from '../types/model'; import { RoleName } from '../types/model';
import SettingService from './setting-service'; import SettingService from './setting-service';
import { SimpleAuthSettings } from '../server-impl'; 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 DisabledError from '../error/disabled-error';
import PasswordMismatch from '../error/password-mismatch'; import PasswordMismatch from '../error/password-mismatch';
import BadDataError from '../error/bad-data-error'; import BadDataError from '../error/bad-data-error';
@ -276,7 +276,7 @@ class UserService {
async loginUser(usernameOrEmail: string, password: string): Promise<IUser> { async loginUser(usernameOrEmail: string, password: string): Promise<IUser> {
const settings = await this.settingService.get<SimpleAuthSettings>( const settings = await this.settingService.get<SimpleAuthSettings>(
simpleAuthKey, simpleAuthSettingsKey,
); );
if (settings?.disabled) { if (settings?.disabled) {

View File

@ -0,0 +1,5 @@
import { IUnleashConfig } from '../option';
export const frontendSettingsKey = 'unleash.frontend';
export type FrontendSettings = Pick<IUnleashConfig, 'frontendApiOrigins'>;

View File

@ -1,4 +1,5 @@
export const simpleAuthKey = 'unleash.auth.simple'; export const simpleAuthSettingsKey = 'unleash.auth.simple';
export interface SimpleAuthSettings { export interface SimpleAuthSettings {
disabled: boolean; disabled: boolean;
} }

View File

@ -29,9 +29,11 @@ test('parseEnvVarBoolean', () => {
}); });
test('parseEnvVarStringList', () => { 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(' ', [])).toEqual([]);
expect(parseEnvVarStrings('', ['*'])).toEqual(['*']);
expect(parseEnvVarStrings('a', ['*'])).toEqual(['a']); expect(parseEnvVarStrings('a', ['*'])).toEqual(['a']);
expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']); expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']);
expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']); expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']);

View File

@ -20,10 +20,10 @@ export function parseEnvVarBoolean(
} }
export function parseEnvVarStrings( export function parseEnvVarStrings(
envVar: string, envVar: string | undefined,
defaultVal: string[], defaultVal: string[],
): string[] { ): string[] {
if (envVar) { if (typeof envVar === 'string') {
return envVar return envVar
.split(',') .split(',')
.map((item) => item.trim()) .map((item) => item.trim())

View 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);
});

View 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}`;
}
}
};

View File

@ -1,10 +1,11 @@
import dbInit, { ITestDb } from '../../helpers/database-init'; 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 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 db: ITestDb;
let app; let app: IUnleashTest;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('config_api_serial', getLogger); db = await dbInit('config_api_serial', getLogger);
@ -16,24 +17,71 @@ afterAll(async () => {
await db.destroy(); await db.destroy();
}); });
beforeEach(async () => {
await app.services.settingService.deleteAll();
});
test('gets ui config fields', async () => { test('gets ui config fields', async () => {
const { body } = await app.request const { body } = await app.request
.get('/api/admin/ui-config') .get('/api/admin/ui-config')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body.unleashUrl).toBe('http://localhost:4242'); expect(body.unleashUrl).toBe('http://localhost:4242');
expect(body.version).toBeDefined(); expect(body.version).toBeDefined();
expect(body.emailEnabled).toBe(false); expect(body.emailEnabled).toBe(false);
}); });
test('gets ui config with disablePasswordAuth', async () => { 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 const { body } = await app.request
.get('/api/admin/ui-config') .get('/api/admin/ui-config')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body.disablePasswordAuth).toBe(true); 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),
);
});

View File

@ -2498,6 +2498,28 @@ Object {
}, },
"type": "array", "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 { "sortOrderSchema": Object {
"additionalProperties": Object { "additionalProperties": Object {
"type": "number", "type": "number",
@ -2829,6 +2851,12 @@ Object {
}, },
"type": "object", "type": "object",
}, },
"frontendApiOrigins": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
"links": Object { "links": Object {
"items": Object { "items": Object {
"type": "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 { "/api/admin/ui-config": Object {
"get": Object { "get": Object {
"operationId": "getUIConfig", "operationId": "getUiConfig",
"responses": Object { "responses": Object {
"200": Object { "200": Object {
"content": Object { "content": Object {
@ -6189,6 +6217,28 @@ If the provided project does not exist, the list of events will be empty.",
"Admin UI", "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 { "/api/admin/user": Object {
"get": Object { "get": Object {

View File

@ -11,9 +11,10 @@ import NotFoundError from '../../../lib/error/notfound-error';
import { IRole } from '../../../lib/types/stores/access-store'; import { IRole } from '../../../lib/types/stores/access-store';
import { RoleName } from '../../../lib/types/model'; import { RoleName } from '../../../lib/types/model';
import SettingService from '../../../lib/services/setting-service'; 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 { addDays, minutesToMilliseconds } from 'date-fns';
import { GroupService } from '../../../lib/services/group-service'; import { GroupService } from '../../../lib/services/group-service';
import { randomId } from '../../../lib/util/random-id';
let db; let db;
let stores; let stores;
@ -22,6 +23,7 @@ let userStore: UserStore;
let adminRole: IRole; let adminRole: IRole;
let viewerRole: IRole; let viewerRole: IRole;
let sessionService: SessionService; let sessionService: SessionService;
let settingService: SettingService;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('user_service_serial', getLogger); db = await dbInit('user_service_serial', getLogger);
@ -32,7 +34,7 @@ beforeAll(async () => {
const resetTokenService = new ResetTokenService(stores, config); const resetTokenService = new ResetTokenService(stores, config);
const emailService = new EmailService(undefined, config.getLogger); const emailService = new EmailService(undefined, config.getLogger);
sessionService = new SessionService(stores, config); sessionService = new SessionService(stores, config);
const settingService = new SettingService(stores, config); settingService = new SettingService(stores, config);
userService = new UserService(stores, config, { userService = new UserService(stores, config, {
accessService, accessService,
@ -101,7 +103,11 @@ test('should create user with password', async () => {
}); });
test('should not login user if simple auth is disabled', 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({ await userService.createUser({
username: 'test_no_pass', username: 'test_no_pass',