Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/event-handler/src/rest/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,20 @@ export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g;
export const SAFE_CHARS = "-._~()'!*:@,;=+&$";

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

/**
* Default CORS configuration
*/
export const DEFAULT_CORS_OPTIONS = {
origin: '*',
allowMethods: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'],
allowHeaders: [
'Authorization',
'Content-Type',
'X-Amz-Date',
'X-Api-Key',
'X-Amz-Security-Token',
],
exposeHeaders: [],
credentials: false,
} as const;
100 changes: 100 additions & 0 deletions packages/event-handler/src/rest/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type {
CorsOptions,
Middleware,
} from '../../types/rest.js';
import {
DEFAULT_CORS_OPTIONS,
HttpErrorCodes,
HttpVerbs,
} from '../constants.js';

/**
* Resolves the origin value based on the configuration
*/
const resolveOrigin = (
originConfig: NonNullable<CorsOptions['origin']>,
requestOrigin: string | null,
): string => {
if (Array.isArray(originConfig)) {
return requestOrigin && originConfig.includes(requestOrigin) ? requestOrigin : '';
}
return originConfig;
};

/**
* Creates a CORS middleware that adds appropriate CORS headers to responses
* and handles OPTIONS preflight requests.
*
* @example
* ```typescript
* import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest';
* import { cors } from '@aws-lambda-powertools/event-handler/experimental-rest/middleware';
*
* const app = new Router();
*
* // Use default configuration
* app.use(cors());
*
* // Custom configuration
* app.use(cors({
* origin: 'https://example.com',
* allowMethods: ['GET', 'POST'],
* credentials: true,
* }));
*
* // Dynamic origin with function
* app.use(cors({
* origin: (origin, reqCtx) => {
* const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
* return origin && allowedOrigins.includes(origin);
* }
* }));
* ```
*
* @param options.origin - The origin to allow requests from
* @param options.allowMethods - The HTTP methods to allow
* @param options.allowHeaders - The headers to allow
* @param options.exposeHeaders - The headers to expose
* @param options.credentials - Whether to allow credentials
* @param options.maxAge - The maximum age for the preflight response
*/
export const cors = (options?: CorsOptions): Middleware => {
const config = {
...DEFAULT_CORS_OPTIONS,
...options
};

return async (_params, reqCtx, next) => {
const requestOrigin = reqCtx.request.headers.get('Origin');
const resolvedOrigin = resolveOrigin(config.origin, requestOrigin);

reqCtx.res.headers.set('access-control-allow-origin', resolvedOrigin);
if (resolvedOrigin !== '*') {
reqCtx.res.headers.set('Vary', 'Origin');
}
config.allowMethods.forEach(method => {
reqCtx.res.headers.append('access-control-allow-methods', method);
});
config.allowHeaders.forEach(header => {
reqCtx.res.headers.append('access-control-allow-headers', header);
});
config.exposeHeaders.forEach(header => {
reqCtx.res.headers.append('access-control-expose-headers', header);
});
reqCtx.res.headers.set('access-control-allow-credentials', config.credentials.toString());
if (config.maxAge !== undefined) {
reqCtx.res.headers.set('access-control-max-age', config.maxAge.toString());
}

// Handle preflight OPTIONS request
if (reqCtx.request.method === HttpVerbs.OPTIONS && reqCtx.request.headers.has('Access-Control-Request-Method')) {
return new Response(null, {
status: HttpErrorCodes.NO_CONTENT,
headers: reqCtx.res.headers,
});
}

// Continue to next middleware/handler
await next();
};
};
1 change: 1 addition & 0 deletions packages/event-handler/src/rest/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { cors } from './cors.js';
1 change: 1 addition & 0 deletions packages/event-handler/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
} from './common.js';

export type {
CorsOptions,
ErrorHandler,
ErrorResolveOptions,
ErrorResponse,
Expand Down
43 changes: 43 additions & 0 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,51 @@ type ValidationResult = {
issues: string[];
};

/**
* Configuration options for CORS middleware
*/
type CorsOptions = {
/**
* The Access-Control-Allow-Origin header value.
* Can be a string, array of strings.
* @default '*'
*/
origin?: string | string[];

/**
* The Access-Control-Allow-Methods header value.
* @default ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']
*/
allowMethods?: string[];

/**
* The Access-Control-Allow-Headers header value.
* @default ['Authorization', 'Content-Type', 'X-Amz-Date', 'X-Api-Key', 'X-Amz-Security-Token']
*/
allowHeaders?: string[];

/**
* The Access-Control-Expose-Headers header value.
* @default []
*/
exposeHeaders?: string[];

/**
* The Access-Control-Allow-Credentials header value.
* @default false
*/
credentials?: boolean;

/**
* The Access-Control-Max-Age header value in seconds.
* Only applicable for preflight requests.
*/
maxAge?: number;
};

export type {
CompiledRoute,
CorsOptions,
DynamicRoute,
ErrorResponse,
ErrorConstructor,
Expand Down
5 changes: 3 additions & 2 deletions packages/event-handler/tests/unit/rest/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import type { Middleware } from '../../../src/types/rest.js';

export const createTestEvent = (
path: string,
httpMethod: string
httpMethod: string,
headers: Record<string, string> = {}
): APIGatewayProxyEvent => ({
path,
httpMethod,
headers: {},
headers,
body: null,
multiValueHeaders: {},
isBase64Encoded: false,
Expand Down
195 changes: 195 additions & 0 deletions packages/event-handler/tests/unit/rest/middleware/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it } from 'vitest';
import context from '@aws-lambda-powertools/testing-utils/context';
import { cors } from '../../../../src/rest/middleware/cors.js';
import { createTestEvent, createTrackingMiddleware } from '../helpers.js';
import { Router } from '../../../../src/rest/Router.js';
import { DEFAULT_CORS_OPTIONS } from 'src/rest/constants.js';

describe('CORS Middleware', () => {
const getRequestEvent = createTestEvent('/test', 'GET');
const optionsRequestEvent = createTestEvent('/test', 'OPTIONS');
let app: Router;

beforeEach(() => {
app = new Router();
app.use(cors());
});

it('uses default configuration when no options are provided', async () => {
// Prepare
const executionOrder: string[] = [];
app.get(
'/test',
[createTrackingMiddleware('middleware1', executionOrder)],
async () => {
executionOrder.push('handler');
return { success: true };
});

// Act
const result = await app.resolve(getRequestEvent, context);

// Assess
expect(result.headers?.['access-control-allow-origin']).toEqual(DEFAULT_CORS_OPTIONS.origin);
expect(result.multiValueHeaders?.['access-control-allow-methods']).toEqual(
DEFAULT_CORS_OPTIONS.allowMethods
);
expect(result.multiValueHeaders?.['access-control-allow-headers']).toEqual(
DEFAULT_CORS_OPTIONS.allowHeaders
);
expect(result.headers?.['access-control-allow-credentials']).toEqual(
DEFAULT_CORS_OPTIONS.credentials.toString()
);
expect(executionOrder).toEqual([
'middleware1-start',
'handler',
'middleware1-end',
]);
});

it('merges user options with defaults', async () => {
// Prepare
const executionOrder: string[] = [];
const application = new Router();
application.get(
'/test',
[
cors({
origin: 'https://example.com',
allowMethods: ['GET', 'POST'],
allowHeaders: ['Authorization', 'Content-Type'],
credentials: true,
exposeHeaders: ['Authorization', 'X-Custom-Header'],
maxAge: 86400,
}),
createTrackingMiddleware('middleware1', executionOrder)
],
async () => {
executionOrder.push('handler');
return { success: true };
});

// Act
const result = await application.resolve(getRequestEvent, context);

// Assess
expect(result.headers?.['access-control-allow-origin']).toEqual('https://example.com');
expect(result.multiValueHeaders?.['access-control-allow-methods']).toEqual(
['GET', 'POST']
);
expect(result.multiValueHeaders?.['access-control-allow-headers']).toEqual(
['Authorization', 'Content-Type']
);
expect(result.headers?.['access-control-allow-credentials']).toEqual(
'true'
);
expect(result.multiValueHeaders?.['access-control-expose-headers']).toEqual(
['Authorization', 'X-Custom-Header']
);
expect(result.headers?.['access-control-max-age']).toEqual(
'86400'
);
expect(executionOrder).toEqual([
'middleware1-start',
'handler',
'middleware1-end',
]);
});

it('handles array origin with matching request', async () => {
// Prepare
const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
const application = new Router();
application.get(
'/test',
[
cors({
origin: allowedOrigins,
allowMethods: ['GET', 'POST'],
allowHeaders: ['Authorization', 'Content-Type'],
credentials: true,
exposeHeaders: ['Authorization', 'X-Custom-Header'],
maxAge: 86400,
}),
],
async () => {
return { success: true };
});

// Act
const result = await application.resolve(createTestEvent('/test', 'GET', {
'Origin': 'https://app.com'
}), context);

// Assess
expect(result.headers?.['access-control-allow-origin']).toEqual('https://app.com');
});

it('handles array origin with non-matching request', async () => {
// Prepare
const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
const application = new Router();
application.get(
'/test',
[
cors({
origin: allowedOrigins,
allowMethods: ['GET', 'POST'],
allowHeaders: ['Authorization', 'Content-Type'],
credentials: true,
exposeHeaders: ['Authorization', 'X-Custom-Header'],
maxAge: 86400,
}),
],
async () => {
return { success: true };
});

// Act
const result = await application.resolve(createTestEvent('/test', 'GET', {
'Origin': 'https://non-matching.com'
}), context);

// Assess
expect(result.headers?.['access-control-allow-origin']).toEqual('');
});

it('handles OPTIONS preflight requests', async () => {
// Prepare
app.options(
'/test',
async () => {
return { foo: 'bar' };
});

// Act
const result = await app.resolve(createTestEvent('/test', 'OPTIONS', {
'Access-Control-Request-Method': 'GET'
}), context);

// Assess
expect(result.statusCode).toBe(204);
});

it('calls the next middleware if the Access-Control-Request-Method is not present', async () => {
// Prepare
const executionOrder: string[] = [];
app.options(
'/test',
[createTrackingMiddleware('middleware1', executionOrder)],
async () => {
executionOrder.push('handler');
return { success: true };
});

// Act
const result = await app.resolve(optionsRequestEvent, context);

// Assess
expect(executionOrder).toEqual([
'middleware1-start',
'handler',
'middleware1-end',
]);
});
});