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

chore(cloudfront): add validations on corsBehavior of ResponseHeadersPolicy #32206

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Construct } from 'constructs';
import { CfnResponseHeadersPolicy } from './cloudfront.generated';
import { Duration, Names, Resource, Token } from '../../core';
import { Duration, Names, Resource, Token, withResolved } from '../../core';

/**
* Represents a response headers policy.
Expand Down Expand Up @@ -130,6 +130,52 @@ export class ResponseHeadersPolicy extends Resource implements IResponseHeadersP
}

private _renderCorsConfig(behavior: ResponseHeadersCorsBehavior): CfnResponseHeadersPolicy.CorsConfigProperty {
withResolved(behavior.accessControlAllowHeaders, (headers) => {
if (headers.length === 0) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy: The parameter Allow Headers needs to have at least one item.
throw new Error('accessControlAllowHeaders needs to have at least one item');
}
for (const header of headers) {
if (Token.isUnresolved(header)) continue;
if (containsMultipleStars(header)) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy
throw new Error("accessControlAllowHeaders contains multiple '*' chars; only 1 is allowed");
} else if (!isValidHeaderName(header)) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy
throw new Error('accessControlAllowHeaders contains illegal character');
}
}
});
withResolved(behavior.accessControlAllowMethods, (methods) => {
const allowedMethods = ['GET', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'ALL'];
if (methods.length === 0) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy
throw new Error('accessControlAllowMethods needs to have at least one item');
} else if (methods.includes('ALL') && methods.length !== 1) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy
throw new Error("accessControlAllowMethods cannot be mixed 'ALL' with other values");
} else if (!methods.every((method) => Token.isUnresolved(method) || allowedMethods.includes(method))) {
// Internal error reported from downstream service during operation 'AWS::CloudFront::ResponseHeadersPolicy'
throw new Error(`accessControlAllowMethods contains unexpected method name; allowed values: ${allowedMethods.join(', ')}`);
}
});
withResolved(behavior.accessControlAllowOrigins, (origins) => {
if (origins.length === 0) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy: The parameter Allow Origin needs to have at least one item.
throw new Error('accessControlAllowOrigins needs to have at least one item');
}
});
withResolved(behavior.accessControlExposeHeaders, (headers) => {
if (!headers) return;
for (const header of headers) {
if (Token.isUnresolved(header)) continue;
if (!isValidHeaderName(header)) {
// Invalid request provided: AWS::CloudFront::ResponseHeadersPolicy
throw new Error('accessControlExposeHeaders contains illegal character');
}
}
});

return {
accessControlAllowCredentials: behavior.accessControlAllowCredentials,
accessControlAllowHeaders: { items: behavior.accessControlAllowHeaders },
Expand Down Expand Up @@ -211,6 +257,9 @@ export interface ResponseHeadersCorsBehavior {

/**
* A list of HTTP methods that CloudFront includes as values for the Access-Control-Allow-Methods HTTP response header.
*
* Allowed methods: `'GET'`, `'DELETE'`, `'HEAD'`, `'OPTIONS'`, `'PATCH'`, `'POST'`, and `'PUT'`.
* You can specify `['ALL']` to allow all methods.
*/
readonly accessControlAllowMethods: string[];

Expand Down Expand Up @@ -509,3 +558,13 @@ function hasMaxDecimalPlaces(num: number, decimals: number): boolean {
const parts = num.toString().split('.');
return parts.length === 1 || parts[1].length <= decimals;
}

function containsMultipleStars(value: string) {
return Array.from(value.matchAll(/\*/g)).length > 1;
}

function isValidHeaderName(value: string) {
// the valid characters are defined in RFC 9110 section 5.6.2.
// https://httpwg.org/specs/rfc9110.html#rule.token.separators
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,128 @@ describe('ResponseHeadersPolicy', () => {
},
});
});

describe('corsBehavior', () => {
test('throws if accessControlAllowHeaders is empty', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: [],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow('accessControlAllowHeaders needs to have at least one item');
});

test("throws if accessControlAllowHeaders contains multiple '*' chars", () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*-*'],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow("accessControlAllowHeaders contains multiple '*' chars; only 1 is allowed");
});

test('throws if accessControlAllowHeaders contains space', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['foo bar'],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow('accessControlAllowHeaders contains illegal character');
});

test.each('"(),/:;<=>?@[\\]{}'.split(''))('throws if accessControlAllowHeaders contains illegal char: %s', (char) => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: [char],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow('accessControlAllowHeaders contains illegal character');
});

test('throws if accessControlAllowMethods is empty', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: [],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow('accessControlAllowMethods needs to have at least one item');
});

test('throws if accessControlAllowMethods is mixed with `ALL` and other values', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['ALL', 'GET'],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow("accessControlAllowMethods cannot be mixed 'ALL' with other values");
});

test('throws if accessControlAllowMethods contains unallowed value', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['PROPFIND'],
accessControlAllowOrigins: ['*'],
originOverride: true,
},
})).toThrow('accessControlAllowMethods contains unexpected method name; allowed values: GET, DELETE, HEAD, OPTIONS, PATCH, POST, PUT, ALL');
});

test('throws if accessControlAllowOrigins is empty', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: [],
originOverride: true,
},
})).toThrow('accessControlAllowOrigins needs to have at least one item');
});

test('throws if accessControlExposeHeaders contains space', () => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: ['*'],
accessControlExposeHeaders: ['foo bar'],
originOverride: true,
},
})).toThrow('accessControlExposeHeaders contains illegal character');
});

test.each('"(),/:;<=>?@[\\]{}'.split(''))('throws if accessControlExposeHeaders contains illegal char: %s', (char) => {
expect(() => new ResponseHeadersPolicy(stack, 'ResponseHeadersPolicy', {
corsBehavior: {
accessControlAllowCredentials: false,
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['ALL'],
accessControlAllowOrigins: ['*'],
accessControlExposeHeaders: [char],
originOverride: true,
},
})).toThrow('accessControlExposeHeaders contains illegal character');
});
});
});
Loading