Skip to content

Commit 972cd1f

Browse files
dcabibdani-abibsdangol
authored
feat(event-handler): add CORS middleware support (#4477)
Signed-off-by: Daniel ABIB <[email protected]> Co-authored-by: Daniel ABIB <[email protected]> Co-authored-by: Swopnil Dangol <[email protected]>
1 parent 320e0dc commit 972cd1f

File tree

7 files changed

+282
-0
lines changed

7 files changed

+282
-0
lines changed

packages/event-handler/src/rest/constants.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ export const SAFE_CHARS = "-._~()'!*:@,;=+&$";
8888

8989
export const UNSAFE_CHARS = '%<> \\[\\]{}|^';
9090

91+
/**
92+
* Default CORS configuration
93+
*/
94+
export const DEFAULT_CORS_OPTIONS = {
95+
origin: '*',
96+
allowMethods: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'],
97+
allowHeaders: [
98+
'Authorization',
99+
'Content-Type',
100+
'X-Amz-Date',
101+
'X-Api-Key',
102+
'X-Amz-Security-Token',
103+
],
104+
exposeHeaders: [],
105+
credentials: false
106+
};
107+
91108
export const DEFAULT_COMPRESSION_RESPONSE_THRESHOLD = 1024;
92109

93110
export const CACHE_CONTROL_NO_TRANSFORM_REGEX =
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type {
2+
CorsOptions,
3+
Middleware,
4+
} from '../../types/rest.js';
5+
import {
6+
DEFAULT_CORS_OPTIONS,
7+
HttpErrorCodes,
8+
HttpVerbs,
9+
} from '../constants.js';
10+
11+
/**
12+
* Resolves the origin value based on the configuration
13+
*/
14+
const resolveOrigin = (
15+
originConfig: NonNullable<CorsOptions['origin']>,
16+
requestOrigin: string | null,
17+
): string => {
18+
if (Array.isArray(originConfig)) {
19+
return requestOrigin && originConfig.includes(requestOrigin) ? requestOrigin : '';
20+
}
21+
return originConfig;
22+
};
23+
24+
/**
25+
* Creates a CORS middleware that adds appropriate CORS headers to responses
26+
* and handles OPTIONS preflight requests.
27+
*
28+
* @example
29+
* ```typescript
30+
* import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest';
31+
* import { cors } from '@aws-lambda-powertools/event-handler/experimental-rest/middleware';
32+
*
33+
* const app = new Router();
34+
*
35+
* // Use default configuration
36+
* app.use(cors());
37+
*
38+
* // Custom configuration
39+
* app.use(cors({
40+
* origin: 'https://example.com',
41+
* allowMethods: ['GET', 'POST'],
42+
* credentials: true,
43+
* }));
44+
*
45+
* // Dynamic origin with function
46+
* app.use(cors({
47+
* origin: (origin, reqCtx) => {
48+
* const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
49+
* return origin && allowedOrigins.includes(origin);
50+
* }
51+
* }));
52+
* ```
53+
*
54+
* @param options.origin - The origin to allow requests from
55+
* @param options.allowMethods - The HTTP methods to allow
56+
* @param options.allowHeaders - The headers to allow
57+
* @param options.exposeHeaders - The headers to expose
58+
* @param options.credentials - Whether to allow credentials
59+
* @param options.maxAge - The maximum age for the preflight response
60+
*/
61+
export const cors = (options?: CorsOptions): Middleware => {
62+
const config = {
63+
...DEFAULT_CORS_OPTIONS,
64+
...options
65+
};
66+
67+
return async (_params, reqCtx, next) => {
68+
const requestOrigin = reqCtx.request.headers.get('Origin');
69+
const resolvedOrigin = resolveOrigin(config.origin, requestOrigin);
70+
71+
reqCtx.res.headers.set('access-control-allow-origin', resolvedOrigin);
72+
if (resolvedOrigin !== '*') {
73+
reqCtx.res.headers.set('Vary', 'Origin');
74+
}
75+
config.allowMethods.forEach(method => {
76+
reqCtx.res.headers.append('access-control-allow-methods', method);
77+
});
78+
config.allowHeaders.forEach(header => {
79+
reqCtx.res.headers.append('access-control-allow-headers', header);
80+
});
81+
config.exposeHeaders.forEach(header => {
82+
reqCtx.res.headers.append('access-control-expose-headers', header);
83+
});
84+
reqCtx.res.headers.set('access-control-allow-credentials', config.credentials.toString());
85+
if (config.maxAge !== undefined) {
86+
reqCtx.res.headers.set('access-control-max-age', config.maxAge.toString());
87+
}
88+
89+
// Handle preflight OPTIONS request
90+
if (reqCtx.request.method === HttpVerbs.OPTIONS && reqCtx.request.headers.has('Access-Control-Request-Method')) {
91+
return new Response(null, {
92+
status: HttpErrorCodes.NO_CONTENT,
93+
headers: reqCtx.res.headers,
94+
});
95+
}
96+
await next();
97+
};
98+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { compress } from './compress.js';
2+
export { cors } from './cors.js';

packages/event-handler/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type {
3232
} from './common.js';
3333

3434
export type {
35+
CorsOptions,
3536
ErrorHandler,
3637
ErrorResolveOptions,
3738
ErrorResponse,

packages/event-handler/src/types/rest.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,56 @@ type ValidationResult = {
111111
issues: string[];
112112
};
113113

114+
/**
115+
* Configuration options for CORS middleware
116+
*/
117+
type CorsOptions = {
118+
/**
119+
* The Access-Control-Allow-Origin header value.
120+
* Can be a string, array of strings.
121+
* @default '*'
122+
*/
123+
origin?: string | string[];
124+
125+
/**
126+
* The Access-Control-Allow-Methods header value.
127+
* @default ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']
128+
*/
129+
allowMethods?: string[];
130+
131+
/**
132+
* The Access-Control-Allow-Headers header value.
133+
* @default ['Authorization', 'Content-Type', 'X-Amz-Date', 'X-Api-Key', 'X-Amz-Security-Token']
134+
*/
135+
allowHeaders?: string[];
136+
137+
/**
138+
* The Access-Control-Expose-Headers header value.
139+
* @default []
140+
*/
141+
exposeHeaders?: string[];
142+
143+
/**
144+
* The Access-Control-Allow-Credentials header value.
145+
* @default false
146+
*/
147+
credentials?: boolean;
148+
149+
/**
150+
* The Access-Control-Max-Age header value in seconds.
151+
* Only applicable for preflight requests.
152+
*/
153+
maxAge?: number;
154+
};
155+
114156
type CompressionOptions = {
115157
encoding?: 'gzip' | 'deflate';
116158
threshold?: number;
117159
};
118160

119161
export type {
120162
CompiledRoute,
163+
CorsOptions,
121164
DynamicRoute,
122165
ErrorResponse,
123166
ErrorConstructor,

packages/event-handler/tests/unit/rest/helpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,14 @@ export const createSettingHeadersMiddleware = (headers: {
7777
});
7878
};
7979
};
80+
81+
export const createHeaderCheckMiddleware = (headers: {
82+
[key: string]: string;
83+
}): Middleware => {
84+
return async (_params, options, next) => {
85+
options.res.headers.forEach((value, key) => {
86+
headers[key] = value;
87+
});
88+
await next();
89+
};
90+
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import context from '@aws-lambda-powertools/testing-utils/context';
3+
import { cors } from '../../../../src/rest/middleware/cors.js';
4+
import { createTestEvent, createHeaderCheckMiddleware } from '../helpers.js';
5+
import { Router } from '../../../../src/rest/Router.js';
6+
import { DEFAULT_CORS_OPTIONS } from 'src/rest/constants.js';
7+
8+
describe('CORS Middleware', () => {
9+
const getRequestEvent = createTestEvent('/test', 'GET');
10+
const optionsRequestEvent = createTestEvent('/test', 'OPTIONS');
11+
let app: Router;
12+
13+
const customCorsOptions = {
14+
origin: 'https://example.com',
15+
allowMethods: ['GET', 'POST'],
16+
allowHeaders: ['Authorization', 'Content-Type'],
17+
credentials: true,
18+
exposeHeaders: ['Authorization', 'X-Custom-Header'],
19+
maxAge: 86400,
20+
};
21+
22+
const expectedDefaultHeaders = {
23+
"access-control-allow-credentials": "false",
24+
"access-control-allow-headers": "Authorization, Content-Type, X-Amz-Date, X-Api-Key, X-Amz-Security-Token",
25+
"access-control-allow-methods": "DELETE, GET, HEAD, PATCH, POST, PUT",
26+
"access-control-allow-origin": "*",
27+
};
28+
29+
beforeEach(() => {
30+
app = new Router();
31+
app.use(cors());
32+
});
33+
34+
it('uses default configuration when no options are provided', async () => {
35+
// Prepare
36+
const corsHeaders: { [key: string]: string } = {};
37+
app.get('/test', [createHeaderCheckMiddleware(corsHeaders)], async () => ({ success: true }));
38+
39+
// Act
40+
const result = await app.resolve(getRequestEvent, context);
41+
42+
// Assess
43+
expect(result.headers?.['access-control-allow-origin']).toEqual(DEFAULT_CORS_OPTIONS.origin);
44+
expect(result.multiValueHeaders?.['access-control-allow-methods']).toEqual(DEFAULT_CORS_OPTIONS.allowMethods);
45+
expect(result.multiValueHeaders?.['access-control-allow-headers']).toEqual(DEFAULT_CORS_OPTIONS.allowHeaders);
46+
expect(result.headers?.['access-control-allow-credentials']).toEqual(DEFAULT_CORS_OPTIONS.credentials.toString());
47+
expect(corsHeaders).toMatchObject(expectedDefaultHeaders);
48+
});
49+
50+
it('merges user options with defaults', async () => {
51+
// Prepare
52+
const corsHeaders: { [key: string]: string } = {};
53+
const app = new Router();
54+
app.get('/test', [cors(customCorsOptions), createHeaderCheckMiddleware(corsHeaders)], async () => ({ success: true }));
55+
56+
// Act
57+
const result = await app.resolve(getRequestEvent, context);
58+
59+
// Assess
60+
expect(result.headers?.['access-control-allow-origin']).toEqual('https://example.com');
61+
expect(result.multiValueHeaders?.['access-control-allow-methods']).toEqual(['GET', 'POST']);
62+
expect(result.multiValueHeaders?.['access-control-allow-headers']).toEqual(['Authorization', 'Content-Type']);
63+
expect(result.headers?.['access-control-allow-credentials']).toEqual('true');
64+
expect(result.multiValueHeaders?.['access-control-expose-headers']).toEqual(['Authorization', 'X-Custom-Header']);
65+
expect(result.headers?.['access-control-max-age']).toEqual('86400');
66+
expect(corsHeaders).toMatchObject({
67+
"access-control-allow-credentials": "true",
68+
"access-control-allow-headers": "Authorization, Content-Type",
69+
"access-control-allow-methods": "GET, POST",
70+
"access-control-allow-origin": "https://example.com",
71+
});
72+
});
73+
74+
it.each([
75+
['matching', 'https://app.com', 'https://app.com'],
76+
['non-matching', 'https://non-matching.com', '']
77+
])('handles array origin with %s request', async (_, origin, expected) => {
78+
// Prepare
79+
const app = new Router();
80+
app.get('/test', [cors({ origin: ['https://app.com', 'https://admin.app.com'] })], async () => ({ success: true }));
81+
82+
// Act
83+
const result = await app.resolve(createTestEvent('/test', 'GET', { 'Origin': origin }), context);
84+
85+
// Assess
86+
expect(result.headers?.['access-control-allow-origin']).toEqual(expected);
87+
});
88+
89+
it('handles OPTIONS preflight requests', async () => {
90+
// Prepare
91+
app.options('/test', async () => ({ foo: 'bar' }));
92+
93+
// Act
94+
const result = await app.resolve(createTestEvent('/test', 'OPTIONS', { 'Access-Control-Request-Method': 'GET' }), context);
95+
96+
// Assess
97+
expect(result.statusCode).toBe(204);
98+
});
99+
100+
it('calls the next middleware if the Access-Control-Request-Method is not present', async () => {
101+
// Prepare
102+
const corsHeaders: { [key: string]: string } = {};
103+
app.options('/test', [createHeaderCheckMiddleware(corsHeaders)], async () => ({ success: true }));
104+
105+
// Act
106+
await app.resolve(optionsRequestEvent, context);
107+
108+
// Assess
109+
expect(corsHeaders).toMatchObject(expectedDefaultHeaders);
110+
});
111+
});

0 commit comments

Comments
 (0)