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

CSP behaviour options #1434

Merged
merged 11 commits into from
Feb 3, 2025
129 changes: 129 additions & 0 deletions packages/static-hosting/lib/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { AssetHashType, DockerImage, Duration } from "aws-cdk-lib";
import { experimental } from "aws-cdk-lib/aws-cloudfront";
import {
Code,
FunctionOptions,
IVersion,
Runtime,
Version,
} from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
import { join } from "path";
import { Esbuild } from "@aligent/cdk-esbuild";

export interface EdgeLambdaFunctionOptions {
handlerName: string;
define?: { [key: string]: string };
functionOptions?: Partial<FunctionOptions>;
}

class EdgeLambdaFunction extends Construct {
readonly edgeFunction: experimental.EdgeFunction;

constructor(
scope: Construct,
id: string,
options: EdgeLambdaFunctionOptions
) {
super(scope, id);

this.edgeFunction = this.createEdgeFunction(id, options);
}

private createEdgeFunction(
id: string,
options: EdgeLambdaFunctionOptions
): experimental.EdgeFunction {
const command = [
"sh",
"-c",
'echo "Docker build not supported. Please install esbuild."',
];

return new experimental.EdgeFunction(
this,
`${id}-${options.handlerName}-fn`,
{
code: Code.fromAsset(join(__dirname, "handlers"), {
assetHashType: AssetHashType.OUTPUT,
bundling: {
command,
image: DockerImage.fromRegistry("busybox"),
local: new Esbuild({
entryPoints: [
join(
__dirname,
`handlers/csp-lambda/${options.handlerName}.ts`
),
],
define: options.define,
minify: false,
}),
},
}),
runtime: Runtime.NODEJS_20_X,
handler: `${options.handlerName}.handler`,
...options.functionOptions,
}
);
}

public getFunctionVersion(): IVersion {
return Version.fromVersionArn(
this,
"checkout-fn-version",
this.edgeFunction.currentVersion.edgeArn
);
}
}

export interface RequestFunctionOptions {
rootObject?: string;
pathPrefix?: string;
functionOptions?: Partial<FunctionOptions>;
}

export class RequestFunction extends EdgeLambdaFunction {
constructor(scope: Construct, id: string, options: RequestFunctionOptions) {
super(scope, id, {
...options,
handlerName: "origin-request",
define: {
"process.env.PATH_PREFIX": JSON.stringify(options.pathPrefix ?? ""),
"process.env.ROOT_OBJECT": JSON.stringify(
options.rootObject ?? "index.html"
),
},
});
}
}

export interface ResponseFunctionOptions {
bucket: string;
cspObject?: string;
reportUri?: string;
fallbackCsp?: string;
functionOptions?: Partial<FunctionOptions>;
}

export class ResponseFunction extends EdgeLambdaFunction {
constructor(scope: Construct, id: string, options: ResponseFunctionOptions) {
super(scope, id, {
...options,
handlerName: "origin-response",
define: {
"process.env.S3_BUCKET": JSON.stringify(options.bucket),
"process.env.CSP_OBJECT": JSON.stringify(
options.cspObject ?? "csp.txt"
),
"process.env.REPORT_URI": JSON.stringify(options.reportUri ?? ""),
"process.env.FALLBACK_CSP": JSON.stringify(options.fallbackCsp ?? ""),
},
functionOptions: {
timeout: Duration.seconds(3),
memorySize: 512,
...options.functionOptions,
},
});
}
}
20 changes: 20 additions & 0 deletions packages/static-hosting/lib/handlers/csp-lambda/origin-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
CloudFrontRequest,
CloudFrontRequestEvent,
CloudFrontResponse,
} from "aws-lambda";
import "source-map-support/register";

const PATH_PREFIX = process.env.PATH_PREFIX;
const ROOT_OBJECT = process.env.ROOT_OBJECT;

export const handler = async (
event: CloudFrontRequestEvent
): Promise<CloudFrontResponse | CloudFrontRequest> => {
const request = event.Records[0].cf.request;

// Override root object
request.uri = `${PATH_PREFIX}/${ROOT_OBJECT}`;

return request;
};
66 changes: 66 additions & 0 deletions packages/static-hosting/lib/handlers/csp-lambda/origin-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CloudFrontResponseEvent, CloudFrontResponse } from "aws-lambda";
import { S3 } from "aws-sdk"; // Lambda comes pre-bundled with SDK v2, so use that instead of v3 for now

const s3 = new S3();

const CSP_OBJECT = process.env.CSP_OBJECT;
const S3_BUCKET = process.env.S3_BUCKET;

const REPORT_URI = process.env.REPORT_URI;

const FALLBACK_CSP = process.env.FALLBACK_CSP;

export const handler = async (
event: CloudFrontResponseEvent
): Promise<CloudFrontResponse> => {
const response = event.Records[0].cf.response;
response.headers = response.headers || {};

let csp = "";

if (REPORT_URI) {
response.headers["reporting-endpoints"] = [
{ key: "Reporting-Endpoints", value: `report_endpoint="${REPORT_URI}"` },
];

// Add both report-to and report-uri for backwards compatibility
csp += `report-uri ${REPORT_URI}; report-to report_endpoint; `;
}

try {
if (!CSP_OBJECT || !S3_BUCKET) {
throw new Error("CSP_FILE or S3_BUCKET environment variable is missing");
}

const params: S3.GetObjectRequest = {
Bucket: S3_BUCKET,
Key: CSP_OBJECT,
};

const s3Object = await s3.getObject(params).promise();

if (!s3Object.Body) {
throw new Error("CSP file is empty or missing");
}

csp += s3Object.Body.toString("utf-8");

console.log("CSP file retrieved:", csp);

response.headers["content-security-policy"] = [
{ key: "Content-Security-Policy", value: csp },
];
} catch (error) {
console.error("Error fetching CSP file or adding header:", error);

// If no fallback was provided, throw the error and 500 response
if (!FALLBACK_CSP) throw error;

response.headers["content-security-policy"] = [
{ key: "Content-Security-Policy", value: FALLBACK_CSP },
];
throw error;
}

return response;
};
2 changes: 1 addition & 1 deletion packages/static-hosting/lib/path-remap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class PathRemapFunction extends Construct {
command,
image: DockerImage.fromRegistry("busybox"),
local: new Esbuild({
entryPoints: [join(__dirname, "handlers/remap.ts")],
entryPoints: [join(__dirname, "handlers/remap/remap.ts")],
define: {
"process.env.REMAP_PATH": `"${options.path}"`,
},
Expand Down
79 changes: 79 additions & 0 deletions packages/static-hosting/lib/static-hosting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from "aws-cdk-lib/aws-s3";
import { CSP } from "../types/csp";
import { PathRemapFunction } from "./path-remap";
import { RequestFunction, ResponseFunction } from "./csp";

export interface StaticHostingProps {
/**
Expand Down Expand Up @@ -202,6 +203,9 @@ export interface StaticHostingProps {
* AWS limits the max header size to 1kb, this is too small for complex csp headers.
* The main purpose of this csp header is to provide a method of setting a report-uri.
*
* For more complex CSP headers, it's recommended to use the cspPath property to apply
* a CSP header to specific paths.
*
* @default undefined
*/
csp?: CSP;
Expand Down Expand Up @@ -300,6 +304,37 @@ export interface StaticHostingProps {
* @default undefined
*/
comment?: string;

/**
* Configuration settings for CSP at a specific path
* If a value is passed through, CSP will be enabled for the given path
*/
cspPaths?: CSPConfig[];
}

export interface CSPConfig {
/**
* Path to apply the CSP behaviour and also the path
*/
path: string;

/**
* Optional path to a different index.html in the bucket. Will default to the path provided
* for the behaviour
*/
indexPath?: string;

/**
* URI to send CSP reports to. Adds to a reporting endpoint called report_endpoint:
* `Reporting-Endpoints: report_endpoint="${reportURI}"`
*/
reportUri: string;

/**
* An optional CSP to fallback to in the event that the CSP from the S3 bucket cannot
* be retrieved or parsed
*/
fallbackCsp?: string;
}

export interface remapPath {
Expand Down Expand Up @@ -547,6 +582,50 @@ export class StaticHosting extends Construct {
}
}

const cspPaths = props.cspPaths || [];
const cspRemapPaths = cspPaths.map(cspPath => {
const { path, indexPath, reportUri, fallbackCsp } = cspPath;

const requestFunction = new RequestFunction(
this,
`AlternativePathFunction-${path}`,
{
pathPrefix: indexPath || path,
}
);

const responseFunction = new ResponseFunction(
this,
`CSPFunction-${path}`,
{
bucket: `${props.subDomainName}.${props.domainName}`,
reportUri: reportUri,
fallbackCsp: fallbackCsp,
}
);
this.bucket.grantRead(responseFunction.edgeFunction);

const remap: remapPath = {
from: path,
behaviour: {
edgeLambdas: [
{
eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
functionVersion: requestFunction.edgeFunction.currentVersion,
},
{
eventType: LambdaEdgeEventType.ORIGIN_RESPONSE,
functionVersion: responseFunction.edgeFunction.currentVersion,
},
],
},
};

return remap;
});
if (props.remapPaths) props.remapPaths.push(...cspRemapPaths);
else props.remapPaths = cspRemapPaths;

// Note: A given path may override if the same path is defined both remapPaths and remapBackendPaths. This is an
// unlikely scenario but worth noting. e.g. `/robots.txt` should be defined in one of the above but not both.
if (props.remapPaths) {
Expand Down