@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.
- package/DESIGN.md +36 -5
- package/README.md +26 -3
- package/dist/file-server.d.ts.map +1 -1
- package/dist/file-server.js +10 -1
- package/dist/file-server.test.js +16 -0
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +113 -24
- package/dist/index.cdk.test.js +266 -3
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +41 -1
- package/dist/index.test.js +85 -1
- package/dist/types.d.ts +69 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/file-server.test.ts +22 -0
- package/src/file-server.ts +10 -1
- package/src/index.cdk.test.ts +352 -3
- package/src/index.cdk.ts +128 -24
- package/src/index.mock.ts +49 -2
- package/src/index.test.ts +131 -1
- package/src/types.ts +66 -5
- package/src/version.ts +1 -1
package/src/file-server.test.ts
CHANGED
|
@@ -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', () => {
|
package/src/file-server.ts
CHANGED
|
@@ -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);
|
package/src/index.cdk.test.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
48
|
+
this.bucket.grantReadWrite(this.executionRole);
|
|
43
49
|
return;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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:
|
|
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.
|
|
191
|
+
this.bucket.grantReadWrite(this.executionRole);
|
|
88
192
|
}
|
|
89
193
|
}
|