canopycms-cdk 0.0.66 → 0.0.67-int.89
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/dist/constructs/asset-support.d.ts +116 -6
- package/dist/constructs/asset-support.js +407 -263
- package/dist/constructs/cms-service.js +6 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +5 -5
|
@@ -307,6 +307,111 @@ export interface AssetCloudFrontBehaviors {
|
|
|
307
307
|
*/
|
|
308
308
|
readonly assetsTransform: cloudfront.BehaviorOptions;
|
|
309
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Options for `assetUploadBehavior` - everything
|
|
312
|
+
* `AssetSupportProps.uploadBehavior` accepts, plus the bucket that the
|
|
313
|
+
* standalone form has no construct to read it from.
|
|
314
|
+
*/
|
|
315
|
+
export interface AssetUploadBehaviorRouteOptions extends AssetUploadBehaviorOptions {
|
|
316
|
+
/**
|
|
317
|
+
* The bucket presigned uploads land in.
|
|
318
|
+
*
|
|
319
|
+
* ALWAYS the BYO case, and the caveat that goes with it is real: this
|
|
320
|
+
* behavior's origin is deliberately OAC-UNSIGNED (it has to be - see
|
|
321
|
+
* `buildUploadBehavior`), so this bucket's own policy is the only thing
|
|
322
|
+
* standing between an anonymous caller and whatever that policy allows at
|
|
323
|
+
* `/`. The URI rewrite means nothing arriving here can address a key, so
|
|
324
|
+
* that is bucket-level operations only, and a bucket with BLOCK_ALL and no
|
|
325
|
+
* public policy refuses all of them - but it is the bucket policy doing the
|
|
326
|
+
* refusing, not this construct.
|
|
327
|
+
*/
|
|
328
|
+
readonly bucket: s3.IBucket;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The presigned-upload CloudFront behavior, built from a bucket alone - the
|
|
332
|
+
* infrastructure half of `media.uploadUrl` for callers who have a bucket and
|
|
333
|
+
* no use for an `AssetSupport`.
|
|
334
|
+
*
|
|
335
|
+
* ```ts
|
|
336
|
+
* const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
|
|
337
|
+
* defaultBehavior: assetUploadBehavior(this, { bucket: assetBucket }),
|
|
338
|
+
* })
|
|
339
|
+
* // media.uploadUrl = `https://${uploads.distributionDomainName}/`
|
|
340
|
+
* ```
|
|
341
|
+
*
|
|
342
|
+
* WHY THIS EXISTS SEPARATELY FROM `AssetSupport.uploadBehavior()`. The upload
|
|
343
|
+
* route depends on the bucket and nothing else, which is what lets it live on
|
|
344
|
+
* its own one-route distribution (the topology `AssetSupport.uploadBehavior()`
|
|
345
|
+
* documents and recommends). But `AssetSupport`'s CONSTRUCTOR builds the
|
|
346
|
+
* transform Lambda unconditionally, and with it a log group, a Function URL, a
|
|
347
|
+
* role, and `grantRead`/`grantPut` on three prefixes. Reaching the upload
|
|
348
|
+
* behavior through the class therefore costs an instantiation whose only
|
|
349
|
+
* purpose is to be instantiated.
|
|
350
|
+
*
|
|
351
|
+
* That footprint is cheaper than it looks, and the reason is worth stating
|
|
352
|
+
* because the opposite is easy to assume: those grants do NOT land in the
|
|
353
|
+
* bucket policy. `Grant.addToPrincipalOrResource` (aws-cdk-lib,
|
|
354
|
+
* `aws-iam/lib/grant.ts`) adds the identity statement first and returns right
|
|
355
|
+
* there when two things hold - the identity statement was actually added, AND
|
|
356
|
+
* the grantee's account is known to match the resource's (`TokenComparison`
|
|
357
|
+
* SAME, or both unresolved). Only when one of those fails does it also write a
|
|
358
|
+
* resource statement. A same-account Lambda with a CDK-managed role satisfies
|
|
359
|
+
* both, so its grants go to that function's OWN execution role and nowhere
|
|
360
|
+
* else. Measured on a synthesized template, an owned bucket plus a
|
|
361
|
+
* same-account `AssetSupport` emits no `AWS::S3::BucketPolicy` at all.
|
|
362
|
+
* Deleting the construct therefore takes the whole footprint with it and
|
|
363
|
+
* leaves nothing behind on a shared bucket's policy.
|
|
364
|
+
*
|
|
365
|
+
* Nor does the cross-account shape invert it, which is the obvious next
|
|
366
|
+
* guess and is also wrong. Measured across all three shapes - an owned
|
|
367
|
+
* bucket, one imported by name, and one imported by ARN with an explicitly
|
|
368
|
+
* DIFFERENT account - every one emits zero `AWS::S3::BucketPolicy`. The
|
|
369
|
+
* second half of `addToPrincipalOrResource` does run for an import, but
|
|
370
|
+
* `addToResourcePolicy` on a bucket CDK does not own is a no-op: it cannot
|
|
371
|
+
* write a policy onto a resource it did not create. So a cross-account
|
|
372
|
+
* adopter gets the identity half here and writes the resource half
|
|
373
|
+
* themselves, on the bucket's own side.
|
|
374
|
+
*
|
|
375
|
+
* What it costs instead is legibility: a second `AssetSupport` standing in a
|
|
376
|
+
* stack that has no asset pipeline, existing only so that a method can be
|
|
377
|
+
* called on it, is fine on the day and unexplainable six months later. That
|
|
378
|
+
* is what this function removes, and it matters most for the shape the
|
|
379
|
+
* one-route topology invites - one bucket shared by every environment, owned
|
|
380
|
+
* by a stack that holds no per-environment resources, with reads and
|
|
381
|
+
* transforms staying per-environment.
|
|
382
|
+
*
|
|
383
|
+
* `AssetSupport.uploadBehavior()` remains the right call when you already have
|
|
384
|
+
* an `AssetSupport`; both funnel into the same builder, so the two cannot
|
|
385
|
+
* drift.
|
|
386
|
+
*
|
|
387
|
+
* TWO THINGS TO KNOW ABOUT `scope`. It receives the CloudFront Function and
|
|
388
|
+
* the two policies as children under fixed ids, so calling this twice with
|
|
389
|
+
* the SAME scope throws CDK's duplicate-construct-id error rather than
|
|
390
|
+
* returning a second behavior - one call per scope. And because the children
|
|
391
|
+
* hang off `scope`, moving an EXISTING deployment from
|
|
392
|
+
* `AssetSupport.uploadBehavior()` to this function changes their construct
|
|
393
|
+
* path and therefore their logical IDs, which replaces all three CloudFront
|
|
394
|
+
* resources. There is no reason to make that move on a stack that already has
|
|
395
|
+
* an `AssetSupport`; this function is for the stack that would otherwise have
|
|
396
|
+
* to grow one.
|
|
397
|
+
*
|
|
398
|
+
* TWO THINGS THIS DOES NOT DO, both deliberate:
|
|
399
|
+
*
|
|
400
|
+
* - It writes NO bucket CORS rule, and needs none. The edge supplies
|
|
401
|
+
* `Access-Control-Allow-Origin`; S3's acceptance of a presigned POST and its
|
|
402
|
+
* ADVERTISEMENT of CORS are independent, and only the second is what a
|
|
403
|
+
* bucket CORS rule governs. `AssetSupportProps.editorOrigins` is the prop
|
|
404
|
+
* that writes one, and it belongs to the construct, not to this route.
|
|
405
|
+
* - It registers no synth-time guard for "built but never attached to a
|
|
406
|
+
* distribution". `AssetSupport` has one, but it fires only in standalone
|
|
407
|
+
* mode with no `editorOrigins` - where opting into `uploadBehavior` is what
|
|
408
|
+
* satisfied the "something must supply ACAO" guard, so opting in and
|
|
409
|
+
* stopping leaves a bucket with neither. A caller here always brings their
|
|
410
|
+
* own bucket and never passes through that guard, so there is no invariant
|
|
411
|
+
* to protect: a discarded return value is an obviously unfinished call, not
|
|
412
|
+
* a silently broken one.
|
|
413
|
+
*/
|
|
414
|
+
export declare function assetUploadBehavior(scope: Construct, options: AssetUploadBehaviorRouteOptions): cloudfront.BehaviorOptions;
|
|
310
415
|
/**
|
|
311
416
|
* Per-site CDK construct for the asset/media delivery system (see
|
|
312
417
|
* `.claude/future-tasks/assets-media-system.md` for the full design record).
|
|
@@ -361,16 +466,14 @@ export declare class AssetSupport extends Construct {
|
|
|
361
466
|
/**
|
|
362
467
|
* The origin inside `upload`, kept so the validation below can ask whether
|
|
363
468
|
* that behavior was ever attached to a distribution - see `UploadOrigin`.
|
|
364
|
-
* Set by `
|
|
469
|
+
* Set by `uploadBehavior()`, i.e. at the same moment as `upload`: the
|
|
470
|
+
* builder is shared with `assetUploadBehavior`, which has no construct to
|
|
471
|
+
* record anything on, so recovering the origin from the returned behavior is
|
|
472
|
+
* this class's job rather than the builder's.
|
|
365
473
|
*/
|
|
366
474
|
private uploadOrigin?;
|
|
367
475
|
constructor(scope: Construct, id: string, props: AssetSupportProps);
|
|
368
476
|
private buildBehaviors;
|
|
369
|
-
/**
|
|
370
|
-
* The behavior that accepts the editor's presigned-POST uploads. Every piece
|
|
371
|
-
* below is load-bearing; see `uploadBehavior()` for where the result goes.
|
|
372
|
-
*/
|
|
373
|
-
private buildUploadBehavior;
|
|
374
477
|
/**
|
|
375
478
|
* The two CloudFront behavior configs this system needs.
|
|
376
479
|
*
|
|
@@ -430,6 +533,13 @@ export declare class AssetSupport extends Construct {
|
|
|
430
533
|
* The upload route is the one part of this system with no per-environment
|
|
431
534
|
* code in it - it depends on the bucket and nothing else - which is why it
|
|
432
535
|
* is the part that can be split off this way.
|
|
536
|
+
*
|
|
537
|
+
* That same property is why `assetUploadBehavior` exists as a free function:
|
|
538
|
+
* it builds this behavior from a bucket alone, for a caller who wants the
|
|
539
|
+
* one-route distribution above and has no other use for an `AssetSupport`.
|
|
540
|
+
* Prefer this method when you already have one; both funnel into the same
|
|
541
|
+
* builder. See that function for what instantiating the construct purely to
|
|
542
|
+
* reach this method actually costs.
|
|
433
543
|
*/
|
|
434
544
|
uploadBehavior(): cloudfront.BehaviorOptions;
|
|
435
545
|
/**
|
|
@@ -225,6 +225,376 @@ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
|
|
|
225
225
|
* `AssetCloudFrontBehaviors`.
|
|
226
226
|
*/
|
|
227
227
|
export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
|
|
228
|
+
/**
|
|
229
|
+
* The `allowedOrigins` guards, shared by both entry points.
|
|
230
|
+
*
|
|
231
|
+
* These live here rather than inline in `buildUploadBehavior` because
|
|
232
|
+
* `AssetSupport` runs them in its CONSTRUCTOR - eagerly, long before anything
|
|
233
|
+
* builds the behavior - and moving them to the builder would delay the throw
|
|
234
|
+
* to the first `uploadBehavior()` call. `assetUploadBehavior` has no such
|
|
235
|
+
* split and runs them on entry. One implementation, two call sites, so the
|
|
236
|
+
* standalone form cannot quietly accept what the construct refuses.
|
|
237
|
+
*
|
|
238
|
+
* `label` is the options path to name in the message, so each caller's error
|
|
239
|
+
* points at the property the caller actually wrote.
|
|
240
|
+
*/
|
|
241
|
+
function validateUploadBehaviorOptions(options, label) {
|
|
242
|
+
// An empty `allowedOrigins` is the same class of mistake as an empty
|
|
243
|
+
// `editorOrigins` and gets the same treatment: CloudFormation rejects
|
|
244
|
+
// `AccessControlAllowOrigins` with no items, so silently falling back to
|
|
245
|
+
// the `['*']` default would turn a forwarded-empty-env-var into either a
|
|
246
|
+
// failed deploy or, worse, a wildcard the caller did not ask for.
|
|
247
|
+
if (options.allowedOrigins?.length === 0) {
|
|
248
|
+
throw new Error(`${label} is an empty array. CloudFront requires at ` +
|
|
249
|
+
'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
|
|
250
|
+
"silently widen what you asked for. Omit the property to take the `['*']` default, " +
|
|
251
|
+
'or list the origin(s) the editor is served from.');
|
|
252
|
+
}
|
|
253
|
+
// Two consumers read `allowedOrigins` and they do not share a matcher: the
|
|
254
|
+
// response headers policy (which decides ACAO on the POST response) accepts
|
|
255
|
+
// CloudFront's pattern grammar, including a leftmost-subdomain wildcard
|
|
256
|
+
// like `https://*.preview.example.com`; the preflight responder compiled
|
|
257
|
+
// into the CloudFront Function can only do exact-string comparison, because
|
|
258
|
+
// reimplementing that grammar in an edge function without being able to
|
|
259
|
+
// test it against CloudFront is how the two silently disagree.
|
|
260
|
+
//
|
|
261
|
+
// So a pattern is refused rather than half-honoured. Accepting one would
|
|
262
|
+
// deploy clean and then fail every upload from a matching origin at the
|
|
263
|
+
// PREFLIGHT, with a correct-looking policy sitting right next to it - the
|
|
264
|
+
// same invisible failure the preflight responder was added to remove.
|
|
265
|
+
const wildcardOrigins = (options.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
|
|
266
|
+
const isBareWildcard = options.allowedOrigins?.join() === '*';
|
|
267
|
+
if (wildcardOrigins.length > 0 && !isBareWildcard) {
|
|
268
|
+
throw new Error(`${label} contains a wildcard pattern ` +
|
|
269
|
+
`(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
|
|
270
|
+
`headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
|
|
271
|
+
`CloudFront Function that compares origins exactly - so every upload from a matching ` +
|
|
272
|
+
`origin would fail its preflight while the policy looked correct. Use exactly ` +
|
|
273
|
+
`['*'] to allow any origin, or list each editor origin in full.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The behavior that accepts the editor's presigned-POST uploads. Every piece
|
|
278
|
+
* below is load-bearing; see `assetUploadBehavior` and
|
|
279
|
+
* `AssetSupport.uploadBehavior()` for where the result goes.
|
|
280
|
+
*
|
|
281
|
+
* `label` prefixes the one error raised here, so the message names whichever
|
|
282
|
+
* entry point the caller used.
|
|
283
|
+
*/
|
|
284
|
+
function buildUploadBehavior(scope, options, label) {
|
|
285
|
+
// A DISTINCT origin for the bucket, with OAC signing OFF - the read
|
|
286
|
+
// behaviors' origin cannot be reused here no matter what this behavior
|
|
287
|
+
// sets, because OAC is a property of the ORIGIN, not the behavior. (On the
|
|
288
|
+
// dedicated distribution `uploadBehavior()` recommends this is the only
|
|
289
|
+
// origin present; the point is that it cannot be the read one.)
|
|
290
|
+
// CloudFront signs origin requests but never hashes
|
|
291
|
+
// the body, so an OAC-signed origin rejects every multipart POST whatever
|
|
292
|
+
// the viewer sent. Measured, that is `400 InvalidArgument`, the body
|
|
293
|
+
// naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
|
|
294
|
+
// ... or a valid sha256 value`. It fails CLOSED, but it is an
|
|
295
|
+
// argument-validation error, NOT the 403 that the Lambda Function URL
|
|
296
|
+
// origin produces in the same situation (docs/deploying-to-aws.md's
|
|
297
|
+
// "CloudFront OAC and request body signing"): there Lambda verifies a
|
|
298
|
+
// SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
|
|
299
|
+
// adopter told to expect 403 goes hunting for a permissions problem that
|
|
300
|
+
// does not exist.
|
|
301
|
+
//
|
|
302
|
+
// `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
|
|
303
|
+
// would also be unsigned, for two reasons. It is the exact shape this
|
|
304
|
+
// whole path was measured against end to end on live AWS resources (204,
|
|
305
|
+
// object landed); and it is the only one of the two that can state the
|
|
306
|
+
// CloudFront->S3 protocol, which for a request carrying a live upload
|
|
307
|
+
// credential in its body should be pinned rather than inferred.
|
|
308
|
+
// `withBucketDefaults()` emits `S3OriginConfig`, which has no
|
|
309
|
+
// `OriginProtocolPolicy` field at all.
|
|
310
|
+
// A dot in the bucket name breaks this origin specifically. S3's wildcard
|
|
311
|
+
// certificate covers one label (`*.s3.<region>.amazonaws.com`), so
|
|
312
|
+
// `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
|
|
313
|
+
// CloudFront answers 502 on every upload. This is a CUSTOM origin doing
|
|
314
|
+
// ordinary TLS validation; the read path uses an S3-type origin, which
|
|
315
|
+
// CloudFront treats differently, so nothing else here would surface it -
|
|
316
|
+
// and dotted names are most likely in exactly the BYO case ("a site's
|
|
317
|
+
// existing content bucket") this behavior is written for. Only checkable
|
|
318
|
+
// when the name is a real literal, which for an imported bucket it is.
|
|
319
|
+
const bucketName = options.bucket.bucketName;
|
|
320
|
+
if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
|
|
321
|
+
throw new Error(`${label} cannot use bucket "${bucketName}" - a dot in the bucket ` +
|
|
322
|
+
`name puts an extra label in its regional domain name, which S3's wildcard ` +
|
|
323
|
+
`certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
|
|
324
|
+
`every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
|
|
325
|
+
`dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
|
|
326
|
+
}
|
|
327
|
+
const uploadOrigin = new UploadOrigin(options.bucket.bucketRegionalDomainName, {
|
|
328
|
+
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
|
|
329
|
+
});
|
|
330
|
+
// S3's POST Object is only valid at the bucket ROOT - without this rewrite
|
|
331
|
+
// the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
|
|
332
|
+
// behavior: rewriting unconditionally means no request arriving here can
|
|
333
|
+
// address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
|
|
334
|
+
// only the bucket-level operations at `/` (list, create, delete bucket).
|
|
335
|
+
// A bucket this construct creates refuses all of them anonymously
|
|
336
|
+
// (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
|
|
337
|
+
// bucket policy to have got right.
|
|
338
|
+
//
|
|
339
|
+
// Their bucket policy is a WEAKER backstop here than on the read path, and
|
|
340
|
+
// the two must not be read as equivalent: the read behaviors go through an
|
|
341
|
+
// OAC-SIGNED origin, so a too-permissive policy there is still gated by a
|
|
342
|
+
// signature CloudFront adds. This origin is deliberately unsigned (it has
|
|
343
|
+
// to be - see the OAC note at the top of this method), so on a BYO bucket
|
|
344
|
+
// the policy is the ONLY thing standing between an anonymous caller and
|
|
345
|
+
// whatever that policy allows at `/`. Stated where a BYO adopter reads it,
|
|
346
|
+
// in `uploadBehavior()`'s caveat block, not only here.
|
|
347
|
+
//
|
|
348
|
+
// The only thing that can WRITE either way is a request carrying a valid
|
|
349
|
+
// presigned policy, and only to the single key that policy names. Do not
|
|
350
|
+
// make the rewrite conditional; that gives key addressability back.
|
|
351
|
+
//
|
|
352
|
+
// The same function also drops `Authorization`. A site behind HTTP basic
|
|
353
|
+
// auth otherwise sends S3 a credential it cannot parse
|
|
354
|
+
// (`400 InvalidArgument - Unsupported Authorization Type`), and the
|
|
355
|
+
// presigned POST carries its own authority in the body, so nothing on this
|
|
356
|
+
// route ever wants the header. It is dropped HERE, in the function, rather
|
|
357
|
+
// than by naming it in the origin request policy's `allExcept` list below,
|
|
358
|
+
// and that placement is deliberate: CloudFront rejects `Authorization` in
|
|
359
|
+
// an origin request policy's header ALLOWLIST outright ("The parameter
|
|
360
|
+
// Headers contains Authorization that is not allowed"), and whether the
|
|
361
|
+
// same validation fires on an `allExcept` list is not something synth can
|
|
362
|
+
// tell you - it would surface as a failed `cdk deploy` on the whole stack.
|
|
363
|
+
// A viewer-request function has no such ambiguity: `method` is the only
|
|
364
|
+
// read-only field on the request object, headers are freely mutable, and
|
|
365
|
+
// `Authorization` is the header CloudFront's own basic-auth function
|
|
366
|
+
// examples read. Dropping it at the viewer also means it is gone before
|
|
367
|
+
// any policy runs, so the guarantee does not depend on the policy at all.
|
|
368
|
+
// The same function ALSO answers the CORS preflight, and that is not
|
|
369
|
+
// optional decoration - without it this route cannot work at all.
|
|
370
|
+
//
|
|
371
|
+
// The editor's upload is NOT a CORS simple request, though it looks like
|
|
372
|
+
// one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
|
|
373
|
+
// but it assigns `xhr.upload.onprogress` before `send()`, and registering
|
|
374
|
+
// ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
|
|
375
|
+
// from the simple-request rules, independent of method, headers and content
|
|
376
|
+
// type. So every browser upload begins with `OPTIONS /`.
|
|
377
|
+
//
|
|
378
|
+
// Nothing else on this route would answer it. CloudFront does not
|
|
379
|
+
// synthesize preflight responses - a response headers policy supplies
|
|
380
|
+
// values for headers "in responses to CORS preflight requests", i.e. it
|
|
381
|
+
// decorates a response that something else produced - and `ALLOW_ALL`
|
|
382
|
+
// forwards the OPTIONS to S3, which with no CORS configuration answers
|
|
383
|
+
// `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
|
|
384
|
+
// is not a 2xx fails the browser's check whatever headers are attached, so
|
|
385
|
+
// the POST is never sent and the upload dies as an opaque network error.
|
|
386
|
+
//
|
|
387
|
+
// This is why the end-to-end measurements did not catch it: they replayed
|
|
388
|
+
// the POST as a script, which has no preflight. A scripted POST is not an
|
|
389
|
+
// acceptance test for this route; a real browser upload is.
|
|
390
|
+
//
|
|
391
|
+
// Short-circuiting at the viewer also strengthens the containment argument
|
|
392
|
+
// above rather than weakening it - an OPTIONS that never reaches the origin
|
|
393
|
+
// can address nothing.
|
|
394
|
+
const allowedOrigins = options.allowedOrigins ?? ['*'];
|
|
395
|
+
const rewriteToBucketRoot = new cloudfront.Function(scope, 'AssetUploadRewriteFunction', {
|
|
396
|
+
code: cloudfront.FunctionCode.fromInline([
|
|
397
|
+
`var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
|
|
398
|
+
'function handler(event) {',
|
|
399
|
+
' var request = event.request;',
|
|
400
|
+
" if (request.method === 'OPTIONS') {",
|
|
401
|
+
' var headers = {',
|
|
402
|
+
` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
|
|
403
|
+
" 'access-control-allow-headers': { value: '*' },",
|
|
404
|
+
` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
|
|
405
|
+
' };',
|
|
406
|
+
// Echo the caller's own Origin when the list is narrowed: a preflight
|
|
407
|
+
// may only be answered with `*` or the single requesting origin, so a
|
|
408
|
+
// multi-entry list cannot be returned verbatim. No match means no
|
|
409
|
+
// ACAO, and the browser's own check refuses the upload - which is the
|
|
410
|
+
// correct outcome, and a clearer one than a 403 here would be.
|
|
411
|
+
" var origin = request.headers.origin ? request.headers.origin.value : '';",
|
|
412
|
+
" if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
|
|
413
|
+
" headers['access-control-allow-origin'] = { value: '*' };",
|
|
414
|
+
' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
|
|
415
|
+
" headers['access-control-allow-origin'] = { value: origin };",
|
|
416
|
+
' }',
|
|
417
|
+
" return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
|
|
418
|
+
' }',
|
|
419
|
+
" request.uri = '/';",
|
|
420
|
+
' delete request.headers.authorization;',
|
|
421
|
+
' return request;',
|
|
422
|
+
'}',
|
|
423
|
+
].join('\n')),
|
|
424
|
+
});
|
|
425
|
+
// `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
|
|
426
|
+
// dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
|
|
427
|
+
// - the shape this path was measured against. It is NOT that policy
|
|
428
|
+
// overall, and must not be replaced by it: the managed one is documented as
|
|
429
|
+
// "Cookies: All, Query strings: All", so adopting it would forward the
|
|
430
|
+
// editor session cookie to S3, which is one of the two hazards this whole
|
|
431
|
+
// policy exists to remove. Only the header behavior is shared.
|
|
432
|
+
//
|
|
433
|
+
// `Authorization` is handled by the function above, not here.
|
|
434
|
+
//
|
|
435
|
+
// Cookies go through `CookiesConfig`, not the header list (a CloudFront
|
|
436
|
+
// Function does not even see them in `request.headers`), so
|
|
437
|
+
// `cookieBehavior.none()` is what strips them: an upload route mounted on
|
|
438
|
+
// a site's own distribution would otherwise hand S3 - and S3's access logs
|
|
439
|
+
// - the editor session cookie. On the dedicated distribution recommended
|
|
440
|
+
// in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
|
|
441
|
+
// none in the first place; this makes it true either way.
|
|
442
|
+
const originRequestPolicy = new cloudfront.OriginRequestPolicy(scope, 'AssetUploadOriginRequestPolicy', {
|
|
443
|
+
cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
|
|
444
|
+
headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
|
|
445
|
+
// A presigned POST carries everything in the multipart body.
|
|
446
|
+
queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
|
|
447
|
+
});
|
|
448
|
+
// The edge supplies Access-Control-Allow-Origin, scoped to this behavior
|
|
449
|
+
// only. This is the measurement that unblocked the whole design: a
|
|
450
|
+
// response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
|
|
451
|
+
// origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
|
|
452
|
+
// object landed). Acceptance and advertisement are independent in S3 -
|
|
453
|
+
// bucket CORS governs only whether S3 advertises - which is exactly the
|
|
454
|
+
// property that lets this construct avoid writing a bucket CORS rule.
|
|
455
|
+
//
|
|
456
|
+
// `originOverride: true` so the header is ours deterministically even on a
|
|
457
|
+
// bucket that does have its own CORS configuration.
|
|
458
|
+
//
|
|
459
|
+
// AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
|
|
460
|
+
// and this path DOES trigger one - see the CloudFront Function above, which
|
|
461
|
+
// answers it, and which exists because nothing else would. They are set
|
|
462
|
+
// here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
|
|
463
|
+
// two cannot tell a browser different things, and because a response
|
|
464
|
+
// headers policy is documented to decorate responses without saying whether
|
|
465
|
+
// a function-generated one is among them. If it is, `originOverride: true`
|
|
466
|
+
// means the policy's values REPLACE the function's identical ones; if it is
|
|
467
|
+
// not, the function's stand alone. Either way the viewer sees exactly one
|
|
468
|
+
// of each, which is what makes it safe not to know.
|
|
469
|
+
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(scope, 'AssetUploadResponseHeadersPolicy', {
|
|
470
|
+
corsBehavior: {
|
|
471
|
+
accessControlAllowOrigins: allowedOrigins,
|
|
472
|
+
accessControlAllowCredentials: false,
|
|
473
|
+
accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
|
|
474
|
+
accessControlAllowHeaders: ['*'],
|
|
475
|
+
accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
|
|
476
|
+
originOverride: true,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
return {
|
|
480
|
+
origin: uploadOrigin,
|
|
481
|
+
// A POST is 405 without this. CloudFront offers no narrower set that
|
|
482
|
+
// includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
|
|
483
|
+
// The URI rewrite above is what makes that acceptable.
|
|
484
|
+
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
|
|
485
|
+
// HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
|
|
486
|
+
// redirects with 301, and a browser turns a 301'd POST into a GET - so
|
|
487
|
+
// an `http://` upload URL would silently become a bodyless GET that this
|
|
488
|
+
// behavior rewrites to `/` and S3 refuses, with the file never sent.
|
|
489
|
+
// HTTPS_ONLY answers 403 instead: same refusal, visible.
|
|
490
|
+
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
|
|
491
|
+
// Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
|
|
492
|
+
// cache key is also what keeps the origin request policy above legal
|
|
493
|
+
// (CloudFront requires it to be a superset of the cache key).
|
|
494
|
+
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
|
|
495
|
+
originRequestPolicy,
|
|
496
|
+
responseHeadersPolicy,
|
|
497
|
+
functionAssociations: [
|
|
498
|
+
{
|
|
499
|
+
function: rewriteToBucketRoot,
|
|
500
|
+
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The presigned-upload CloudFront behavior, built from a bucket alone - the
|
|
507
|
+
* infrastructure half of `media.uploadUrl` for callers who have a bucket and
|
|
508
|
+
* no use for an `AssetSupport`.
|
|
509
|
+
*
|
|
510
|
+
* ```ts
|
|
511
|
+
* const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
|
|
512
|
+
* defaultBehavior: assetUploadBehavior(this, { bucket: assetBucket }),
|
|
513
|
+
* })
|
|
514
|
+
* // media.uploadUrl = `https://${uploads.distributionDomainName}/`
|
|
515
|
+
* ```
|
|
516
|
+
*
|
|
517
|
+
* WHY THIS EXISTS SEPARATELY FROM `AssetSupport.uploadBehavior()`. The upload
|
|
518
|
+
* route depends on the bucket and nothing else, which is what lets it live on
|
|
519
|
+
* its own one-route distribution (the topology `AssetSupport.uploadBehavior()`
|
|
520
|
+
* documents and recommends). But `AssetSupport`'s CONSTRUCTOR builds the
|
|
521
|
+
* transform Lambda unconditionally, and with it a log group, a Function URL, a
|
|
522
|
+
* role, and `grantRead`/`grantPut` on three prefixes. Reaching the upload
|
|
523
|
+
* behavior through the class therefore costs an instantiation whose only
|
|
524
|
+
* purpose is to be instantiated.
|
|
525
|
+
*
|
|
526
|
+
* That footprint is cheaper than it looks, and the reason is worth stating
|
|
527
|
+
* because the opposite is easy to assume: those grants do NOT land in the
|
|
528
|
+
* bucket policy. `Grant.addToPrincipalOrResource` (aws-cdk-lib,
|
|
529
|
+
* `aws-iam/lib/grant.ts`) adds the identity statement first and returns right
|
|
530
|
+
* there when two things hold - the identity statement was actually added, AND
|
|
531
|
+
* the grantee's account is known to match the resource's (`TokenComparison`
|
|
532
|
+
* SAME, or both unresolved). Only when one of those fails does it also write a
|
|
533
|
+
* resource statement. A same-account Lambda with a CDK-managed role satisfies
|
|
534
|
+
* both, so its grants go to that function's OWN execution role and nowhere
|
|
535
|
+
* else. Measured on a synthesized template, an owned bucket plus a
|
|
536
|
+
* same-account `AssetSupport` emits no `AWS::S3::BucketPolicy` at all.
|
|
537
|
+
* Deleting the construct therefore takes the whole footprint with it and
|
|
538
|
+
* leaves nothing behind on a shared bucket's policy.
|
|
539
|
+
*
|
|
540
|
+
* Nor does the cross-account shape invert it, which is the obvious next
|
|
541
|
+
* guess and is also wrong. Measured across all three shapes - an owned
|
|
542
|
+
* bucket, one imported by name, and one imported by ARN with an explicitly
|
|
543
|
+
* DIFFERENT account - every one emits zero `AWS::S3::BucketPolicy`. The
|
|
544
|
+
* second half of `addToPrincipalOrResource` does run for an import, but
|
|
545
|
+
* `addToResourcePolicy` on a bucket CDK does not own is a no-op: it cannot
|
|
546
|
+
* write a policy onto a resource it did not create. So a cross-account
|
|
547
|
+
* adopter gets the identity half here and writes the resource half
|
|
548
|
+
* themselves, on the bucket's own side.
|
|
549
|
+
*
|
|
550
|
+
* What it costs instead is legibility: a second `AssetSupport` standing in a
|
|
551
|
+
* stack that has no asset pipeline, existing only so that a method can be
|
|
552
|
+
* called on it, is fine on the day and unexplainable six months later. That
|
|
553
|
+
* is what this function removes, and it matters most for the shape the
|
|
554
|
+
* one-route topology invites - one bucket shared by every environment, owned
|
|
555
|
+
* by a stack that holds no per-environment resources, with reads and
|
|
556
|
+
* transforms staying per-environment.
|
|
557
|
+
*
|
|
558
|
+
* `AssetSupport.uploadBehavior()` remains the right call when you already have
|
|
559
|
+
* an `AssetSupport`; both funnel into the same builder, so the two cannot
|
|
560
|
+
* drift.
|
|
561
|
+
*
|
|
562
|
+
* TWO THINGS TO KNOW ABOUT `scope`. It receives the CloudFront Function and
|
|
563
|
+
* the two policies as children under fixed ids, so calling this twice with
|
|
564
|
+
* the SAME scope throws CDK's duplicate-construct-id error rather than
|
|
565
|
+
* returning a second behavior - one call per scope. And because the children
|
|
566
|
+
* hang off `scope`, moving an EXISTING deployment from
|
|
567
|
+
* `AssetSupport.uploadBehavior()` to this function changes their construct
|
|
568
|
+
* path and therefore their logical IDs, which replaces all three CloudFront
|
|
569
|
+
* resources. There is no reason to make that move on a stack that already has
|
|
570
|
+
* an `AssetSupport`; this function is for the stack that would otherwise have
|
|
571
|
+
* to grow one.
|
|
572
|
+
*
|
|
573
|
+
* TWO THINGS THIS DOES NOT DO, both deliberate:
|
|
574
|
+
*
|
|
575
|
+
* - It writes NO bucket CORS rule, and needs none. The edge supplies
|
|
576
|
+
* `Access-Control-Allow-Origin`; S3's acceptance of a presigned POST and its
|
|
577
|
+
* ADVERTISEMENT of CORS are independent, and only the second is what a
|
|
578
|
+
* bucket CORS rule governs. `AssetSupportProps.editorOrigins` is the prop
|
|
579
|
+
* that writes one, and it belongs to the construct, not to this route.
|
|
580
|
+
* - It registers no synth-time guard for "built but never attached to a
|
|
581
|
+
* distribution". `AssetSupport` has one, but it fires only in standalone
|
|
582
|
+
* mode with no `editorOrigins` - where opting into `uploadBehavior` is what
|
|
583
|
+
* satisfied the "something must supply ACAO" guard, so opting in and
|
|
584
|
+
* stopping leaves a bucket with neither. A caller here always brings their
|
|
585
|
+
* own bucket and never passes through that guard, so there is no invariant
|
|
586
|
+
* to protect: a discarded return value is an obviously unfinished call, not
|
|
587
|
+
* a silently broken one.
|
|
588
|
+
*/
|
|
589
|
+
export function assetUploadBehavior(scope, options) {
|
|
590
|
+
validateUploadBehaviorOptions(options, 'assetUploadBehavior: allowedOrigins');
|
|
591
|
+
// Snapshotted for the same reason `AssetSupport` snapshots it - the guard
|
|
592
|
+
// above has just read the array, so holding the caller's reference would let
|
|
593
|
+
// a later `origins.length = 0` walk past a check that already passed. Here
|
|
594
|
+
// the window is only as long as this call, but the cost of closing it is a
|
|
595
|
+
// `slice()`.
|
|
596
|
+
return buildUploadBehavior(scope, { ...options, allowedOrigins: options.allowedOrigins?.slice() }, 'assetUploadBehavior');
|
|
597
|
+
}
|
|
228
598
|
/**
|
|
229
599
|
* Per-site CDK construct for the asset/media delivery system (see
|
|
230
600
|
* `.claude/future-tasks/assets-media-system.md` for the full design record).
|
|
@@ -385,8 +755,8 @@ export class AssetSupport extends Construct {
|
|
|
385
755
|
code: lambda.Code.fromAsset(transformAssetDir),
|
|
386
756
|
handler: 'handler.handler',
|
|
387
757
|
// nodejs20.x was deprecated 2026-04-30; CDK's CloudFormation validation
|
|
388
|
-
// now fails synth on it. The esbuild bundle
|
|
389
|
-
//
|
|
758
|
+
// now fails synth on it. The esbuild bundle (lambda/asset-transform/
|
|
759
|
+
// build.mjs) targets node22 to match; move the two together.
|
|
390
760
|
runtime: lambda.Runtime.NODEJS_22_X,
|
|
391
761
|
architecture: lambda.Architecture.ARM_64,
|
|
392
762
|
memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
|
|
@@ -432,39 +802,13 @@ export class AssetSupport extends Construct {
|
|
|
432
802
|
authType: lambda.FunctionUrlAuthType.AWS_IAM,
|
|
433
803
|
});
|
|
434
804
|
this.behaviors = this.buildBehaviors();
|
|
435
|
-
// Validated eagerly, built lazily (see `upload`'s comment)
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// `
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
throw new Error('AssetSupport: uploadBehavior.allowedOrigins is an empty array. CloudFront requires at ' +
|
|
443
|
-
'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
|
|
444
|
-
"silently widen what you asked for. Omit the property to take the `['*']` default, " +
|
|
445
|
-
'or list the origin(s) the editor is served from.');
|
|
446
|
-
}
|
|
447
|
-
// Two consumers read `allowedOrigins` and they do not share a matcher: the
|
|
448
|
-
// response headers policy (which decides ACAO on the POST response) accepts
|
|
449
|
-
// CloudFront's pattern grammar, including a leftmost-subdomain wildcard
|
|
450
|
-
// like `https://*.preview.example.com`; the preflight responder compiled
|
|
451
|
-
// into the CloudFront Function can only do exact-string comparison, because
|
|
452
|
-
// reimplementing that grammar in an edge function without being able to
|
|
453
|
-
// test it against CloudFront is how the two silently disagree.
|
|
454
|
-
//
|
|
455
|
-
// So a pattern is refused rather than half-honoured. Accepting one would
|
|
456
|
-
// deploy clean and then fail every upload from a matching origin at the
|
|
457
|
-
// PREFLIGHT, with a correct-looking policy sitting right next to it - the
|
|
458
|
-
// same invisible failure the preflight responder was added to remove.
|
|
459
|
-
const wildcardOrigins = (props.uploadBehavior?.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
|
|
460
|
-
const isBareWildcard = props.uploadBehavior?.allowedOrigins?.join() === '*';
|
|
461
|
-
if (wildcardOrigins.length > 0 && !isBareWildcard) {
|
|
462
|
-
throw new Error(`AssetSupport: uploadBehavior.allowedOrigins contains a wildcard pattern ` +
|
|
463
|
-
`(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
|
|
464
|
-
`headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
|
|
465
|
-
`CloudFront Function that compares origins exactly - so every upload from a matching ` +
|
|
466
|
-
`origin would fail its preflight while the policy looked correct. Use exactly ` +
|
|
467
|
-
`['*'] to allow any origin, or list each editor origin in full.`);
|
|
805
|
+
// Validated eagerly, built lazily (see `upload`'s comment) - the accessor
|
|
806
|
+
// may never be called, and a bad `allowedOrigins` should not wait for it
|
|
807
|
+
// to find out. The guards themselves are shared with
|
|
808
|
+
// `assetUploadBehavior`, which runs them on entry instead; see
|
|
809
|
+
// `validateUploadBehaviorOptions` for what they refuse and why.
|
|
810
|
+
if (props.uploadBehavior) {
|
|
811
|
+
validateUploadBehaviorOptions(props.uploadBehavior, 'AssetSupport: uploadBehavior.allowedOrigins');
|
|
468
812
|
}
|
|
469
813
|
// Snapshotted, not aliased. The guard above runs now but `allowedOrigins`
|
|
470
814
|
// is not READ until the accessor builds the behavior, so holding the
|
|
@@ -509,7 +853,11 @@ export class AssetSupport extends Construct {
|
|
|
509
853
|
', so nothing supplies Access-Control-Allow-Origin. With no `editorOrigins` ' +
|
|
510
854
|
'either, a browser upload will land the object in asset-staging/ and still ' +
|
|
511
855
|
'report a network error. Pass the behavior to a distribution (see ' +
|
|
512
|
-
"uploadBehavior()'s doc comment), or pass `editorOrigins` instead."
|
|
856
|
+
"uploadBehavior()'s doc comment), or pass `editorOrigins` instead. If you " +
|
|
857
|
+
'built the upload route from this bucket with the `assetUploadBehavior` free ' +
|
|
858
|
+
'function, ACAO IS supplied and this check cannot see it - it observes only ' +
|
|
859
|
+
'the origin this construct minted. Call uploadBehavior() here instead; you ' +
|
|
860
|
+
'already have the construct, so the free function buys you nothing.',
|
|
513
861
|
];
|
|
514
862
|
}
|
|
515
863
|
return [];
|
|
@@ -561,232 +909,6 @@ export class AssetSupport extends Construct {
|
|
|
561
909
|
};
|
|
562
910
|
return { assets, assetsTransform };
|
|
563
911
|
}
|
|
564
|
-
/**
|
|
565
|
-
* The behavior that accepts the editor's presigned-POST uploads. Every piece
|
|
566
|
-
* below is load-bearing; see `uploadBehavior()` for where the result goes.
|
|
567
|
-
*/
|
|
568
|
-
buildUploadBehavior(options) {
|
|
569
|
-
// A DISTINCT origin for the bucket, with OAC signing OFF - the read
|
|
570
|
-
// behaviors' origin cannot be reused here no matter what this behavior
|
|
571
|
-
// sets, because OAC is a property of the ORIGIN, not the behavior. (On the
|
|
572
|
-
// dedicated distribution `uploadBehavior()` recommends this is the only
|
|
573
|
-
// origin present; the point is that it cannot be the read one.)
|
|
574
|
-
// CloudFront signs origin requests but never hashes
|
|
575
|
-
// the body, so an OAC-signed origin rejects every multipart POST whatever
|
|
576
|
-
// the viewer sent. Measured, that is `400 InvalidArgument`, the body
|
|
577
|
-
// naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
|
|
578
|
-
// ... or a valid sha256 value`. It fails CLOSED, but it is an
|
|
579
|
-
// argument-validation error, NOT the 403 that the Lambda Function URL
|
|
580
|
-
// origin produces in the same situation (docs/deploying-to-aws.md's
|
|
581
|
-
// "CloudFront OAC and request body signing"): there Lambda verifies a
|
|
582
|
-
// SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
|
|
583
|
-
// adopter told to expect 403 goes hunting for a permissions problem that
|
|
584
|
-
// does not exist.
|
|
585
|
-
//
|
|
586
|
-
// `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
|
|
587
|
-
// would also be unsigned, for two reasons. It is the exact shape this
|
|
588
|
-
// whole path was measured against end to end on live AWS resources (204,
|
|
589
|
-
// object landed); and it is the only one of the two that can state the
|
|
590
|
-
// CloudFront->S3 protocol, which for a request carrying a live upload
|
|
591
|
-
// credential in its body should be pinned rather than inferred.
|
|
592
|
-
// `withBucketDefaults()` emits `S3OriginConfig`, which has no
|
|
593
|
-
// `OriginProtocolPolicy` field at all.
|
|
594
|
-
// A dot in the bucket name breaks this origin specifically. S3's wildcard
|
|
595
|
-
// certificate covers one label (`*.s3.<region>.amazonaws.com`), so
|
|
596
|
-
// `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
|
|
597
|
-
// CloudFront answers 502 on every upload. This is a CUSTOM origin doing
|
|
598
|
-
// ordinary TLS validation; the read path uses an S3-type origin, which
|
|
599
|
-
// CloudFront treats differently, so nothing else here would surface it -
|
|
600
|
-
// and dotted names are most likely in exactly the BYO case ("a site's
|
|
601
|
-
// existing content bucket") this behavior is written for. Only checkable
|
|
602
|
-
// when the name is a real literal, which for an imported bucket it is.
|
|
603
|
-
const bucketName = this.bucket.bucketName;
|
|
604
|
-
if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
|
|
605
|
-
throw new Error(`AssetSupport: uploadBehavior cannot use bucket "${bucketName}" - a dot in the bucket ` +
|
|
606
|
-
`name puts an extra label in its regional domain name, which S3's wildcard ` +
|
|
607
|
-
`certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
|
|
608
|
-
`every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
|
|
609
|
-
`dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
|
|
610
|
-
}
|
|
611
|
-
const uploadOrigin = new UploadOrigin(this.bucket.bucketRegionalDomainName, {
|
|
612
|
-
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
|
|
613
|
-
});
|
|
614
|
-
this.uploadOrigin = uploadOrigin;
|
|
615
|
-
// S3's POST Object is only valid at the bucket ROOT - without this rewrite
|
|
616
|
-
// the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
|
|
617
|
-
// behavior: rewriting unconditionally means no request arriving here can
|
|
618
|
-
// address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
|
|
619
|
-
// only the bucket-level operations at `/` (list, create, delete bucket).
|
|
620
|
-
// A bucket this construct creates refuses all of them anonymously
|
|
621
|
-
// (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
|
|
622
|
-
// bucket policy to have got right.
|
|
623
|
-
//
|
|
624
|
-
// Their bucket policy is a WEAKER backstop here than on the read path, and
|
|
625
|
-
// the two must not be read as equivalent: the read behaviors go through an
|
|
626
|
-
// OAC-SIGNED origin, so a too-permissive policy there is still gated by a
|
|
627
|
-
// signature CloudFront adds. This origin is deliberately unsigned (it has
|
|
628
|
-
// to be - see the OAC note at the top of this method), so on a BYO bucket
|
|
629
|
-
// the policy is the ONLY thing standing between an anonymous caller and
|
|
630
|
-
// whatever that policy allows at `/`. Stated where a BYO adopter reads it,
|
|
631
|
-
// in `uploadBehavior()`'s caveat block, not only here.
|
|
632
|
-
//
|
|
633
|
-
// The only thing that can WRITE either way is a request carrying a valid
|
|
634
|
-
// presigned policy, and only to the single key that policy names. Do not
|
|
635
|
-
// make the rewrite conditional; that gives key addressability back.
|
|
636
|
-
//
|
|
637
|
-
// The same function also drops `Authorization`. A site behind HTTP basic
|
|
638
|
-
// auth otherwise sends S3 a credential it cannot parse
|
|
639
|
-
// (`400 InvalidArgument - Unsupported Authorization Type`), and the
|
|
640
|
-
// presigned POST carries its own authority in the body, so nothing on this
|
|
641
|
-
// route ever wants the header. It is dropped HERE, in the function, rather
|
|
642
|
-
// than by naming it in the origin request policy's `allExcept` list below,
|
|
643
|
-
// and that placement is deliberate: CloudFront rejects `Authorization` in
|
|
644
|
-
// an origin request policy's header ALLOWLIST outright ("The parameter
|
|
645
|
-
// Headers contains Authorization that is not allowed"), and whether the
|
|
646
|
-
// same validation fires on an `allExcept` list is not something synth can
|
|
647
|
-
// tell you - it would surface as a failed `cdk deploy` on the whole stack.
|
|
648
|
-
// A viewer-request function has no such ambiguity: `method` is the only
|
|
649
|
-
// read-only field on the request object, headers are freely mutable, and
|
|
650
|
-
// `Authorization` is the header CloudFront's own basic-auth function
|
|
651
|
-
// examples read. Dropping it at the viewer also means it is gone before
|
|
652
|
-
// any policy runs, so the guarantee does not depend on the policy at all.
|
|
653
|
-
// The same function ALSO answers the CORS preflight, and that is not
|
|
654
|
-
// optional decoration - without it this route cannot work at all.
|
|
655
|
-
//
|
|
656
|
-
// The editor's upload is NOT a CORS simple request, though it looks like
|
|
657
|
-
// one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
|
|
658
|
-
// but it assigns `xhr.upload.onprogress` before `send()`, and registering
|
|
659
|
-
// ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
|
|
660
|
-
// from the simple-request rules, independent of method, headers and content
|
|
661
|
-
// type. So every browser upload begins with `OPTIONS /`.
|
|
662
|
-
//
|
|
663
|
-
// Nothing else on this route would answer it. CloudFront does not
|
|
664
|
-
// synthesize preflight responses - a response headers policy supplies
|
|
665
|
-
// values for headers "in responses to CORS preflight requests", i.e. it
|
|
666
|
-
// decorates a response that something else produced - and `ALLOW_ALL`
|
|
667
|
-
// forwards the OPTIONS to S3, which with no CORS configuration answers
|
|
668
|
-
// `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
|
|
669
|
-
// is not a 2xx fails the browser's check whatever headers are attached, so
|
|
670
|
-
// the POST is never sent and the upload dies as an opaque network error.
|
|
671
|
-
//
|
|
672
|
-
// This is why the end-to-end measurements did not catch it: they replayed
|
|
673
|
-
// the POST as a script, which has no preflight. A scripted POST is not an
|
|
674
|
-
// acceptance test for this route; a real browser upload is.
|
|
675
|
-
//
|
|
676
|
-
// Short-circuiting at the viewer also strengthens the containment argument
|
|
677
|
-
// above rather than weakening it - an OPTIONS that never reaches the origin
|
|
678
|
-
// can address nothing.
|
|
679
|
-
const allowedOrigins = options.allowedOrigins ?? ['*'];
|
|
680
|
-
const rewriteToBucketRoot = new cloudfront.Function(this, 'AssetUploadRewriteFunction', {
|
|
681
|
-
code: cloudfront.FunctionCode.fromInline([
|
|
682
|
-
`var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
|
|
683
|
-
'function handler(event) {',
|
|
684
|
-
' var request = event.request;',
|
|
685
|
-
" if (request.method === 'OPTIONS') {",
|
|
686
|
-
' var headers = {',
|
|
687
|
-
` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
|
|
688
|
-
" 'access-control-allow-headers': { value: '*' },",
|
|
689
|
-
` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
|
|
690
|
-
' };',
|
|
691
|
-
// Echo the caller's own Origin when the list is narrowed: a preflight
|
|
692
|
-
// may only be answered with `*` or the single requesting origin, so a
|
|
693
|
-
// multi-entry list cannot be returned verbatim. No match means no
|
|
694
|
-
// ACAO, and the browser's own check refuses the upload - which is the
|
|
695
|
-
// correct outcome, and a clearer one than a 403 here would be.
|
|
696
|
-
" var origin = request.headers.origin ? request.headers.origin.value : '';",
|
|
697
|
-
" if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
|
|
698
|
-
" headers['access-control-allow-origin'] = { value: '*' };",
|
|
699
|
-
' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
|
|
700
|
-
" headers['access-control-allow-origin'] = { value: origin };",
|
|
701
|
-
' }',
|
|
702
|
-
" return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
|
|
703
|
-
' }',
|
|
704
|
-
" request.uri = '/';",
|
|
705
|
-
' delete request.headers.authorization;',
|
|
706
|
-
' return request;',
|
|
707
|
-
'}',
|
|
708
|
-
].join('\n')),
|
|
709
|
-
});
|
|
710
|
-
// `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
|
|
711
|
-
// dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
|
|
712
|
-
// - the shape this path was measured against. It is NOT that policy
|
|
713
|
-
// overall, and must not be replaced by it: the managed one is documented as
|
|
714
|
-
// "Cookies: All, Query strings: All", so adopting it would forward the
|
|
715
|
-
// editor session cookie to S3, which is one of the two hazards this whole
|
|
716
|
-
// policy exists to remove. Only the header behavior is shared.
|
|
717
|
-
//
|
|
718
|
-
// `Authorization` is handled by the function above, not here.
|
|
719
|
-
//
|
|
720
|
-
// Cookies go through `CookiesConfig`, not the header list (a CloudFront
|
|
721
|
-
// Function does not even see them in `request.headers`), so
|
|
722
|
-
// `cookieBehavior.none()` is what strips them: an upload route mounted on
|
|
723
|
-
// a site's own distribution would otherwise hand S3 - and S3's access logs
|
|
724
|
-
// - the editor session cookie. On the dedicated distribution recommended
|
|
725
|
-
// in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
|
|
726
|
-
// none in the first place; this makes it true either way.
|
|
727
|
-
const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, 'AssetUploadOriginRequestPolicy', {
|
|
728
|
-
cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
|
|
729
|
-
headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
|
|
730
|
-
// A presigned POST carries everything in the multipart body.
|
|
731
|
-
queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
|
|
732
|
-
});
|
|
733
|
-
// The edge supplies Access-Control-Allow-Origin, scoped to this behavior
|
|
734
|
-
// only. This is the measurement that unblocked the whole design: a
|
|
735
|
-
// response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
|
|
736
|
-
// origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
|
|
737
|
-
// object landed). Acceptance and advertisement are independent in S3 -
|
|
738
|
-
// bucket CORS governs only whether S3 advertises - which is exactly the
|
|
739
|
-
// property that lets this construct avoid writing a bucket CORS rule.
|
|
740
|
-
//
|
|
741
|
-
// `originOverride: true` so the header is ours deterministically even on a
|
|
742
|
-
// bucket that does have its own CORS configuration.
|
|
743
|
-
//
|
|
744
|
-
// AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
|
|
745
|
-
// and this path DOES trigger one - see the CloudFront Function above, which
|
|
746
|
-
// answers it, and which exists because nothing else would. They are set
|
|
747
|
-
// here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
|
|
748
|
-
// two cannot tell a browser different things, and because a response
|
|
749
|
-
// headers policy is documented to decorate responses without saying whether
|
|
750
|
-
// a function-generated one is among them. If it is, `originOverride: true`
|
|
751
|
-
// means the policy's values REPLACE the function's identical ones; if it is
|
|
752
|
-
// not, the function's stand alone. Either way the viewer sees exactly one
|
|
753
|
-
// of each, which is what makes it safe not to know.
|
|
754
|
-
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'AssetUploadResponseHeadersPolicy', {
|
|
755
|
-
corsBehavior: {
|
|
756
|
-
accessControlAllowOrigins: allowedOrigins,
|
|
757
|
-
accessControlAllowCredentials: false,
|
|
758
|
-
accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
|
|
759
|
-
accessControlAllowHeaders: ['*'],
|
|
760
|
-
accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
|
|
761
|
-
originOverride: true,
|
|
762
|
-
},
|
|
763
|
-
});
|
|
764
|
-
return {
|
|
765
|
-
origin: uploadOrigin,
|
|
766
|
-
// A POST is 405 without this. CloudFront offers no narrower set that
|
|
767
|
-
// includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
|
|
768
|
-
// The URI rewrite above is what makes that acceptable.
|
|
769
|
-
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
|
|
770
|
-
// HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
|
|
771
|
-
// redirects with 301, and a browser turns a 301'd POST into a GET - so
|
|
772
|
-
// an `http://` upload URL would silently become a bodyless GET that this
|
|
773
|
-
// behavior rewrites to `/` and S3 refuses, with the file never sent.
|
|
774
|
-
// HTTPS_ONLY answers 403 instead: same refusal, visible.
|
|
775
|
-
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
|
|
776
|
-
// Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
|
|
777
|
-
// cache key is also what keeps the origin request policy above legal
|
|
778
|
-
// (CloudFront requires it to be a superset of the cache key).
|
|
779
|
-
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
|
|
780
|
-
originRequestPolicy,
|
|
781
|
-
responseHeadersPolicy,
|
|
782
|
-
functionAssociations: [
|
|
783
|
-
{
|
|
784
|
-
function: rewriteToBucketRoot,
|
|
785
|
-
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
|
786
|
-
},
|
|
787
|
-
],
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
912
|
/**
|
|
791
913
|
* The two CloudFront behavior configs this system needs.
|
|
792
914
|
*
|
|
@@ -848,6 +970,13 @@ export class AssetSupport extends Construct {
|
|
|
848
970
|
* The upload route is the one part of this system with no per-environment
|
|
849
971
|
* code in it - it depends on the bucket and nothing else - which is why it
|
|
850
972
|
* is the part that can be split off this way.
|
|
973
|
+
*
|
|
974
|
+
* That same property is why `assetUploadBehavior` exists as a free function:
|
|
975
|
+
* it builds this behavior from a bucket alone, for a caller who wants the
|
|
976
|
+
* one-route distribution above and has no other use for an `AssetSupport`.
|
|
977
|
+
* Prefer this method when you already have one; both funnel into the same
|
|
978
|
+
* builder. See that function for what instantiating the construct purely to
|
|
979
|
+
* reach this method actually costs.
|
|
851
980
|
*/
|
|
852
981
|
uploadBehavior() {
|
|
853
982
|
if (!this.uploadOptions) {
|
|
@@ -858,7 +987,22 @@ export class AssetSupport extends Construct {
|
|
|
858
987
|
}
|
|
859
988
|
// Memoized: calling this twice must not attach two sets of policies, and
|
|
860
989
|
// must not fail on duplicate construct ids.
|
|
861
|
-
|
|
990
|
+
//
|
|
991
|
+
// `scope` is `this`, which is what keeps the three child ids
|
|
992
|
+
// (`AssetUploadRewriteFunction` and the two policies) exactly where they
|
|
993
|
+
// were before the builder moved out of this class - the emitted template
|
|
994
|
+
// is unchanged, so no existing deployment sees a resource replacement.
|
|
995
|
+
//
|
|
996
|
+
// `validateUploadBehaviorOptions` is NOT re-run here: the constructor
|
|
997
|
+
// already ran it on these same options, and `this.uploadOptions` is its
|
|
998
|
+
// own snapshot, so there is nothing a second pass could catch.
|
|
999
|
+
this.upload ??= buildUploadBehavior(this, { ...this.uploadOptions, bucket: this.bucket }, 'AssetSupport: uploadBehavior');
|
|
1000
|
+
// The builder returns the origin it made; the attach guard needs a
|
|
1001
|
+
// reference to it (see `UploadOrigin`), and this is the only place that
|
|
1002
|
+
// knows the two belong to the same construct.
|
|
1003
|
+
if (this.upload.origin instanceof UploadOrigin) {
|
|
1004
|
+
this.uploadOrigin = this.upload.origin;
|
|
1005
|
+
}
|
|
862
1006
|
return this.upload;
|
|
863
1007
|
}
|
|
864
1008
|
/**
|
|
@@ -656,8 +656,12 @@ export class CanopyCmsService extends Construct {
|
|
|
656
656
|
launchTemplate,
|
|
657
657
|
minCapacity: 1,
|
|
658
658
|
maxCapacity: 1,
|
|
659
|
-
healthCheck
|
|
660
|
-
|
|
659
|
+
// `healthChecks`, not the deprecated `healthCheck`/`HealthCheck.ec2({ grace })`:
|
|
660
|
+
// both synthesize the same HealthCheckType/HealthCheckGracePeriod, but the
|
|
661
|
+
// deprecated form prints a jsii warning on every synth -- in adopters'
|
|
662
|
+
// `cdk synth` output, not just ours -- and is slated for removal in v3.
|
|
663
|
+
healthChecks: autoscaling.HealthChecks.ec2({
|
|
664
|
+
gracePeriod: Duration.minutes(5),
|
|
661
665
|
}),
|
|
662
666
|
// Without an updatePolicy, CloudFormation's default behavior for an ASG
|
|
663
667
|
// behind a changed launch template is to update the template resource
|
package/dist/index.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ export { CanopyCmsService } from './constructs/cms-service.js';
|
|
|
4
4
|
export type { CanopyCmsServiceProps } from './constructs/cms-service.js';
|
|
5
5
|
export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
|
|
6
6
|
export type { CanopyCmsDistributionProps } from './constructs/cms-distribution.js';
|
|
7
|
-
export { AssetSupport } from './constructs/asset-support.js';
|
|
8
|
-
export type { AssetSupportProps, AssetCloudFrontBehaviors, AssetUploadBehaviorOptions, } from './constructs/asset-support.js';
|
|
7
|
+
export { AssetSupport, assetUploadBehavior } from './constructs/asset-support.js';
|
|
8
|
+
export type { AssetSupportProps, AssetCloudFrontBehaviors, AssetUploadBehaviorOptions, AssetUploadBehaviorRouteOptions, } from './constructs/asset-support.js';
|
package/dist/index.js
CHANGED
|
@@ -3,4 +3,4 @@ export { CmsWorker } from './worker.js';
|
|
|
3
3
|
// CDK Constructs
|
|
4
4
|
export { CanopyCmsService } from './constructs/cms-service.js';
|
|
5
5
|
export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
|
|
6
|
-
export { AssetSupport } from './constructs/asset-support.js';
|
|
6
|
+
export { AssetSupport, assetUploadBehavior } from './constructs/asset-support.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopycms-cdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.67-int.89",
|
|
4
4
|
"description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"aws-cdk-lib": "^2.260.0",
|
|
33
33
|
"constructs": "^10.0.0",
|
|
34
|
-
"canopycms": "0.0.
|
|
34
|
+
"canopycms": "0.0.67-int.89"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
37
37
|
"aws-cdk-lib": {
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
"tsx": "^4.19.0",
|
|
56
56
|
"typescript": "^5.6.3",
|
|
57
57
|
"vitest": "^4.1.0",
|
|
58
|
-
"canopycms": "0.0.
|
|
59
|
-
"canopycms
|
|
58
|
+
"canopycms-auth-clerk": "0.0.67-int.89",
|
|
59
|
+
"canopycms": "0.0.67-int.89"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
|
|
63
|
-
"build:worker": "esbuild worker/index.ts --bundle --platform=node --target=
|
|
63
|
+
"build:worker": "esbuild worker/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=worker/dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
|
|
64
64
|
"build:lambda": "node lambda/asset-transform/build.mjs",
|
|
65
65
|
"build:test-fixtures": "pnpm run build:worker && node lambda/asset-transform/build.mjs --skip-native",
|
|
66
66
|
"lint": "eslint src/ 'lambda/**/*.ts' 'canary/**/*.ts' 'worker/**/*.ts' 'test-support/**/*.ts'",
|