mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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