Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(apigateway): cors preflight support #4211

Merged
merged 23 commits into from
Oct 17, 2019
Merged
Show file tree
Hide file tree
Changes from 18 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
55 changes: 55 additions & 0 deletions packages/@aws-cdk/aws-apigateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,61 @@ new route53.ARecord(this, 'CustomDomainAliasRecord', {
});
```

### Cross Origin Resource Sharing (CORS)

[Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) is a mechanism
eladb marked this conversation as resolved.
Show resolved Hide resolved
that uses additional HTTP headers to tell browsers to give a web application
running at one origin, access to selected resources from a different origin. A
web application executes a cross-origin HTTP request when it requests a resource
that has a different origin (domain, protocol, or port) from its own.

You can add the CORS [preflight](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests) OPTIONS HTTP method to any API resource via the `addCorsPreflight` method.
eladb marked this conversation as resolved.
Show resolved Hide resolved

The following example will add an OPTIONS method to the `myResource` API resource, which
only allows GET and PUT HTTP requests from the origin https://amazon.com.

```ts
myResource.addCorsPreflight({
allowOrigins: [ 'https://amazon.com' ],
allowMethods: [ 'GET', 'PUT' ]
});
```

See the
[`CorsOptions`](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-apigateway.CorsOptions.html)
API reference for a detailed list of supported configuration options.

You can specify default options for all resources within an API or a sub-tree using
`defaultCorsPreflightOptions`:


```ts
new apigateway.RestApi(this, 'api', {
defaultCorsPreflightOptions: {
allowOrigins: [ 'https://amazon.com' ]
}
});
```

This means that the construct will add a CORS preflight OPTIONS method to
**all** HTTP resources in this API gateway.

Similarly, you can specify this at the resource level:

```ts
const subtree = resource.addResource('subtree', {
defaultCorsPreflightOptions: {
allowOrigins: [ 'https://amazon.com' ]
}
});
```

This means that all resources under `subtree` (inclusive) will have a preflight
OPTIONS added to them.

See [#906](https://github.com/aws/aws-cdk/issues/906) for a list of CORS
features which are not yet supported.

----

This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project.
105 changes: 105 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Duration } from '@aws-cdk/core';
import { ALL_METHODS } from './util';

export interface CorsOptions {
/**
* Specifies the response status code returned from the OPTIONS method.
*
* @default 204
*/
readonly statusCode?: number;

/**
* The Access-Control-Allow-Origin response header indicates whether the
* response can be shared with requesting code from the given origin.
*
* Specifies the list of origins that are allowed to make requests to this resource.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
eladb marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly allowOrigins: string[];

/**
* The Access-Control-Allow-Headers response header is used in response to a
* preflight request which includes the Access-Control-Request-Headers to
* indicate which HTTP headers can be used during the actual request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
* @default Cors.DEFAULT_HEADERS
*/
readonly allowHeaders?: string[];

/**
* The Access-Control-Allow-Methods response header specifies the method or
* methods allowed when accessing the resource in response to a preflight request.
*
* If `ANY` is specified, it will be expanded to `Cors.ALL_METHODS`.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
* @default Cors.ALL_METHODS
*/
readonly allowMethods?: string[];

/**
* The Access-Control-Allow-Credentials response header tells browsers whether
* to expose the response to frontend JavaScript code when the request's
* credentials mode (Request.credentials) is "include".
*
* When a request's credentials mode (Request.credentials) is "include",
* browsers will only expose the response to frontend JavaScript code if the
* Access-Control-Allow-Credentials value is true.
*
* Credentials are cookies, authorization headers or TLS client certificates.
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
* @default false
*/
readonly allowCredentials?: boolean;

/**
* The Access-Control-Max-Age response header indicates how long the results of
* a preflight request (that is the information contained in the
* Access-Control-Allow-Methods and Access-Control-Allow-Headers headers)
* can be cached.
*
* To disable caching altogther use `disableCache: true`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't specifying 0 (or less) here be an effective way to signal "I don't want no caching"? I'm not too sure the extra flag is actually so useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec doesn't say anything about 0 and we can't express -1 as a Duration.

*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
* @default - browser-specific (see reference)
*/
readonly maxAge?: Duration;

/**
* Sets Access-Control-Max-Age to -1, which means that caching is disabled.
* This option cannot be used with `maxAge`.
*
* @default - cache is enabled
*/
readonly disableCache?: boolean;

/**
* The Access-Control-Expose-Headers response header indicates which headers
* can be exposed as part of the response by listing their names.
*
* If you want clients to be able to access other headers, you have to list
* them using the Access-Control-Expose-Headers header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
*
* @default - only the 6 CORS-safelisted response headers are exposed:
* Cache-Control, Content-Language, Content-Type, Expires, Last-Modified,
* Pragma
*/
readonly exposeHeaders?: string[];
}

export class Cors {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please give this class a private constructor?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

/**
* All HTTP methods.
*/
public static readonly ALL_METHODS = ALL_METHODS;

/**
* The set of default headers allowed for CORS and useful for API Gateway.
*/
public static readonly DEFAULT_HEADERS = [ 'Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key', 'X-Amz-Security-Token', 'X-Amz-User-Agent' ];
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-apigateway/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './authorizer';
export * from './json-schema';
export * from './domain-name';
export * from './base-path-mapping';
export * from './cors';

// AWS::ApiGateway CloudFormation Resources:
export * from './apigateway.generated';
Expand Down
163 changes: 163 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/resource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Construct, IResource as IResourceBase, Resource as ResourceConstruct } from '@aws-cdk/core';
import { CfnResource, CfnResourceProps } from './apigateway.generated';
import { Cors, CorsOptions } from './cors';
import { Integration } from './integration';
import { MockIntegration } from './integrations';
import { Method, MethodOptions } from './method';
import { RestApi } from './restapi';

Expand Down Expand Up @@ -43,6 +45,11 @@ export interface IResource extends IResourceBase {
*/
readonly defaultMethodOptions?: MethodOptions;

/**
* Default options for CORS preflight OPTIONS method.
*/
readonly defaultCorsPreflightOptions?: CorsOptions;

/**
* Gets or create all resources leading up to the specified path.
*
Expand Down Expand Up @@ -85,6 +92,21 @@ export interface IResource extends IResourceBase {
* @returns The newly created `Method` object.
*/
addMethod(httpMethod: string, target?: Integration, options?: MethodOptions): Method;

/**
* Adds an OPTIONS method to this resource which responds to Cross-Origin
* Resource Sharing (CORS) preflight requests.
*
* Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional
* HTTP headers to tell browsers to give a web application running at one
* origin, access to selected resources from a different origin. A web
* application executes a cross-origin HTTP request when it requests a
* resource that has a different origin (domain, protocol, or port) from its
* own.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
*/
addCorsPreflight(options: CorsOptions): Method;
eladb marked this conversation as resolved.
Show resolved Hide resolved
}

export interface ResourceOptions {
Expand All @@ -103,6 +125,16 @@ export interface ResourceOptions {
* @default - Inherited from parent.
*/
readonly defaultMethodOptions?: MethodOptions;

/**
* Adds a CORS preflight OPTIONS method to this resource and all child
* resources.
*
* You can add CORS at the resource-level using `addCorsPreflight`.
*
* @default - CORS is disabled
*/
readonly defaultCorsPreflightOptions?: CorsOptions;
}

export interface ResourceProps extends ResourceOptions {
Expand All @@ -125,6 +157,7 @@ export abstract class ResourceBase extends ResourceConstruct implements IResourc
public abstract readonly path: string;
public abstract readonly defaultIntegration?: Integration;
public abstract readonly defaultMethodOptions?: MethodOptions;
public abstract readonly defaultCorsPreflightOptions?: CorsOptions;

private readonly children: { [pathPart: string]: Resource } = { };

Expand All @@ -144,6 +177,130 @@ export abstract class ResourceBase extends ResourceConstruct implements IResourc
return new ProxyResource(this, '{proxy+}', { parent: this, ...options });
}

public addCorsPreflight(options: CorsOptions) {
const headers: { [name: string]: string } = { };

//
// Access-Control-Allow-Headers

const allowHeaders = options.allowHeaders || Cors.DEFAULT_HEADERS;
headers['Access-Control-Allow-Headers'] = `'${allowHeaders.join(',')}'`;

//
// Access-Control-Allow-Origin

if (options.allowOrigins.length === 0) {
throw new Error('allowOrigins must contain at least one origin');
}

// we use the first origin here and if there are more origins in the list, we
// will match against them in the response velocity template
headers['Access-Control-Allow-Origin'] = `'${options.allowOrigins[0]}'`;

//
// Access-Control-Allow-Methods

let allowMethods = options.allowMethods || Cors.ALL_METHODS;

if (allowMethods.includes('ANY')) {
if (allowMethods.length > 1) {
throw new Error(`ANY cannot be used with any other method. Received: ${allowMethods.join(',')}`);
}

allowMethods = Cors.ALL_METHODS;
}

headers['Access-Control-Allow-Methods'] = `'${allowMethods.join(',')}'`;

//
// Access-Control-Allow-Credentials

if (options.allowCredentials) {
headers['Access-Control-Allow-Credentials'] = `'true'`;
}

//
// Access-Control-Max-Age

let maxAgeSeconds;

if (options.maxAge && options.disableCache) {
throw new Error(`The options "maxAge" and "disableCache" are mutually exclusive`);
}

if (options.maxAge) {
maxAgeSeconds = options.maxAge.toSeconds();
}

if (options.disableCache) {
maxAgeSeconds = -1;
}

if (maxAgeSeconds) {
headers['Access-Control-Max-Age'] = `'${maxAgeSeconds}'`;
}

//
// Access-Control-Expose-Headers
//

if (options.exposeHeaders) {
headers['Access-Control-Expose-Headers'] = `'${options.exposeHeaders.join(',')}'`;
}

//
// statusCode

const statusCode = options.statusCode !== undefined ? options.statusCode : 204;

//
// prepare responseParams

const integrationResponseParams: { [p: string]: string } = { };
const methodReponseParams: { [p: string]: boolean } = { };

for (const [ name, value ] of Object.entries(headers)) {
const key = `method.response.header.${name}`;
integrationResponseParams[key] = value;
methodReponseParams[key] = true;
}

return this.addMethod('OPTIONS', new MockIntegration({
requestTemplates: { 'application/json': '{ statusCode: 200 }' },
integrationResponses: [
{ statusCode: `${statusCode}`, responseParameters: integrationResponseParams, responseTemplates: renderResponseTemplate() }
],
}), {
methodResponses: [
{ statusCode: `${statusCode}`, responseParameters: methodReponseParams }
]
});

// renders the response template to match all possible origins (if we have more than one)
function renderResponseTemplate() {
eladb marked this conversation as resolved.
Show resolved Hide resolved
const origins = options.allowOrigins.slice(1);

if (origins.length === 0) {
return undefined;
}

const template = new Array<string>();

template.push(`#set($origin = $input.params("Origin"))`);
template.push(`#if($origin == "") #set($origin = $input.params("origin")) #end`);

const condition = origins.map(o => `$origin.matches("${o}")`).join(' || ');

template.push(`#if(${condition})`);
template.push(` #set($context.responseOverride.header.Access-Control-Allow-Origin = $origin)`);
template.push('#end');

return {
'application/json': template.join('\n')
};
}
}

public getResource(pathPart: string): IResource | undefined {
return this.children[pathPart];
}
Expand Down Expand Up @@ -192,6 +349,7 @@ export class Resource extends ResourceBase {

public readonly defaultIntegration?: Integration;
public readonly defaultMethodOptions?: MethodOptions;
public readonly defaultCorsPreflightOptions?: CorsOptions;

constructor(scope: Construct, id: string, props: ResourceProps) {
super(scope, id);
Expand Down Expand Up @@ -232,6 +390,11 @@ export class Resource extends ResourceBase {
...props.parent.defaultMethodOptions,
...props.defaultMethodOptions
};
this.defaultCorsPreflightOptions = props.defaultCorsPreflightOptions || props.parent.defaultCorsPreflightOptions;

if (this.defaultCorsPreflightOptions) {
this.addCorsPreflight(this.defaultCorsPreflightOptions);
}
}
}

Expand Down
Loading