1
0
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:
olav 2022-08-19 08:09:44 +02:00 committed by GitHub
parent 831380333e
commit 0d293929f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 141 additions and 11 deletions

View File

@ -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",

View File

@ -62,6 +62,7 @@ Object {
},
"eventHook": undefined,
"experimental": Object {},
"frontendApiOrigins": Array [],
"getLogger": [Function],
"import": Object {
"dropBeforeImport": false,

View File

@ -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));

View File

@ -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,

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

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

View File

@ -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: '',

View File

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

View File

@ -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']);
});

View File

@ -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;
}

View File

@ -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);

View File

@ -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"