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:
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) => {
|
||||
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'), allowedOrigins),
|
||||
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