@kensio/yulin 1.20.15 → 1.20.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/sdk/module/sim-sdk-module-client-interceptor.d.ts +5 -0
- package/dist/sdk/module/sim-sdk-module-client-interceptor.js +14 -0
- package/dist/sdk/module/sim-sdk-static-client-factory.d.ts +26 -0
- package/dist/sdk/module/sim-sdk-static-client-factory.js +59 -0
- package/dist/sdk/send-patch.d.ts +8 -0
- package/dist/sdk/send-patch.js +11 -0
- package/dist/service/aws/caller/sim-aws-caller-resolver.d.ts +28 -6
- package/dist/service/aws/caller/sim-aws-caller-resolver.js +18 -11
- package/dist/service/aws/caller/sim-aws-caller.d.ts +9 -0
- package/dist/service/aws/caller/sim-aws-resolved-caller.d.ts +19 -0
- package/dist/service/aws/caller/sim-aws-resolved-caller.js +26 -0
- package/dist/service/aws/factory/sim-aws-account-region-service-builder.d.ts +6 -0
- package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +3 -0
- package/dist/service/aws/factory/sim-aws-account-service-cache.d.ts +6 -0
- package/dist/service/aws/factory/sim-aws-account-service-cache.js +5 -0
- package/dist/service/aws/factory/sim-aws-service-factory.d.ts +5 -0
- package/dist/service/aws/factory/sim-aws-service-factory.js +6 -0
- package/dist/service/aws/sim-aws-properties.d.ts +15 -0
- package/dist/service/aws/sim-aws.js +2 -1
- package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.d.ts +30 -7
- package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.js +20 -11
- package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.d.ts +2 -1
- package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.js +5 -1
- package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.d.ts +10 -0
- package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.js +15 -3
- package/dist/service/iam/authorize/sim-iam-account-auth-z.d.ts +11 -4
- package/dist/service/iam/authorize/sim-iam-account-auth-z.js +5 -3
- package/dist/service/iam/authorize/sim-iam-authorizer.d.ts +2 -1
- package/dist/service/iam/authorize/sim-iam-authorizer.js +1 -0
- package/dist/service/iam/role/sim-iam-role-with-policy.factory.d.ts +9 -0
- package/dist/service/iam/role/sim-iam-role-with-policy.factory.js +4 -2
- package/dist/service/iam/sim-iam-account-parts.d.ts +8 -0
- package/dist/service/iam/sim-iam-account-parts.js +1 -0
- package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.d.ts +9 -2
- package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.js +2 -2
- package/dist/service/sts/auth-z/assume-role-source-account-auth-z.d.ts +10 -4
- package/dist/service/sts/auth-z/assume-role-source-account-auth-z.js +8 -7
- package/dist/service/sts/auth-z/assume-role-target-auth-z.d.ts +2 -2
- package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.d.ts +6 -2
- package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.js +14 -11
- package/dist/service/sts/command/assume-role/assume-role.handler.d.ts +5 -1
- package/dist/service/sts/command/assume-role/assume-role.handler.js +5 -2
- package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.d.ts +5 -1
- package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.js +4 -1
- package/dist/service/sts/service-role/sim-service-role.js +5 -1
- package/dist/service/sts/sim-sts.d.ts +9 -1
- package/dist/service/sts/sim-sts.js +4 -0
- package/docs/README.md +54 -0
- package/docs/ai-skill/README.md +69 -0
- package/docs/cli/README.md +284 -0
- package/docs/factories/README.md +140 -0
- package/docs/lint/README.md +176 -0
- package/docs/non-aws-dependencies/README.md +261 -0
- package/docs/sdk/README.md +272 -0
- package/docs/serve/README.md +1126 -0
- package/docs/services/acm/README.md +823 -0
- package/docs/services/apigateway/README.md +1919 -0
- package/docs/services/apigatewayv2/README.md +2678 -0
- package/docs/services/athena/README.md +1044 -0
- package/docs/services/bedrock/README.md +350 -0
- package/docs/services/cloudformation/README.md +3440 -0
- package/docs/services/cloudfront/README.md +2874 -0
- package/docs/services/cloudwatch/README.md +420 -0
- package/docs/services/cognito/README.md +4923 -0
- package/docs/services/dynamodb/README.md +3784 -0
- package/docs/services/ecr/README.md +302 -0
- package/docs/services/ecs/README.md +2010 -0
- package/docs/services/elbv2/README.md +1897 -0
- package/docs/services/eventbridge/README.md +1110 -0
- package/docs/services/firehose/README.md +805 -0
- package/docs/services/glue/README.md +501 -0
- package/docs/services/iam/README.md +1283 -0
- package/docs/services/kinesis/README.md +454 -0
- package/docs/services/kms/README.md +659 -0
- package/docs/services/lambda/README.md +3645 -0
- package/docs/services/logs/README.md +868 -0
- package/docs/services/organizations/README.md +574 -0
- package/docs/services/personalize/README.md +835 -0
- package/docs/services/rekognition/README.md +1070 -0
- package/docs/services/route53/README.md +1783 -0
- package/docs/services/s3/README.md +3024 -0
- package/docs/services/scheduler/README.md +806 -0
- package/docs/services/secretsmanager/README.md +667 -0
- package/docs/services/ses/README.md +1011 -0
- package/docs/services/sns/README.md +1904 -0
- package/docs/services/sqs/README.md +1185 -0
- package/docs/services/ssm/README.md +1072 -0
- package/docs/services/stepfunctions/README.md +1332 -0
- package/docs/services/sts/README.md +245 -0
- package/docs/services/wafv2/README.md +1378 -0
- package/docs/terraform/README.md +248 -0
- package/docs/time/README.md +284 -0
- package/llms.txt +59 -0
- package/package.json +7 -4
|
@@ -0,0 +1,2874 @@
|
|
|
1
|
+
# Simulated CloudFront
|
|
2
|
+
|
|
3
|
+
Yulin includes a simulated CloudFront service for tests and local development.
|
|
4
|
+
|
|
5
|
+
Sim CloudFront can be used directly through `SimAws`, and it can also be served on localhost
|
|
6
|
+
alongside other simulated AWS services, so application code can make HTTP requests through a
|
|
7
|
+
CloudFront-like layer without talking to real AWS.
|
|
8
|
+
|
|
9
|
+
`SimCloudFront` can also be instantiated on its own, in which case it has its own isolated state,
|
|
10
|
+
standing apart from any wider simulated AWS environment.
|
|
11
|
+
|
|
12
|
+
## Basic Distribution setup
|
|
13
|
+
|
|
14
|
+
Create a simulated AWS environment, add a sim S3 Bucket, and create a sim CloudFront Distribution
|
|
15
|
+
pointing at that Bucket.
|
|
16
|
+
|
|
17
|
+
```typescript sim-cloudfront-distribution-s3-origin
|
|
18
|
+
/**
|
|
19
|
+
* Creating a simulated CloudFront Distribution with a simulated S3 Origin.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
23
|
+
import {
|
|
24
|
+
CreateBucketCommand,
|
|
25
|
+
PutBucketPolicyCommand,
|
|
26
|
+
PutPublicAccessBlockCommand,
|
|
27
|
+
} from "@aws-sdk/client-s3";
|
|
28
|
+
|
|
29
|
+
import { SimAws } from "@kensio/yulin";
|
|
30
|
+
|
|
31
|
+
const simAws = new SimAws();
|
|
32
|
+
const simS3 = simAws.s3();
|
|
33
|
+
const simCloudFront = simAws.cloudFront();
|
|
34
|
+
|
|
35
|
+
await simS3.createBucket(
|
|
36
|
+
new CreateBucketCommand({
|
|
37
|
+
Bucket: "foo-bucket",
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// The Origin below has no origin access control, so it reads the Bucket
|
|
42
|
+
// anonymously and only a public read grant lets it serve anything.
|
|
43
|
+
await simS3.putPublicAccessBlock(
|
|
44
|
+
new PutPublicAccessBlockCommand({
|
|
45
|
+
Bucket: "foo-bucket",
|
|
46
|
+
PublicAccessBlockConfiguration: {
|
|
47
|
+
BlockPublicAcls: true,
|
|
48
|
+
IgnorePublicAcls: true,
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
await simS3.putBucketPolicy(
|
|
53
|
+
new PutBucketPolicyCommand({
|
|
54
|
+
Bucket: "foo-bucket",
|
|
55
|
+
Policy: JSON.stringify({
|
|
56
|
+
Version: "2012-10-17",
|
|
57
|
+
Statement: {
|
|
58
|
+
Effect: "Allow",
|
|
59
|
+
Principal: "*",
|
|
60
|
+
Action: "s3:GetObject",
|
|
61
|
+
Resource: "arn:aws:s3:::foo-bucket/*",
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const distributionCreation = await simCloudFront.createDistribution(
|
|
68
|
+
new CreateDistributionCommand({
|
|
69
|
+
DistributionConfig: {
|
|
70
|
+
CallerReference: "assets-cdn",
|
|
71
|
+
Comment: "Assets CDN",
|
|
72
|
+
Enabled: true,
|
|
73
|
+
Origins: {
|
|
74
|
+
Quantity: 1,
|
|
75
|
+
Items: [
|
|
76
|
+
{
|
|
77
|
+
Id: "assets-origin",
|
|
78
|
+
DomainName: "foo-bucket.s3.amazonaws.com",
|
|
79
|
+
S3OriginConfig: {
|
|
80
|
+
OriginAccessIdentity: "",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
DefaultCacheBehavior: {
|
|
86
|
+
TargetOriginId: "assets-origin",
|
|
87
|
+
ViewerProtocolPolicy: "allow-all",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
console.log(distributionCreation.Distribution?.DomainName);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## What an S3 Origin can read
|
|
97
|
+
|
|
98
|
+
An S3 Origin reads its Bucket through the ordinary GetObject command. The Bucket policy decides what
|
|
99
|
+
the Distribution can serve. An Origin with no origin access control reads anonymously, the unsigned
|
|
100
|
+
request real CloudFront sends to the S3 REST endpoint. An Object has to be publicly readable for the
|
|
101
|
+
Distribution to serve it, and a Bucket with no policy answers 403 for every Object.
|
|
102
|
+
|
|
103
|
+
An Origin that does have an origin access control reads as the CloudFront service principal. The
|
|
104
|
+
Bucket stays private and its policy names the Distribution. See
|
|
105
|
+
[Origin access controls](#origin-access-controls) for the Bucket policy that takes.
|
|
106
|
+
|
|
107
|
+
That is what the two commands in the example above do. `PutPublicAccessBlockCommand` opts out of the
|
|
108
|
+
block on public Bucket policies, then `PutBucketPolicyCommand` grants `s3:GetObject` to
|
|
109
|
+
`Principal: "*"`. The same pair is what a static website Bucket needs, and it is what CDK's
|
|
110
|
+
`publicReadAccess: true` generates.
|
|
111
|
+
|
|
112
|
+
A denied read reaches the viewer as a 403 from the Origin, and a Distribution's custom error
|
|
113
|
+
response for 403 replaces it. The usual single-page-app setup, rewriting 403 to `/index.html`,
|
|
114
|
+
behaves here as it does in AWS.
|
|
115
|
+
|
|
116
|
+
`S3OriginConfig.OriginAccessIdentity` is refused. Leave it empty, as CloudFront itself writes it for
|
|
117
|
+
an Origin that signs nothing.
|
|
118
|
+
|
|
119
|
+
## Static sites, default root objects and error pages
|
|
120
|
+
|
|
121
|
+
A static site behind CloudFront usually leans on two Distribution settings. `DefaultRootObject`
|
|
122
|
+
makes a request for the site root return the home page. `CustomErrorResponses` makes a URL that
|
|
123
|
+
matches no object return the site's own error page in place of the Origin's. Sim CloudFront applies
|
|
124
|
+
both, and a test can assert what a visitor would actually see.
|
|
125
|
+
|
|
126
|
+
```typescript sim-cloudfront-static-site
|
|
127
|
+
/**
|
|
128
|
+
* Serving a static site with a default root object and a custom error page.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
132
|
+
import {
|
|
133
|
+
CreateBucketCommand,
|
|
134
|
+
PutBucketPolicyCommand,
|
|
135
|
+
PutObjectCommand,
|
|
136
|
+
PutPublicAccessBlockCommand,
|
|
137
|
+
} from "@aws-sdk/client-s3";
|
|
138
|
+
|
|
139
|
+
import { SimAws } from "@kensio/yulin";
|
|
140
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
141
|
+
|
|
142
|
+
const simAws = new SimAws();
|
|
143
|
+
const srv = await serveSimAws({ simAws });
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const simS3 = simAws.s3();
|
|
147
|
+
|
|
148
|
+
await simS3.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
|
|
149
|
+
|
|
150
|
+
// A CloudFront S3 Origin with no origin access control reads the Bucket
|
|
151
|
+
// anonymously, so what it serves has to be publicly readable.
|
|
152
|
+
await simS3.putPublicAccessBlock(
|
|
153
|
+
new PutPublicAccessBlockCommand({
|
|
154
|
+
Bucket: "site-bucket",
|
|
155
|
+
PublicAccessBlockConfiguration: {
|
|
156
|
+
BlockPublicAcls: true,
|
|
157
|
+
IgnorePublicAcls: true,
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
await simS3.putBucketPolicy(
|
|
162
|
+
new PutBucketPolicyCommand({
|
|
163
|
+
Bucket: "site-bucket",
|
|
164
|
+
Policy: JSON.stringify({
|
|
165
|
+
Version: "2012-10-17",
|
|
166
|
+
Statement: {
|
|
167
|
+
Effect: "Allow",
|
|
168
|
+
Principal: "*",
|
|
169
|
+
Action: "s3:GetObject",
|
|
170
|
+
Resource: "arn:aws:s3:::site-bucket/*",
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const pages = {
|
|
177
|
+
"index.html": "<h1>Home</h1>",
|
|
178
|
+
"404.html": "<h1>Page not found</h1>",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (const [key, body] of Object.entries(pages)) {
|
|
182
|
+
await simS3.putObject(
|
|
183
|
+
new PutObjectCommand({
|
|
184
|
+
Bucket: "site-bucket",
|
|
185
|
+
Key: key,
|
|
186
|
+
ContentType: "text/html",
|
|
187
|
+
Body: body,
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const distributionCreation = await simAws.cloudFront().createDistribution(
|
|
193
|
+
new CreateDistributionCommand({
|
|
194
|
+
DistributionConfig: {
|
|
195
|
+
CallerReference: "static-site",
|
|
196
|
+
Comment: "Static site",
|
|
197
|
+
Enabled: true,
|
|
198
|
+
DefaultRootObject: "index.html",
|
|
199
|
+
CustomErrorResponses: {
|
|
200
|
+
Quantity: 2,
|
|
201
|
+
Items: [
|
|
202
|
+
{
|
|
203
|
+
ErrorCode: 404,
|
|
204
|
+
ResponsePagePath: "/404.html",
|
|
205
|
+
ResponseCode: "404",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
ErrorCode: 403,
|
|
209
|
+
ResponsePagePath: "/404.html",
|
|
210
|
+
ResponseCode: "404",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
Origins: {
|
|
215
|
+
Quantity: 1,
|
|
216
|
+
Items: [
|
|
217
|
+
{
|
|
218
|
+
Id: "site-origin",
|
|
219
|
+
DomainName: "site-bucket.s3.amazonaws.com",
|
|
220
|
+
S3OriginConfig: { OriginAccessIdentity: "" },
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
DefaultCacheBehavior: {
|
|
225
|
+
TargetOriginId: "site-origin",
|
|
226
|
+
ViewerProtocolPolicy: "allow-all",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const distroHostname = distributionCreation.Distribution!.DomainName!;
|
|
233
|
+
|
|
234
|
+
const home = await fetch(srv.localUrl(`http://${distroHostname}/`));
|
|
235
|
+
console.log(await home.text()); // <h1>Home</h1>
|
|
236
|
+
|
|
237
|
+
const missing = await fetch(srv.localUrl(`http://${distroHostname}/nowhere`));
|
|
238
|
+
console.log(missing.status); // 404
|
|
239
|
+
console.log(await missing.text()); // <h1>Page not found</h1>
|
|
240
|
+
} finally {
|
|
241
|
+
await srv.close();
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The default root object stands in for a request to the root of the Distribution and nothing else. A
|
|
246
|
+
request for `/blog/` is passed to the Origin as it arrived, even where that folder holds its own
|
|
247
|
+
`index.html`. That is where CloudFront differs from an S3 website index document. The substituted
|
|
248
|
+
path is what the rest of request handling sees, and a Cache Behavior pattern and a `viewer-request`
|
|
249
|
+
CloudFront Function both act on the object being served. The value names an object at the Origin. It may be a
|
|
250
|
+
path such as `public/index.html`, and it must not begin with a forward slash. Sim CloudFront refuses one that does with `InvalidDefaultRootObject`. The alternative would
|
|
251
|
+
be a Distribution that answers its own root with a 403.
|
|
252
|
+
|
|
253
|
+
A custom error response replaces the Origin's response when its status matches `ErrorCode`. The
|
|
254
|
+
codes CloudFront supports are 400, 403, 404, 405, 414, 416, 500, 501, 502, 503 and 504. The response
|
|
255
|
+
page is fetched as a request in its own right, and the Cache Behavior matching `ResponsePagePath`
|
|
256
|
+
chooses which Origin it comes from. Error pages can live somewhere other than the content that
|
|
257
|
+
failed. `ResponseCode` is the status the viewer sees. That is how a single-page app serves its shell
|
|
258
|
+
with a 200 for a URL the Bucket has no object for. It is one of the same error codes or 200, the set
|
|
259
|
+
CloudFront allows. Where the response page is itself missing, the viewer gets the status from
|
|
260
|
+
fetching it, as in CloudFront.
|
|
261
|
+
|
|
262
|
+
A viewer-response function never sees a custom error page. CloudFront runs no viewer-response
|
|
263
|
+
function once the Origin has answered 400 or higher, and simulated CloudFront does the same, for a
|
|
264
|
+
CloudFront Function and a Lambda@Edge function alike. The status the Origin returned is what decides
|
|
265
|
+
that, whatever `ResponseCode` puts in its place. `ErrorCachingMinTTL` is accepted and ignored, along
|
|
266
|
+
with a rule that sets nothing else, since sim CloudFront has no cache to apply it to.
|
|
267
|
+
|
|
268
|
+
## Serve simulated CloudFront on localhost
|
|
269
|
+
|
|
270
|
+
Use `serveSimAws` when you want to make real HTTP requests to the simulated system on localhost.
|
|
271
|
+
|
|
272
|
+
```typescript serve-sim-cloudfront-localhost
|
|
273
|
+
/**
|
|
274
|
+
* Serving a simulated CloudFront Distribution on localhost.
|
|
275
|
+
*/
|
|
276
|
+
|
|
277
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
278
|
+
import {
|
|
279
|
+
CreateBucketCommand,
|
|
280
|
+
PutBucketPolicyCommand,
|
|
281
|
+
PutObjectCommand,
|
|
282
|
+
PutPublicAccessBlockCommand,
|
|
283
|
+
} from "@aws-sdk/client-s3";
|
|
284
|
+
|
|
285
|
+
import { SimAws } from "@kensio/yulin";
|
|
286
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
287
|
+
|
|
288
|
+
const simAws = new SimAws();
|
|
289
|
+
const srv = await serveSimAws({ simAws });
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const simS3 = simAws.s3();
|
|
293
|
+
const simCloudFront = simAws.cloudFront();
|
|
294
|
+
|
|
295
|
+
await simS3.createBucket(
|
|
296
|
+
new CreateBucketCommand({
|
|
297
|
+
Bucket: "foo-bucket",
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// A CloudFront S3 Origin with no origin access control reads the Bucket
|
|
302
|
+
// anonymously, so what it serves has to be publicly readable.
|
|
303
|
+
await simS3.putPublicAccessBlock(
|
|
304
|
+
new PutPublicAccessBlockCommand({
|
|
305
|
+
Bucket: "foo-bucket",
|
|
306
|
+
PublicAccessBlockConfiguration: {
|
|
307
|
+
BlockPublicAcls: true,
|
|
308
|
+
IgnorePublicAcls: true,
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
await simS3.putBucketPolicy(
|
|
313
|
+
new PutBucketPolicyCommand({
|
|
314
|
+
Bucket: "foo-bucket",
|
|
315
|
+
Policy: JSON.stringify({
|
|
316
|
+
Version: "2012-10-17",
|
|
317
|
+
Statement: {
|
|
318
|
+
Effect: "Allow",
|
|
319
|
+
Principal: "*",
|
|
320
|
+
Action: "s3:GetObject",
|
|
321
|
+
Resource: "arn:aws:s3:::foo-bucket/*",
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await simS3.putObject(
|
|
328
|
+
new PutObjectCommand({
|
|
329
|
+
Bucket: "foo-bucket",
|
|
330
|
+
Key: "hello.txt",
|
|
331
|
+
Body: "Hello from simulated CloudFront",
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const distributionCreation = await simCloudFront.createDistribution(
|
|
336
|
+
new CreateDistributionCommand({
|
|
337
|
+
DistributionConfig: {
|
|
338
|
+
CallerReference: "localhost-assets-cdn",
|
|
339
|
+
Comment: "Localhost Assets CDN",
|
|
340
|
+
Enabled: true,
|
|
341
|
+
Origins: {
|
|
342
|
+
Quantity: 1,
|
|
343
|
+
Items: [
|
|
344
|
+
{
|
|
345
|
+
Id: "assets-origin",
|
|
346
|
+
DomainName: "foo-bucket.s3.amazonaws.com",
|
|
347
|
+
S3OriginConfig: {
|
|
348
|
+
OriginAccessIdentity: "",
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
DefaultCacheBehavior: {
|
|
354
|
+
TargetOriginId: "assets-origin",
|
|
355
|
+
ViewerProtocolPolicy: "allow-all",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const distroHostname = distributionCreation.Distribution!.DomainName!;
|
|
362
|
+
|
|
363
|
+
const url = srv.localUrl(`http://${distroHostname}/hello.txt`);
|
|
364
|
+
const response = await fetch(url);
|
|
365
|
+
|
|
366
|
+
console.log(response.status);
|
|
367
|
+
console.log(await response.text());
|
|
368
|
+
} finally {
|
|
369
|
+
await srv.close();
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The Distribution domain is adapted through `server.localUrl(...)` so that the request is sent to the
|
|
374
|
+
local Yulin server while preserving the simulated CloudFront hostname.
|
|
375
|
+
|
|
376
|
+
A test that needs no browser can skip the port. `SimAwsHttp` answers the same requests in the
|
|
377
|
+
process, with no server listening and no URL to adapt. An alternate domain name a simulated Route53
|
|
378
|
+
answers for is requested by its own name, and `simAwsHttp.fetch("https://cdn.example.test/")` reaches
|
|
379
|
+
the Distribution behind it. See
|
|
380
|
+
[requests without a port](https://yulinsim.dev/serve/#requests-without-a-port "Requests without a port docs").
|
|
381
|
+
|
|
382
|
+
## Custom Origins
|
|
383
|
+
|
|
384
|
+
An Origin with a `CustomOriginConfig` is one CloudFront reaches over HTTP, in place of reading an S3
|
|
385
|
+
Bucket. Sim CloudFront resolves its `DomainName` in the simulated environment and serves the request
|
|
386
|
+
in process. A Distribution can front a simulated HTTP API endpoint
|
|
387
|
+
(`<api-id>.execute-api.<region>.amazonaws.com`), a simulated Lambda Function URL
|
|
388
|
+
(`<url-id>.lambda-url.<region>.on.aws`), or anything a simulated Route53 record points at one of
|
|
389
|
+
those.
|
|
390
|
+
|
|
391
|
+
That covers the common arrangement of one Distribution serving static assets from a Bucket and
|
|
392
|
+
sending `/api/*` to an API:
|
|
393
|
+
|
|
394
|
+
```typescript sim-cloudfront-distribution-custom-origin
|
|
395
|
+
/**
|
|
396
|
+
* A simulated CloudFront Distribution fronting a simulated HTTP API.
|
|
397
|
+
*/
|
|
398
|
+
|
|
399
|
+
import {
|
|
400
|
+
CreateApiCommand,
|
|
401
|
+
CreateIntegrationCommand,
|
|
402
|
+
CreateRouteCommand,
|
|
403
|
+
CreateStageCommand,
|
|
404
|
+
} from "@aws-sdk/client-apigatewayv2";
|
|
405
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
406
|
+
import {
|
|
407
|
+
AddPermissionCommand,
|
|
408
|
+
CreateFunctionCommand,
|
|
409
|
+
} from "@aws-sdk/client-lambda";
|
|
410
|
+
import {
|
|
411
|
+
CreateBucketCommand,
|
|
412
|
+
PutBucketPolicyCommand,
|
|
413
|
+
PutObjectCommand,
|
|
414
|
+
PutPublicAccessBlockCommand,
|
|
415
|
+
} from "@aws-sdk/client-s3";
|
|
416
|
+
|
|
417
|
+
import { SimAws } from "@kensio/yulin";
|
|
418
|
+
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
|
|
419
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
420
|
+
|
|
421
|
+
const simAws = new SimAws();
|
|
422
|
+
|
|
423
|
+
// A Bucket holding the site, readable by the Origin that reads it anonymously.
|
|
424
|
+
await simAws.s3().createBucket(new CreateBucketCommand({ Bucket: "site" }));
|
|
425
|
+
await simAws.s3().putObject(
|
|
426
|
+
new PutObjectCommand({
|
|
427
|
+
Bucket: "site",
|
|
428
|
+
Key: "index.html",
|
|
429
|
+
Body: "<h1>Site</h1>",
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
await simAws.s3().putPublicAccessBlock(
|
|
433
|
+
new PutPublicAccessBlockCommand({
|
|
434
|
+
Bucket: "site",
|
|
435
|
+
PublicAccessBlockConfiguration: {
|
|
436
|
+
BlockPublicAcls: true,
|
|
437
|
+
IgnorePublicAcls: true,
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
await simAws.s3().putBucketPolicy(
|
|
442
|
+
new PutBucketPolicyCommand({
|
|
443
|
+
Bucket: "site",
|
|
444
|
+
Policy: JSON.stringify({
|
|
445
|
+
Version: "2012-10-17",
|
|
446
|
+
Statement: {
|
|
447
|
+
Effect: "Allow",
|
|
448
|
+
Principal: "*",
|
|
449
|
+
Action: "s3:GetObject",
|
|
450
|
+
Resource: "arn:aws:s3:::site/*",
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
}),
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// An HTTP API serving /api/things from a function.
|
|
457
|
+
const { FunctionArn } = await simAws.lambda().createFunction(
|
|
458
|
+
new CreateFunctionCommand({
|
|
459
|
+
FunctionName: "things",
|
|
460
|
+
Role: "arn:aws:iam::111111111111:role/ThingsRole",
|
|
461
|
+
Code: { ZipFile: makeLambdaZipFileInput(() => ({ things: ["kettle"] })) },
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const apiGateway = simAws.apiGatewayV2();
|
|
466
|
+
|
|
467
|
+
const { ApiId, ApiEndpoint } = await apiGateway.createApi(
|
|
468
|
+
new CreateApiCommand({ Name: "things", ProtocolType: "HTTP" }),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const { IntegrationId } = await apiGateway.createIntegration(
|
|
472
|
+
new CreateIntegrationCommand({
|
|
473
|
+
ApiId,
|
|
474
|
+
IntegrationType: "AWS_PROXY",
|
|
475
|
+
IntegrationUri: FunctionArn,
|
|
476
|
+
PayloadFormatVersion: "2.0",
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
await apiGateway.createRoute(
|
|
481
|
+
new CreateRouteCommand({
|
|
482
|
+
ApiId,
|
|
483
|
+
RouteKey: "GET /api/things",
|
|
484
|
+
Target: `integrations/${IntegrationId}`,
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
await apiGateway.createStage(
|
|
489
|
+
new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
await simAws.lambda().addPermission(
|
|
493
|
+
new AddPermissionCommand({
|
|
494
|
+
FunctionName: "things",
|
|
495
|
+
StatementId: "api-gateway-invoke",
|
|
496
|
+
Action: "lambda:InvokeFunction",
|
|
497
|
+
Principal: "apigateway.amazonaws.com",
|
|
498
|
+
SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
// One Distribution serving the site, with /api/* going to the API.
|
|
503
|
+
const distributionCreation = await simAws.cloudFront().createDistribution(
|
|
504
|
+
new CreateDistributionCommand({
|
|
505
|
+
DistributionConfig: {
|
|
506
|
+
CallerReference: "site-and-api",
|
|
507
|
+
Comment: "Site and API CDN",
|
|
508
|
+
Enabled: true,
|
|
509
|
+
Origins: {
|
|
510
|
+
Quantity: 2,
|
|
511
|
+
Items: [
|
|
512
|
+
{
|
|
513
|
+
Id: "site-origin",
|
|
514
|
+
DomainName: "site.s3.amazonaws.com",
|
|
515
|
+
S3OriginConfig: { OriginAccessIdentity: "" },
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
Id: "api-origin",
|
|
519
|
+
DomainName: new URL(ApiEndpoint).hostname,
|
|
520
|
+
CustomOriginConfig: {
|
|
521
|
+
HTTPPort: 80,
|
|
522
|
+
HTTPSPort: 443,
|
|
523
|
+
OriginProtocolPolicy: "https-only",
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
DefaultCacheBehavior: {
|
|
529
|
+
TargetOriginId: "site-origin",
|
|
530
|
+
ViewerProtocolPolicy: "allow-all",
|
|
531
|
+
},
|
|
532
|
+
CacheBehaviors: {
|
|
533
|
+
Quantity: 1,
|
|
534
|
+
Items: [
|
|
535
|
+
{
|
|
536
|
+
PathPattern: "/api/*",
|
|
537
|
+
TargetOriginId: "api-origin",
|
|
538
|
+
ViewerProtocolPolicy: "allow-all",
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const distroHostname = distributionCreation.Distribution!.DomainName!;
|
|
547
|
+
const srv = await serveSimAws({ simAws });
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
const page = await fetch(srv.localUrl(`http://${distroHostname}/index.html`));
|
|
551
|
+
const things = await fetch(
|
|
552
|
+
srv.localUrl(`http://${distroHostname}/api/things`),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
console.log(await page.text());
|
|
556
|
+
console.log(await things.text());
|
|
557
|
+
} finally {
|
|
558
|
+
await srv.close();
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The Origin domain is resolved when a request is served, and the Distribution and the service behind
|
|
563
|
+
its Origin can be created in either order, whichever way round a CloudFormation template happens to
|
|
564
|
+
declare them.
|
|
565
|
+
|
|
566
|
+
`OriginPath` is prefixed to the request path, as it is for an S3 Origin. An Origin path of `/v1`
|
|
567
|
+
sends a request for `/things` on to `/v1/things`.
|
|
568
|
+
|
|
569
|
+
Three things follow from the request never leaving the process:
|
|
570
|
+
|
|
571
|
+
- A domain unknown to the simulation fails with an error naming the Origin and the
|
|
572
|
+
domain. No real request is made to it, and external HTTP Origins are unsupported.
|
|
573
|
+
- The settings inside `CustomOriginConfig` describe how CloudFront connects over the network. The
|
|
574
|
+
protocol policy, ports, SSL protocols and timeouts are accepted and ignored.
|
|
575
|
+
- The Origin is reached anonymously unless it has an origin access control, as CloudFront reaches an
|
|
576
|
+
Origin it has nothing to sign for. A Function URL or an HTTP API route authorizing with `AWS_IAM`
|
|
577
|
+
therefore refuses the request. [Origin access controls](#origin-access-controls) covers the
|
|
578
|
+
Function URL that admits the Distribution and nothing else.
|
|
579
|
+
|
|
580
|
+
## Custom headers on an Origin
|
|
581
|
+
|
|
582
|
+
CloudFront adds an Origin's custom headers to every request it sends that Origin. An origin that
|
|
583
|
+
answers only requests carrying a header nothing else knows is how AWS documents
|
|
584
|
+
[restricting a custom origin to CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html#forward-custom-headers-restrict-access),
|
|
585
|
+
and a sim Distribution sends them the same way.
|
|
586
|
+
|
|
587
|
+
The CloudFront API and CloudFormation name the field differently, and both spellings are accepted
|
|
588
|
+
here. The API has `CustomHeaders` inside an `Origin`, and `AWS::CloudFront::Distribution` has
|
|
589
|
+
`OriginCustomHeaders`, as the two differ over the viewer certificate ARN.
|
|
590
|
+
|
|
591
|
+
```typescript sim-cloudfront-origin-custom-headers
|
|
592
|
+
/**
|
|
593
|
+
* An HTTP API answering only the requests that came through the Distribution.
|
|
594
|
+
*/
|
|
595
|
+
|
|
596
|
+
import {
|
|
597
|
+
CreateApiCommand,
|
|
598
|
+
CreateIntegrationCommand,
|
|
599
|
+
CreateRouteCommand,
|
|
600
|
+
CreateStageCommand,
|
|
601
|
+
} from "@aws-sdk/client-apigatewayv2";
|
|
602
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
603
|
+
import {
|
|
604
|
+
AddPermissionCommand,
|
|
605
|
+
CreateFunctionCommand,
|
|
606
|
+
} from "@aws-sdk/client-lambda";
|
|
607
|
+
|
|
608
|
+
import { SimAws } from "@kensio/yulin";
|
|
609
|
+
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
|
|
610
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
611
|
+
|
|
612
|
+
const simAws = new SimAws();
|
|
613
|
+
const originSecret = "5d6e2b0c6f564c1e9d5b2f1a5b8c9d70";
|
|
614
|
+
|
|
615
|
+
// A function serving the API, which reads the secret off every request.
|
|
616
|
+
const { FunctionArn } = await simAws.lambda().createFunction(
|
|
617
|
+
new CreateFunctionCommand({
|
|
618
|
+
FunctionName: "profile",
|
|
619
|
+
Role: "arn:aws:iam::111111111111:role/ProfileRole",
|
|
620
|
+
Code: {
|
|
621
|
+
ZipFile: makeLambdaZipFileInput(
|
|
622
|
+
(event: { headers: Record<string, string> }) =>
|
|
623
|
+
event.headers["x-origin-secret"] === originSecret
|
|
624
|
+
? { name: "Ada" }
|
|
625
|
+
: { message: "Forbidden" },
|
|
626
|
+
),
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const apiGateway = simAws.apiGatewayV2();
|
|
632
|
+
|
|
633
|
+
const { ApiId, ApiEndpoint } = await apiGateway.createApi(
|
|
634
|
+
new CreateApiCommand({ Name: "profile", ProtocolType: "HTTP" }),
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const { IntegrationId } = await apiGateway.createIntegration(
|
|
638
|
+
new CreateIntegrationCommand({
|
|
639
|
+
ApiId,
|
|
640
|
+
IntegrationType: "AWS_PROXY",
|
|
641
|
+
IntegrationUri: FunctionArn,
|
|
642
|
+
PayloadFormatVersion: "2.0",
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
await apiGateway.createRoute(
|
|
647
|
+
new CreateRouteCommand({
|
|
648
|
+
ApiId,
|
|
649
|
+
RouteKey: "GET /user/profile",
|
|
650
|
+
Target: `integrations/${IntegrationId}`,
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
await apiGateway.createStage(
|
|
655
|
+
new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
await simAws.lambda().addPermission(
|
|
659
|
+
new AddPermissionCommand({
|
|
660
|
+
FunctionName: "profile",
|
|
661
|
+
StatementId: "api-gateway-invoke",
|
|
662
|
+
Action: "lambda:InvokeFunction",
|
|
663
|
+
Principal: "apigateway.amazonaws.com",
|
|
664
|
+
SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
// A Distribution that sends the secret with every request to that Origin.
|
|
669
|
+
const distributionCreation = await simAws.cloudFront().createDistribution(
|
|
670
|
+
new CreateDistributionCommand({
|
|
671
|
+
DistributionConfig: {
|
|
672
|
+
CallerReference: "user-site",
|
|
673
|
+
Comment: "User API CDN",
|
|
674
|
+
Enabled: true,
|
|
675
|
+
Origins: {
|
|
676
|
+
Quantity: 1,
|
|
677
|
+
Items: [
|
|
678
|
+
{
|
|
679
|
+
Id: "api-origin",
|
|
680
|
+
DomainName: new URL(ApiEndpoint).hostname,
|
|
681
|
+
CustomOriginConfig: {
|
|
682
|
+
HTTPPort: 80,
|
|
683
|
+
HTTPSPort: 443,
|
|
684
|
+
OriginProtocolPolicy: "https-only",
|
|
685
|
+
},
|
|
686
|
+
CustomHeaders: {
|
|
687
|
+
Quantity: 1,
|
|
688
|
+
Items: [
|
|
689
|
+
{ HeaderName: "x-origin-secret", HeaderValue: originSecret },
|
|
690
|
+
],
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
DefaultCacheBehavior: {
|
|
696
|
+
TargetOriginId: "api-origin",
|
|
697
|
+
ViewerProtocolPolicy: "allow-all",
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
}),
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
const distroHostname = distributionCreation.Distribution!.DomainName!;
|
|
704
|
+
const srv = await serveSimAws({ simAws });
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
const throughCdn = await fetch(
|
|
708
|
+
srv.localUrl(`http://${distroHostname}/user/profile`),
|
|
709
|
+
);
|
|
710
|
+
const direct = await fetch(srv.localUrl(`${ApiEndpoint}/user/profile`));
|
|
711
|
+
|
|
712
|
+
// {"name":"Ada"}
|
|
713
|
+
console.log(await throughCdn.text());
|
|
714
|
+
// {"message":"Forbidden"}
|
|
715
|
+
console.log(await direct.text());
|
|
716
|
+
} finally {
|
|
717
|
+
await srv.close();
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Two rules follow CloudFront's own:
|
|
722
|
+
|
|
723
|
+
- A header the viewer already sent is overwritten with the Origin's value, whatever case the viewer
|
|
724
|
+
wrote it in. A viewer cannot reach the origin with a guessed secret by sending the header through
|
|
725
|
+
the Distribution.
|
|
726
|
+
- A header name CloudFront refuses to add fails the Distribution at create and fails the Stack at
|
|
727
|
+
deploy, naming the header. The
|
|
728
|
+
[denied names](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html#add-origin-custom-headers-denylist)
|
|
729
|
+
run from `Cache-Control` to `X-Real-Ip`, along with anything beginning `X-Amz-` or `X-Edge-`.
|
|
730
|
+
|
|
731
|
+
An S3 Origin takes the headers and reaches nothing with them. Sim CloudFront reads a Bucket through
|
|
732
|
+
`GetObject` and builds no HTTP request for a header to travel on, and real S3 ignores a header it
|
|
733
|
+
has no use for.
|
|
734
|
+
|
|
735
|
+
## An Origin declared twice
|
|
736
|
+
|
|
737
|
+
Two Origins over one domain name are ordinary. They differ by `OriginPath`, by their custom headers
|
|
738
|
+
or by how CloudFront connects, and a Behavior points at whichever one it wants.
|
|
739
|
+
|
|
740
|
+
Two Origins that match in every property but the `Id` are one Origin written twice. Copying a
|
|
741
|
+
Behavior and its Origin together, then editing the path pattern, leaves exactly that behind. Sim
|
|
742
|
+
CloudFront keys an Origin by `Id`, as CloudFront does, and serves both Behaviors alike. An account
|
|
743
|
+
has been seen to serve them differently, refusing every request on the second Behavior at the
|
|
744
|
+
Origin. Why it did that is unconfirmed.
|
|
745
|
+
|
|
746
|
+
The Distribution records each repeat as it is created or updated, and warns about it on the console.
|
|
747
|
+
Each entry in `redundantOrigins` names the Origin, the earlier Origin it repeats and the domain both
|
|
748
|
+
of them name. A test can assert the list is empty.
|
|
749
|
+
|
|
750
|
+
```typescript sim-cloudfront-redundant-origins
|
|
751
|
+
/**
|
|
752
|
+
* Catching an Origin a Distribution declares twice.
|
|
753
|
+
*/
|
|
754
|
+
|
|
755
|
+
import {
|
|
756
|
+
CreateDistributionCommand,
|
|
757
|
+
type Origin,
|
|
758
|
+
} from "@aws-sdk/client-cloudfront";
|
|
759
|
+
|
|
760
|
+
import { SimAws } from "@kensio/yulin";
|
|
761
|
+
|
|
762
|
+
const simCloudFront = new SimAws().cloudFront();
|
|
763
|
+
|
|
764
|
+
// The two Behaviors below were written by copying one of them, so the second
|
|
765
|
+
// Origin says everything the first one says.
|
|
766
|
+
const apiOrigin = (originId: string): Origin => ({
|
|
767
|
+
Id: originId,
|
|
768
|
+
DomainName: "api.example.test",
|
|
769
|
+
CustomOriginConfig: {
|
|
770
|
+
HTTPPort: 80,
|
|
771
|
+
HTTPSPort: 443,
|
|
772
|
+
OriginProtocolPolicy: "https-only",
|
|
773
|
+
},
|
|
774
|
+
CustomHeaders: {
|
|
775
|
+
Quantity: 1,
|
|
776
|
+
Items: [
|
|
777
|
+
{
|
|
778
|
+
HeaderName: "x-origin-secret",
|
|
779
|
+
HeaderValue: "5d6e2b0c6f564c1e9d5b2f1a5b8c9d70",
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const creation = await simCloudFront.createDistribution(
|
|
786
|
+
new CreateDistributionCommand({
|
|
787
|
+
DistributionConfig: {
|
|
788
|
+
CallerReference: "user-site",
|
|
789
|
+
Comment: "User API CDN",
|
|
790
|
+
Enabled: true,
|
|
791
|
+
Origins: {
|
|
792
|
+
Quantity: 2,
|
|
793
|
+
Items: [apiOrigin("live-origin"), apiOrigin("preview-origin")],
|
|
794
|
+
},
|
|
795
|
+
DefaultCacheBehavior: {
|
|
796
|
+
TargetOriginId: "live-origin",
|
|
797
|
+
ViewerProtocolPolicy: "allow-all",
|
|
798
|
+
},
|
|
799
|
+
CacheBehaviors: {
|
|
800
|
+
Quantity: 1,
|
|
801
|
+
Items: [
|
|
802
|
+
{
|
|
803
|
+
PathPattern: "/preview/*",
|
|
804
|
+
TargetOriginId: "preview-origin",
|
|
805
|
+
ViewerProtocolPolicy: "allow-all",
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
}),
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
const distribution = simCloudFront.getSimDistributionById(
|
|
814
|
+
creation.Distribution!.Id!,
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
// [
|
|
818
|
+
// {
|
|
819
|
+
// originId: "preview-origin",
|
|
820
|
+
// repeatsOriginId: "live-origin",
|
|
821
|
+
// domainName: "api.example.test",
|
|
822
|
+
// },
|
|
823
|
+
// ]
|
|
824
|
+
console.log(distribution?.redundantOrigins);
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Sameness is every property the config declares apart from the `Id`, and not only the properties the
|
|
828
|
+
simulation reads. An Origin differing by a connection setting sim CloudFront ignores is left alone.
|
|
829
|
+
A property written as an empty string counts as one left out, and custom headers count by name and
|
|
830
|
+
value however they were ordered or cased.
|
|
831
|
+
|
|
832
|
+
## Viewer certificates
|
|
833
|
+
|
|
834
|
+
A Distribution with alternate domain names needs an ACM certificate, and CloudFront accepts only
|
|
835
|
+
certain ones. Sim CloudFront applies the same rules. A Distribution that real CloudFront would
|
|
836
|
+
reject at deploy time is rejected here first, with `InvalidViewerCertificate`:
|
|
837
|
+
|
|
838
|
+
- the certificate must be in `us-east-1`, wherever the rest of your infrastructure lives
|
|
839
|
+
- the certificate must exist and be `ISSUED`
|
|
840
|
+
- every alternate domain name must be covered by the certificate's domain name or one of its subject
|
|
841
|
+
alternative names, with a wildcard covering exactly one label
|
|
842
|
+
|
|
843
|
+
The `us-east-1` rule is easy to miss, because nothing else in a stack cares about it. A Distribution
|
|
844
|
+
in `eu-west-2` with a certificate alongside it looks fine until CloudFront refuses it.
|
|
845
|
+
|
|
846
|
+
```typescript sim-cloudfront-viewer-certificate
|
|
847
|
+
/**
|
|
848
|
+
* Catching an ACM certificate CloudFront will not accept.
|
|
849
|
+
*/
|
|
850
|
+
|
|
851
|
+
import { RequestCertificateCommand } from "@aws-sdk/client-acm";
|
|
852
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
853
|
+
|
|
854
|
+
import { SimAws } from "@kensio/yulin";
|
|
855
|
+
|
|
856
|
+
const simAws = new SimAws();
|
|
857
|
+
|
|
858
|
+
// A certificate alongside the rest of the stack, rather than in us-east-1.
|
|
859
|
+
const requestOutput = await simAws
|
|
860
|
+
.region("eu-west-2")
|
|
861
|
+
.acm()
|
|
862
|
+
.requestCertificate(
|
|
863
|
+
new RequestCertificateCommand({ DomainName: "example.test" }),
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
await simAws.backgroundTasksComplete();
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
await simAws.cloudFront().createDistribution(
|
|
870
|
+
new CreateDistributionCommand({
|
|
871
|
+
DistributionConfig: {
|
|
872
|
+
CallerReference: "site-distribution",
|
|
873
|
+
Comment: "Site distribution",
|
|
874
|
+
Enabled: true,
|
|
875
|
+
Aliases: { Quantity: 1, Items: ["example.test"] },
|
|
876
|
+
Origins: { Quantity: 0, Items: [] },
|
|
877
|
+
DefaultCacheBehavior: {
|
|
878
|
+
TargetOriginId: "origin",
|
|
879
|
+
ViewerProtocolPolicy: "redirect-to-https",
|
|
880
|
+
},
|
|
881
|
+
ViewerCertificate: {
|
|
882
|
+
ACMCertificateArn: requestOutput.CertificateArn,
|
|
883
|
+
SSLSupportMethod: "sni-only",
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
}),
|
|
887
|
+
);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
// InvalidViewerCertificate: ... is in eu-west-2, but CloudFront only accepts
|
|
890
|
+
// ACM Certificates in us-east-1
|
|
891
|
+
console.log((error as Error).message);
|
|
892
|
+
}
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
The CloudFront API and CloudFormation capitalise this field differently, and sim CloudFront accepts
|
|
896
|
+
both. SDK calls use `ACMCertificateArn` and `SSLSupportMethod`, as above.
|
|
897
|
+
`AWS::CloudFront::Distribution` uses `AcmCertificateArn` and `SslSupportMethod`. A template or CDK
|
|
898
|
+
app works without changes.
|
|
899
|
+
|
|
900
|
+
A Distribution using `CloudFrontDefaultCertificate` needs no ACM certificate, and it goes
|
|
901
|
+
unchecked. A standalone `new SimCloudFront()` has no sim ACM to check against, and skips the check
|
|
902
|
+
as well.
|
|
903
|
+
|
|
904
|
+
## Disabling and deleting a Distribution
|
|
905
|
+
|
|
906
|
+
`DeleteDistributionCommand` removes a Distribution. CloudFront will only delete one that has stopped
|
|
907
|
+
serving. The sequence is `UpdateDistributionCommand` with `Enabled: false` first, then the deletion. Deleting an enabled Distribution answers `DistributionNotDisabled`, as it does in AWS.
|
|
908
|
+
|
|
909
|
+
`UpdateDistributionCommand` takes a whole `DistributionConfig`, and applies the update as a
|
|
910
|
+
replacement. Anything left out of the new config is dropped, including alternate domain names and
|
|
911
|
+
the default root object. Read the Distribution first, change the field you want, and send the config
|
|
912
|
+
back.
|
|
913
|
+
|
|
914
|
+
Once the Distribution is deleted, a request to its CloudFront domain or any of its alternate domain
|
|
915
|
+
names stops resolving to it, and those alternate domain names are free for another Distribution.
|
|
916
|
+
|
|
917
|
+
```typescript sim-cloudfront-delete-distribution
|
|
918
|
+
/**
|
|
919
|
+
* Disabling a simulated CloudFront Distribution and then deleting it.
|
|
920
|
+
*/
|
|
921
|
+
|
|
922
|
+
import {
|
|
923
|
+
CreateDistributionCommand,
|
|
924
|
+
DeleteDistributionCommand,
|
|
925
|
+
type DistributionConfig,
|
|
926
|
+
GetDistributionCommand,
|
|
927
|
+
UpdateDistributionCommand,
|
|
928
|
+
} from "@aws-sdk/client-cloudfront";
|
|
929
|
+
import { CreateBucketCommand } from "@aws-sdk/client-s3";
|
|
930
|
+
|
|
931
|
+
import { SimAws } from "@kensio/yulin";
|
|
932
|
+
|
|
933
|
+
const simAws = new SimAws();
|
|
934
|
+
const simCloudFront = simAws.cloudFront();
|
|
935
|
+
|
|
936
|
+
await simAws
|
|
937
|
+
.s3()
|
|
938
|
+
.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
|
|
939
|
+
|
|
940
|
+
const distributionConfig: DistributionConfig = {
|
|
941
|
+
CallerReference: "site-distribution",
|
|
942
|
+
Comment: "Site distribution",
|
|
943
|
+
Enabled: true,
|
|
944
|
+
Origins: {
|
|
945
|
+
Quantity: 1,
|
|
946
|
+
Items: [
|
|
947
|
+
{
|
|
948
|
+
Id: "site-origin",
|
|
949
|
+
DomainName: "site-bucket.s3.amazonaws.com",
|
|
950
|
+
S3OriginConfig: { OriginAccessIdentity: "" },
|
|
951
|
+
},
|
|
952
|
+
],
|
|
953
|
+
},
|
|
954
|
+
DefaultCacheBehavior: {
|
|
955
|
+
TargetOriginId: "site-origin",
|
|
956
|
+
ViewerProtocolPolicy: "allow-all",
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const created = await simCloudFront.createDistribution(
|
|
961
|
+
new CreateDistributionCommand({ DistributionConfig: distributionConfig }),
|
|
962
|
+
);
|
|
963
|
+
await simAws.backgroundTasksComplete();
|
|
964
|
+
|
|
965
|
+
const distributionId = created.Distribution?.Id;
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
await simCloudFront.deleteDistribution(
|
|
969
|
+
new DeleteDistributionCommand({ Id: distributionId }),
|
|
970
|
+
);
|
|
971
|
+
} catch (error) {
|
|
972
|
+
// DistributionNotDisabled: Sim CloudFront Distribution ... is enabled, so it
|
|
973
|
+
// cannot be deleted. Disable it with UpdateDistribution first.
|
|
974
|
+
console.log((error as Error).message);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Disable the Distribution, then delete it.
|
|
978
|
+
await simCloudFront.updateDistribution(
|
|
979
|
+
new UpdateDistributionCommand({
|
|
980
|
+
Id: distributionId,
|
|
981
|
+
DistributionConfig: { ...distributionConfig, Enabled: false },
|
|
982
|
+
}),
|
|
983
|
+
);
|
|
984
|
+
await simAws.backgroundTasksComplete();
|
|
985
|
+
|
|
986
|
+
await simCloudFront.deleteDistribution(
|
|
987
|
+
new DeleteDistributionCommand({ Id: distributionId }),
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
try {
|
|
991
|
+
await simCloudFront.getDistribution(
|
|
992
|
+
new GetDistributionCommand({ Id: distributionId }),
|
|
993
|
+
);
|
|
994
|
+
} catch (error) {
|
|
995
|
+
// NoSuchDistribution: No sim CloudFront Distribution with ID ...
|
|
996
|
+
console.log((error as Error).message);
|
|
997
|
+
}
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
`DeleteFunctionCommand` removes a CloudFront Function by name, and answers `NoSuchFunctionExists`
|
|
1001
|
+
when the name matches nothing. A cache Behavior still pointing at a deleted Function runs no
|
|
1002
|
+
Function code.
|
|
1003
|
+
|
|
1004
|
+
## Simulated CloudFront Functions
|
|
1005
|
+
|
|
1006
|
+
The sim CloudFront supports `viewer-request` and `viewer-response` CloudFront Functions.
|
|
1007
|
+
|
|
1008
|
+
A `viewer-response` Function runs for an Origin status below 400. CloudFront skips the
|
|
1009
|
+
viewer-response event once the Origin has answered 400 or higher (see
|
|
1010
|
+
[Limitations](#limitations)), and so does this simulation.
|
|
1011
|
+
|
|
1012
|
+
Use `makeCffFunctionCodeInput` to pass a JavaScript handler function to `CreateFunctionCommand`.
|
|
1013
|
+
|
|
1014
|
+
The `host` header a function sees is the hostname the request was made to CloudFront with, being the
|
|
1015
|
+
Distribution domain name or one of its alternate domain names. Requests served on localhost arrive
|
|
1016
|
+
with a Yulin-local host such as `distro123.cloudfront.net.sim-aws.localhost:52341`, and the local
|
|
1017
|
+
suffix and port are dropped before the function runs. A function building a URL from
|
|
1018
|
+
`event.request.headers.host.value` behaves as it would on AWS. As on AWS, `host` is read-only, and a
|
|
1019
|
+
host a function writes is discarded before the Origin sees it.
|
|
1020
|
+
|
|
1021
|
+
A header arriving more than once reaches the Function as one entry holding every value it arrived
|
|
1022
|
+
with. `value` carries the first, and `multiValue` carries all of them, the same shape a repeated
|
|
1023
|
+
query string parameter has. A response setting three cookies gives a viewer-response Function this:
|
|
1024
|
+
|
|
1025
|
+
```typescript
|
|
1026
|
+
event.response.headers["set-cookie"];
|
|
1027
|
+
// {
|
|
1028
|
+
// value: "session=abc123; Path=/",
|
|
1029
|
+
// multiValue: [
|
|
1030
|
+
// { value: "session=abc123; Path=/" },
|
|
1031
|
+
// { value: "state=; Max-Age=0" },
|
|
1032
|
+
// { value: "signed-in=1; Path=/" },
|
|
1033
|
+
// ],
|
|
1034
|
+
// }
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
A Function returning that response untouched leaves all three cookies on their way to the viewer. A
|
|
1038
|
+
Function writing `multiValue` sends one header per value in it, and CloudFront ignores `value` while
|
|
1039
|
+
both are there. Writing `value` on its own sends a single header.
|
|
1040
|
+
|
|
1041
|
+
A Function reads the query string as the viewer spelled it. `?q=%E5%AE%B6` arrives as
|
|
1042
|
+
`event.request.querystring.q.value === "%E5%AE%B6"`, `?q=a+b` keeps its plus, and a percent-encoded
|
|
1043
|
+
parameter name stays encoded. Whatever a Function leaves in `querystring` goes on to the Origin as
|
|
1044
|
+
it stands. A Function returning the request untouched forwards the query byte for byte, and one
|
|
1045
|
+
writing a value of its own encodes it (the same job it has on AWS).
|
|
1046
|
+
|
|
1047
|
+
```typescript sim-cloudfront-function
|
|
1048
|
+
/**
|
|
1049
|
+
* Simulated CloudFront Functions.
|
|
1050
|
+
*/
|
|
1051
|
+
|
|
1052
|
+
import {
|
|
1053
|
+
CreateDistributionCommand,
|
|
1054
|
+
CreateFunctionCommand,
|
|
1055
|
+
} from "@aws-sdk/client-cloudfront";
|
|
1056
|
+
import {
|
|
1057
|
+
CreateBucketCommand,
|
|
1058
|
+
PutBucketPolicyCommand,
|
|
1059
|
+
PutPublicAccessBlockCommand,
|
|
1060
|
+
} from "@aws-sdk/client-s3";
|
|
1061
|
+
|
|
1062
|
+
import { SimAws } from "@kensio/yulin";
|
|
1063
|
+
import {
|
|
1064
|
+
makeCffFunctionCodeInput,
|
|
1065
|
+
type CloudFrontFunction,
|
|
1066
|
+
} from "@kensio/yulin/cloudfront";
|
|
1067
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
1068
|
+
|
|
1069
|
+
const simAws = new SimAws();
|
|
1070
|
+
const srv = await serveSimAws({ simAws });
|
|
1071
|
+
|
|
1072
|
+
try {
|
|
1073
|
+
const simS3 = simAws.s3();
|
|
1074
|
+
const simCloudFront = simAws.cloudFront();
|
|
1075
|
+
|
|
1076
|
+
await simS3.createBucket(
|
|
1077
|
+
new CreateBucketCommand({
|
|
1078
|
+
Bucket: "foo-bucket",
|
|
1079
|
+
}),
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
// A CloudFront S3 Origin with no origin access control reads the Bucket
|
|
1083
|
+
// anonymously, so what it serves has to be publicly readable.
|
|
1084
|
+
await simS3.putPublicAccessBlock(
|
|
1085
|
+
new PutPublicAccessBlockCommand({
|
|
1086
|
+
Bucket: "foo-bucket",
|
|
1087
|
+
PublicAccessBlockConfiguration: {
|
|
1088
|
+
BlockPublicAcls: true,
|
|
1089
|
+
IgnorePublicAcls: true,
|
|
1090
|
+
},
|
|
1091
|
+
}),
|
|
1092
|
+
);
|
|
1093
|
+
await simS3.putBucketPolicy(
|
|
1094
|
+
new PutBucketPolicyCommand({
|
|
1095
|
+
Bucket: "foo-bucket",
|
|
1096
|
+
Policy: JSON.stringify({
|
|
1097
|
+
Version: "2012-10-17",
|
|
1098
|
+
Statement: {
|
|
1099
|
+
Effect: "Allow",
|
|
1100
|
+
Principal: "*",
|
|
1101
|
+
Action: "s3:GetObject",
|
|
1102
|
+
Resource: "arn:aws:s3:::foo-bucket/*",
|
|
1103
|
+
},
|
|
1104
|
+
}),
|
|
1105
|
+
}),
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
function viewerRequestFunction(
|
|
1109
|
+
event: CloudFrontFunction.ViewerRequestEvent,
|
|
1110
|
+
): CloudFrontFunction.Request | CloudFrontFunction.Response {
|
|
1111
|
+
if (event.request.uri === "/old-page.html") {
|
|
1112
|
+
return {
|
|
1113
|
+
statusCode: 302,
|
|
1114
|
+
statusDescription: "Found",
|
|
1115
|
+
headers: {
|
|
1116
|
+
location: {
|
|
1117
|
+
value: "https://example.test/new-page.html",
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return event.request;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const functionCreation = await simCloudFront.createFunction(
|
|
1127
|
+
new CreateFunctionCommand({
|
|
1128
|
+
Name: "redirect-old-page",
|
|
1129
|
+
FunctionConfig: {
|
|
1130
|
+
Comment: "Redirect old page",
|
|
1131
|
+
Runtime: "cloudfront-js-2.0",
|
|
1132
|
+
},
|
|
1133
|
+
FunctionCode: makeCffFunctionCodeInput(viewerRequestFunction),
|
|
1134
|
+
}),
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const distributionCreation = await simCloudFront.createDistribution(
|
|
1138
|
+
new CreateDistributionCommand({
|
|
1139
|
+
DistributionConfig: {
|
|
1140
|
+
CallerReference: "function-cdn",
|
|
1141
|
+
Comment: "Function CDN",
|
|
1142
|
+
Enabled: true,
|
|
1143
|
+
Origins: {
|
|
1144
|
+
Quantity: 1,
|
|
1145
|
+
Items: [
|
|
1146
|
+
{
|
|
1147
|
+
Id: "assets-origin",
|
|
1148
|
+
DomainName: "foo-bucket.s3.amazonaws.com",
|
|
1149
|
+
S3OriginConfig: {
|
|
1150
|
+
OriginAccessIdentity: "",
|
|
1151
|
+
},
|
|
1152
|
+
},
|
|
1153
|
+
],
|
|
1154
|
+
},
|
|
1155
|
+
DefaultCacheBehavior: {
|
|
1156
|
+
TargetOriginId: "assets-origin",
|
|
1157
|
+
ViewerProtocolPolicy: "allow-all",
|
|
1158
|
+
FunctionAssociations: {
|
|
1159
|
+
Quantity: 1,
|
|
1160
|
+
Items: [
|
|
1161
|
+
{
|
|
1162
|
+
EventType: "viewer-request",
|
|
1163
|
+
FunctionARN: functionCreation.FunctionMetadata.FunctionARN,
|
|
1164
|
+
},
|
|
1165
|
+
],
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
},
|
|
1169
|
+
}),
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
const distroHostname = distributionCreation.Distribution!.DomainName!;
|
|
1173
|
+
|
|
1174
|
+
const url = srv.localUrl(`http://${distroHostname}/old-page.html`);
|
|
1175
|
+
const response = await fetch(url, { redirect: "manual" });
|
|
1176
|
+
|
|
1177
|
+
console.log(response.status);
|
|
1178
|
+
console.log(response.headers.get("location"));
|
|
1179
|
+
} finally {
|
|
1180
|
+
await srv.close();
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
If your CloudFront Function code lives in a module that exports the handler, use
|
|
1185
|
+
`cloudFrontFunctionSourceFromModule` in your CDK Stack to load it as inline CloudFront Function
|
|
1186
|
+
code. This lets the same function file use an export like `export function handler(...)` while
|
|
1187
|
+
still being accepted by CloudFront Function inline code.
|
|
1188
|
+
|
|
1189
|
+
```typescript sim-cloudfront-function-module-export
|
|
1190
|
+
/**
|
|
1191
|
+
* cloudFrontFunctionSourceFromModule util function
|
|
1192
|
+
*/
|
|
1193
|
+
|
|
1194
|
+
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
1195
|
+
import { Stack } from "aws-cdk-lib";
|
|
1196
|
+
import type { Construct } from "constructs";
|
|
1197
|
+
|
|
1198
|
+
import { cloudFrontFunctionSourceFromModule } from "@kensio/yulin/cloudfront";
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Example CDK stack using cloudFrontFunctionSourceFromModule to extract source
|
|
1202
|
+
* code for a CloudFront Function handler from a module that uses `export`.
|
|
1203
|
+
*/
|
|
1204
|
+
export class WebsiteStack extends Stack {
|
|
1205
|
+
constructor(scope: Construct, id: string) {
|
|
1206
|
+
super(scope, id);
|
|
1207
|
+
|
|
1208
|
+
new cloudfront.Function(this, "RewriteFunction", {
|
|
1209
|
+
code: cloudfront.FunctionCode.fromInline(
|
|
1210
|
+
cloudFrontFunctionSourceFromModule("src/cff/rewrite.cff.js"),
|
|
1211
|
+
),
|
|
1212
|
+
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
The referenced CloudFront Function module can then keep an exported handler:
|
|
1219
|
+
|
|
1220
|
+
```javascript
|
|
1221
|
+
/**
|
|
1222
|
+
* @typedef {import("@kensio/yulin/cloudfront").CloudFrontFunction.Event} CloudFrontEvent
|
|
1223
|
+
* @typedef {import("@kensio/yulin/cloudfront").CloudFrontFunction.Request} CloudFrontRequest
|
|
1224
|
+
* @typedef {import("@kensio/yulin/cloudfront").CloudFrontFunction.Response} CloudFrontResponse
|
|
1225
|
+
*/
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Handles a CloudFront Functions viewer request event.
|
|
1229
|
+
* @param {CloudFrontEvent} event - The CloudFront Functions event object.
|
|
1230
|
+
* @returns {CloudFrontRequest|CloudFrontResponse} A CloudFront request object or response object.
|
|
1231
|
+
*/
|
|
1232
|
+
export function handler(event) {
|
|
1233
|
+
var request = event.request;
|
|
1234
|
+
var uri = request.uri;
|
|
1235
|
+
|
|
1236
|
+
if (uri.endsWith("/")) {
|
|
1237
|
+
request.uri += "index.html";
|
|
1238
|
+
} else if (!uri.includes(".") && !uri.endsWith("/")) {
|
|
1239
|
+
request.uri += "/index.html";
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return request;
|
|
1243
|
+
}
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
CloudFront Functions run JS2, ECMAScript 5.1 plus a named subset of ES 6 to 12. It refuses
|
|
1247
|
+
constructs ordinary JavaScript allows. Yulin publishes ESLint and Oxlint configs that report those
|
|
1248
|
+
refusals in the editor, ahead of publication. See
|
|
1249
|
+
[Linting CloudFront Functions JS2](https://yulinsim.dev/lint/ "CloudFront Functions JS2 lint config usage docs").
|
|
1250
|
+
|
|
1251
|
+
CloudFront also caps Function code at 10 KB, counted on the source as uploaded, comments and all.
|
|
1252
|
+
Simulated `CreateFunction` refuses anything larger with `FunctionSizeLimitExceeded`, as the real
|
|
1253
|
+
service does. A test that deploys the Stack reports the overrun where the rest of the suite runs,
|
|
1254
|
+
ahead of `cdk deploy`. A handler passed as a function reference carries no source to count, and the
|
|
1255
|
+
limit leaves it alone.
|
|
1256
|
+
|
|
1257
|
+
### Calling a Function handler without a Distribution
|
|
1258
|
+
|
|
1259
|
+
A test of the handler on its own, with no Distribution in front of it, still has to pass it a whole
|
|
1260
|
+
event. `cloudFrontViewerRequestEventFactory` and `cloudFrontViewerResponseEventFactory` make the
|
|
1261
|
+
two, so such a test says what the request or the response was and leaves the rest alone:
|
|
1262
|
+
|
|
1263
|
+
```typescript sim-cloudfront-function-event-factory
|
|
1264
|
+
/**
|
|
1265
|
+
* Making a CloudFront Functions event to call a handler with.
|
|
1266
|
+
*/
|
|
1267
|
+
|
|
1268
|
+
import { VariantFactory } from "@kensio/part-factory";
|
|
1269
|
+
|
|
1270
|
+
import {
|
|
1271
|
+
cloudFrontViewerResponseEventFactory,
|
|
1272
|
+
type CloudFrontFunction,
|
|
1273
|
+
} from "@kensio/yulin/cloudfront";
|
|
1274
|
+
|
|
1275
|
+
function securityHeadersHandler(
|
|
1276
|
+
event: CloudFrontFunction.ViewerResponseEvent,
|
|
1277
|
+
): CloudFrontFunction.Response {
|
|
1278
|
+
const response = event.response;
|
|
1279
|
+
const contentType = response.headers["content-type"]?.value ?? "";
|
|
1280
|
+
|
|
1281
|
+
if (contentType.startsWith("text/html")) {
|
|
1282
|
+
response.headers["x-frame-options"] = { value: "DENY" };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return response;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// A response carrying a page. Those are the ones the policy is about.
|
|
1289
|
+
const documentResponseFactory = new VariantFactory(
|
|
1290
|
+
cloudFrontViewerResponseEventFactory,
|
|
1291
|
+
{
|
|
1292
|
+
response: {
|
|
1293
|
+
headers: { "content-type": { value: "text/html; charset=utf-8" } },
|
|
1294
|
+
},
|
|
1295
|
+
},
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
const page = securityHeadersHandler(documentResponseFactory.make());
|
|
1299
|
+
|
|
1300
|
+
// DENY
|
|
1301
|
+
console.log(page.headers["x-frame-options"]?.value);
|
|
1302
|
+
|
|
1303
|
+
// One response, for a test about a single asset. Everything else about it, down
|
|
1304
|
+
// to the request that asked for it, is filled in as a served response's is.
|
|
1305
|
+
const asset = securityHeadersHandler(
|
|
1306
|
+
cloudFrontViewerResponseEventFactory.make({
|
|
1307
|
+
response: { headers: { "content-type": { value: "text/css" } } },
|
|
1308
|
+
}),
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
// undefined
|
|
1312
|
+
console.log(asset.headers["x-frame-options"]?.value);
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
The defaults describe a request for `/cloudfront/` reaching the Distribution, with a `host` of
|
|
1316
|
+
`yulin.test`, a session cookie and a viewer address. A viewer-response event carries the request
|
|
1317
|
+
that asked for it as well as the response, and the response's own defaults are a status code and no
|
|
1318
|
+
headers.
|
|
1319
|
+
|
|
1320
|
+
The [event factories page](https://yulinsim.dev/factories/ "Test factories for AWS event shapes usage docs")
|
|
1321
|
+
covers what the factories have in common.
|
|
1322
|
+
|
|
1323
|
+
## Simulated Lambda@Edge
|
|
1324
|
+
|
|
1325
|
+
A cache Behavior can run a Lambda function at any of CloudFront's four events through
|
|
1326
|
+
`LambdaFunctionAssociations`. Those are `viewer-request` and `viewer-response` at the edge, and
|
|
1327
|
+
`origin-request` and `origin-response` either side of the Origin fetch. Where a CloudFront Function
|
|
1328
|
+
is a small piece of JavaScript running in CloudFront's own runtime, a Lambda@Edge function is an
|
|
1329
|
+
ordinary simulated Lambda function, with an execution role, an environment and whatever SDK calls
|
|
1330
|
+
its handler makes.
|
|
1331
|
+
|
|
1332
|
+
Three things about Lambda@Edge catch people out on AWS, and simulated CloudFront refuses all three
|
|
1333
|
+
the way AWS refuses them, when the Distribution is written rather than when a request arrives.
|
|
1334
|
+
|
|
1335
|
+
- **The function lives in `us-east-1`**, wherever the rest of the stack lives.
|
|
1336
|
+
- **The association names a published version**, such as `:1`. An unqualified ARN, `$LATEST` and an
|
|
1337
|
+
alias are each refused.
|
|
1338
|
+
- **The execution role trusts `edgelambda.amazonaws.com`** as well as `lambda.amazonaws.com`. A role
|
|
1339
|
+
set up for an ordinary function is the usual reason a first Lambda@Edge deploy fails.
|
|
1340
|
+
|
|
1341
|
+
```typescript sim-cloudfront-lambda-edge
|
|
1342
|
+
/**
|
|
1343
|
+
* A Lambda@Edge function rewriting a request at the viewer.
|
|
1344
|
+
*/
|
|
1345
|
+
|
|
1346
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
1347
|
+
import { CreateRoleCommand } from "@aws-sdk/client-iam";
|
|
1348
|
+
import {
|
|
1349
|
+
CreateFunctionCommand,
|
|
1350
|
+
PublishVersionCommand,
|
|
1351
|
+
} from "@aws-sdk/client-lambda";
|
|
1352
|
+
|
|
1353
|
+
import { SimAws } from "@kensio/yulin";
|
|
1354
|
+
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
|
|
1355
|
+
import type { LambdaAtEdge } from "@kensio/yulin/cloudfront";
|
|
1356
|
+
|
|
1357
|
+
const simAws = new SimAws();
|
|
1358
|
+
|
|
1359
|
+
// A Lambda@Edge execution role trusts both service principals.
|
|
1360
|
+
const role = await simAws.iam().createRole(
|
|
1361
|
+
new CreateRoleCommand({
|
|
1362
|
+
RoleName: "EdgeRewriteRole",
|
|
1363
|
+
AssumeRolePolicyDocument: JSON.stringify({
|
|
1364
|
+
Version: "2012-10-17",
|
|
1365
|
+
Statement: {
|
|
1366
|
+
Effect: "Allow",
|
|
1367
|
+
Principal: {
|
|
1368
|
+
Service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"],
|
|
1369
|
+
},
|
|
1370
|
+
Action: "sts:AssumeRole",
|
|
1371
|
+
},
|
|
1372
|
+
}),
|
|
1373
|
+
}),
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
// The function has to be in us-east-1, and the Behavior names a version.
|
|
1377
|
+
const edgeLambda = simAws.region("us-east-1").lambda();
|
|
1378
|
+
|
|
1379
|
+
await edgeLambda.createFunction(
|
|
1380
|
+
new CreateFunctionCommand({
|
|
1381
|
+
FunctionName: "rewrite-uri",
|
|
1382
|
+
Role: role.Role.Arn,
|
|
1383
|
+
Code: {
|
|
1384
|
+
ZipFile: makeLambdaZipFileInput((event: LambdaAtEdge.RequestEvent) => {
|
|
1385
|
+
const { request } = event.Records[0].cf;
|
|
1386
|
+
|
|
1387
|
+
// A header is a list keyed by its lowercase name, and a status is a
|
|
1388
|
+
// string. Both differ from the CloudFront Functions shapes.
|
|
1389
|
+
if (request.headers["x-preview"]?.[0]?.value === "1") {
|
|
1390
|
+
return {
|
|
1391
|
+
status: "302",
|
|
1392
|
+
headers: {
|
|
1393
|
+
location: [{ key: "Location", value: "/preview.html" }],
|
|
1394
|
+
},
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
request.uri = "/index.html";
|
|
1399
|
+
|
|
1400
|
+
return request;
|
|
1401
|
+
}),
|
|
1402
|
+
},
|
|
1403
|
+
}),
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
const version = await edgeLambda.publishVersion(
|
|
1407
|
+
new PublishVersionCommand({ FunctionName: "rewrite-uri" }),
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
await simAws.cloudFront().createDistribution(
|
|
1411
|
+
new CreateDistributionCommand({
|
|
1412
|
+
DistributionConfig: {
|
|
1413
|
+
CallerReference: "edge-rewrite",
|
|
1414
|
+
Comment: "Rewriting at the viewer",
|
|
1415
|
+
Enabled: true,
|
|
1416
|
+
Origins: {
|
|
1417
|
+
Quantity: 1,
|
|
1418
|
+
Items: [
|
|
1419
|
+
{
|
|
1420
|
+
Id: "site-origin",
|
|
1421
|
+
DomainName: "edge-site.s3.amazonaws.com",
|
|
1422
|
+
S3OriginConfig: { OriginAccessIdentity: "" },
|
|
1423
|
+
},
|
|
1424
|
+
],
|
|
1425
|
+
},
|
|
1426
|
+
DefaultCacheBehavior: {
|
|
1427
|
+
TargetOriginId: "site-origin",
|
|
1428
|
+
ViewerProtocolPolicy: "allow-all",
|
|
1429
|
+
LambdaFunctionAssociations: {
|
|
1430
|
+
Quantity: 1,
|
|
1431
|
+
Items: [
|
|
1432
|
+
{
|
|
1433
|
+
EventType: "viewer-request",
|
|
1434
|
+
LambdaFunctionARN: version.FunctionArn,
|
|
1435
|
+
IncludeBody: false,
|
|
1436
|
+
},
|
|
1437
|
+
],
|
|
1438
|
+
},
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
}),
|
|
1442
|
+
);
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
A `viewer-request` handler returning the request carries on to the Origin with whatever it changed.
|
|
1446
|
+
Returning a response answers the viewer there and then, and the Origin is never read. A
|
|
1447
|
+
`viewer-response` handler returns the response the viewer gets.
|
|
1448
|
+
|
|
1449
|
+
Set `IncludeBody` to give a `viewer-request` or `origin-request` handler the request body, which
|
|
1450
|
+
arrives base64 encoded under `request.body.data`. A handler setting `request.body.action` to
|
|
1451
|
+
`replace` sends its own body to the Origin. The field belongs to the two request events, and an
|
|
1452
|
+
association setting it on `viewer-response` or `origin-response` is refused, as CloudFront refuses
|
|
1453
|
+
one.
|
|
1454
|
+
|
|
1455
|
+
A handler that throws answers the viewer with a 502, as CloudFront answers a failed edge function.
|
|
1456
|
+
The error reaches the function's own output and nothing else.
|
|
1457
|
+
|
|
1458
|
+
### The origin events
|
|
1459
|
+
|
|
1460
|
+
An `origin-request` function runs after the Behavior has resolved the Origin and before the fetch.
|
|
1461
|
+
Its event carries `request.origin`, holding the Origin the fetch is about to read, under `custom` or
|
|
1462
|
+
`s3` for the kind it is. A handler rewriting `origin.custom.domainName` sends the fetch to another
|
|
1463
|
+
Origin, and one rewriting `path` reads under another prefix. A header added to `customHeaders`
|
|
1464
|
+
reaches the Origin and the viewer never sees it. A handler returning a response answers the viewer
|
|
1465
|
+
with the Origin unread.
|
|
1466
|
+
|
|
1467
|
+
An `origin-response` function runs after the fetch and before the custom error page replaces an
|
|
1468
|
+
error status. It runs on whatever the Origin answered, including a 400 and above. That is where the
|
|
1469
|
+
origin events differ from the viewer events, and CloudFront documents it. Returning a response
|
|
1470
|
+
replaces what the viewer gets.
|
|
1471
|
+
|
|
1472
|
+
At both origin events the `host` header holds the Origin's own domain name. A viewer event shows the
|
|
1473
|
+
domain the viewer used.
|
|
1474
|
+
|
|
1475
|
+
```typescript sim-cloudfront-lambda-edge-origin
|
|
1476
|
+
/**
|
|
1477
|
+
* A Lambda@Edge function choosing the Origin, and another one stamping what
|
|
1478
|
+
* that Origin answered.
|
|
1479
|
+
*/
|
|
1480
|
+
|
|
1481
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
1482
|
+
import { CreateRoleCommand } from "@aws-sdk/client-iam";
|
|
1483
|
+
import {
|
|
1484
|
+
CreateFunctionCommand,
|
|
1485
|
+
PublishVersionCommand,
|
|
1486
|
+
} from "@aws-sdk/client-lambda";
|
|
1487
|
+
|
|
1488
|
+
import { SimAws } from "@kensio/yulin";
|
|
1489
|
+
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
|
|
1490
|
+
import type { LambdaAtEdge } from "@kensio/yulin/cloudfront";
|
|
1491
|
+
|
|
1492
|
+
const simAws = new SimAws();
|
|
1493
|
+
|
|
1494
|
+
// A Lambda@Edge execution role trusts both service principals, at the origin
|
|
1495
|
+
// events as at the viewer events.
|
|
1496
|
+
const role = await simAws.iam().createRole(
|
|
1497
|
+
new CreateRoleCommand({
|
|
1498
|
+
RoleName: "EdgeOriginRole",
|
|
1499
|
+
AssumeRolePolicyDocument: JSON.stringify({
|
|
1500
|
+
Version: "2012-10-17",
|
|
1501
|
+
Statement: {
|
|
1502
|
+
Effect: "Allow",
|
|
1503
|
+
Principal: {
|
|
1504
|
+
Service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"],
|
|
1505
|
+
},
|
|
1506
|
+
Action: "sts:AssumeRole",
|
|
1507
|
+
},
|
|
1508
|
+
}),
|
|
1509
|
+
}),
|
|
1510
|
+
);
|
|
1511
|
+
|
|
1512
|
+
const edgeLambda = simAws.region("us-east-1").lambda();
|
|
1513
|
+
|
|
1514
|
+
await edgeLambda.createFunction(
|
|
1515
|
+
new CreateFunctionCommand({
|
|
1516
|
+
FunctionName: "route-origin",
|
|
1517
|
+
Role: role.Role.Arn,
|
|
1518
|
+
Code: {
|
|
1519
|
+
ZipFile: makeLambdaZipFileInput(
|
|
1520
|
+
(event: LambdaAtEdge.OriginRequestEvent) => {
|
|
1521
|
+
const { request } = event.Records[0].cf;
|
|
1522
|
+
const { custom } = request.origin;
|
|
1523
|
+
|
|
1524
|
+
if (custom === undefined) {
|
|
1525
|
+
return request;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// Everything under /api is served by the second Origin.
|
|
1529
|
+
if (request.uri.startsWith("/api/")) {
|
|
1530
|
+
custom.domainName = "orders.example.test";
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// A header the viewer never sent and never sees.
|
|
1534
|
+
custom.customHeaders["x-from-cloudfront"] = [
|
|
1535
|
+
{ key: "X-From-CloudFront", value: "yes" },
|
|
1536
|
+
];
|
|
1537
|
+
|
|
1538
|
+
return request;
|
|
1539
|
+
},
|
|
1540
|
+
),
|
|
1541
|
+
},
|
|
1542
|
+
}),
|
|
1543
|
+
);
|
|
1544
|
+
|
|
1545
|
+
const routeVersion = await edgeLambda.publishVersion(
|
|
1546
|
+
new PublishVersionCommand({ FunctionName: "route-origin" }),
|
|
1547
|
+
);
|
|
1548
|
+
|
|
1549
|
+
await edgeLambda.createFunction(
|
|
1550
|
+
new CreateFunctionCommand({
|
|
1551
|
+
FunctionName: "stamp-origin-response",
|
|
1552
|
+
Role: role.Role.Arn,
|
|
1553
|
+
Code: {
|
|
1554
|
+
ZipFile: makeLambdaZipFileInput(
|
|
1555
|
+
(event: LambdaAtEdge.OriginResponseEvent): LambdaAtEdge.Response => {
|
|
1556
|
+
const { response } = event.Records[0].cf;
|
|
1557
|
+
|
|
1558
|
+
// This runs for an Origin error too, so the status is worth keeping.
|
|
1559
|
+
return {
|
|
1560
|
+
...response,
|
|
1561
|
+
headers: {
|
|
1562
|
+
...response.headers,
|
|
1563
|
+
"x-origin-status": [
|
|
1564
|
+
{ key: "X-Origin-Status", value: response.status },
|
|
1565
|
+
],
|
|
1566
|
+
},
|
|
1567
|
+
};
|
|
1568
|
+
},
|
|
1569
|
+
),
|
|
1570
|
+
},
|
|
1571
|
+
}),
|
|
1572
|
+
);
|
|
1573
|
+
|
|
1574
|
+
const stampVersion = await edgeLambda.publishVersion(
|
|
1575
|
+
new PublishVersionCommand({ FunctionName: "stamp-origin-response" }),
|
|
1576
|
+
);
|
|
1577
|
+
|
|
1578
|
+
const customOriginConfig = {
|
|
1579
|
+
HTTPPort: 80,
|
|
1580
|
+
HTTPSPort: 443,
|
|
1581
|
+
OriginProtocolPolicy: "https-only",
|
|
1582
|
+
} as const;
|
|
1583
|
+
|
|
1584
|
+
await simAws.cloudFront().createDistribution(
|
|
1585
|
+
new CreateDistributionCommand({
|
|
1586
|
+
DistributionConfig: {
|
|
1587
|
+
CallerReference: "edge-origin-routing",
|
|
1588
|
+
Comment: "Choosing the Origin at the edge",
|
|
1589
|
+
Enabled: true,
|
|
1590
|
+
Origins: {
|
|
1591
|
+
Quantity: 2,
|
|
1592
|
+
Items: [
|
|
1593
|
+
{
|
|
1594
|
+
Id: "site-origin",
|
|
1595
|
+
DomainName: "site.example.test",
|
|
1596
|
+
CustomOriginConfig: customOriginConfig,
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
Id: "orders-origin",
|
|
1600
|
+
DomainName: "orders.example.test",
|
|
1601
|
+
CustomOriginConfig: customOriginConfig,
|
|
1602
|
+
},
|
|
1603
|
+
],
|
|
1604
|
+
},
|
|
1605
|
+
DefaultCacheBehavior: {
|
|
1606
|
+
TargetOriginId: "site-origin",
|
|
1607
|
+
ViewerProtocolPolicy: "allow-all",
|
|
1608
|
+
LambdaFunctionAssociations: {
|
|
1609
|
+
Quantity: 2,
|
|
1610
|
+
Items: [
|
|
1611
|
+
{
|
|
1612
|
+
EventType: "origin-request",
|
|
1613
|
+
LambdaFunctionARN: routeVersion.FunctionArn,
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
EventType: "origin-response",
|
|
1617
|
+
LambdaFunctionARN: stampVersion.FunctionArn,
|
|
1618
|
+
},
|
|
1619
|
+
],
|
|
1620
|
+
},
|
|
1621
|
+
},
|
|
1622
|
+
},
|
|
1623
|
+
}),
|
|
1624
|
+
);
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
Two Origin rewrites are refused, and the viewer gets the 502 a failed edge function gets, carrying
|
|
1628
|
+
the reason (see [Limitations](#limitations)). One switches an Origin between `custom` and `s3`. The
|
|
1629
|
+
other moves an S3 Origin to another Bucket.
|
|
1630
|
+
|
|
1631
|
+
### Which edge function runs where
|
|
1632
|
+
|
|
1633
|
+
CloudFront takes one edge function per event type, and it does not combine CloudFront Functions with
|
|
1634
|
+
Lambda@Edge at the viewer events. A Behavior with a viewer-request CloudFront Function and a
|
|
1635
|
+
viewer-response Lambda@Edge function is refused, and so is a Behavior naming both at one event type.
|
|
1636
|
+
Simulated CloudFront refuses the same combinations. The rule stops at the viewer. A viewer-request
|
|
1637
|
+
CloudFront Function runs alongside a Lambda@Edge function on either origin event.
|
|
1638
|
+
|
|
1639
|
+
Neither kind runs at the viewer response once the Origin has answered 400 or higher. CloudFront
|
|
1640
|
+
skips that event for an Origin error, and the status the Origin returned is what decides it (see
|
|
1641
|
+
[Limitations](#limitations)).
|
|
1642
|
+
|
|
1643
|
+
Both kinds of function see the `host` header as the hostname the viewer reached CloudFront with,
|
|
1644
|
+
rather than the Yulin-local host a request served on localhost arrives with. As on AWS, `host` is
|
|
1645
|
+
read-only at the viewer request, and a host a handler writes is discarded before the Origin sees it.
|
|
1646
|
+
|
|
1647
|
+
### From CloudFormation
|
|
1648
|
+
|
|
1649
|
+
`AWS::CloudFront::Distribution` takes `LambdaFunctionAssociations` on `DefaultCacheBehavior` and on
|
|
1650
|
+
any entry of `CacheBehaviors`. CloudFormation writes the list as a plain array where the SDK writes
|
|
1651
|
+
the `Quantity` and `Items` pair. `Ref` on an `AWS::Lambda::Version` answers the qualified function
|
|
1652
|
+
ARN. That is the value an association names, and the two fit together directly:
|
|
1653
|
+
|
|
1654
|
+
```yaml
|
|
1655
|
+
EdgeVersion:
|
|
1656
|
+
Type: AWS::Lambda::Version
|
|
1657
|
+
Properties:
|
|
1658
|
+
FunctionName: !Ref RewriteFunction
|
|
1659
|
+
|
|
1660
|
+
SiteDistribution:
|
|
1661
|
+
Type: AWS::CloudFront::Distribution
|
|
1662
|
+
Properties:
|
|
1663
|
+
DistributionConfig:
|
|
1664
|
+
DefaultCacheBehavior:
|
|
1665
|
+
TargetOriginId: SiteOrigin
|
|
1666
|
+
ViewerProtocolPolicy: allow-all
|
|
1667
|
+
LambdaFunctionAssociations:
|
|
1668
|
+
- EventType: viewer-request
|
|
1669
|
+
LambdaFunctionARN: !Ref EdgeVersion
|
|
1670
|
+
```
|
|
1671
|
+
|
|
1672
|
+
The function still has to live in us-east-1. A stack holding one is a us-east-1 stack.
|
|
1673
|
+
|
|
1674
|
+
An association naming a function version this simulation does not hold is left out of the deployed
|
|
1675
|
+
Distribution and recorded on `stack.ignoredProperties`, under the event type it was on. A template
|
|
1676
|
+
pointing at a function in a real account is the usual reason. The rest of the Behavior deploys, and
|
|
1677
|
+
the event the skipped association was on is left empty. A test that cares reads the record.
|
|
1678
|
+
|
|
1679
|
+
Everything real CloudFront refuses still fails the deployment. A function outside us-east-1, an ARN
|
|
1680
|
+
without a version qualifier, an execution role missing the `edgelambda.amazonaws.com` trust, an
|
|
1681
|
+
`EventType` that is none of CloudFront's four, two functions on one event type and a viewer event
|
|
1682
|
+
running both kinds of edge function each fail a real deploy of the same template.
|
|
1683
|
+
|
|
1684
|
+
CDK reaches a Behavior through `edgeLambdas`, given a `lambda.Version` from the same stack:
|
|
1685
|
+
|
|
1686
|
+
```typescript sim-cloudfront-lambda-edge-cdk
|
|
1687
|
+
/**
|
|
1688
|
+
* A CDK Distribution running a Lambda@Edge function at the viewer request.
|
|
1689
|
+
*/
|
|
1690
|
+
|
|
1691
|
+
import { Stack } from "aws-cdk-lib";
|
|
1692
|
+
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
1693
|
+
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
1694
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
1695
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
1696
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
1697
|
+
import type { Construct } from "constructs";
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Example CDK stack whose Distribution rewrites every request at the edge.
|
|
1701
|
+
*
|
|
1702
|
+
* The stack is in us-east-1, the one Region CloudFront runs a Lambda@Edge
|
|
1703
|
+
* function from.
|
|
1704
|
+
*/
|
|
1705
|
+
export class SiteStack extends Stack {
|
|
1706
|
+
constructor(scope: Construct, id: string) {
|
|
1707
|
+
super(scope, id, { env: { region: "us-east-1" } });
|
|
1708
|
+
|
|
1709
|
+
const siteBucket = new s3.Bucket(this, "SiteBucket");
|
|
1710
|
+
|
|
1711
|
+
// A Lambda@Edge execution role trusts both service principals.
|
|
1712
|
+
const edgeRole = new iam.Role(this, "EdgeRole", {
|
|
1713
|
+
assumedBy: new iam.CompositePrincipal(
|
|
1714
|
+
new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
1715
|
+
new iam.ServicePrincipal("edgelambda.amazonaws.com"),
|
|
1716
|
+
),
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
const rewriteFunction = new lambda.Function(this, "RewriteFunction", {
|
|
1720
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
1721
|
+
handler: "index.handler",
|
|
1722
|
+
role: edgeRole,
|
|
1723
|
+
code: lambda.Code.fromInline(`
|
|
1724
|
+
exports.handler = async (event) => {
|
|
1725
|
+
const { request } = event.Records[0].cf;
|
|
1726
|
+
request.uri = "/index.html";
|
|
1727
|
+
return request;
|
|
1728
|
+
};
|
|
1729
|
+
`),
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
new cloudfront.Distribution(this, "SiteDistribution", {
|
|
1733
|
+
defaultBehavior: {
|
|
1734
|
+
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
|
|
1735
|
+
edgeLambdas: [
|
|
1736
|
+
{
|
|
1737
|
+
// edgeLambdas takes a published version, and currentVersion
|
|
1738
|
+
// is one.
|
|
1739
|
+
functionVersion: rewriteFunction.currentVersion,
|
|
1740
|
+
eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
|
|
1741
|
+
},
|
|
1742
|
+
],
|
|
1743
|
+
},
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
```
|
|
1748
|
+
|
|
1749
|
+
`cloudfront.experimental.EdgeFunction` deploys from a us-east-1 stack, where the construct creates
|
|
1750
|
+
the function alongside everything else.
|
|
1751
|
+
|
|
1752
|
+
From a stack in any other Region the construct writes the function, its published version and an SSM
|
|
1753
|
+
parameter holding the version ARN into a support stack in us-east-1. The stack using the function
|
|
1754
|
+
reads that parameter back through a `Custom::CrossRegionStringParameterReader` resource, and the
|
|
1755
|
+
Behavior's `LambdaFunctionARN` is an `Fn::GetAtt` on it. Simulated CloudFormation makes that read
|
|
1756
|
+
itself, against the Region the resource names, and the association ends up holding the ARN the
|
|
1757
|
+
support stack published. Both stacks have to deploy, which is what deploying the whole cloud
|
|
1758
|
+
assembly does:
|
|
1759
|
+
|
|
1760
|
+
```typescript
|
|
1761
|
+
await simAws.cloudFormation().deployCdkOut("cdk.out");
|
|
1762
|
+
```
|
|
1763
|
+
|
|
1764
|
+
Deploy the using stack's template on its own and no parameter has been written. The read finds
|
|
1765
|
+
nothing and says so on `stack.ignoredProperties`, the Behavior deploys without the association, and
|
|
1766
|
+
the site serves from the Origin (see [Limitations](#limitations)).
|
|
1767
|
+
|
|
1768
|
+
## Web ACLs
|
|
1769
|
+
|
|
1770
|
+
A Distribution can put a WAFv2 web ACL in front of everything it serves. Name the web ACL's ARN in
|
|
1771
|
+
`WebACLId` and the Distribution evaluates it against every request that arrives. A request the web
|
|
1772
|
+
ACL blocks gets 403 from the edge. A request it allows carries on to the cache Behavior and the
|
|
1773
|
+
Origin.
|
|
1774
|
+
|
|
1775
|
+
CloudFront takes its web ACL this way. WAFv2's `AssociateWebACL` covers the regional resource types.
|
|
1776
|
+
|
|
1777
|
+
The web ACL has to be a `CLOUDFRONT` scope one, created in `us-east-1` (see
|
|
1778
|
+
[scopes](https://yulinsim.dev/services/wafv2/#scopes)). A `WebACLId` naming a `REGIONAL` web ACL, or one this
|
|
1779
|
+
simulation never created, is refused with `InvalidWebACLId` at `CreateDistribution` and at
|
|
1780
|
+
`UpdateDistribution`.
|
|
1781
|
+
|
|
1782
|
+
The web ACL decides before any other stage sees the request. A blocked request never reaches a
|
|
1783
|
+
viewer-request CloudFront Function, a cache Behavior, a response headers policy or the Origin.
|
|
1784
|
+
|
|
1785
|
+
A CloudFormation Distribution naming a web ACL this simulation does not hold deploys without one.
|
|
1786
|
+
The `WebACLId` lands on `stack.ignoredProperties` and every request is served, including the ones
|
|
1787
|
+
the web ACL would have decided. A template naming a web ACL from a real account is ordinary, and a
|
|
1788
|
+
site that failed to deploy over its firewall would cost a local dev server and a test suite every
|
|
1789
|
+
request they make. `CreateDistribution` still refuses the same `WebACLId`, as real CloudFront
|
|
1790
|
+
refuses it.
|
|
1791
|
+
|
|
1792
|
+
```typescript sim-cloudfront-web-acl
|
|
1793
|
+
/**
|
|
1794
|
+
* Blocking a request to a Distribution with a web ACL.
|
|
1795
|
+
*/
|
|
1796
|
+
|
|
1797
|
+
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
|
|
1798
|
+
import {
|
|
1799
|
+
CreateBucketCommand,
|
|
1800
|
+
PutBucketPolicyCommand,
|
|
1801
|
+
PutObjectCommand,
|
|
1802
|
+
} from "@aws-sdk/client-s3";
|
|
1803
|
+
import { CreateWebACLCommand } from "@aws-sdk/client-wafv2";
|
|
1804
|
+
|
|
1805
|
+
import { SimAws } from "@kensio/yulin";
|
|
1806
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
1807
|
+
|
|
1808
|
+
const simAws = new SimAws();
|
|
1809
|
+
const srv = await serveSimAws({ simAws });
|
|
1810
|
+
|
|
1811
|
+
try {
|
|
1812
|
+
const simS3 = simAws.s3();
|
|
1813
|
+
await simS3.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
|
|
1814
|
+
await simS3.putBucketPolicy(
|
|
1815
|
+
new PutBucketPolicyCommand({
|
|
1816
|
+
Bucket: "site-bucket",
|
|
1817
|
+
Policy: JSON.stringify({
|
|
1818
|
+
Version: "2012-10-17",
|
|
1819
|
+
Statement: {
|
|
1820
|
+
Effect: "Allow",
|
|
1821
|
+
Principal: "*",
|
|
1822
|
+
Action: "s3:GetObject",
|
|
1823
|
+
Resource: "arn:aws:s3:::site-bucket/*",
|
|
1824
|
+
},
|
|
1825
|
+
}),
|
|
1826
|
+
}),
|
|
1827
|
+
);
|
|
1828
|
+
await simS3.putObject(
|
|
1829
|
+
new PutObjectCommand({
|
|
1830
|
+
Bucket: "site-bucket",
|
|
1831
|
+
Key: "admin/users.html",
|
|
1832
|
+
ContentType: "text/html",
|
|
1833
|
+
Body: "<h1>Users</h1>",
|
|
1834
|
+
}),
|
|
1835
|
+
);
|
|
1836
|
+
|
|
1837
|
+
// A CLOUDFRONT scope web ACL lives in us-east-1, wherever the Distribution
|
|
1838
|
+
// was created from.
|
|
1839
|
+
const acl = await simAws
|
|
1840
|
+
.accountRegionScope(simAws.defaultAccountId, "us-east-1")
|
|
1841
|
+
.wafV2()
|
|
1842
|
+
.createWebAcl(
|
|
1843
|
+
new CreateWebACLCommand({
|
|
1844
|
+
Name: "site-acl",
|
|
1845
|
+
Scope: "CLOUDFRONT",
|
|
1846
|
+
DefaultAction: { Allow: {} },
|
|
1847
|
+
VisibilityConfig: {
|
|
1848
|
+
SampledRequestsEnabled: false,
|
|
1849
|
+
CloudWatchMetricsEnabled: false,
|
|
1850
|
+
MetricName: "site",
|
|
1851
|
+
},
|
|
1852
|
+
Rules: [
|
|
1853
|
+
{
|
|
1854
|
+
Name: "block-admin",
|
|
1855
|
+
Priority: 0,
|
|
1856
|
+
Action: { Block: {} },
|
|
1857
|
+
Statement: {
|
|
1858
|
+
ByteMatchStatement: {
|
|
1859
|
+
FieldToMatch: { UriPath: {} },
|
|
1860
|
+
PositionalConstraint: "STARTS_WITH",
|
|
1861
|
+
SearchString: Buffer.from("/admin"),
|
|
1862
|
+
TextTransformations: [{ Priority: 0, Type: "LOWERCASE" }],
|
|
1863
|
+
},
|
|
1864
|
+
},
|
|
1865
|
+
VisibilityConfig: {
|
|
1866
|
+
SampledRequestsEnabled: false,
|
|
1867
|
+
CloudWatchMetricsEnabled: false,
|
|
1868
|
+
MetricName: "block-admin",
|
|
1869
|
+
},
|
|
1870
|
+
},
|
|
1871
|
+
],
|
|
1872
|
+
}),
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
const creation = await simAws.cloudFront().createDistribution(
|
|
1876
|
+
new CreateDistributionCommand({
|
|
1877
|
+
DistributionConfig: {
|
|
1878
|
+
CallerReference: "guarded-site",
|
|
1879
|
+
Comment: "Site behind a web ACL",
|
|
1880
|
+
Enabled: true,
|
|
1881
|
+
WebACLId: acl.Summary!.ARN,
|
|
1882
|
+
Origins: {
|
|
1883
|
+
Quantity: 1,
|
|
1884
|
+
Items: [
|
|
1885
|
+
{
|
|
1886
|
+
Id: "site-origin",
|
|
1887
|
+
DomainName: "site-bucket.s3.amazonaws.com",
|
|
1888
|
+
S3OriginConfig: { OriginAccessIdentity: "" },
|
|
1889
|
+
},
|
|
1890
|
+
],
|
|
1891
|
+
},
|
|
1892
|
+
DefaultCacheBehavior: {
|
|
1893
|
+
TargetOriginId: "site-origin",
|
|
1894
|
+
ViewerProtocolPolicy: "allow-all",
|
|
1895
|
+
},
|
|
1896
|
+
},
|
|
1897
|
+
}),
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
const distroHostname = creation.Distribution!.DomainName!;
|
|
1901
|
+
|
|
1902
|
+
const blocked = await fetch(
|
|
1903
|
+
srv.localUrl(`http://${distroHostname}/admin/users.html`),
|
|
1904
|
+
);
|
|
1905
|
+
console.log(blocked.status); // 403
|
|
1906
|
+
|
|
1907
|
+
// The Bucket still holds the page. The request never got as far as the
|
|
1908
|
+
// Origin to ask for it.
|
|
1909
|
+
} finally {
|
|
1910
|
+
await srv.close();
|
|
1911
|
+
}
|
|
1912
|
+
```
|
|
1913
|
+
|
|
1914
|
+
See [simulated WAFv2](https://yulinsim.dev/services/wafv2/) for what a rule can inspect and how a blocked request is
|
|
1915
|
+
answered.
|
|
1916
|
+
|
|
1917
|
+
## Response headers policies
|
|
1918
|
+
|
|
1919
|
+
A response headers policy sets headers on everything a cache Behavior serves. Declare one as
|
|
1920
|
+
`AWS::CloudFront::ResponseHeadersPolicy` and point a Behavior's `ResponseHeadersPolicyId` at it with
|
|
1921
|
+
a `Ref`, which is what CDK's `ResponseHeadersPolicy` construct synthesizes.
|
|
1922
|
+
|
|
1923
|
+
```typescript sim-cloudfront-response-headers-policy
|
|
1924
|
+
/**
|
|
1925
|
+
* Setting response headers on what a cache Behavior serves.
|
|
1926
|
+
*/
|
|
1927
|
+
|
|
1928
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
1929
|
+
import { SimAws } from "@kensio/yulin";
|
|
1930
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
1931
|
+
|
|
1932
|
+
const simAws = new SimAws();
|
|
1933
|
+
const srv = await serveSimAws({ simAws });
|
|
1934
|
+
|
|
1935
|
+
try {
|
|
1936
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
1937
|
+
stackName: "site-stack",
|
|
1938
|
+
template: {
|
|
1939
|
+
Resources: {
|
|
1940
|
+
SiteBucket: {
|
|
1941
|
+
Type: "AWS::S3::Bucket",
|
|
1942
|
+
Properties: {
|
|
1943
|
+
BucketName: "site-bucket",
|
|
1944
|
+
PublicAccessBlockConfiguration: {
|
|
1945
|
+
BlockPublicAcls: true,
|
|
1946
|
+
IgnorePublicAcls: true,
|
|
1947
|
+
},
|
|
1948
|
+
},
|
|
1949
|
+
},
|
|
1950
|
+
// The Origin reads the Bucket anonymously, so the site needs a policy
|
|
1951
|
+
// making it publicly readable.
|
|
1952
|
+
SiteBucketPolicy: {
|
|
1953
|
+
Type: "AWS::S3::BucketPolicy",
|
|
1954
|
+
DependsOn: "SiteBucket",
|
|
1955
|
+
Properties: {
|
|
1956
|
+
Bucket: "site-bucket",
|
|
1957
|
+
PolicyDocument: {
|
|
1958
|
+
Version: "2012-10-17",
|
|
1959
|
+
Statement: {
|
|
1960
|
+
Effect: "Allow",
|
|
1961
|
+
Principal: "*",
|
|
1962
|
+
Action: "s3:GetObject",
|
|
1963
|
+
Resource: "arn:aws:s3:::site-bucket/*",
|
|
1964
|
+
},
|
|
1965
|
+
},
|
|
1966
|
+
},
|
|
1967
|
+
},
|
|
1968
|
+
CacheHeaders: {
|
|
1969
|
+
Type: "AWS::CloudFront::ResponseHeadersPolicy",
|
|
1970
|
+
Properties: {
|
|
1971
|
+
ResponseHeadersPolicyConfig: {
|
|
1972
|
+
Name: "CacheHeaders",
|
|
1973
|
+
CustomHeadersConfig: {
|
|
1974
|
+
Items: [
|
|
1975
|
+
{
|
|
1976
|
+
Header: "Cache-Control",
|
|
1977
|
+
Override: true,
|
|
1978
|
+
Value: "public, max-age=0, must-revalidate",
|
|
1979
|
+
},
|
|
1980
|
+
],
|
|
1981
|
+
},
|
|
1982
|
+
},
|
|
1983
|
+
},
|
|
1984
|
+
},
|
|
1985
|
+
SiteDistribution: {
|
|
1986
|
+
Type: "AWS::CloudFront::Distribution",
|
|
1987
|
+
DependsOn: ["SiteBucket", "CacheHeaders"],
|
|
1988
|
+
Properties: {
|
|
1989
|
+
DistributionConfig: {
|
|
1990
|
+
DefaultRootObject: "index.html",
|
|
1991
|
+
Origins: [
|
|
1992
|
+
{
|
|
1993
|
+
Id: "SiteOrigin",
|
|
1994
|
+
DomainName: "site-bucket.s3.amazonaws.com",
|
|
1995
|
+
S3OriginConfig: {},
|
|
1996
|
+
},
|
|
1997
|
+
],
|
|
1998
|
+
DefaultCacheBehavior: {
|
|
1999
|
+
TargetOriginId: "SiteOrigin",
|
|
2000
|
+
ViewerProtocolPolicy: "allow-all",
|
|
2001
|
+
ResponseHeadersPolicyId: { Ref: "CacheHeaders" },
|
|
2002
|
+
},
|
|
2003
|
+
},
|
|
2004
|
+
},
|
|
2005
|
+
},
|
|
2006
|
+
},
|
|
2007
|
+
Outputs: {
|
|
2008
|
+
DistributionDomainName: {
|
|
2009
|
+
Value: { "Fn::GetAtt": ["SiteDistribution", "DomainName"] },
|
|
2010
|
+
},
|
|
2011
|
+
},
|
|
2012
|
+
},
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
await stack.waitForDeployComplete();
|
|
2016
|
+
|
|
2017
|
+
await simAws.s3().putObject(
|
|
2018
|
+
new PutObjectCommand({
|
|
2019
|
+
Bucket: "site-bucket",
|
|
2020
|
+
Key: "index.html",
|
|
2021
|
+
ContentType: "text/html",
|
|
2022
|
+
Body: "<h1>Home</h1>",
|
|
2023
|
+
}),
|
|
2024
|
+
);
|
|
2025
|
+
|
|
2026
|
+
const domainName = stack.output("DistributionDomainName");
|
|
2027
|
+
const response = await fetch(srv.localUrl(`http://${domainName}/`));
|
|
2028
|
+
|
|
2029
|
+
console.log(response.headers.get("cache-control"));
|
|
2030
|
+
} finally {
|
|
2031
|
+
await srv.close();
|
|
2032
|
+
}
|
|
2033
|
+
```
|
|
2034
|
+
|
|
2035
|
+
Each header in `CustomHeadersConfig` carries an `Override` boolean. With it set, the policy's value
|
|
2036
|
+
replaces one the Origin sent. Without it, the Origin's value is kept and the policy's is dropped. A
|
|
2037
|
+
header the Origin left out is added either way.
|
|
2038
|
+
|
|
2039
|
+
`RemoveHeadersConfig` takes headers away, and is applied before the added ones. A header named in
|
|
2040
|
+
both sections ends up present with the policy's value.
|
|
2041
|
+
|
|
2042
|
+
The policy is applied after a custom error response is fetched and before the viewer-response event,
|
|
2043
|
+
as CloudFront does. An error page carries the policy's headers. A viewer-response Function sees them
|
|
2044
|
+
in `event.response.headers` and can change them, where the Origin answered below 400 and the
|
|
2045
|
+
Function ran at all.
|
|
2046
|
+
|
|
2047
|
+
`SecurityHeadersConfig` is what CDK's `ResponseHeadersPolicy` construct synthesizes from
|
|
2048
|
+
`securityHeadersBehavior`, and every one of its sections is modelled. `ContentSecurityPolicy`,
|
|
2049
|
+
`ContentTypeOptions`, `FrameOptions`, `ReferrerPolicy`, `StrictTransportSecurity` and `XSSProtection`
|
|
2050
|
+
each become the header CloudFront documents for it, honouring the section's own `Override` the same
|
|
2051
|
+
way a `CustomHeadersConfig` item does.
|
|
2052
|
+
|
|
2053
|
+
`ServerTimingHeadersConfig` adds a `Server-Timing` header once `Enabled` is true. `SamplingRate` is
|
|
2054
|
+
ignored. This simulation adds the header to every response. A test asserting on it never depends on
|
|
2055
|
+
chance, and the header's value is a fixed placeholder in place of real Origin timing.
|
|
2056
|
+
|
|
2057
|
+
`CorsConfig` is what CDK's `corsBehavior` synthesizes. CloudFront reflects the viewer request's
|
|
2058
|
+
`Origin` header against `AccessControlAllowOrigins`, in place of sending the list itself. A request
|
|
2059
|
+
naming an Origin the list allows gets the CORS headers the section configures, with the response
|
|
2060
|
+
varying on `Origin` unless the list contains `*`. A request naming one the list omits gets none of
|
|
2061
|
+
them, matching CloudFront, which sends none in preference to a mismatched one.
|
|
2062
|
+
`AccessControlAllowMethods` of `["ALL"]` expands to CloudFront's full method list, and
|
|
2063
|
+
`AccessControlAllowCredentials: false` leaves `Access-Control-Allow-Credentials` off entirely, since
|
|
2064
|
+
a header naming `false` means the same as its absence to a browser.
|
|
2065
|
+
|
|
2066
|
+
`AccessControlAllowMethods`, `AccessControlAllowHeaders` and `AccessControlMaxAgeSec` answer what a
|
|
2067
|
+
preflight asks, and their headers go on a response to an `OPTIONS` request alone. Every other method
|
|
2068
|
+
is answered without them, as CloudFront answers it. `Access-Control-Allow-Origin`,
|
|
2069
|
+
`Access-Control-Allow-Credentials` and `Access-Control-Expose-Headers` come back either way, and so
|
|
2070
|
+
does the `Vary: Origin` a reflected Origin carries. A list left empty sends no header at all, which
|
|
2071
|
+
is how `SimpleCORS` answers with the Origin header by itself.
|
|
2072
|
+
|
|
2073
|
+
An allow-list entry may use the wildcard on its own, meaning every Origin, or as the leftmost
|
|
2074
|
+
subdomain, so `*.example.org` matches `https://site.example.org`. It stands for exactly one label, as
|
|
2075
|
+
a wildcard certificate does, and it leaves `https://deep.site.example.org` unmatched. An entry naming
|
|
2076
|
+
no scheme matches the host whichever scheme the request used. CloudFront allows the wildcard nowhere
|
|
2077
|
+
else, and an entry placing one elsewhere (`example.*`, `test.*.example.org`, `*test.example.org`,
|
|
2078
|
+
`exa*mple.org`) fails the stack.
|
|
2079
|
+
|
|
2080
|
+
`OriginOverride` decides the whole CORS section at once, where the `Override` on a custom or security
|
|
2081
|
+
header decides one header. Without it, an Origin response carrying any CORS header at all, named by
|
|
2082
|
+
the policy or otherwise, keeps every header the section would have set off the response.
|
|
2083
|
+
|
|
2084
|
+
### Managed policies
|
|
2085
|
+
|
|
2086
|
+
CloudFront's five managed policies are here from the start, under the IDs AWS publishes, and a
|
|
2087
|
+
Behavior names one without a template creating anything. `SecurityHeadersPolicy`
|
|
2088
|
+
(`67f7725c-6f97-4210-82d7-5512b31e9d03`), `SimpleCORS` (`60669652-455b-4ae9-85a4-c4c02393f86c`),
|
|
2089
|
+
`CORS-With-Preflight` (`5cc3b908-e619-4b99-88e5-2cf7f45965bd`), `CORS-and-SecurityHeadersPolicy`
|
|
2090
|
+
(`e61eb60c-9c35-4d20-a928-2b84e02af89c`) and `CORS-with-preflight-and-SecurityHeadersPolicy`
|
|
2091
|
+
(`eaab4381-ed33-4a86-88ca-d9558dc6cd63`) each carry the sections
|
|
2092
|
+
[AWS documents](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-response-headers-policies.html)
|
|
2093
|
+
for it. CDK's `ResponseHeadersPolicy.SECURITY_HEADERS` and its four siblings synthesize those IDs, so
|
|
2094
|
+
a stack reaching for one deploys and serves the headers.
|
|
2095
|
+
|
|
2096
|
+
The managed policies sit in CloudFront's own namespace. A template may create a policy called
|
|
2097
|
+
`SecurityHeadersPolicy` of its own, and deleting that stack leaves the managed one where it was.
|
|
2098
|
+
|
|
2099
|
+
A CloudFormation Distribution whose Behavior names a policy that is neither managed nor created here
|
|
2100
|
+
deploys without one. The `ResponseHeadersPolicyId` lands on `stack.ignoredProperties` under that
|
|
2101
|
+
Behavior, and the Behavior serves every response without the headers the policy would have set. A
|
|
2102
|
+
template naming a policy from a real account, or one another stack created, is ordinary, and a site
|
|
2103
|
+
that failed to deploy over a set of headers would cost a local dev server and a test suite every
|
|
2104
|
+
request they make. One Behavior losing its policy leaves the others holding theirs, and a path
|
|
2105
|
+
Behavior is recorded under its `PathPattern`, the way a skipped Lambda@Edge association is.
|
|
2106
|
+
|
|
2107
|
+
`CreateDistribution` and `UpdateDistribution` still refuse the same ID, as real CloudFront refuses
|
|
2108
|
+
it.
|
|
2109
|
+
|
|
2110
|
+
## Origin access controls
|
|
2111
|
+
|
|
2112
|
+
An origin access control is how a Distribution authenticates to a private Origin. The Origin then
|
|
2113
|
+
admits the Distribution and nothing else. Declare one as `AWS::CloudFront::OriginAccessControl` and
|
|
2114
|
+
point an Origin's `OriginAccessControlId` at it with a `Ref`, which is what CDK's
|
|
2115
|
+
`S3BucketOrigin.withOriginAccessControl` synthesizes.
|
|
2116
|
+
|
|
2117
|
+
An `OriginAccessControlOriginType` of `s3` signs for an S3 Bucket Origin, and one of `lambda` signs
|
|
2118
|
+
for a Lambda Function URL Origin. The origin type has to match the Origin it is attached to. An `s3`
|
|
2119
|
+
origin access control on a custom Origin, or a `lambda` one on an S3 Origin, fails the Stack when
|
|
2120
|
+
the Distribution is created, as CloudFront refuses it.
|
|
2121
|
+
|
|
2122
|
+
An S3 Origin whose origin access control signs reads its Bucket as the `cloudfront.amazonaws.com`
|
|
2123
|
+
service principal, carrying the Distribution's ARN as `aws:SourceArn`. The Bucket policy is then the
|
|
2124
|
+
whole decision. The Bucket needs a statement granting `s3:GetObject` to that principal, conditioned
|
|
2125
|
+
on the Distribution allowed to read it. That is the policy CDK writes. A condition naming a different
|
|
2126
|
+
Distribution, or an Origin that was never given an origin access control, answers 403.
|
|
2127
|
+
|
|
2128
|
+
```typescript sim-cloudfront-origin-access-control
|
|
2129
|
+
/**
|
|
2130
|
+
* Serving a private S3 Bucket through an origin access control.
|
|
2131
|
+
*/
|
|
2132
|
+
|
|
2133
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
2134
|
+
|
|
2135
|
+
import { SimAws } from "@kensio/yulin";
|
|
2136
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
2137
|
+
|
|
2138
|
+
const simAws = new SimAws();
|
|
2139
|
+
const srv = await serveSimAws({ simAws });
|
|
2140
|
+
|
|
2141
|
+
try {
|
|
2142
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
2143
|
+
stackName: "site-stack",
|
|
2144
|
+
template: {
|
|
2145
|
+
Resources: {
|
|
2146
|
+
SiteBucket: {
|
|
2147
|
+
Type: "AWS::S3::Bucket",
|
|
2148
|
+
Properties: { BucketName: "site-bucket" },
|
|
2149
|
+
},
|
|
2150
|
+
SiteOac: {
|
|
2151
|
+
Type: "AWS::CloudFront::OriginAccessControl",
|
|
2152
|
+
Properties: {
|
|
2153
|
+
OriginAccessControlConfig: {
|
|
2154
|
+
Name: "site-oac",
|
|
2155
|
+
OriginAccessControlOriginType: "s3",
|
|
2156
|
+
SigningBehavior: "always",
|
|
2157
|
+
SigningProtocol: "sigv4",
|
|
2158
|
+
},
|
|
2159
|
+
},
|
|
2160
|
+
},
|
|
2161
|
+
SiteDistribution: {
|
|
2162
|
+
Type: "AWS::CloudFront::Distribution",
|
|
2163
|
+
Properties: {
|
|
2164
|
+
DistributionConfig: {
|
|
2165
|
+
Enabled: true,
|
|
2166
|
+
DefaultRootObject: "index.html",
|
|
2167
|
+
Origins: [
|
|
2168
|
+
{
|
|
2169
|
+
Id: "SiteOrigin",
|
|
2170
|
+
DomainName: "site-bucket.s3.amazonaws.com",
|
|
2171
|
+
S3OriginConfig: {},
|
|
2172
|
+
OriginAccessControlId: { Ref: "SiteOac" },
|
|
2173
|
+
},
|
|
2174
|
+
],
|
|
2175
|
+
DefaultCacheBehavior: {
|
|
2176
|
+
TargetOriginId: "SiteOrigin",
|
|
2177
|
+
ViewerProtocolPolicy: "allow-all",
|
|
2178
|
+
},
|
|
2179
|
+
},
|
|
2180
|
+
},
|
|
2181
|
+
},
|
|
2182
|
+
// Nothing but this Distribution may read the Bucket, which is what the
|
|
2183
|
+
// condition on the Distribution's ARN says.
|
|
2184
|
+
SiteBucketPolicy: {
|
|
2185
|
+
Type: "AWS::S3::BucketPolicy",
|
|
2186
|
+
Properties: {
|
|
2187
|
+
Bucket: { Ref: "SiteBucket" },
|
|
2188
|
+
PolicyDocument: {
|
|
2189
|
+
Version: "2012-10-17",
|
|
2190
|
+
Statement: [
|
|
2191
|
+
{
|
|
2192
|
+
Effect: "Allow",
|
|
2193
|
+
Principal: { Service: "cloudfront.amazonaws.com" },
|
|
2194
|
+
Action: "s3:GetObject",
|
|
2195
|
+
Resource: "arn:aws:s3:::site-bucket/*",
|
|
2196
|
+
Condition: {
|
|
2197
|
+
StringEquals: {
|
|
2198
|
+
"AWS:SourceArn": {
|
|
2199
|
+
"Fn::Join": [
|
|
2200
|
+
"",
|
|
2201
|
+
[
|
|
2202
|
+
"arn:aws:cloudfront::",
|
|
2203
|
+
{ Ref: "AWS::AccountId" },
|
|
2204
|
+
":distribution/",
|
|
2205
|
+
{ Ref: "SiteDistribution" },
|
|
2206
|
+
],
|
|
2207
|
+
],
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
},
|
|
2212
|
+
],
|
|
2213
|
+
},
|
|
2214
|
+
},
|
|
2215
|
+
},
|
|
2216
|
+
},
|
|
2217
|
+
Outputs: {
|
|
2218
|
+
SiteHostname: {
|
|
2219
|
+
Value: { "Fn::GetAtt": ["SiteDistribution", "DomainName"] },
|
|
2220
|
+
},
|
|
2221
|
+
},
|
|
2222
|
+
},
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
await stack.waitForDeployComplete();
|
|
2226
|
+
|
|
2227
|
+
await simAws.s3().putObject(
|
|
2228
|
+
new PutObjectCommand({
|
|
2229
|
+
Bucket: "site-bucket",
|
|
2230
|
+
Key: "index.html",
|
|
2231
|
+
ContentType: "text/html",
|
|
2232
|
+
Body: "<h1>Home</h1>",
|
|
2233
|
+
}),
|
|
2234
|
+
);
|
|
2235
|
+
|
|
2236
|
+
const siteHostname = stack.output("SiteHostname");
|
|
2237
|
+
const home = await fetch(srv.localUrl(`http://${siteHostname}/`));
|
|
2238
|
+
|
|
2239
|
+
console.log(await home.text()); // <h1>Home</h1>
|
|
2240
|
+
} finally {
|
|
2241
|
+
await srv.close();
|
|
2242
|
+
}
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
The Bucket policy names the Distribution's ARN, and is created after the Distribution. The `Ref`
|
|
2246
|
+
inside `Fn::Join` is the dependency CloudFormation orders the Stack by. The read is settled per
|
|
2247
|
+
request, because the policy deciding it comes into existence after the Distribution does. The Origin
|
|
2248
|
+
works out who it is reading as each time.
|
|
2249
|
+
|
|
2250
|
+
### A Lambda Function URL Origin
|
|
2251
|
+
|
|
2252
|
+
Putting a Function URL with `AuthType: AWS_IAM` behind a Distribution takes the origin access
|
|
2253
|
+
control with `OriginAccessControlOriginType: lambda`, a custom Origin naming it whose `DomainName`
|
|
2254
|
+
is the Function URL's hostname, and two `AWS::Lambda::Permission` Resources granting
|
|
2255
|
+
`cloudfront.amazonaws.com` for that Distribution. It is the only way to serve a Function URL through
|
|
2256
|
+
CloudFront without leaving the Function URL open to anyone who finds its endpoint.
|
|
2257
|
+
|
|
2258
|
+
Both permissions are needed. One grants `lambda:InvokeFunctionUrl` and the other
|
|
2259
|
+
`lambda:InvokeFunction`, to the same principal with the same `SourceArn`, as
|
|
2260
|
+
[Restrict access to an AWS Lambda function URL origin](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html)
|
|
2261
|
+
sets out. CDK's `FunctionUrlOrigin.withOriginAccessControl` writes only the first. A CDK app has to
|
|
2262
|
+
add the second itself:
|
|
2263
|
+
|
|
2264
|
+
```typescript
|
|
2265
|
+
greeterFunction.addPermission("InvokeFunctionFromCloudFront", {
|
|
2266
|
+
principal: new iam.ServicePrincipal("cloudfront.amazonaws.com"),
|
|
2267
|
+
action: "lambda:InvokeFunction",
|
|
2268
|
+
sourceArn: cdk.Fn.join("", [
|
|
2269
|
+
"arn:",
|
|
2270
|
+
cdk.Aws.PARTITION,
|
|
2271
|
+
":cloudfront::",
|
|
2272
|
+
cdk.Aws.ACCOUNT_ID,
|
|
2273
|
+
":distribution/",
|
|
2274
|
+
distribution.distributionId,
|
|
2275
|
+
]),
|
|
2276
|
+
});
|
|
2277
|
+
```
|
|
2278
|
+
|
|
2279
|
+
The Origin request is made as the `cloudfront.amazonaws.com` service principal carrying the
|
|
2280
|
+
Distribution's ARN, the same pair an S3 Origin read carries, and the function's resource policy is
|
|
2281
|
+
the whole decision. A Stack missing either permission, or with one naming a different Distribution,
|
|
2282
|
+
deploys and then answers 403 through the Distribution, as the real deployment does. The function is
|
|
2283
|
+
never invoked, and writes no logs to look at either.
|
|
2284
|
+
|
|
2285
|
+
```typescript sim-cloudfront-function-url-origin-access-control
|
|
2286
|
+
/**
|
|
2287
|
+
* Serving a private Lambda Function URL through an origin access control.
|
|
2288
|
+
*/
|
|
2289
|
+
|
|
2290
|
+
import { SimAws } from "@kensio/yulin";
|
|
2291
|
+
import { serveSimAws } from "@kensio/yulin/serve";
|
|
2292
|
+
|
|
2293
|
+
const simAws = new SimAws();
|
|
2294
|
+
const srv = await serveSimAws({ simAws });
|
|
2295
|
+
|
|
2296
|
+
try {
|
|
2297
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
2298
|
+
stackName: "greeter-stack",
|
|
2299
|
+
template: {
|
|
2300
|
+
Resources: {
|
|
2301
|
+
GreeterFunction: {
|
|
2302
|
+
Type: "AWS::Lambda::Function",
|
|
2303
|
+
Properties: {
|
|
2304
|
+
FunctionName: "greeter",
|
|
2305
|
+
Role: "arn:aws:iam::888888888888:role/GreeterRole",
|
|
2306
|
+
Handler: "index.handler",
|
|
2307
|
+
Runtime: "nodejs22.x",
|
|
2308
|
+
Code: {
|
|
2309
|
+
ZipFile:
|
|
2310
|
+
"exports.handler = async () => " +
|
|
2311
|
+
"({ statusCode: 200, body: 'Hello from behind CloudFront' });",
|
|
2312
|
+
},
|
|
2313
|
+
},
|
|
2314
|
+
},
|
|
2315
|
+
GreeterUrl: {
|
|
2316
|
+
Type: "AWS::Lambda::Url",
|
|
2317
|
+
Properties: {
|
|
2318
|
+
TargetFunctionArn: { "Fn::GetAtt": ["GreeterFunction", "Arn"] },
|
|
2319
|
+
AuthType: "AWS_IAM",
|
|
2320
|
+
},
|
|
2321
|
+
},
|
|
2322
|
+
GreeterOac: {
|
|
2323
|
+
Type: "AWS::CloudFront::OriginAccessControl",
|
|
2324
|
+
Properties: {
|
|
2325
|
+
OriginAccessControlConfig: {
|
|
2326
|
+
Name: "greeter-oac",
|
|
2327
|
+
OriginAccessControlOriginType: "lambda",
|
|
2328
|
+
SigningBehavior: "always",
|
|
2329
|
+
SigningProtocol: "sigv4",
|
|
2330
|
+
},
|
|
2331
|
+
},
|
|
2332
|
+
},
|
|
2333
|
+
GreeterDistribution: {
|
|
2334
|
+
Type: "AWS::CloudFront::Distribution",
|
|
2335
|
+
Properties: {
|
|
2336
|
+
DistributionConfig: {
|
|
2337
|
+
Enabled: true,
|
|
2338
|
+
Origins: [
|
|
2339
|
+
{
|
|
2340
|
+
Id: "GreeterOrigin",
|
|
2341
|
+
// An Origin takes a domain name, and the Function URL
|
|
2342
|
+
// attribute is a URL, so the host comes out of it.
|
|
2343
|
+
DomainName: {
|
|
2344
|
+
"Fn::Select": [
|
|
2345
|
+
2,
|
|
2346
|
+
{
|
|
2347
|
+
"Fn::Split": [
|
|
2348
|
+
"/",
|
|
2349
|
+
{ "Fn::GetAtt": ["GreeterUrl", "FunctionUrl"] },
|
|
2350
|
+
],
|
|
2351
|
+
},
|
|
2352
|
+
],
|
|
2353
|
+
},
|
|
2354
|
+
CustomOriginConfig: { OriginProtocolPolicy: "https-only" },
|
|
2355
|
+
OriginAccessControlId: { Ref: "GreeterOac" },
|
|
2356
|
+
},
|
|
2357
|
+
],
|
|
2358
|
+
DefaultCacheBehavior: {
|
|
2359
|
+
TargetOriginId: "GreeterOrigin",
|
|
2360
|
+
ViewerProtocolPolicy: "allow-all",
|
|
2361
|
+
},
|
|
2362
|
+
},
|
|
2363
|
+
},
|
|
2364
|
+
},
|
|
2365
|
+
// Nothing but this Distribution may invoke the Function URL, which is
|
|
2366
|
+
// what the condition on the Distribution's ARN says. Reaching the URL
|
|
2367
|
+
// takes both actions, so leaving either one out is a 403.
|
|
2368
|
+
InvokeFunctionUrlFromCloudFront: {
|
|
2369
|
+
Type: "AWS::Lambda::Permission",
|
|
2370
|
+
Properties: {
|
|
2371
|
+
FunctionName: { "Fn::GetAtt": ["GreeterFunction", "Arn"] },
|
|
2372
|
+
Action: "lambda:InvokeFunctionUrl",
|
|
2373
|
+
Principal: "cloudfront.amazonaws.com",
|
|
2374
|
+
SourceArn: {
|
|
2375
|
+
"Fn::Join": [
|
|
2376
|
+
"",
|
|
2377
|
+
[
|
|
2378
|
+
"arn:aws:cloudfront::",
|
|
2379
|
+
{ Ref: "AWS::AccountId" },
|
|
2380
|
+
":distribution/",
|
|
2381
|
+
{ Ref: "GreeterDistribution" },
|
|
2382
|
+
],
|
|
2383
|
+
],
|
|
2384
|
+
},
|
|
2385
|
+
},
|
|
2386
|
+
},
|
|
2387
|
+
InvokeFunctionFromCloudFront: {
|
|
2388
|
+
Type: "AWS::Lambda::Permission",
|
|
2389
|
+
Properties: {
|
|
2390
|
+
FunctionName: { "Fn::GetAtt": ["GreeterFunction", "Arn"] },
|
|
2391
|
+
Action: "lambda:InvokeFunction",
|
|
2392
|
+
Principal: "cloudfront.amazonaws.com",
|
|
2393
|
+
SourceArn: {
|
|
2394
|
+
"Fn::Join": [
|
|
2395
|
+
"",
|
|
2396
|
+
[
|
|
2397
|
+
"arn:aws:cloudfront::",
|
|
2398
|
+
{ Ref: "AWS::AccountId" },
|
|
2399
|
+
":distribution/",
|
|
2400
|
+
{ Ref: "GreeterDistribution" },
|
|
2401
|
+
],
|
|
2402
|
+
],
|
|
2403
|
+
},
|
|
2404
|
+
},
|
|
2405
|
+
},
|
|
2406
|
+
},
|
|
2407
|
+
Outputs: {
|
|
2408
|
+
SiteHostname: {
|
|
2409
|
+
Value: { "Fn::GetAtt": ["GreeterDistribution", "DomainName"] },
|
|
2410
|
+
},
|
|
2411
|
+
},
|
|
2412
|
+
},
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
await stack.waitForDeployComplete();
|
|
2416
|
+
|
|
2417
|
+
const siteHostname = stack.output("SiteHostname");
|
|
2418
|
+
const greeting = await fetch(srv.localUrl(`http://${siteHostname}/greeting`));
|
|
2419
|
+
|
|
2420
|
+
console.log(await greeting.text()); // Hello from behind CloudFront
|
|
2421
|
+
} finally {
|
|
2422
|
+
await srv.close();
|
|
2423
|
+
}
|
|
2424
|
+
```
|
|
2425
|
+
|
|
2426
|
+
The Function URL is reachable directly as well, on its own endpoint, and it refuses a request that
|
|
2427
|
+
arrives there without the permission the Distribution has. That is the point of the auth type. The
|
|
2428
|
+
endpoint exists, and only the Distribution may use it.
|
|
2429
|
+
|
|
2430
|
+
`SigningBehavior` takes any of `always`, `never` and `no-override`. `always` and `no-override` both
|
|
2431
|
+
sign, since nothing here sends a pre-signed viewer request to an Origin for `no-override` to pass
|
|
2432
|
+
through. `never` turns the origin access control off while leaving it in place, and the Origin is
|
|
2433
|
+
reached anonymously, as an Origin with no origin access control is. An S3 Origin then needs a Bucket
|
|
2434
|
+
policy allowing that, and an `AWS_IAM` Function URL refuses the request outright.
|
|
2435
|
+
|
|
2436
|
+
`Ref` and `Fn::GetAtt` on `Id` both return the ID, so either resolves an Origin's
|
|
2437
|
+
`OriginAccessControlId`. An Origin naming an ID no origin access control holds is refused with
|
|
2438
|
+
`InvalidOriginAccessControl` when the Distribution is created. Tearing the Stack down removes the
|
|
2439
|
+
origin access control, and its name is free again.
|
|
2440
|
+
|
|
2441
|
+
`OriginAccessControlOriginType` must be `s3` or `lambda`, and `SigningProtocol` must be `sigv4`. Any
|
|
2442
|
+
other value fails the Stack by name.
|
|
2443
|
+
|
|
2444
|
+
A CloudFormation template is the only way to make one. There is no `CreateOriginAccessControl`
|
|
2445
|
+
command here.
|
|
2446
|
+
|
|
2447
|
+
#### Posting to a Function URL Origin
|
|
2448
|
+
|
|
2449
|
+
A POST or PUT through an origin access control has to carry the SHA-256 of its body in an
|
|
2450
|
+
`x-amz-content-sha256` header. CloudFront streams the viewer's body on to the Origin without
|
|
2451
|
+
buffering it, and has no hash of its own to sign with. It signs the hash the viewer declared, and
|
|
2452
|
+
`UNSIGNED-PAYLOAD` where the viewer declared none. Lambda supports no unsigned payload, and answers
|
|
2453
|
+
`403` with `The request signature we calculated does not match the signature you provided`. The
|
|
2454
|
+
handler never runs. The declared hash is checked against the body that arrived, and a digest of
|
|
2455
|
+
other bytes is refused the same way.
|
|
2456
|
+
|
|
2457
|
+
A viewer computes the digest of what it is about to send, the way any SigV4 client does:
|
|
2458
|
+
|
|
2459
|
+
```typescript
|
|
2460
|
+
const body = JSON.stringify({ email: "someone@example.com" });
|
|
2461
|
+
const response = await fetch(`http://${siteHostname}/sign-in`, {
|
|
2462
|
+
method: "POST",
|
|
2463
|
+
body,
|
|
2464
|
+
headers: {
|
|
2465
|
+
"content-type": "application/json",
|
|
2466
|
+
"x-amz-content-sha256": createHash("sha256").update(body).digest("hex"),
|
|
2467
|
+
},
|
|
2468
|
+
});
|
|
2469
|
+
```
|
|
2470
|
+
|
|
2471
|
+
A GET or a HEAD is left alone. SigV4 hashes an empty payload for a request without a body, and
|
|
2472
|
+
CloudFront can sign one of those on its own. An origin access control with a `SigningBehavior` of
|
|
2473
|
+
`never` signs no Origin request, and states no payload hash for one. A POST through one reaches
|
|
2474
|
+
the Origin anonymously, as it did before.
|
|
2475
|
+
|
|
2476
|
+
AWS documents the requirement on
|
|
2477
|
+
[Restrict access to an AWS Lambda function URL origin](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html).
|
|
2478
|
+
A simulated Distribution refuses the request for the same reason a real one does. A form post
|
|
2479
|
+
missing the header fails in a test as well as on the deployment.
|
|
2480
|
+
|
|
2481
|
+
## Key value stores
|
|
2482
|
+
|
|
2483
|
+
A key value store holds data a CloudFront Function reads at request time. A redirect table or a
|
|
2484
|
+
feature flag can live there instead of being baked into the Function's code.
|
|
2485
|
+
|
|
2486
|
+
AWS splits this across two SDK clients, and so does the simulator. The CloudFront client owns the
|
|
2487
|
+
store, through `CreateKeyValueStoreCommand`, `DescribeKeyValueStoreCommand`,
|
|
2488
|
+
`ListKeyValueStoresCommand`, `UpdateKeyValueStoreCommand` and `DeleteKeyValueStoreCommand`, all
|
|
2489
|
+
addressing a store by name. The key value store client owns the data, through `GetKeyCommand`,
|
|
2490
|
+
`PutKeyCommand`, `DeleteKeyCommand`, `ListKeysCommand`, `UpdateKeysCommand` and its own
|
|
2491
|
+
`DescribeKeyValueStoreCommand`, all addressing a store by ARN.
|
|
2492
|
+
|
|
2493
|
+
Both clients are intercepted by `SimSdk`. Used directly, they are `simAws.cloudFront().keyValueStores()`
|
|
2494
|
+
and `simAws.cloudFrontKeyValueStore()`.
|
|
2495
|
+
|
|
2496
|
+
```typescript sim-cloudfront-key-value-store
|
|
2497
|
+
/**
|
|
2498
|
+
* Creating a CloudFront key value store and writing keys to it.
|
|
2499
|
+
*/
|
|
2500
|
+
|
|
2501
|
+
import { CreateKeyValueStoreCommand } from "@aws-sdk/client-cloudfront";
|
|
2502
|
+
import {
|
|
2503
|
+
DescribeKeyValueStoreCommand,
|
|
2504
|
+
GetKeyCommand,
|
|
2505
|
+
UpdateKeysCommand,
|
|
2506
|
+
} from "@aws-sdk/client-cloudfront-keyvaluestore";
|
|
2507
|
+
|
|
2508
|
+
import { SimAws } from "@kensio/yulin";
|
|
2509
|
+
|
|
2510
|
+
const simAws = new SimAws();
|
|
2511
|
+
|
|
2512
|
+
// The CloudFront client owns the store itself.
|
|
2513
|
+
const created = await simAws
|
|
2514
|
+
.cloudFront()
|
|
2515
|
+
.keyValueStores()
|
|
2516
|
+
.createKeyValueStore(
|
|
2517
|
+
new CreateKeyValueStoreCommand({
|
|
2518
|
+
Name: "redirects",
|
|
2519
|
+
Comment: "Where old paths go",
|
|
2520
|
+
}),
|
|
2521
|
+
);
|
|
2522
|
+
|
|
2523
|
+
const kvsArn = created.KeyValueStore.ARN;
|
|
2524
|
+
const data = simAws.cloudFrontKeyValueStore();
|
|
2525
|
+
|
|
2526
|
+
// The key value store client owns the data, and addresses the store by ARN.
|
|
2527
|
+
// Every write carries an ETag, and it is this API's own: the one the
|
|
2528
|
+
// CloudFront client returned above versions the resource, not the keys.
|
|
2529
|
+
const described = await data.describeKeyValueStore(
|
|
2530
|
+
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }),
|
|
2531
|
+
);
|
|
2532
|
+
|
|
2533
|
+
const written = await data.updateKeys(
|
|
2534
|
+
new UpdateKeysCommand({
|
|
2535
|
+
KvsARN: kvsArn,
|
|
2536
|
+
IfMatch: described.ETag,
|
|
2537
|
+
Puts: [
|
|
2538
|
+
{ Key: "/old-page", Value: "/new-page" },
|
|
2539
|
+
{ Key: "/legacy", Value: "/current" },
|
|
2540
|
+
],
|
|
2541
|
+
}),
|
|
2542
|
+
);
|
|
2543
|
+
|
|
2544
|
+
console.log(written.ItemCount); // 2
|
|
2545
|
+
|
|
2546
|
+
const read = await data.getKey(
|
|
2547
|
+
new GetKeyCommand({ KvsARN: kvsArn, Key: "/old-page" }),
|
|
2548
|
+
);
|
|
2549
|
+
|
|
2550
|
+
console.log(read.Value); // /new-page
|
|
2551
|
+
```
|
|
2552
|
+
|
|
2553
|
+
A new store is `PROVISIONING` when the command returns and becomes `READY` in the background, as in
|
|
2554
|
+
CloudFront. `await simAws.backgroundTasksComplete()` waits for that.
|
|
2555
|
+
|
|
2556
|
+
### ETags
|
|
2557
|
+
|
|
2558
|
+
The key value store commands do check `IfMatch`, where the Distribution and Function commands ignore
|
|
2559
|
+
it. Both APIs require it on every write and CloudFront refuses a stale one, which is what stops two
|
|
2560
|
+
writers overwriting each other. A write carrying a stale ETag is refused with `PreconditionFailed`,
|
|
2561
|
+
and a caller has to thread the ETag through the way it does against CloudFront. Each write returns
|
|
2562
|
+
the new ETag for the next one.
|
|
2563
|
+
|
|
2564
|
+
A store has two ETags and they are not interchangeable, as in AWS. Each `DescribeKeyValueStore`
|
|
2565
|
+
returns its own. The CloudFront client's versions the store's configuration, and the key value store
|
|
2566
|
+
client's versions the keys. Writing a key leaves the configuration's ETag where it was, and changing
|
|
2567
|
+
the comment leaves the keys' where it was. A write carrying the other API's ETag is refused, and the
|
|
2568
|
+
message says which of the two it wanted.
|
|
2569
|
+
|
|
2570
|
+
### Reading a store from a CloudFront Function
|
|
2571
|
+
|
|
2572
|
+
A Function reads its store through `cf`, which it gets from `import cf from "cloudfront"`. That is
|
|
2573
|
+
the one import JS 2.0 has. `cf.kvs()` opens the store the Function is associated with, and its
|
|
2574
|
+
`get`, `exists` and `meta` are all promises. A Function that reads a store is async.
|
|
2575
|
+
|
|
2576
|
+
A Function names the store it may read with `KeyValueStoreAssociations` on its `FunctionConfig`.
|
|
2577
|
+
CloudFront takes at most one, and only on `cloudfront-js-2.0`. An association on the 1.0 runtime is
|
|
2578
|
+
refused, because that runtime has no `cf` to reach a store through.
|
|
2579
|
+
|
|
2580
|
+
```typescript sim-cloudfront-function-key-value-store
|
|
2581
|
+
/**
|
|
2582
|
+
* Reading a key value store from a CloudFront Function.
|
|
2583
|
+
*/
|
|
2584
|
+
|
|
2585
|
+
import {
|
|
2586
|
+
CreateFunctionCommand,
|
|
2587
|
+
CreateKeyValueStoreCommand,
|
|
2588
|
+
} from "@aws-sdk/client-cloudfront";
|
|
2589
|
+
import {
|
|
2590
|
+
DescribeKeyValueStoreCommand,
|
|
2591
|
+
PutKeyCommand,
|
|
2592
|
+
} from "@aws-sdk/client-cloudfront-keyvaluestore";
|
|
2593
|
+
|
|
2594
|
+
import { SimAws } from "@kensio/yulin";
|
|
2595
|
+
|
|
2596
|
+
const simAws = new SimAws();
|
|
2597
|
+
|
|
2598
|
+
const created = await simAws
|
|
2599
|
+
.cloudFront()
|
|
2600
|
+
.keyValueStores()
|
|
2601
|
+
.createKeyValueStore(new CreateKeyValueStoreCommand({ Name: "redirects" }));
|
|
2602
|
+
|
|
2603
|
+
const kvsArn = created.KeyValueStore.ARN;
|
|
2604
|
+
const data = simAws.cloudFrontKeyValueStore();
|
|
2605
|
+
|
|
2606
|
+
const described = await data.describeKeyValueStore(
|
|
2607
|
+
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }),
|
|
2608
|
+
);
|
|
2609
|
+
|
|
2610
|
+
await data.putKey(
|
|
2611
|
+
new PutKeyCommand({
|
|
2612
|
+
KvsARN: kvsArn,
|
|
2613
|
+
Key: "/old-page",
|
|
2614
|
+
Value: "/new-page",
|
|
2615
|
+
IfMatch: described.ETag,
|
|
2616
|
+
}),
|
|
2617
|
+
);
|
|
2618
|
+
|
|
2619
|
+
// The Function names the store it may read. It gets `cf` from the one import
|
|
2620
|
+
// JS 2.0 has, and the read is awaited, so the handler is async.
|
|
2621
|
+
await simAws.cloudFront().createFunction(
|
|
2622
|
+
new CreateFunctionCommand({
|
|
2623
|
+
Name: "redirect-cff",
|
|
2624
|
+
FunctionConfig: {
|
|
2625
|
+
Comment: "Redirects from a key value store",
|
|
2626
|
+
Runtime: "cloudfront-js-2.0",
|
|
2627
|
+
KeyValueStoreAssociations: {
|
|
2628
|
+
Quantity: 1,
|
|
2629
|
+
Items: [{ KeyValueStoreARN: kvsArn }],
|
|
2630
|
+
},
|
|
2631
|
+
},
|
|
2632
|
+
FunctionCode: Buffer.from(`
|
|
2633
|
+
import cf from "cloudfront";
|
|
2634
|
+
|
|
2635
|
+
async function handler(event) {
|
|
2636
|
+
const request = event.request;
|
|
2637
|
+
|
|
2638
|
+
if (await cf.kvs().exists(request.uri)) {
|
|
2639
|
+
const target = await cf.kvs().get(request.uri);
|
|
2640
|
+
|
|
2641
|
+
return {
|
|
2642
|
+
statusCode: 302,
|
|
2643
|
+
statusDescription: "Found",
|
|
2644
|
+
headers: { location: { value: target } },
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
return request;
|
|
2649
|
+
}
|
|
2650
|
+
`),
|
|
2651
|
+
}),
|
|
2652
|
+
);
|
|
2653
|
+
|
|
2654
|
+
const cff = simAws.cloudFront().getCloudFrontFunctionByName("redirect-cff");
|
|
2655
|
+
|
|
2656
|
+
const redirected = await cff!.handleViewerRequest(
|
|
2657
|
+
new Request("https://cdn.test/old-page"),
|
|
2658
|
+
);
|
|
2659
|
+
|
|
2660
|
+
console.log((redirected as Response).status); // 302
|
|
2661
|
+
console.log((redirected as Response).headers.get("location")); // /new-page
|
|
2662
|
+
```
|
|
2663
|
+
|
|
2664
|
+
`get` reads a string by default, and takes `{ format: "json" }` to parse the stored string or
|
|
2665
|
+
`{ format: "bytes" }` for its UTF-8 bytes. A missing key rejects. A Function that wants a default checks
|
|
2666
|
+
`exists` first, as the example does.
|
|
2667
|
+
|
|
2668
|
+
A Function written as a function reference has no import to write, and reads `cf` as a global.
|
|
2669
|
+
Importing `@kensio/yulin/cloudfront/globals` gives that global a type, along with the CloudFront
|
|
2670
|
+
Function event types. Each invocation gets its own `cf` through Node.js asynchronous context, and two
|
|
2671
|
+
Functions associated with different stores read their own even when they run at the same time.
|
|
2672
|
+
|
|
2673
|
+
### From CloudFormation
|
|
2674
|
+
|
|
2675
|
+
`AWS::CloudFront::KeyValueStore` creates a store, and a Function associates one with
|
|
2676
|
+
`FunctionConfig.KeyValueStoreAssociations`. CloudFormation takes a plain array there, where the SDK
|
|
2677
|
+
takes a `Quantity` and `Items` pair. `Ref` on a key value store is its ARN, and the two fit together
|
|
2678
|
+
directly:
|
|
2679
|
+
|
|
2680
|
+
```yaml
|
|
2681
|
+
Redirects:
|
|
2682
|
+
Type: AWS::CloudFront::KeyValueStore
|
|
2683
|
+
Properties:
|
|
2684
|
+
Name: redirects
|
|
2685
|
+
|
|
2686
|
+
RedirectFunction:
|
|
2687
|
+
Type: AWS::CloudFront::Function
|
|
2688
|
+
Properties:
|
|
2689
|
+
Name: redirect-cff
|
|
2690
|
+
AutoPublish: true
|
|
2691
|
+
FunctionCode: !Sub "..."
|
|
2692
|
+
FunctionConfig:
|
|
2693
|
+
Comment: Redirects from a key value store
|
|
2694
|
+
Runtime: cloudfront-js-2.0
|
|
2695
|
+
KeyValueStoreAssociations:
|
|
2696
|
+
- KeyValueStoreARN: !Ref Redirects
|
|
2697
|
+
```
|
|
2698
|
+
|
|
2699
|
+
`Fn::GetAtt` supports `Arn`, `Id` and `Status`. Deleting the Stack deletes the store, after the
|
|
2700
|
+
Functions holding it have gone.
|
|
2701
|
+
|
|
2702
|
+
CDK's `cloudfront.KeyValueStore` and the `keyValueStore` prop on `cloudfront.Function` both deploy.
|
|
2703
|
+
A CDK stack needs no hand-editing.
|
|
2704
|
+
|
|
2705
|
+
`cf.kvs()` refuses when the Function is associated with no store, and refuses an ID belonging to some
|
|
2706
|
+
other store. Handing back an empty store would let a Function that lost its association run to
|
|
2707
|
+
completion and quietly take every default.
|
|
2708
|
+
|
|
2709
|
+
## Available functionality
|
|
2710
|
+
|
|
2711
|
+
Sim CloudFront currently supports:
|
|
2712
|
+
|
|
2713
|
+
- `CreateDistributionCommand`, `GetDistributionCommand`, `UpdateDistributionCommand` and
|
|
2714
|
+
`DeleteDistributionCommand`
|
|
2715
|
+
- `CreateFunctionCommand` and `DeleteFunctionCommand`
|
|
2716
|
+
- Refusing Function code over CloudFront's 10 KB size limit, with `FunctionSizeLimitExceeded`
|
|
2717
|
+
- Key value stores, through both the CloudFront client and the key value store data client
|
|
2718
|
+
- S3 Origins backed by sim S3 Buckets, reading them as the Bucket policy allows
|
|
2719
|
+
- Custom Origins reaching sim HTTP APIs and sim Lambda Function URLs in process
|
|
2720
|
+
- `CustomHeaders` and `OriginCustomHeaders` on an Origin, for an origin that admits only CloudFront
|
|
2721
|
+
- CloudFront Distribution hostnames such as `distro123.cloudfront.net`
|
|
2722
|
+
- Default cache Behavior and path-based cache Behaviors
|
|
2723
|
+
- `DefaultRootObject` and `CustomErrorResponses`, for static sites and single-page apps
|
|
2724
|
+
- `viewer-request` and `viewer-response` CloudFront Functions, including async ones
|
|
2725
|
+
- Lambda@Edge functions at all four events, through `LambdaFunctionAssociations`
|
|
2726
|
+
- `LambdaFunctionAssociations` on a template's Distribution, and CDK's `edgeLambdas`
|
|
2727
|
+
- CloudFront Functions reading an associated key value store through `cf.kvs()`
|
|
2728
|
+
- `AWS::CloudFront::ResponseHeadersPolicy`, for headers a cache Behavior sets on every response
|
|
2729
|
+
- `AWS::CloudFront::KeyValueStore`, and `KeyValueStoreAssociations` on `AWS::CloudFront::Function`
|
|
2730
|
+
- `AWS::CloudFront::OriginAccessControl`, letting an Origin read a private Bucket as CloudFront
|
|
2731
|
+
- Viewer certificates from sim ACM, including CloudFront's `us-east-1` requirement
|
|
2732
|
+
- `WebACLId`, putting a simulated WAFv2 web ACL in front of everything a Distribution serves
|
|
2733
|
+
- Serving simulated CloudFront traffic on localhost with `serveSimAws`
|
|
2734
|
+
|
|
2735
|
+
The simulator focuses on useful behaviour for tests and local development, ahead of full CloudFront
|
|
2736
|
+
feature parity. Unsupported CloudFront options may be ignored or may throw errors depending on
|
|
2737
|
+
whether the simulator needs them to model the requested behaviour safely.
|
|
2738
|
+
|
|
2739
|
+
## Limitations
|
|
2740
|
+
|
|
2741
|
+
Where sim CloudFront knowingly behaves differently from AWS:
|
|
2742
|
+
|
|
2743
|
+
- **The origin events run on every request that reaches the Origin.** Real CloudFront runs
|
|
2744
|
+
`origin-request` and `origin-response` on a cache miss, and serves a cache hit without reaching
|
|
2745
|
+
either. Simulated CloudFront holds no cache, and every request that gets as far as the Origin is a
|
|
2746
|
+
miss here. A request a web ACL blocked or a viewer-request function answered reaches neither
|
|
2747
|
+
event, and an `origin-request` function that returns a response leaves the Origin unread with no
|
|
2748
|
+
`origin-response` event after it.
|
|
2749
|
+
- **An Origin keeps its kind and its Bucket through an origin-request function.** Real CloudFront
|
|
2750
|
+
lets a handler hand back `origin.s3` where it was given `origin.custom`, or point an S3 Origin at
|
|
2751
|
+
another Bucket. Both need something a simulated Origin does not hold, the dispatcher that reaches
|
|
2752
|
+
a custom Origin and the Bucket a domain name resolved to when the Distribution was written. Each
|
|
2753
|
+
is refused with the 502 a failed edge function gets, carrying the reason. The domain name, the
|
|
2754
|
+
Origin path and the custom headers are the parts a handler can rewrite.
|
|
2755
|
+
- **A custom Origin reports CloudFront's default connection settings.** `keepaliveTimeout`,
|
|
2756
|
+
`port`, `protocol`, `readTimeout` and `sslProtocols` are what an origin event carries for every
|
|
2757
|
+
custom Origin, whatever `CustomOriginConfig` said, and a handler writing them changes nothing
|
|
2758
|
+
about the fetch. Nothing here opens a socket for them to apply to. The `customHeaders` of an S3
|
|
2759
|
+
Origin are empty for the same kind of reason. An S3 Origin reads its Bucket through GetObject and
|
|
2760
|
+
builds no request for a header to travel on.
|
|
2761
|
+
- **A custom error page is fetched without the origin events.** CloudFront fetches
|
|
2762
|
+
`ResponsePagePath` from the Origin, and an origin function runs for that fetch as it does for any
|
|
2763
|
+
other. Here the page is fetched directly. An `origin-request` function that rewrote the Origin
|
|
2764
|
+
leaves the error page coming from the Behavior's own Origin.
|
|
2765
|
+
- **A CDK `EdgeFunction` outside us-east-1 wants the whole cloud assembly.**
|
|
2766
|
+
`cloudfront.experimental.EdgeFunction` writes the function into a us-east-1 support stack and
|
|
2767
|
+
reads its ARN back through a custom resource in the stack that uses it. `deployCdkOut` deploys
|
|
2768
|
+
both stacks and the read finds the ARN. `deployTemplateFile` on the using stack's template alone
|
|
2769
|
+
deploys one of them, the read finds nothing, and the Distribution goes up without the
|
|
2770
|
+
association, recorded on `stack.ignoredProperties`. `cdk deploy` deploys both either way.
|
|
2771
|
+
- **Nothing is replicated.** Real Lambda@Edge copies the function out to every Region and creates the
|
|
2772
|
+
`AWSServiceRoleForLambdaReplicator` service-linked role to do it. Here the function is invoked
|
|
2773
|
+
where it was created. The trust policy and the `lambda:GetFunction` and `lambda:EnableReplication`
|
|
2774
|
+
permissions a real association needs are still checked, because those are what a first deploy
|
|
2775
|
+
fails on.
|
|
2776
|
+
- **A Lambda@Edge body is never truncated.** CloudFront caps the body it sends a `viewer-request`
|
|
2777
|
+
function and reports `inputTruncated` when it had to cut one. Every simulated body arrives whole
|
|
2778
|
+
and `inputTruncated` is always false, so a test finds out nothing about whether its request would
|
|
2779
|
+
be too large for a real edge function.
|
|
2780
|
+
- **The Origin's status decides whether a viewer-response function runs.** CloudFront skips the
|
|
2781
|
+
viewer-response event once the Origin answers 400 or higher, and both kinds of function are
|
|
2782
|
+
skipped here on that rule. Where the status is replaced further down the pipeline, by a custom
|
|
2783
|
+
error response or by an `origin-response` function, the Origin's own status still decides. AWS
|
|
2784
|
+
documents the restriction against the Origin's status and says nothing about the status something
|
|
2785
|
+
else puts in its place. A Distribution combining the two is where this simulation is guessing. A
|
|
2786
|
+
response an `origin-request` function generated has no Origin status behind it, and its own status
|
|
2787
|
+
stands in.
|
|
2788
|
+
- **CloudFront's disallowed and read-only header lists go unchecked.** Real CloudFront answers 502
|
|
2789
|
+
when an edge function adds `Connection` or edits `Content-Length`. Both kinds of function here
|
|
2790
|
+
write what they like, apart from the viewer-request `host`, which is restored.
|
|
2791
|
+
- **An S3 Origin with no origin access control reads its Bucket anonymously.** That is the unsigned
|
|
2792
|
+
request real CloudFront sends to the S3 REST endpoint without one. The Bucket policy has to make
|
|
2793
|
+
an Object publicly readable for the Distribution to serve it. A legacy
|
|
2794
|
+
`S3OriginConfig.OriginAccessIdentity` is refused by name. It signs the Origin request as a
|
|
2795
|
+
CloudFront canonical user nothing here models, and a Bucket policy written for one would deny the
|
|
2796
|
+
read in silence.
|
|
2797
|
+
- **A signed Origin request carries no signature.** An Origin whose origin access control signs
|
|
2798
|
+
reaches the Origin as the `cloudfront.amazonaws.com` service principal carrying the Distribution's
|
|
2799
|
+
ARN. That pair is what the Bucket policy or the function's resource policy is evaluated against,
|
|
2800
|
+
and no SigV4 signature is computed or checked. A Function URL Origin is told who the request is
|
|
2801
|
+
from at the simulated HTTP boundary, the same way anything else calling into simulated AWS in
|
|
2802
|
+
process says who it is. No other simulated request is signed here either, and the signature
|
|
2803
|
+
itself is beyond what a test can assert on. The payload hash is the one part of a signature that
|
|
2804
|
+
is stated and checked, because a Function URL turns a POST away over it. See
|
|
2805
|
+
[posting to a Function URL Origin](#posting-to-a-function-url-origin).
|
|
2806
|
+
- **An origin access control signs for an S3 or Lambda Function URL Origin only.** CloudFront also
|
|
2807
|
+
signs for MediaStore and MediaPackage V2 Origins, and both are left out. An
|
|
2808
|
+
`OriginAccessControlOriginType` other than `s3` or `lambda`, or a `SigningProtocol` other than
|
|
2809
|
+
`sigv4`, fails the Stack by naming the value. Neither is quietly treated as one of the two.
|
|
2810
|
+
- **An origin access control name is unique, and that is the whole of the checking.** A second one
|
|
2811
|
+
claiming a name is refused with `OriginAccessControlAlreadyExists`, as CloudFront refuses one.
|
|
2812
|
+
- **An origin access control has no command surface.** `CreateOriginAccessControl` and its siblings
|
|
2813
|
+
are absent, and `AWS::CloudFront::OriginAccessControl` is the only way to make one.
|
|
2814
|
+
- **A list's `Quantity` is only checked when it is there.** Every CloudFront list carries a count
|
|
2815
|
+
alongside its items, and a `Quantity` that disagrees with `Items` is refused with
|
|
2816
|
+
`InconsistentQuantities`, as CloudFront refuses it. A list arriving as a plain array, which is the
|
|
2817
|
+
CloudFormation shape, has no count to disagree with, and a template goes unchecked this way. So
|
|
2818
|
+
does a hand-written `{ Items: [...] }` with the count left out. The AWS SDK types make omitting
|
|
2819
|
+
`Quantity` a compile error, so what arrives without one is a different mistake from the one this
|
|
2820
|
+
catches.
|
|
2821
|
+
- **A web ACL a Distribution names has to exist here.** `WebACLId` resolves to a web ACL created in
|
|
2822
|
+
this simulation, and the ARN carries the Account and Region holding it. A managed web ACL, or one
|
|
2823
|
+
from a real account, is refused at create and at update. A CloudFormation Distribution is the
|
|
2824
|
+
exception and deploys without it, recording the property. Deleting a web ACL a Distribution still
|
|
2825
|
+
names leaves the Distribution answering `InvalidWebACLId` on every request, because real WAF
|
|
2826
|
+
refuses that deletion and nothing here tracks the association to refuse it.
|
|
2827
|
+
- **`IfMatch` ETags are ignored on a Distribution or a Function.** `UpdateDistributionCommand`,
|
|
2828
|
+
`DeleteDistributionCommand` and `DeleteFunctionCommand` all accept `IfMatch` and ignore it,
|
|
2829
|
+
leaving both `PreconditionFailed` and `InvalidIfMatchVersion` unused there. A stale ETag there
|
|
2830
|
+
costs a retry. The key value store commands are the exception and do check it, because the data
|
|
2831
|
+
API is built around it, and two writers racing on one store is the case it exists to catch.
|
|
2832
|
+
- **A key value store has no size quota.** CloudFront caps a store's total size and the length of a
|
|
2833
|
+
single key and value, and refuses a write that would exceed either. Nothing here counts against a
|
|
2834
|
+
quota, and `TotalSizeInBytes` is reported without being enforced. A test can find out nothing
|
|
2835
|
+
about whether its data would be too large for a real store.
|
|
2836
|
+
- **A key value store association is fixed once the Function is created.** There is no
|
|
2837
|
+
`UpdateFunction` here, and the store a Function reads is the one it was created with. Delete the
|
|
2838
|
+
Function and create it again to change it.
|
|
2839
|
+
- **A bound handler goes unmeasured.** Function code over CloudFront's 10 KB limit is refused with
|
|
2840
|
+
`FunctionSizeLimitExceeded`, counted on the source as uploaded. A handler passed as a function
|
|
2841
|
+
reference, through `makeCffFunctionCodeInput` or a CloudFormation binding, carries no source to
|
|
2842
|
+
count. The limit reaches only the inline code a real deploy would upload.
|
|
2843
|
+
- **`ImportSource` is unsupported.** `CreateKeyValueStoreCommand` ignores it, and
|
|
2844
|
+
`AWS::CloudFront::KeyValueStore` refuses a Resource carrying one. Nothing here reads an S3 Object
|
|
2845
|
+
as key data, and deploying an empty store would let a test pass against data the deploy should
|
|
2846
|
+
have seeded. Write the keys with `PutKey` or `UpdateKeys`.
|
|
2847
|
+
- **A `Status` Output holds the status at deploy time.** CloudFormation Outputs are resolved once,
|
|
2848
|
+
while a new store is still `PROVISIONING`, so `Fn::GetAtt` on `Status` in an Output reads
|
|
2849
|
+
`PROVISIONING` even though the store goes on to become `READY`. Read the store itself for its
|
|
2850
|
+
current status.
|
|
2851
|
+
- **Key listing is unpaginated.** `ListKeysCommand` and `ListKeyValueStoresCommand` answer with
|
|
2852
|
+
everything and never set a `NextToken` or `NextMarker`, leaving a test with no paging loop to
|
|
2853
|
+
exercise.
|
|
2854
|
+
- **A deletion goes ahead without waiting for the disable to deploy.** Real CloudFront needs the
|
|
2855
|
+
disabled Distribution to reach `Deployed` before it accepts the deletion. Here, `Enabled: false`
|
|
2856
|
+
is enough.
|
|
2857
|
+
- **A disabled Distribution still serves requests.** Real CloudFront answers a disabled Distribution
|
|
2858
|
+
with a 403. Only deleting a Distribution stops it serving here.
|
|
2859
|
+
- **`DeleteFunctionCommand` never answers `FunctionInUse`.** A CloudFront Function is never told
|
|
2860
|
+
that a cache Behavior has taken it up, and every Function is deletable. A Behavior left pointing at a
|
|
2861
|
+
deleted Function runs no Function code.
|
|
2862
|
+
- **A response headers policy name is unique, and that is the whole of the checking.** A second
|
|
2863
|
+
policy claiming a name is refused with `ResponseHeadersPolicyAlreadyExists`, as CloudFront refuses
|
|
2864
|
+
one. The header names and values themselves are stored as written.
|
|
2865
|
+
- **A response headers policy has no command surface.** `CreateResponseHeadersPolicy` and its
|
|
2866
|
+
siblings are absent, and `AWS::CloudFront::ResponseHeadersPolicy` is the only way to make one.
|
|
2867
|
+
- **`ServerTimingHeadersConfig` always adds the header once enabled.** `SamplingRate` decides what
|
|
2868
|
+
share of real responses carry `Server-Timing`. This simulation adds it to every response once
|
|
2869
|
+
`Enabled` is true. A test asserting on it never depends on chance. The header's value is a
|
|
2870
|
+
fixed placeholder, since nothing here measures an Origin fetch the way CloudFront's edge does.
|
|
2871
|
+
- **`CachePolicyId` and `OriginRequestPolicyId` are accepted and ignored.** Sim CloudFront models no
|
|
2872
|
+
edge caching. A Behavior's cache policy, including an AWS managed policy such as
|
|
2873
|
+
`CachingOptimized`, is left unvalidated and unapplied to TTLs and the cache key. Every request
|
|
2874
|
+
reaches the Origin, whatever the policy would have cached on real CloudFront.
|