From 09c87c755facbf645a29d913bc84e4867b8ac6cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?=
Date: Wed, 14 Dec 2022 17:35:22 +0100
Subject: [PATCH] Fix/remove settings cache (#2694)
In this PR we remove the general SettingService cache, as it will not
work across multiple horizontal unleash instances, events are not
published across.
We also fix the CORS origin to:
- Access-Control-Allow-Origin set to "*" if no Origin is configured
- Access-Control-Allow-Origin set to "*" if any Origin is configured to
"*"
- - Access-Control-Allow-Origin set to array and have the "cors"
middleware to return an exact match on the user provided Origin.
Co-authored-by: Fredrik Oseberg
---
.../src/component/admin/cors/CorsForm.tsx | 18 ++-
.../component/admin/cors/CorsHelpAlert.tsx | 9 ++
.../middleware/cors-origin-middleware.test.ts | 136 +++++++++++++-----
src/lib/middleware/cors-origin-middleware.ts | 26 ++--
src/lib/routes/admin-api/config.ts | 10 +-
src/lib/server-impl.ts | 1 +
src/lib/services/index.ts | 1 +
src/lib/services/proxy-service.ts | 67 ++++++++-
src/lib/services/setting-service.ts | 38 +----
src/test/e2e/api/admin/config.e2e.test.ts | 2 +-
src/test/e2e/helpers/test-helper.ts | 1 +
11 files changed, 218 insertions(+), 91 deletions(-)
diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx
index cc9b124eaf..eebea9671f 100644
--- a/frontend/src/component/admin/cors/CorsForm.tsx
+++ b/frontend/src/component/admin/cors/CorsForm.tsx
@@ -6,6 +6,7 @@ import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi'
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useId } from 'hooks/useId';
+import { fontSize } from '@mui/system';
interface ICorsFormProps {
frontendApiOrigins: string[] | undefined;
@@ -35,8 +36,23 @@ export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
Which origins should be allowed to call the Frontend API?
- Add only one origin per line.
+ Add only one origin per line. The CORS specification does
+ not support wildcard for subdomains, it needs to be a fully
+ qualified domain, including the protocol.
+
+
+ If you specify "*" it will be the chosen origin.
+
+
+ Example:
+
+
+ https://www.example.com
+
+ https://www.example2.com
+
+
{
An asterisk (*
) may be used to allow API calls from
any origin.
+
+
+ Be aware that changes here will take up to two minutes to be
+ updated. In addition, there is a maxAge on the
+ Access-Control-Allow-Origin header that will instruct browsers
+ to cache this header for some time. The cache period is set to
+ the maxium that the browser allows (2h for Chrome, 24h for
+ Firefox).
+
);
};
diff --git a/src/lib/middleware/cors-origin-middleware.test.ts b/src/lib/middleware/cors-origin-middleware.test.ts
index 2696c01bc8..95206b4f87 100644
--- a/src/lib/middleware/cors-origin-middleware.test.ts
+++ b/src/lib/middleware/cors-origin-middleware.test.ts
@@ -1,86 +1,152 @@
-import { allowRequestOrigin } from './cors-origin-middleware';
+import { resolveOrigin } 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';
+import FakeProjectStore from '../../test/fixtures/fake-project-store';
+import { ProxyService, SettingService } from '../../lib/services';
+import { ISettingStore } from '../../lib/types';
+import { frontendSettingsKey } from '../../lib/types/settings/frontend-settings';
+import { minutesToMilliseconds } from 'date-fns';
-const createSettingService = (frontendApiOrigins: string[]): SettingService => {
+const createSettingService = (
+ frontendApiOrigins: string[],
+): { proxyService: ProxyService; settingStore: ISettingStore } => {
const config = createTestConfig({ frontendApiOrigins });
const stores = {
settingStore: new FakeSettingStore(),
eventStore: new FakeEventStore(),
+ projectStore: new FakeProjectStore(),
};
- return new SettingService(stores, config);
+ const services = {
+ settingService: new SettingService(stores, config),
+ };
+
+ return {
+ //@ts-ignore
+ proxyService: new ProxyService(config, stores, services),
+ settingStore: stores.settingStore,
+ };
};
-test('allowRequestOrigin', () => {
+test('resolveOrigin', () => {
const dotCom = 'https://example.com';
const dotOrg = 'https://example.org';
- expect(allowRequestOrigin('', [])).toEqual(false);
- expect(allowRequestOrigin(dotCom, [])).toEqual(false);
- expect(allowRequestOrigin(dotCom, [dotOrg])).toEqual(false);
-
- expect(allowRequestOrigin(dotCom, [dotCom, dotOrg])).toEqual(true);
- expect(allowRequestOrigin(dotCom, [dotOrg, dotCom])).toEqual(true);
- expect(allowRequestOrigin(dotCom, [dotCom, dotCom])).toEqual(true);
-
- expect(allowRequestOrigin(dotCom, ['*'])).toEqual(true);
- expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true);
- expect(allowRequestOrigin(dotCom, [dotCom, dotOrg, '*'])).toEqual(true);
+ expect(resolveOrigin([])).toEqual('*');
+ expect(resolveOrigin(['*'])).toEqual('*');
+ expect(resolveOrigin([dotOrg])).toEqual([dotOrg]);
+ expect(resolveOrigin([dotCom, dotOrg])).toEqual([dotCom, dotOrg]);
+ expect(resolveOrigin([dotOrg, '*'])).toEqual('*');
});
test('corsOriginMiddleware origin validation', async () => {
- const service = createSettingService([]);
+ const { proxyService } = createSettingService([]);
const userName = randomId();
await expect(() =>
- service.setFrontendSettings({ frontendApiOrigins: ['a'] }, userName),
+ proxyService.setFrontendSettings(
+ { frontendApiOrigins: ['a'] },
+ userName,
+ ),
).rejects.toThrow('Invalid origin: a');
+ proxyService.destroy();
});
test('corsOriginMiddleware without config', async () => {
- const service = createSettingService([]);
+ const { proxyService, settingStore } = createSettingService([]);
const userName = randomId();
- expect(await service.getFrontendSettings()).toEqual({
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: [],
});
- await service.setFrontendSettings({ frontendApiOrigins: [] }, userName);
- expect(await service.getFrontendSettings()).toEqual({
+ await proxyService.setFrontendSettings(
+ { frontendApiOrigins: [] },
+ userName,
+ );
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: [],
});
- await service.setFrontendSettings({ frontendApiOrigins: ['*'] }, userName);
- expect(await service.getFrontendSettings()).toEqual({
+ await proxyService.setFrontendSettings(
+ { frontendApiOrigins: ['*'] },
+ userName,
+ );
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: ['*'],
});
- await service.delete(frontendSettingsKey, userName);
- expect(await service.getFrontendSettings()).toEqual({
+ await settingStore.delete(frontendSettingsKey);
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: [],
});
+ proxyService.destroy();
});
test('corsOriginMiddleware with config', async () => {
- const service = createSettingService(['*']);
+ const { proxyService, settingStore } = createSettingService(['*']);
const userName = randomId();
- expect(await service.getFrontendSettings()).toEqual({
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: ['*'],
});
- await service.setFrontendSettings({ frontendApiOrigins: [] }, userName);
- expect(await service.getFrontendSettings()).toEqual({
+ await proxyService.setFrontendSettings(
+ { frontendApiOrigins: [] },
+ userName,
+ );
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: [],
});
- await service.setFrontendSettings(
+ await proxyService.setFrontendSettings(
{ frontendApiOrigins: ['https://example.com', 'https://example.org'] },
userName,
);
- expect(await service.getFrontendSettings()).toEqual({
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: ['https://example.com', 'https://example.org'],
});
- await service.delete(frontendSettingsKey, userName);
- expect(await service.getFrontendSettings()).toEqual({
+ await settingStore.delete(frontendSettingsKey);
+ expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: ['*'],
});
+ proxyService.destroy();
+});
+
+test('corsOriginMiddleware with caching enabled', async () => {
+ jest.useFakeTimers();
+
+ const { proxyService } = createSettingService([]);
+
+ const userName = randomId();
+ expect(await proxyService.getFrontendSettings()).toEqual({
+ frontendApiOrigins: [],
+ });
+
+ //setting
+ await proxyService.setFrontendSettings(
+ { frontendApiOrigins: ['*'] },
+ userName,
+ );
+
+ //still get cached value
+ expect(await proxyService.getFrontendSettings()).toEqual({
+ frontendApiOrigins: [],
+ });
+
+ jest.advanceTimersByTime(minutesToMilliseconds(2));
+
+ jest.useRealTimers();
+
+ /*
+ This is needed because it is not enough to fake time to test the
+ updated cache, we also need to make sure that all promises are
+ executed and completed, in the right order.
+ */
+ await new Promise((resolve) =>
+ process.nextTick(async () => {
+ const settings = await proxyService.getFrontendSettings();
+
+ expect(settings).toEqual({
+ frontendApiOrigins: ['*'],
+ });
+ resolve();
+ }),
+ );
+ proxyService.destroy();
});
diff --git a/src/lib/middleware/cors-origin-middleware.ts b/src/lib/middleware/cors-origin-middleware.ts
index 2fc971cdf9..6645c93e8b 100644
--- a/src/lib/middleware/cors-origin-middleware.ts
+++ b/src/lib/middleware/cors-origin-middleware.ts
@@ -2,32 +2,32 @@ import { RequestHandler } from 'express';
import cors from 'cors';
import { IUnleashConfig, IUnleashServices } from '../types';
-export const allowRequestOrigin = (
- requestOrigin: string,
- allowedOrigins: string[],
-): boolean => {
- return allowedOrigins.some((allowedOrigin) => {
- return allowedOrigin === requestOrigin || allowedOrigin === '*';
- });
+export const resolveOrigin = (allowedOrigins: string[]): string | string[] => {
+ if (allowedOrigins.length === 0) {
+ return '*';
+ }
+ if (allowedOrigins.some((origin: string) => origin === '*')) {
+ return '*';
+ } else {
+ return allowedOrigins;
+ }
};
// 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 = (
- { settingService }: Pick,
+ { proxyService }: Pick,
config: IUnleashConfig,
): RequestHandler => {
return cors(async (req, callback) => {
try {
const { frontendApiOrigins = [] } =
- await settingService.getFrontendSettings();
+ await proxyService.getFrontendSettings();
callback(null, {
- origin: allowRequestOrigin(
- req.header('Origin'),
- frontendApiOrigins,
- ),
+ origin: resolveOrigin(frontendApiOrigins),
maxAge: config.accessControlMaxAge,
exposedHeaders: 'ETag',
+ credentials: true,
});
} catch (error) {
callback(error);
diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts
index 3604909b59..cf38d9098c 100644
--- a/src/lib/routes/admin-api/config.ts
+++ b/src/lib/routes/admin-api/config.ts
@@ -24,12 +24,15 @@ 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';
+import { ProxyService } from 'lib/services';
class ConfigController extends Controller {
private versionService: VersionService;
private settingService: SettingService;
+ private proxyService: ProxyService;
+
private emailService: EmailService;
private readonly openApiService: OpenApiService;
@@ -41,12 +44,14 @@ class ConfigController extends Controller {
settingService,
emailService,
openApiService,
+ proxyService,
}: Pick<
IUnleashServices,
| 'versionService'
| 'settingService'
| 'emailService'
| 'openApiService'
+ | 'proxyService'
>,
) {
super(config);
@@ -54,6 +59,7 @@ class ConfigController extends Controller {
this.settingService = settingService;
this.emailService = emailService;
this.openApiService = openApiService;
+ this.proxyService = proxyService;
this.route({
method: 'get',
@@ -92,7 +98,7 @@ class ConfigController extends Controller {
res: Response,
): Promise {
const [frontendSettings, simpleAuthSettings] = await Promise.all([
- this.settingService.getFrontendSettings(),
+ this.proxyService.getFrontendSettings(false),
this.settingService.get(simpleAuthSettingsKey),
]);
@@ -133,7 +139,7 @@ class ConfigController extends Controller {
res: Response,
): Promise {
if (req.body.frontendSettings) {
- await this.settingService.setFrontendSettings(
+ await this.proxyService.setFrontendSettings(
req.body.frontendSettings,
extractUsername(req),
);
diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts
index d44fcd6c3d..8815984d9b 100644
--- a/src/lib/server-impl.ts
+++ b/src/lib/server-impl.ts
@@ -55,6 +55,7 @@ async function createApp(
metricsMonitor.stopMonitoring();
stores.clientInstanceStore.destroy();
services.clientMetricsServiceV2.destroy();
+ services.proxyService.destroy();
await db.destroy();
};
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index f0a310ef53..ddfd38201a 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -109,6 +109,7 @@ export const createServices = (
featureToggleServiceV2,
clientMetricsServiceV2,
segmentService,
+ settingService,
});
const edgeService = new EdgeService(stores, config);
diff --git a/src/lib/services/proxy-service.ts b/src/lib/services/proxy-service.ts
index c858c2790e..9041846cde 100644
--- a/src/lib/services/proxy-service.ts
+++ b/src/lib/services/proxy-service.ts
@@ -10,16 +10,29 @@ import {
UnleashEvents,
} from 'unleash-client';
import { ProxyRepository } from '../proxy';
-import assert from 'assert';
import { ApiTokenType } from '../types/models/api-token';
+import {
+ FrontendSettings,
+ frontendSettingsKey,
+} from '../types/settings/frontend-settings';
+import { validateOrigins } from '../util';
+import { BadDataError } from '../error';
+import assert from 'assert';
+import { minutesToMilliseconds } from 'date-fns';
-type Config = Pick;
+type Config = Pick<
+ IUnleashConfig,
+ 'getLogger' | 'frontendApi' | 'frontendApiOrigins'
+>;
type Stores = Pick;
type Services = Pick<
IUnleashServices,
- 'featureToggleServiceV2' | 'segmentService' | 'clientMetricsServiceV2'
+ | 'featureToggleServiceV2'
+ | 'segmentService'
+ | 'clientMetricsServiceV2'
+ | 'settingService'
>;
export class ProxyService {
@@ -33,11 +46,20 @@ export class ProxyService {
private readonly clients: Map = new Map();
+ private cachedFrontendSettings?: FrontendSettings;
+
+ private timer: NodeJS.Timeout;
+
constructor(config: Config, stores: Stores, services: Services) {
this.config = config;
this.logger = config.getLogger('services/proxy-service.ts');
this.stores = stores;
this.services = services;
+
+ this.timer = setInterval(
+ () => this.fetchFrontendSettings(),
+ minutesToMilliseconds(2),
+ ).unref();
}
async getProxyFeatures(
@@ -138,4 +160,43 @@ export class ProxyService {
private static assertExpectedTokenType({ type }: ApiUser) {
assert(type === ApiTokenType.FRONTEND || type === ApiTokenType.ADMIN);
}
+
+ async setFrontendSettings(
+ value: FrontendSettings,
+ createdBy: string,
+ ): Promise {
+ const error = validateOrigins(value.frontendApiOrigins);
+ if (error) {
+ throw new BadDataError(error);
+ }
+ await this.services.settingService.insert(
+ frontendSettingsKey,
+ value,
+ createdBy,
+ );
+ }
+
+ private async fetchFrontendSettings(): Promise {
+ this.cachedFrontendSettings = await this.services.settingService.get(
+ frontendSettingsKey,
+ {
+ frontendApiOrigins: this.config.frontendApiOrigins,
+ },
+ );
+ return this.cachedFrontendSettings;
+ }
+
+ async getFrontendSettings(
+ useCache: boolean = true,
+ ): Promise {
+ if (useCache && this.cachedFrontendSettings) {
+ return this.cachedFrontendSettings;
+ }
+ return this.fetchFrontendSettings();
+ }
+
+ destroy(): void {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
}
diff --git a/src/lib/services/setting-service.ts b/src/lib/services/setting-service.ts
index 77ad7c54a2..0f2f64703b 100644
--- a/src/lib/services/setting-service.ts
+++ b/src/lib/services/setting-service.ts
@@ -8,12 +8,6 @@ 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;
@@ -24,10 +18,6 @@ export default class SettingService {
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,
@@ -42,14 +32,11 @@ export default class SettingService {
}
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;
+ const value = await this.settingStore.get(id);
+ return value || 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);
@@ -71,7 +58,6 @@ 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({
@@ -84,26 +70,6 @@ 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/test/e2e/api/admin/config.e2e.test.ts b/src/test/e2e/api/admin/config.e2e.test.ts
index 4bf36e8812..c6e30be67e 100644
--- a/src/test/e2e/api/admin/config.e2e.test.ts
+++ b/src/test/e2e/api/admin/config.e2e.test.ts
@@ -44,7 +44,7 @@ test('gets ui config with disablePasswordAuth', async () => {
test('gets ui config with frontendSettings', async () => {
const frontendApiOrigins = ['https://example.net'];
- await app.services.settingService.setFrontendSettings(
+ await app.services.proxyService.setFrontendSettings(
{ frontendApiOrigins },
randomId(),
);
diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts
index 172be1cfe4..ad265d0db0 100644
--- a/src/test/e2e/helpers/test-helper.ts
+++ b/src/test/e2e/helpers/test-helper.ts
@@ -47,6 +47,7 @@ async function createApp(
services.clientInstanceService.destroy();
services.clientMetricsServiceV2.destroy();
services.apiTokenService.destroy();
+ services.proxyService.destroy();
};
// TODO: use create from server-impl instead?