mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: add CORS support to the proxy endpoints (#1936)
* feat: add CORS support to the proxy endpoints * refactor: remove unused development mode CORS support
This commit is contained in:
parent
831380333e
commit
0d293929f5
@ -132,6 +132,7 @@
|
||||
"@apidevtools/swagger-parser": "10.1.0",
|
||||
"@babel/core": "7.18.10",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "4.17.13",
|
||||
"@types/express-session": "1.17.5",
|
||||
"@types/faker": "5.5.9",
|
||||
|
@ -62,6 +62,7 @@ Object {
|
||||
},
|
||||
"eventHook": undefined,
|
||||
"experimental": Object {},
|
||||
"frontendApiOrigins": Array [],
|
||||
"getLogger": [Function],
|
||||
"import": Object {
|
||||
"dropBeforeImport": false,
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { publicFolder } from 'unleash-frontend';
|
||||
import express, { Application, RequestHandler } from 'express';
|
||||
import cors from 'cors';
|
||||
import compression from 'compression';
|
||||
import favicon from 'serve-favicon';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import errorHandler from 'errorhandler';
|
||||
import { responseTimeMetrics } from './middleware/response-time-metrics';
|
||||
import { corsOriginMiddleware } from './middleware/cors-origin-middleware';
|
||||
import rbacMiddleware from './middleware/rbac-middleware';
|
||||
import apiTokenMiddleware from './middleware/api-token-middleware';
|
||||
import { IUnleashServices } from './types/services';
|
||||
@ -49,10 +49,6 @@ export default async function getApp(
|
||||
config.preHook(app, config, services);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use(cors());
|
||||
}
|
||||
|
||||
app.use(compression());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ strict: false }));
|
||||
@ -73,6 +69,19 @@ export default async function getApp(
|
||||
services.openApiService.useDocs(app);
|
||||
}
|
||||
|
||||
if (
|
||||
config.experimental.embedProxy &&
|
||||
config.frontendApiOrigins.length > 0
|
||||
) {
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.authentication.type) {
|
||||
case IAuthType.OPEN_SOURCE: {
|
||||
app.use(baseUriPath, apiTokenMiddleware(config, services));
|
||||
|
@ -29,7 +29,11 @@ import {
|
||||
mapLegacyToken,
|
||||
validateApiToken,
|
||||
} from './types/models/api-token';
|
||||
import { parseEnvVarBoolean, parseEnvVarNumber } from './util/env';
|
||||
import {
|
||||
parseEnvVarBoolean,
|
||||
parseEnvVarNumber,
|
||||
parseEnvVarStrings,
|
||||
} from './util/parseEnvVar';
|
||||
import { IExperimentalOptions } from './experimental';
|
||||
import {
|
||||
DEFAULT_SEGMENT_VALUES_LIMIT,
|
||||
@ -402,6 +406,10 @@ 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 {
|
||||
@ -426,6 +434,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
||||
eventBus: new EventEmitter(),
|
||||
environmentEnableOverrides,
|
||||
additionalCspAllowedDomains,
|
||||
frontendApiOrigins,
|
||||
inlineSegmentConstraints,
|
||||
segmentValuesLimit,
|
||||
strategySegmentsLimit,
|
||||
|
18
src/lib/middleware/cors-origin-middleware.test.ts
Normal file
18
src/lib/middleware/cors-origin-middleware.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { allowRequestOrigin } from './cors-origin-middleware';
|
||||
|
||||
test('allowRequestOrigin', () => {
|
||||
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);
|
||||
});
|
25
src/lib/middleware/cors-origin-middleware.ts
Normal file
25
src/lib/middleware/cors-origin-middleware.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
const ANY_ORIGIN = '*';
|
||||
|
||||
export const allowRequestOrigin = (
|
||||
requestOrigin: string,
|
||||
allowedOrigins: string[],
|
||||
): boolean => {
|
||||
return allowedOrigins.some((allowedOrigin) => {
|
||||
return allowedOrigin === requestOrigin || allowedOrigin === ANY_ORIGIN;
|
||||
});
|
||||
};
|
||||
|
||||
// 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),
|
||||
});
|
||||
});
|
||||
};
|
@ -17,6 +17,7 @@ import { ProxyClientSchema } from '../../openapi/spec/proxy-client-schema';
|
||||
import { createResponseSchema } from '../../openapi/util/create-response-schema';
|
||||
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
||||
import { emptyResponse } from '../../openapi/util/standard-responses';
|
||||
import { corsOriginMiddleware } from '../../middleware/cors-origin-middleware';
|
||||
|
||||
interface ApiUserRequest<
|
||||
PARAM = any,
|
||||
@ -34,7 +35,6 @@ export default class ProxyController extends Controller {
|
||||
|
||||
private openApiService: OpenApiService;
|
||||
|
||||
// TODO(olav): Add CORS config to all proxy endpoints.
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{
|
||||
@ -47,6 +47,12 @@ export default class ProxyController extends Controller {
|
||||
this.proxyService = proxyService;
|
||||
this.openApiService = openApiService;
|
||||
|
||||
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.route({
|
||||
method: 'get',
|
||||
path: '',
|
||||
|
@ -106,6 +106,7 @@ export interface IUnleashOptions {
|
||||
email?: Partial<IEmailOption>;
|
||||
secureHeaders?: boolean;
|
||||
additionalCspAllowedDomains?: ICspDomainOptions;
|
||||
frontendApiOrigins?: string[];
|
||||
enableOAS?: boolean;
|
||||
preHook?: Function;
|
||||
preRouterHook?: Function;
|
||||
@ -179,6 +180,7 @@ export interface IUnleashConfig {
|
||||
email: IEmailOption;
|
||||
secureHeaders: boolean;
|
||||
additionalCspAllowedDomains: ICspDomainConfig;
|
||||
frontendApiOrigins: string[];
|
||||
enableOAS: boolean;
|
||||
preHook?: Function;
|
||||
preRouterHook?: Function;
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { parseEnvVarBoolean, parseEnvVarNumber } from './env';
|
||||
import {
|
||||
parseEnvVarBoolean,
|
||||
parseEnvVarNumber,
|
||||
parseEnvVarStrings,
|
||||
} from './parseEnvVar';
|
||||
|
||||
test('parseEnvVarNumber', () => {
|
||||
expect(parseEnvVarNumber('', 1)).toEqual(1);
|
||||
@ -23,3 +27,13 @@ test('parseEnvVarBoolean', () => {
|
||||
expect(parseEnvVarBoolean('false', false)).toEqual(false);
|
||||
expect(parseEnvVarBoolean('test', false)).toEqual(false);
|
||||
});
|
||||
|
||||
test('parseEnvVarStringList', () => {
|
||||
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']);
|
||||
expect(parseEnvVarStrings(' a,,,b, c , ,', [])).toEqual(['a', 'b', 'c']);
|
||||
});
|
@ -18,3 +18,17 @@ export function parseEnvVarBoolean(
|
||||
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
export function parseEnvVarStrings(
|
||||
envVar: string,
|
||||
defaultVal: string[],
|
||||
): string[] {
|
||||
if (envVar) {
|
||||
return envVar
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return defaultVal;
|
||||
}
|
@ -15,7 +15,9 @@ let db: ITestDb;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('proxy', getLogger);
|
||||
app = await setupAppWithAuth(db.stores);
|
||||
app = await setupAppWithAuth(db.stores, {
|
||||
frontendApiOrigins: ['https://example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@ -179,8 +181,32 @@ test('should return 405 from unimplemented endpoints', async () => {
|
||||
.expect(405);
|
||||
});
|
||||
|
||||
// TODO(olav): Test CORS config for all proxy endpoints.
|
||||
test.todo('should enforce token CORS settings');
|
||||
test('should enforce frontend API CORS config', async () => {
|
||||
const allowedOrigin = 'https://example.com';
|
||||
const unknownOrigin = 'https://example.org';
|
||||
const origin = 'access-control-allow-origin';
|
||||
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
|
||||
await app.request
|
||||
.options('/api/frontend')
|
||||
.set('Origin', unknownOrigin)
|
||||
.set('Authorization', frontendToken.secret)
|
||||
.expect((res) => expect(res.headers[origin]).toBeUndefined());
|
||||
await app.request
|
||||
.options('/api/frontend')
|
||||
.set('Origin', allowedOrigin)
|
||||
.set('Authorization', frontendToken.secret)
|
||||
.expect((res) => expect(res.headers[origin]).toEqual(allowedOrigin));
|
||||
await app.request
|
||||
.get('/api/frontend')
|
||||
.set('Origin', unknownOrigin)
|
||||
.set('Authorization', frontendToken.secret)
|
||||
.expect((res) => expect(res.headers[origin]).toBeUndefined());
|
||||
await app.request
|
||||
.get('/api/frontend')
|
||||
.set('Origin', allowedOrigin)
|
||||
.set('Authorization', frontendToken.secret)
|
||||
.expect((res) => expect(res.headers[origin]).toEqual(allowedOrigin));
|
||||
});
|
||||
|
||||
test('should accept client registration requests', async () => {
|
||||
const frontendToken = await createApiToken(ApiTokenType.FRONTEND);
|
||||
|
@ -1103,6 +1103,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz"
|
||||
integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==
|
||||
|
||||
"@types/cors@^2.8.12":
|
||||
version "2.8.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
|
||||
integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
|
||||
|
||||
"@types/express-serve-static-core@^4.17.18":
|
||||
version "4.17.24"
|
||||
resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz"
|
||||
|
Loading…
Reference in New Issue
Block a user