@aws-blocks/bb-file-bucket 0.1.4 → 0.2.0

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.
@@ -49,6 +49,28 @@ afterEach(() => {
49
49
  server.close();
50
50
  });
51
51
 
52
+
53
+ // ── CORS headers ────────────────────────────────────────────────────────────
54
+
55
+ describe('file-server: CORS headers', () => {
56
+ test('OPTIONS preflight does NOT send Access-Control-Allow-Credentials', async () => {
57
+ // Reflecting an arbitrary Origin together with credentials:true is an
58
+ // unsafe combination; the dev server must not advertise credentialed CORS.
59
+ const res = await fetch(`http://localhost:${port}/.bb-file-bucket/fsrv-cors/ping.txt`, {
60
+ method: 'OPTIONS',
61
+ headers: { Origin: 'https://evil.example.com' },
62
+ });
63
+ assert.strictEqual(res.status, 200);
64
+ assert.strictEqual(
65
+ res.headers.get('access-control-allow-credentials'),
66
+ null,
67
+ 'dev server must not send Access-Control-Allow-Credentials',
68
+ );
69
+ // The other CORS headers remain intact.
70
+ assert.strictEqual(res.headers.get('access-control-allow-origin'), 'https://evil.example.com');
71
+ assert.ok(res.headers.get('access-control-allow-methods'));
72
+ });
73
+ });
52
74
  // ── Basic presigned URL round-trip ──────────────────────────────────────────
53
75
 
54
76
  describe('file-server: basic GET/PUT', () => {
@@ -70,10 +70,19 @@ export function attach(httpServer: Server) {
70
70
 
71
71
  // CORS for browser uploads/downloads
72
72
  const origin = req.headers.origin || '*';
73
+ // Reflecting the request Origin (falling back to '*') is intentional: it
74
+ // lets a localhost cross-port dev server (e.g. Vite/webpack on a different
75
+ // port) call this local file-server. This dev file-server is local tooling
76
+ // only and NEVER deploys to AWS / production.
77
+ // It is safe because Access-Control-Allow-Credentials is deliberately NOT
78
+ // set, so the reflected origin carries no ambient credentials: presigned-URL
79
+ // auth is a query-string token, not a cookie / ambient session. That is what
80
+ // makes reflecting an arbitrary origin here safe.
81
+ // WARNING: do not re-add Access-Control-Allow-Credentials and do not
82
+ // 'tighten' this reflection without understanding the above.
73
83
  res.setHeader('Access-Control-Allow-Origin', origin);
74
84
  res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
75
85
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
76
- res.setHeader('Access-Control-Allow-Credentials', 'true');
77
86
 
78
87
  if (req.method === 'OPTIONS') {
79
88
  res.writeHead(200);
@@ -12,30 +12,45 @@ import { test } from 'node:test';
12
12
  import assert from 'node:assert';
13
13
  import * as cdk from 'aws-cdk-lib';
14
14
  import type { Construct } from 'constructs';
15
- import { Template } from 'aws-cdk-lib/assertions';
16
- import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
15
+ import { Template, Match } from 'aws-cdk-lib/assertions';
16
+ import { RetentionDays } from 'aws-cdk-lib/aws-logs';
17
+ import { Scope, DEFAULT_NODE_RUNTIME, BlocksPresets, type BlocksDefaults } from '@aws-blocks/core/cdk';
17
18
  import { FileBucket } from './index.cdk.js';
18
19
 
20
+ // Minimal BlocksStack-shaped parent. The production code path uses BlocksStack,
21
+ // which exposes the shared `executionRole` (blocks grant to it) plus `handler`,
22
+ // both living inside a `cdk.Stack`. We reproduce them here so FileBucket can
23
+ // call grantReadWrite(this.executionRole) and still synth into a real stack. It
24
+ // also carries `defaults` — Building Blocks resolve `scope.defaults` by walking
25
+ // up to the owning BlocksStack/BlocksBackend, falling back to
26
+ // `globalThis.CURRENT_BLOCKS_STACK`, which is this stub in these tests.
19
27
  class StubBlocksStack extends cdk.Stack {
20
28
  public readonly handler: cdk.aws_lambda.Function;
29
+ public readonly executionRole: cdk.aws_iam.IRole;
21
30
  public readonly id: string;
31
+ public defaults: BlocksDefaults = BlocksPresets.production;
22
32
  constructor(scope: Construct, id: string) {
23
33
  super(scope, id);
24
34
  this.id = id;
25
35
  (globalThis as any).CURRENT_BLOCKS_STACK = this;
36
+ this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
37
+ assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
38
+ });
26
39
  this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
27
40
  runtime: DEFAULT_NODE_RUNTIME,
28
41
  handler: 'index.handler',
29
42
  code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
43
+ role: this.executionRole,
30
44
  });
31
45
  }
32
46
  }
33
47
 
34
- function setup(): { stack: StubBlocksStack; parent: Scope } {
48
+ function setup(defaults: BlocksDefaults = BlocksPresets.production): { stack: StubBlocksStack; parent: Scope } {
35
49
  const app = new cdk.App();
36
50
  // S3 bucket names must be lowercase. The default-mode FileBucket derives
37
51
  // its bucket name from the scope chain, so keep ids lowercase.
38
52
  const stack = new StubBlocksStack(app, 'teststack');
53
+ stack.defaults = defaults;
39
54
  const parent = new Scope('app');
40
55
  return { stack, parent };
41
56
  }
@@ -82,3 +97,337 @@ test('CDK: fromExisting skips derived-name validation even when the chain is ove
82
97
  }),
83
98
  );
84
99
  });
100
+
101
+ // ── Security hardening: secure defaults ─────────────────────────────────────
102
+
103
+ test('CDK: default FileBucket enforces SSL (aws:SecureTransport deny)', () => {
104
+ const { stack, parent } = setup();
105
+ new FileBucket(parent, 'uploads');
106
+ const template = Template.fromStack(stack);
107
+ // enforceSSL:true makes CDK attach a bucket policy denying non-TLS requests.
108
+ template.hasResourceProperties('AWS::S3::BucketPolicy', Match.objectLike({
109
+ PolicyDocument: Match.objectLike({
110
+ Statement: Match.arrayWith([
111
+ Match.objectLike({
112
+ Effect: 'Deny',
113
+ Condition: { Bool: { 'aws:SecureTransport': 'false' } },
114
+ }),
115
+ ]),
116
+ }),
117
+ }));
118
+ });
119
+
120
+ test('CDK: default FileBucket enables versioning (new secure default)', () => {
121
+ const { stack, parent } = setup();
122
+ new FileBucket(parent, 'uploads');
123
+ const template = Template.fromStack(stack);
124
+ template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({
125
+ VersioningConfiguration: { Status: 'Enabled' },
126
+ }));
127
+ });
128
+
129
+ test('CDK: versioned:false opt-out disables versioning', () => {
130
+ const { stack, parent } = setup();
131
+ new FileBucket(parent, 'uploads', { versioned: false });
132
+ const template = Template.fromStack(stack);
133
+ // No VersioningConfiguration is emitted when versioning is disabled.
134
+ const buckets = template.findResources('AWS::S3::Bucket');
135
+ const props = Object.values(buckets)[0].Properties ?? {};
136
+ assert.strictEqual((props as any).VersioningConfiguration, undefined);
137
+ });
138
+
139
+ // ── Posture routed through BlocksDefaults (PR review comment C) ──────────────
140
+
141
+ test('CDK: default FileBucket adopts the SANDBOX removal posture (DESTROY + autoDelete)', () => {
142
+ const { stack, parent } = setup(BlocksPresets.sandbox);
143
+ new FileBucket(parent, 'uploads');
144
+ const template = Template.fromStack(stack);
145
+ // DESTROY removal policy plus the auto-delete custom resource CDK wires in
146
+ // only when autoDeleteObjects is true.
147
+ template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Delete' });
148
+ template.resourceCountIs('Custom::S3AutoDeleteObjects', 1);
149
+ });
150
+
151
+ test('CDK: default FileBucket adopts the PRODUCTION removal posture (RETAIN, no autoDelete)', () => {
152
+ const { stack, parent } = setup(BlocksPresets.production);
153
+ new FileBucket(parent, 'uploads');
154
+ const template = Template.fromStack(stack);
155
+ template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Retain' });
156
+ template.resourceCountIs('Custom::S3AutoDeleteObjects', 0);
157
+ });
158
+
159
+ test('CDK: per-block removalPolicy overrides the resolved default', () => {
160
+ const { stack, parent } = setup(BlocksPresets.production);
161
+ new FileBucket(parent, 'uploads', { removalPolicy: 'destroy' });
162
+ const template = Template.fromStack(stack);
163
+ // Per-block 'destroy' wins over the production RETAIN default and enables
164
+ // autoDeleteObjects alongside it.
165
+ template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Delete' });
166
+ template.resourceCountIs('Custom::S3AutoDeleteObjects', 1);
167
+ });
168
+
169
+ test('CDK: accessLogging provisions a locked-down log bucket with lifecycle + logging config', () => {
170
+ const { stack, parent } = setup();
171
+ new FileBucket(parent, 'uploads', { accessLogging: true });
172
+ const template = Template.fromStack(stack);
173
+ // Main bucket + dedicated access-log bucket.
174
+ template.resourceCountIs('AWS::S3::Bucket', 2);
175
+ // Both buckets are TLS-enforced: enforceSSL:true attaches a bucket policy
176
+ // with an aws:SecureTransport deny to EACH bucket (main + log bucket), so
177
+ // the log bucket is provably locked down, not just the main one.
178
+ template.resourceCountIs('AWS::S3::BucketPolicy', 2);
179
+ const policies = template.findResources('AWS::S3::BucketPolicy');
180
+ for (const policy of Object.values(policies)) {
181
+ assert.ok(
182
+ (policy.Properties.PolicyDocument.Statement as any[]).some(
183
+ (s) =>
184
+ s.Effect === 'Deny' &&
185
+ s.Condition?.Bool?.['aws:SecureTransport'] === 'false',
186
+ ),
187
+ 'every bucket (main + log) must have an enforceSSL deny statement',
188
+ );
189
+ }
190
+ // Distinguish the two buckets: the main bucket carries the derived
191
+ // BucketName; the log bucket does not. Prove the MAIN bucket's
192
+ // LoggingConfiguration points at the LOG bucket specifically.
193
+ const buckets = template.findResources('AWS::S3::Bucket');
194
+ const entries = Object.entries(buckets);
195
+ const mainEntry = entries.find(([, r]) => (r.Properties as any).BucketName !== undefined);
196
+ const logEntry = entries.find(([id]) => id !== mainEntry?.[0]);
197
+ assert.ok(mainEntry && logEntry, 'expected one named main bucket and one log bucket');
198
+ const [logLogicalId, logResource] = logEntry;
199
+ // Main bucket delivers its access logs to the log bucket under access-logs/.
200
+ assert.deepStrictEqual(
201
+ (mainEntry[1].Properties as any).LoggingConfiguration,
202
+ { DestinationBucketName: { Ref: logLogicalId }, LogFilePrefix: 'access-logs/' },
203
+ );
204
+ // Log bucket expires access logs after the framework logRetention default
205
+ // (production preset => ONE_YEAR => 365 days).
206
+ assert.ok(
207
+ ((logResource.Properties as any).LifecycleConfiguration.Rules as any[]).some(
208
+ (r) => r.ExpirationInDays === 365 && r.Status === 'Enabled',
209
+ ),
210
+ 'log bucket must expire access logs after the production logRetention (365 days)',
211
+ );
212
+ // Log bucket blocks all public access.
213
+ assert.deepStrictEqual((logResource.Properties as any).PublicAccessBlockConfiguration, {
214
+ BlockPublicAcls: true,
215
+ BlockPublicPolicy: true,
216
+ IgnorePublicAcls: true,
217
+ RestrictPublicBuckets: true,
218
+ });
219
+ });
220
+
221
+ test('CDK: accessLogging resolves from defaults — a preset opting in creates the log bucket with no per-block option', () => {
222
+ const { stack, parent } = setup({ ...BlocksPresets.production, accessLogging: true });
223
+ new FileBucket(parent, 'uploads');
224
+ const template = Template.fromStack(stack);
225
+ // No per-block accessLogging option, yet the resolved default enables it.
226
+ template.resourceCountIs('AWS::S3::Bucket', 2);
227
+ });
228
+
229
+ test('CDK: access-log lifecycle expiration equals Duration.days(defaults.logRetention) — sandbox = 7', () => {
230
+ const { stack, parent } = setup({ ...BlocksPresets.sandbox, accessLogging: true });
231
+ new FileBucket(parent, 'uploads', { removalPolicy: 'retain' });
232
+ const template = Template.fromStack(stack);
233
+ // The per-block 'retain' override wins over the sandbox DESTROY default: the
234
+ // main bucket is retained on teardown.
235
+ template.hasResource('AWS::S3::Bucket', { DeletionPolicy: 'Retain' });
236
+ const buckets = template.findResources('AWS::S3::Bucket');
237
+ const logEntry = Object.values(buckets).find(
238
+ (r) => (r.Properties as any).BucketName === undefined,
239
+ );
240
+ assert.ok(logEntry, 'expected a log bucket');
241
+ assert.ok(
242
+ ((logEntry!.Properties as any).LifecycleConfiguration.Rules as any[]).some(
243
+ (r) => r.ExpirationInDays === 7 && r.Status === 'Enabled',
244
+ ),
245
+ 'log bucket must expire access logs after the sandbox logRetention (ONE_WEEK = 7 days)',
246
+ );
247
+ });
248
+
249
+ test('CDK: no access-log bucket is created when accessLogging resolves false', () => {
250
+ const { stack, parent } = setup();
251
+ new FileBucket(parent, 'uploads');
252
+ const template = Template.fromStack(stack);
253
+ template.resourceCountIs('AWS::S3::Bucket', 1);
254
+ });
255
+
256
+ test('CDK: logRetention INFINITE omits the access-log lifecycle rule (logs kept indefinitely)', () => {
257
+ const { stack, parent } = setup({
258
+ ...BlocksPresets.production,
259
+ logRetention: RetentionDays.INFINITE,
260
+ accessLogging: true,
261
+ });
262
+ new FileBucket(parent, 'uploads');
263
+ const template = Template.fromStack(stack);
264
+ // Main bucket + log bucket both exist.
265
+ template.resourceCountIs('AWS::S3::Bucket', 2);
266
+ // The access-LOG bucket (the one without a derived BucketName) must carry NO
267
+ // LifecycleConfiguration at all — INFINITE means "never expire", so the rule
268
+ // is omitted rather than emitted at a spurious 9999-day expiry.
269
+ const buckets = template.findResources('AWS::S3::Bucket');
270
+ const logEntry = Object.values(buckets).find(
271
+ (r) => (r.Properties as any).BucketName === undefined,
272
+ );
273
+ assert.ok(logEntry, 'expected a log bucket');
274
+ assert.strictEqual(
275
+ (logEntry!.Properties as any).LifecycleConfiguration,
276
+ undefined,
277
+ 'INFINITE logRetention must omit the access-log LifecycleConfiguration entirely',
278
+ );
279
+ });
280
+
281
+ test('CDK: per-block accessLogging:false overrides a defaults-enabled preset (?? treats explicit false as non-nullish)', () => {
282
+ const { stack, parent } = setup({ ...BlocksPresets.production, accessLogging: true });
283
+ new FileBucket(parent, 'uploads', { accessLogging: false });
284
+ const template = Template.fromStack(stack);
285
+ // Defaults opt logging in, but the explicit per-block `false` wins: only the
286
+ // main bucket is provisioned, no dedicated access-log bucket.
287
+ template.resourceCountIs('AWS::S3::Bucket', 1);
288
+ });
289
+
290
+ // ── Noncurrent-version expiration (PR review comment A) ──────────────────────
291
+
292
+ test('CDK: versioned bucket expires noncurrent versions after the default 90 days', () => {
293
+ const { stack, parent } = setup();
294
+ new FileBucket(parent, 'uploads');
295
+ const template = Template.fromStack(stack);
296
+ template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({
297
+ VersioningConfiguration: { Status: 'Enabled' },
298
+ LifecycleConfiguration: Match.objectLike({
299
+ Rules: Match.arrayWith([
300
+ Match.objectLike({
301
+ NoncurrentVersionExpiration: { NoncurrentDays: 90 },
302
+ Status: 'Enabled',
303
+ }),
304
+ ]),
305
+ }),
306
+ }));
307
+ });
308
+
309
+ test('CDK: noncurrentVersionExpirationDays is honored', () => {
310
+ const { stack, parent } = setup();
311
+ new FileBucket(parent, 'uploads', { noncurrentVersionExpirationDays: 30 });
312
+ const template = Template.fromStack(stack);
313
+ template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({
314
+ LifecycleConfiguration: Match.objectLike({
315
+ Rules: Match.arrayWith([
316
+ Match.objectLike({ NoncurrentVersionExpiration: { NoncurrentDays: 30 } }),
317
+ ]),
318
+ }),
319
+ }));
320
+ });
321
+
322
+ test('CDK: noncurrentVersionExpirationDays of 0 throws at synth', () => {
323
+ const { parent } = setup();
324
+ assert.throws(
325
+ () => new FileBucket(parent, 'uploads', { noncurrentVersionExpirationDays: 0 }),
326
+ (err: unknown) =>
327
+ err instanceof Error &&
328
+ /noncurrentVersionExpirationDays must be a positive integer/.test(err.message) &&
329
+ /got 0/.test(err.message),
330
+ );
331
+ });
332
+
333
+ test('CDK: negative noncurrentVersionExpirationDays throws at synth', () => {
334
+ const { parent } = setup();
335
+ assert.throws(
336
+ () => new FileBucket(parent, 'uploads', { noncurrentVersionExpirationDays: -1 }),
337
+ (err: unknown) =>
338
+ err instanceof Error &&
339
+ /noncurrentVersionExpirationDays must be a positive integer/.test(err.message) &&
340
+ /got -1/.test(err.message),
341
+ );
342
+ });
343
+
344
+ test('CDK: non-integer noncurrentVersionExpirationDays throws at synth', () => {
345
+ const { parent } = setup();
346
+ assert.throws(
347
+ () => new FileBucket(parent, 'uploads', { noncurrentVersionExpirationDays: 1.5 }),
348
+ (err: unknown) =>
349
+ err instanceof Error &&
350
+ /noncurrentVersionExpirationDays must be a positive integer/.test(err.message),
351
+ );
352
+ });
353
+
354
+ test('CDK: versioned:false bucket has NO noncurrent-version expiration rule', () => {
355
+ const { stack, parent } = setup();
356
+ new FileBucket(parent, 'uploads', { versioned: false });
357
+ const template = Template.fromStack(stack);
358
+ const buckets = template.findResources('AWS::S3::Bucket');
359
+ const props = (Object.values(buckets)[0].Properties ?? {}) as any;
360
+ const rules: any[] = props.LifecycleConfiguration?.Rules ?? [];
361
+ assert.ok(
362
+ !rules.some((r) => r.NoncurrentVersionExpiration !== undefined),
363
+ 'a non-versioned bucket must not carry a noncurrent-version expiration rule',
364
+ );
365
+ });
366
+
367
+ test('CDK: noncurrentVersionExpirationDays FORMAT is validated even when versioned:false', () => {
368
+ // The format guard is decoupled from the `versioned` gate: a malformed value
369
+ // must fail loudly at synth regardless of whether versioning is on, rather
370
+ // than being silently ignored because the rule would not be applied.
371
+ for (const bad of [0, -1, 1.5]) {
372
+ const { parent } = setup();
373
+ assert.throws(
374
+ () => new FileBucket(parent, 'uploads', { versioned: false, noncurrentVersionExpirationDays: bad }),
375
+ (err: unknown) =>
376
+ err instanceof Error &&
377
+ /noncurrentVersionExpirationDays must be a positive integer/.test(err.message) &&
378
+ new RegExp(`got ${bad}`).test(err.message),
379
+ `versioned:false with noncurrentVersionExpirationDays=${bad} must throw at synth`,
380
+ );
381
+ }
382
+ });
383
+
384
+ test('CDK: versioned:false with NO noncurrent option does not throw and adds no noncurrent rule', () => {
385
+ const { stack, parent } = setup();
386
+ assert.doesNotThrow(() => new FileBucket(parent, 'uploads', { versioned: false }));
387
+ const template = Template.fromStack(stack);
388
+ const buckets = template.findResources('AWS::S3::Bucket');
389
+ const props = (Object.values(buckets)[0].Properties ?? {}) as any;
390
+ const rules: any[] = props.LifecycleConfiguration?.Rules ?? [];
391
+ assert.ok(
392
+ !rules.some((r) => r.NoncurrentVersionExpiration !== undefined),
393
+ 'versioned:false with no noncurrent option must add no noncurrent-version expiration rule',
394
+ );
395
+ });
396
+
397
+ // ── CORS synth guard (unchanged behavior) ───────────────────────────────────
398
+
399
+ test('CDK: wildcard-origin CORS with a mutating method throws at synth', () => {
400
+ const { parent } = setup();
401
+ assert.throws(
402
+ () =>
403
+ new FileBucket(parent, 'uploads', {
404
+ corsRules: [
405
+ { allowedOrigins: ['*'], allowedMethods: ['GET', 'PUT'] },
406
+ ],
407
+ }),
408
+ (err: unknown) =>
409
+ err instanceof Error &&
410
+ /\*/.test(err.message) &&
411
+ /PUT/.test(err.message),
412
+ );
413
+ });
414
+
415
+ test('CDK: wildcard-origin CORS with only safe methods is allowed', () => {
416
+ const { parent } = setup();
417
+ assert.doesNotThrow(() =>
418
+ new FileBucket(parent, 'uploads', {
419
+ corsRules: [{ allowedOrigins: ['*'], allowedMethods: ['GET', 'HEAD'] }],
420
+ }),
421
+ );
422
+ });
423
+
424
+ test('CDK: explicit-origin CORS with a mutating method is allowed', () => {
425
+ const { parent } = setup();
426
+ assert.doesNotThrow(() =>
427
+ new FileBucket(parent, 'uploads', {
428
+ corsRules: [
429
+ { allowedOrigins: ['https://app.example.com'], allowedMethods: ['PUT', 'POST'] },
430
+ ],
431
+ }),
432
+ );
433
+ });
package/src/index.cdk.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import * as s3 from 'aws-cdk-lib/aws-s3';
5
- import * as cdk from 'aws-cdk-lib';
6
5
  import { Duration, RemovalPolicy } from 'aws-cdk-lib';
6
+ import { RetentionDays } from 'aws-cdk-lib/aws-logs';
7
7
  import { Scope } from '@aws-blocks/core/cdk';
8
8
  import type { ScopeParent } from '@aws-blocks/core';
9
9
  import type { FileBucketOptions, CorsRule, LifecycleRule, ExternalBucketRef } from './types.js';
@@ -20,6 +20,12 @@ const httpMethodMap: Record<string, s3.HttpMethods> = {
20
20
  HEAD: s3.HttpMethods.HEAD,
21
21
  };
22
22
 
23
+ /** Default number of days after which noncurrent object versions expire. */
24
+ const DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS = 90;
25
+
26
+ /** HTTP methods that mutate bucket state; unsafe to expose to wildcard origins. */
27
+ const MUTATING_CORS_METHODS: ReadonlyArray<CorsRule['allowedMethods'][number]> = ['PUT', 'POST', 'DELETE'];
28
+
23
29
  export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
24
30
  private bucket: s3.IBucket;
25
31
 
@@ -39,34 +45,139 @@ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends
39
45
  // `fromExisting`: don't provision; bind to the pre-existing bucket and
40
46
  // grant read/write to the Blocks runtime Lambda.
41
47
  this.bucket = s3.Bucket.fromBucketName(this, 'bucket', options.bucket.bucketName);
42
- this.bucket.grantReadWrite(this.handler);
48
+ this.bucket.grantReadWrite(this.executionRole);
43
49
  return;
44
50
  }
45
51
 
46
- // In sandbox mode, default to DESTROY + autoDeleteObjects so
47
- // `cdk destroy` can fully clean up without manual bucket emptying.
48
- // Explicit `removalPolicy` from the customer takes precedence.
49
- // `autoDeleteObjects: true` is only valid paired with DESTROY (CDK
50
- // validates this at construct time), so we tie the two together.
51
- const isSandbox = cdk.Stack.of(this).node.tryGetContext('sandboxMode') === 'true';
52
- const destroy = options?.removalPolicy === 'destroy' || (isSandbox && options?.removalPolicy === undefined);
52
+ // Resolve durability from the per-block option (a `'destroy'|'retain'`
53
+ // string, normalized to a CDK RemovalPolicy) falling back to the
54
+ // stack-wide `defaults`. This replaces the old `sandboxMode` context
55
+ // read the sandbox posture now flows in through the chosen preset,
56
+ // exactly like bb-kv-store. Explicit `removalPolicy` from the customer
57
+ // still takes precedence. `autoDeleteObjects: true` is only valid paired
58
+ // with DESTROY (CDK validates this at construct time), so we derive the
59
+ // two from the same resolved policy.
60
+ const removalPolicy =
61
+ options?.removalPolicy === 'destroy'
62
+ ? RemovalPolicy.DESTROY
63
+ : options?.removalPolicy === 'retain'
64
+ ? RemovalPolicy.RETAIN
65
+ : this.defaults.removalPolicy;
66
+ const destroy = removalPolicy === RemovalPolicy.DESTROY;
53
67
 
54
68
  // Bucket name is derived from the scope chain. Validate against S3's
55
69
  // naming rules at synth so an invalid name fails here rather than at
56
70
  // `cdk deploy` (where CloudFormation rejects it with a cryptic error).
71
+ // Run this first: an unusable bucket name is the most fundamental synth
72
+ // error, so surface it before the option-level guards below.
57
73
  validateBucketName(this.fullId);
58
74
 
75
+ // Reject unsafe CORS at synth: a wildcard origin ('*') combined with a
76
+ // mutating method (PUT/POST/DELETE) lets any site issue state-changing
77
+ // cross-origin requests. Fail loud here rather than deploying it.
78
+ for (const rule of options?.corsRules ?? []) {
79
+ if (rule.allowedOrigins.includes('*')) {
80
+ const mutating = rule.allowedMethods.filter(m => MUTATING_CORS_METHODS.includes(m));
81
+ if (mutating.length > 0) {
82
+ throw new Error(
83
+ `FileBucket "${this.fullId}": CORS rule with wildcard origin '*' must not allow mutating method(s) ${mutating.join(', ')}. ` +
84
+ `Specify explicit allowedOrigins (e.g. 'https://app.example.com') for ${mutating.join(', ')} instead of '*'.`,
85
+ );
86
+ }
87
+ }
88
+ }
89
+
90
+ // Reject a non-positive or non-integer noncurrent-version expiration at
91
+ // synth whenever the option is provided. A zero, negative, or fractional
92
+ // value would produce a degenerate lifecycle expiration
93
+ // (Duration.days(0) / negative) that only surfaces at deploy. The FORMAT
94
+ // is validated regardless of `versioned` so a malformed value is caught
95
+ // even when versioning is off; the rule itself is only APPLIED when
96
+ // versioning is on (see the main-bucket lifecycle rules below).
97
+ if (options?.noncurrentVersionExpirationDays !== undefined) {
98
+ const days = options.noncurrentVersionExpirationDays;
99
+ if (!Number.isInteger(days) || days <= 0) {
100
+ throw new Error(
101
+ `FileBucket "${this.fullId}": noncurrentVersionExpirationDays must be a positive integer (got ${days}). ` +
102
+ `Omit it to use the default of ${DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS} days.`,
103
+ );
104
+ }
105
+ }
106
+
107
+ // Versioning stays on by default (secure default): a posture-driven
108
+ // `versioned` default would require a new `BlocksDefaults` field in
109
+ // core, which is out of scope for this change, so we keep the
110
+ // default-on and bound its cost with a noncurrent-version expiration
111
+ // below.
112
+ const versioned = options?.versioned ?? true;
113
+
114
+ // Opt-in server access logging: provision a dedicated, locked-down log
115
+ // bucket and expire its logs after the framework retention. Kept
116
+ // separate from the data bucket so log delivery can't loop back on it.
117
+ // Resolves from the stack `defaults.accessLogging` when no per-block
118
+ // option is given, so a production-postured stack opts every FileBucket
119
+ // in without a per-block flag.
120
+ const accessLogging = options?.accessLogging ?? this.defaults.accessLogging;
121
+ let serverAccessLogsBucket: s3.Bucket | undefined;
122
+ if (accessLogging) {
123
+ // The access-log lifecycle expiry derives from the framework-wide
124
+ // `logRetention` default (a `RetentionDays` enum). `RetentionDays`
125
+ // is a numeric enum whose member value IS the day count
126
+ // (ONE_WEEK === 7, ONE_YEAR === 365), so it maps directly to
127
+ // `Duration.days(...)`. The one non-day member is INFINITE (=== 9999,
128
+ // "retain forever"): for it we omit the lifecycle rule so logs are
129
+ // never expired, rather than expiring them at a spurious 9999 days.
130
+ const logRetention = this.defaults.logRetention;
131
+ const logLifecycleRules =
132
+ logRetention === RetentionDays.INFINITE
133
+ ? undefined
134
+ : [{ id: 'expire-access-logs', expiration: Duration.days(logRetention) }];
135
+ serverAccessLogsBucket = new s3.Bucket(this, 'access-logs', {
136
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
137
+ encryption: s3.BucketEncryption.S3_MANAGED,
138
+ enforceSSL: true,
139
+ removalPolicy,
140
+ autoDeleteObjects: destroy,
141
+ lifecycleRules: logLifecycleRules,
142
+ });
143
+ }
144
+
145
+ // Main-bucket lifecycle rules: the noncurrent-version expiration (only
146
+ // when versioning is on, to bound version-storage growth) merged with
147
+ // any customer-supplied lifecycle rules into a single array.
148
+ const lifecycleRules: s3.LifecycleRule[] = [];
149
+ if (versioned) {
150
+ lifecycleRules.push({
151
+ id: 'ExpireNoncurrentVersions',
152
+ enabled: true,
153
+ noncurrentVersionExpiration: Duration.days(
154
+ options?.noncurrentVersionExpirationDays ?? DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS,
155
+ ),
156
+ });
157
+ }
158
+ for (const rule of options?.lifecycleRules ?? []) {
159
+ lifecycleRules.push({
160
+ prefix: rule.prefix,
161
+ expiration: rule.expirationDays ? Duration.days(rule.expirationDays) : undefined,
162
+ transitions: rule.transitionToIaDays ? [{
163
+ storageClass: s3.StorageClass.INFREQUENT_ACCESS,
164
+ transitionAfter: Duration.days(rule.transitionToIaDays),
165
+ }] : undefined,
166
+ });
167
+ }
168
+
59
169
  this.bucket = new s3.Bucket(this, 'bucket', {
60
170
  bucketName: this.fullId,
61
171
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
62
172
  encryption: s3.BucketEncryption.S3_MANAGED,
63
- versioned: options?.versioned ?? false,
64
- removalPolicy: destroy
65
- ? RemovalPolicy.DESTROY
66
- : options?.removalPolicy === 'retain'
67
- ? RemovalPolicy.RETAIN
68
- : undefined,
173
+ // All FileBucket traffic (SDK calls + presigned URLs) is HTTPS, so
174
+ // enforce TLS to close the in-transit exposure gap unconditionally.
175
+ enforceSSL: true,
176
+ versioned,
177
+ removalPolicy,
69
178
  autoDeleteObjects: destroy,
179
+ serverAccessLogsBucket,
180
+ serverAccessLogsPrefix: serverAccessLogsBucket ? 'access-logs/' : undefined,
70
181
  cors: options?.corsRules?.map((rule: CorsRule) => ({
71
182
  allowedOrigins: rule.allowedOrigins,
72
183
  allowedMethods: rule.allowedMethods.map(m => httpMethodMap[m]),
@@ -74,16 +185,9 @@ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends
74
185
  exposedHeaders: rule.exposedHeaders,
75
186
  maxAge: rule.maxAge,
76
187
  })),
77
- lifecycleRules: options?.lifecycleRules?.map((rule: LifecycleRule) => ({
78
- prefix: rule.prefix,
79
- expiration: rule.expirationDays ? Duration.days(rule.expirationDays) : undefined,
80
- transitions: rule.transitionToIaDays ? [{
81
- storageClass: s3.StorageClass.INFREQUENT_ACCESS,
82
- transitionAfter: Duration.days(rule.transitionToIaDays),
83
- }] : undefined,
84
- })),
188
+ lifecycleRules: lifecycleRules.length > 0 ? lifecycleRules : undefined,
85
189
  });
86
190
 
87
- this.bucket.grantReadWrite(this.handler);
191
+ this.bucket.grantReadWrite(this.executionRole);
88
192
  }
89
193
  }