@aws-blocks/bb-file-bucket 0.1.5 → 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 +111 -22
- package/dist/index.cdk.test.js +261 -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 +347 -3
- package/src/index.cdk.ts +126 -22
- 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/DESIGN.md
CHANGED
|
@@ -38,6 +38,7 @@ versions/{key}/__deleted__ delete marker (sentinel)
|
|
|
38
38
|
**D-FB-6: Versioning is opt-in with runtime API support**
|
|
39
39
|
**Decision:** `versioned: true` enables S3 object versioning and unlocks version-aware methods (`listVersions`, `restoreVersion`, optional `versionId` on `get`/`delete`/`getUrl`/`getFileHandle`). Without the flag, the API surface is unchanged.
|
|
40
40
|
**Rationale:** Versioning adds storage cost and complexity. Making it opt-in keeps the default simple. When enabled, the runtime API exposes the full version lifecycle — listing, retrieving specific versions, permanent deletion of individual versions, and restoring old versions. `restoreVersion` is implemented as a CopyObject from the old version (S3 has no native restore), which creates a new version that becomes current.
|
|
41
|
+
**Update:** The **default flipped to ON** — see D-FB-10. The runtime API described here is unchanged; only the default value of `versioned` and its typing gate changed.
|
|
41
42
|
|
|
42
43
|
**D-FB-7: Mock versioning uses filesystem directories**
|
|
43
44
|
**Decision:** Versioned mock stores each version in `versions/{key}/v{n}` with monotonic IDs. Delete markers are `versions/{key}/__deleted__` sentinel files.
|
|
@@ -47,17 +48,47 @@ versions/{key}/__deleted__ delete marker (sentinel)
|
|
|
47
48
|
**Decision:** The derived bucket name (`scope.fullId`) is validated against S3's naming rules (`bucket-name.ts`) before the bucket is constructed. An invalid name throws a `ValidationFailed` error with an actionable message. The same validator runs in the mock constructor so local dev (`bb dev`) fails identically — parity. `FileBucket.fromExisting(...)` skips validation since the name is externally owned.
|
|
48
49
|
**Rationale:** S3 bucket names are globally unique and immutable. Truncating to fit 63 chars risks collisions, and a name that shifts between deploys (e.g. after a hash input changes) would orphan or replace the customer's data — a far worse outcome than a fast, fixable synth error. Erroring puts the fix in the developer's hands (shorten a scope id once; the name is then stable forever) and matches the manual-shortening pattern already used in `bb-agent`. This deliberately differs from DynamoDB-backed BBs (KVStore/DistributedTable) which `substring(0, 255)` — DynamoDB's 255 limit is generous and table names are internal, disposable, and not globally unique, so silent truncation is acceptable there.
|
|
49
50
|
|
|
51
|
+
**D-FB-9: TLS enforced unconditionally (enforceSSL)**
|
|
52
|
+
**Decision:** Every provisioned bucket (the data bucket and the opt-in access-log bucket) sets `enforceSSL: true`, so CDK attaches a bucket policy denying any request where `aws:SecureTransport` is `false`. Not configurable.
|
|
53
|
+
**Rationale:** All FileBucket traffic — SDK calls and presigned URLs alike — is already HTTPS, so enforcing TLS closes the in-transit exposure gap with zero functional cost. There is no legitimate reason a FileBucket consumer needs plaintext S3 access, so this is a hard default rather than an option. Covered by the `enforces SSL` CDK test.
|
|
54
|
+
|
|
55
|
+
**D-FB-10: Versioning default ON — supersedes D-FB-6's opt-in default**
|
|
56
|
+
**Decision:** `versioned` now defaults to `true`; consumers opt out with the literal `versioned: false`. The version-aware runtime API and its option typings (`GetOptionsFor` et al.) remain unchanged in shape, but the conditional types now select the non-versioned (optionless) form only for the literal `versioned: false` — a non-literal `boolean` or an absent value resolves to the versioned-aware form, matching the new runtime default.
|
|
57
|
+
**Rationale:** Accidental overwrites and deletes are unrecoverable without versioning; defaulting it on makes the safe choice the default and matches the "secure by default" posture of D-FB-9. This is a **behavior/breaking change** for existing consumers (buckets that were non-versioned by default now enable versioning, which adds storage cost for prior versions), so it ships with a `minor` changeset bump under the pre-1.0 (0.x) convention that a minor signals a breaking change. The literal-`false` typing gate is a deliberate consequence of `extends { versioned: false }`: TypeScript cannot narrow a widened `boolean` to the `false` branch, so the versioned-aware typings are the safe fallback.
|
|
58
|
+
|
|
59
|
+
**D-FB-11: Opt-in server access logging with a dedicated locked-down log bucket**
|
|
60
|
+
**Decision:** `accessLogging: true` provisions a second, dedicated S3 bucket (all public access blocked, S3-managed encryption, `enforceSSL`) that receives the main bucket's server access logs under the `access-logs/` prefix. Logs expire via a lifecycle rule after `logRetentionDays` (default `DEFAULT_ACCESS_LOG_RETENTION_DAYS = 90`). `logRetentionDays` is validated at synth: when access logging is enabled, a non-positive or non-integer value throws — a degenerate `Duration.days(0)`/negative lifecycle would otherwise only surface at deploy. The value is inert (and therefore not validated) when `accessLogging` is off.
|
|
61
|
+
**Rationale:** Access logging is off by default because it has a real cost (extra bucket, log storage) and most consumers don't need an audit trail; making it opt-in keeps the default lean. The log bucket is kept **separate** from the data bucket so log delivery can't loop back onto the bucket being logged. Validating `logRetentionDays` at synth follows the same fail-fast principle as D-FB-8 (bucket name) and the CORS guard (D-FB-12): misconfiguration fails loud at synth, not minutes into a deploy.
|
|
62
|
+
**Update (superseded by D-FB-13):** The per-block `logRetentionDays` option was **removed**. Access-log retention is no longer configured per-block; it now derives from the framework-wide `scope.defaults.logRetention` (`RetentionDays.ONE_WEEK` in sandbox / `ONE_YEAR` in production; `RetentionDays.INFINITE` omits the expiry rule so logs are kept indefinitely). The `accessLogging` toggle itself likewise falls back to `scope.defaults.accessLogging` when not set per-block. The dedicated locked-down log bucket and its separation from the data bucket (the substance of this decision) are unchanged — only the *source* of the retention/enable posture moved from a per-block number to the stack `BlocksDefaults`. The synth-time validation that formerly guarded `logRetentionDays` no longer applies (there is no per-block retention input); the equivalent guard now lives on `noncurrentVersionExpirationDays` — see D-FB-14.
|
|
63
|
+
|
|
64
|
+
**D-FB-12: Wildcard-origin CORS + mutating method rejected at synth**
|
|
65
|
+
**Decision:** A CORS rule whose `allowedOrigins` includes `'*'` and whose `allowedMethods` includes a mutating method (`PUT`/`POST`/`DELETE`, the `MUTATING_CORS_METHODS` set) throws a synth-time `Error`. Wildcard + safe methods (`GET`/`HEAD`) is allowed; explicit origins + mutating methods is allowed.
|
|
66
|
+
**Rationale:** A wildcard origin on a state-changing method lets any website issue authenticated cross-origin writes/deletes against the bucket — a CSRF-shaped exposure. Rather than silently deploying it, FileBucket fails loud at synth with an actionable message pointing the developer at explicit origins. A hard error (not a warning) was chosen deliberately: this PR supersedes the prior behavior where such a rule deployed unchallenged, and the user opted for the strict gate over a soft warning.
|
|
67
|
+
|
|
68
|
+
**D-FB-13: Posture knobs route through the framework `BlocksDefaults` — supersedes the per-block `logRetentionDays` and the `sandboxMode`-context removal read**
|
|
69
|
+
**Decision:** `removalPolicy`, `accessLogging`, and access-log retention are resolved from the stack-wide `BlocksDefaults` model (exposed as `scope.defaults`) rather than from per-block day-numbers or a `sandboxMode` CDK context read. Each posture knob follows the framework's `options?.field ?? scope.defaults.field` contract (the same pattern `bb-kv-store` uses for `removalPolicy`/`deletionProtection`, and documented on the `Scope.defaults` getter in `@aws-blocks/core/cdk`):
|
|
70
|
+
- `removalPolicy` — an explicit per-block `'destroy'|'retain'` still wins; when omitted it falls back to `scope.defaults.removalPolicy` (`DESTROY` in the sandbox preset, `RETAIN` in production). This replaces the previous `sandboxMode`-derived removal.
|
|
71
|
+
- `accessLogging` — falls back to `scope.defaults.accessLogging` when not set per-block, so a production-postured stack can opt every FileBucket into logging without a per-block flag.
|
|
72
|
+
- **access-log retention** — derives from `scope.defaults.logRetention` (a `RetentionDays` enum: `ONE_WEEK` sandbox / `ONE_YEAR` production). `RetentionDays` is a numeric enum whose member value *is* the day count, so it maps directly to `Duration.days(...)`; the one non-day member `INFINITE` omits the lifecycle expiry rule (logs kept forever) rather than expiring at a spurious 9999 days.
|
|
73
|
+
**Rationale:** Addresses review comment (C): posture should come from the one stack-level model every block already consumes, not from a grab-bag of per-block numbers and a legacy `sandboxMode` context flag. Centralizing on `BlocksDefaults` means a stack picks `BlocksPresets.sandbox`/`.production` once and every FileBucket inherits a coherent removal + logging + retention posture, while per-block overrides remain available for the knobs that still accept them (`removalPolicy`, `accessLogging`). This also removes the last consumer of the `sandboxMode` context read from this package.
|
|
74
|
+
|
|
75
|
+
**D-FB-14: Noncurrent-version expiration bounded by default (90 days)**
|
|
76
|
+
**Decision:** When versioning is enabled (the default — D-FB-10), the main bucket gets a lifecycle rule (`ExpireNoncurrentVersions`) that permanently expires **noncurrent** object versions after `noncurrentVersionExpirationDays` (default `DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS = 90`). The value's FORMAT is validated at synth whenever the option is provided, regardless of `versioned`: a non-positive or non-integer value throws (a degenerate `Duration.days(0)`/negative would otherwise only surface at deploy — same fail-fast principle as D-FB-8/D-FB-12). The rule itself is only APPLIED when versioning is on. There is no "disable" sentinel; to drop the rule entirely, disable versioning (`versioned: false`), which removes it along with versioning. The rule is inert on the mock and browser runtimes (no AWS resource).
|
|
77
|
+
**Rationale:** Addresses review comment (A): defaulting versioning ON (D-FB-10) makes overwrites/deletes recoverable but lets prior versions accrue storage cost without bound. Capping noncurrent versions at 90 days by default keeps the safe-by-default posture affordable — the common case (recover from a recent bad write) is well within 90 days, while stale versions no longer pile up indefinitely. It pairs with, and offsets the cost concern raised by, the versioning-default-on change rather than reopening that decision.
|
|
78
|
+
|
|
50
79
|
## Infrastructure (CDK)
|
|
51
80
|
|
|
52
|
-
Creates a single S3 bucket:
|
|
81
|
+
Creates a single S3 bucket (plus a dedicated log bucket when `accessLogging` is enabled):
|
|
53
82
|
|
|
54
83
|
- **Bucket name:** Derived from `scope.fullId` (the bucket id joined to its parent scope ids with `-`). Validated at synth against S3's naming rules — see D-FB-6.
|
|
55
84
|
- **Block public access:** All four settings enabled (BLOCK_ALL)
|
|
56
85
|
- **Encryption:** S3-managed keys (SSE-S3)
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
86
|
+
- **TLS:** Enforced unconditionally (`enforceSSL: true`) — a bucket policy denies any request where `aws:SecureTransport` is `false`. Not configurable. See D-FB-9.
|
|
87
|
+
- **Versioning:** Enabled by default (secure default); opt out via `options.versioned: false`. See D-FB-10. When on, a lifecycle rule expires noncurrent versions after `options.noncurrentVersionExpirationDays` (default 90, validated as a positive integer at synth) to bound version-storage cost. See D-FB-14.
|
|
88
|
+
- **Server access logging:** Enabled per-block via `options.accessLogging`, falling back to `scope.defaults.accessLogging` when omitted; when on, provisions a separate, locked-down log bucket whose logs expire after the stack posture's `scope.defaults.logRetention` (`ONE_WEEK` sandbox / `ONE_YEAR` production; `INFINITE` = no expiry). See D-FB-11 and D-FB-13.
|
|
89
|
+
- **CORS:** Configured from `options.corsRules` if provided. A wildcard origin (`'*'`) combined with a mutating method (PUT/POST/DELETE) is rejected at synth. See D-FB-12.
|
|
90
|
+
- **Lifecycle rules:** Configured from `options.lifecycleRules` if provided (merged with the noncurrent-version expiration rule above)
|
|
91
|
+
- **Removal policy:** Resolved from `options.removalPolicy` (`'destroy'|'retain'`) when set, else the stack posture default `scope.defaults.removalPolicy` (DESTROY sandbox / RETAIN production) — replacing the former `sandboxMode` context read. See D-FB-13.
|
|
61
92
|
- **Auto-delete objects:** Enabled when removal policy is DESTROY
|
|
62
93
|
- **Permissions:** `grantReadWrite` to the parent scope's handler automatically
|
|
63
94
|
|
package/README.md
CHANGED
|
@@ -33,12 +33,16 @@ const bucket = new FileBucket(scope, id, options?)
|
|
|
33
33
|
|
|
34
34
|
| Option | Type | Description |
|
|
35
35
|
|--------|------|-------------|
|
|
36
|
-
| `versioned` | `boolean` | Enable S3 object versioning. Default: `false
|
|
37
|
-
| `
|
|
36
|
+
| `versioned` | `boolean` | Enable S3 object versioning. **Default: `true`.** Pass `versioned: false` to opt out. Note: the version-aware method typings (`versionId` on `get`/`delete`/`getUrl`/`getFileHandle`) are selected only by the literal `versioned: false`; a non-literal `boolean` or an absent value resolves to the versioned-aware typings. |
|
|
37
|
+
| `noncurrentVersionExpirationDays` | `number` | Days after which **noncurrent** (superseded) object versions are permanently expired, bounding the storage cost that versioning would otherwise let grow unbounded. Only applies when versioning is enabled (the default). **Default: `90`.** Must be a positive integer — a non-positive or non-integer value throws at synth. There is no disable sentinel; to drop the rule entirely, disable versioning (`versioned: false`). Ignored by the mock and browser runtimes. |
|
|
38
|
+
| `corsRules` | `CorsRule[]` | CORS rules for browser-based access. See [CorsRule](#corsrule) for the wildcard-origin synth guard. |
|
|
38
39
|
| `lifecycleRules` | `LifecycleRule[]` | Lifecycle rules for automatic expiration or storage class transitions. |
|
|
40
|
+
| `accessLogging` | `boolean` | Enable S3 server access logging. **Default: falls back to the stack posture (`BlocksDefaults.accessLogging`)** when omitted — so a production-postured stack can opt every FileBucket into logging without a per-block flag. When enabled, a dedicated, locked-down log bucket is provisioned (all public access blocked, S3-managed encryption, SSL enforced) and the main bucket delivers its access logs there under the `access-logs/` prefix. Access logs expire automatically after the stack posture's `logRetention` (`ONE_WEEK` in sandbox / `ONE_YEAR` in production; `RetentionDays.INFINITE` keeps them indefinitely). Ignored by the mock and browser runtimes. |
|
|
39
41
|
| `bucket` | `ExternalBucketRef` | Wrap an existing S3 bucket instead of creating one. |
|
|
40
42
|
| `logger` | `ChildLogger` | Optional logger for internal operations. When omitted, a default error-level logger is created. |
|
|
41
|
-
| `removalPolicy` | `'destroy' \| 'retain'` | CDK removal behavior for the underlying S3 bucket. When omitted,
|
|
43
|
+
| `removalPolicy` | `'destroy' \| 'retain'` | CDK removal behavior for the underlying S3 bucket. When omitted, it falls back to the stack posture default (`BlocksDefaults.removalPolicy` — `DESTROY` in sandbox, `RETAIN` in production); pass `'destroy'` or `'retain'` to set it explicitly per-block. `'destroy'` also enables `autoDeleteObjects` so the bucket can be emptied on teardown. Ignored by the mock and browser runtimes. |
|
|
44
|
+
|
|
45
|
+
All FileBucket-provisioned buckets **enforce TLS unconditionally** (`enforceSSL: true`) — CDK attaches a bucket policy denying any request where `aws:SecureTransport` is `false`, closing the in-transit exposure gap. This is not configurable.
|
|
42
46
|
|
|
43
47
|
### PutOptions
|
|
44
48
|
|
|
@@ -60,6 +64,8 @@ CORS configuration for browser-based access. Supplied via the `corsRules` option
|
|
|
60
64
|
| `exposedHeaders` | `string[]` | Optional. Response headers exposed to the browser. |
|
|
61
65
|
| `maxAge` | `number` | Optional. Seconds the browser may cache the preflight response. |
|
|
62
66
|
|
|
67
|
+
> **Wildcard origins and mutating methods:** a CORS rule that combines a wildcard origin (`'*'`) with a mutating method (`PUT`, `POST`, or `DELETE`) **throws at synth** — it would let any site issue state-changing cross-origin requests. Specify explicit origins (e.g. `['https://app.example.com']`) for mutating methods. A wildcard origin with only safe methods (`GET`/`HEAD`) is allowed.
|
|
68
|
+
|
|
63
69
|
### LifecycleRule
|
|
64
70
|
|
|
65
71
|
Lifecycle configuration for automatic expiration or storage-class transitions. Supplied via the `lifecycleRules` option.
|
|
@@ -184,8 +190,12 @@ const bucket = new FileBucket(scope, 'legacy', {
|
|
|
184
190
|
});
|
|
185
191
|
```
|
|
186
192
|
|
|
193
|
+
> **A wrapped bucket does not receive FileBucket's secure defaults.** When you supply `bucket`, FileBucket binds to the existing bucket as-is and returns early — none of the secure defaults it normally applies are applied: not `enforceSSL`, versioning / noncurrent-version expiration, server access logging, `blockPublicAccess`, encryption, nor the wildcard-CORS guard. You own that bucket's security posture; configure these on the bucket itself (or via its own CDK construct) before wrapping it.
|
|
194
|
+
|
|
187
195
|
### Versioned Bucket
|
|
188
196
|
|
|
197
|
+
Versioning is **on by default**, so the version-aware methods (`listVersions`, `restoreVersion`, and optional `versionId` on `get`/`delete`/`getUrl`/`getFileHandle`) are available without any option. Pass `versioned: false` to opt out (which also removes the `versionId` option typings). The example below passes `versioned: true` explicitly for clarity:
|
|
198
|
+
|
|
189
199
|
```typescript
|
|
190
200
|
const bucket = new FileBucket(scope, 'docs', { versioned: true });
|
|
191
201
|
|
|
@@ -205,6 +215,18 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({
|
|
|
205
215
|
}));
|
|
206
216
|
```
|
|
207
217
|
|
|
218
|
+
### Access Logging
|
|
219
|
+
|
|
220
|
+
Opt in to S3 server access logging. A dedicated, locked-down log bucket is provisioned and access logs expire automatically after the stack posture's `logRetention` (`ONE_WEEK` in sandbox, `ONE_YEAR` in production):
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
// Logs delivered to a separate locked-down bucket.
|
|
224
|
+
// Retention follows the stack posture's logRetention default — not a per-block option.
|
|
225
|
+
const bucket = new FileBucket(scope, 'uploads', {
|
|
226
|
+
accessLogging: true,
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
208
230
|
## Best Practices
|
|
209
231
|
|
|
210
232
|
- Use path prefixes to organize files (e.g., `uploads/{userId}/`, `reports/`). If a segment can contain URL-shaped or special characters (e.g. an OIDC `userId` of `${iss}:${sub}` like `https://issuer:sub`), wrap it in `encodeURIComponent()` first — the local mock normalizes `//` in keys via the filesystem, so an un-encoded `//` makes `scan({ prefix })` miss the file locally even though it works against S3.
|
|
@@ -213,6 +235,7 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({
|
|
|
213
235
|
- Prefer `scan({ prefix })` over unscoped `scan()` to limit enumeration cost
|
|
214
236
|
- For browser uploads/downloads returned from API methods, prefer `createUploadHandle`/`getFileHandle` over raw presigned URLs — they encode the fetch protocol into typed methods so the client can't misuse them
|
|
215
237
|
- Use `deleteBatch()` instead of looping `delete()` for bulk operations
|
|
238
|
+
- Versioning is **on by default**; noncurrent (superseded) versions are automatically expired after **90 days** (`noncurrentVersionExpirationDays`, default `90`) so version history doesn't grow storage cost unbounded. Tune the window per-block, or disable versioning (`versioned: false`) to drop the expiration rule along with versioning.
|
|
216
239
|
|
|
217
240
|
## Scaling & Cost (AWS)
|
|
218
241
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-server.d.ts","sourceRoot":"","sources":["../src/file-server.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAmC,MAAM,WAAW,CAAC;AAyCzE,wBAAgB,MAAM,CAAC,UAAU,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"file-server.d.ts","sourceRoot":"","sources":["../src/file-server.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAmC,MAAM,WAAW,CAAC;AAyCzE,wBAAgB,MAAM,CAAC,UAAU,EAAE,MAAM,QA0KxC"}
|
package/dist/file-server.js
CHANGED
|
@@ -53,10 +53,19 @@ export function attach(httpServer) {
|
|
|
53
53
|
}
|
|
54
54
|
// CORS for browser uploads/downloads
|
|
55
55
|
const origin = req.headers.origin || '*';
|
|
56
|
+
// Reflecting the request Origin (falling back to '*') is intentional: it
|
|
57
|
+
// lets a localhost cross-port dev server (e.g. Vite/webpack on a different
|
|
58
|
+
// port) call this local file-server. This dev file-server is local tooling
|
|
59
|
+
// only and NEVER deploys to AWS / production.
|
|
60
|
+
// It is safe because Access-Control-Allow-Credentials is deliberately NOT
|
|
61
|
+
// set, so the reflected origin carries no ambient credentials: presigned-URL
|
|
62
|
+
// auth is a query-string token, not a cookie / ambient session. That is what
|
|
63
|
+
// makes reflecting an arbitrary origin here safe.
|
|
64
|
+
// WARNING: do not re-add Access-Control-Allow-Credentials and do not
|
|
65
|
+
// 'tighten' this reflection without understanding the above.
|
|
56
66
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
57
67
|
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
|
|
58
68
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
59
|
-
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
60
69
|
if (req.method === 'OPTIONS') {
|
|
61
70
|
res.writeHead(200);
|
|
62
71
|
res.end();
|
package/dist/file-server.test.js
CHANGED
|
@@ -42,6 +42,22 @@ beforeEach(async () => {
|
|
|
42
42
|
afterEach(() => {
|
|
43
43
|
server.close();
|
|
44
44
|
});
|
|
45
|
+
// ── CORS headers ────────────────────────────────────────────────────────────
|
|
46
|
+
describe('file-server: CORS headers', () => {
|
|
47
|
+
test('OPTIONS preflight does NOT send Access-Control-Allow-Credentials', async () => {
|
|
48
|
+
// Reflecting an arbitrary Origin together with credentials:true is an
|
|
49
|
+
// unsafe combination; the dev server must not advertise credentialed CORS.
|
|
50
|
+
const res = await fetch(`http://localhost:${port}/.bb-file-bucket/fsrv-cors/ping.txt`, {
|
|
51
|
+
method: 'OPTIONS',
|
|
52
|
+
headers: { Origin: 'https://evil.example.com' },
|
|
53
|
+
});
|
|
54
|
+
assert.strictEqual(res.status, 200);
|
|
55
|
+
assert.strictEqual(res.headers.get('access-control-allow-credentials'), null, 'dev server must not send Access-Control-Allow-Credentials');
|
|
56
|
+
// The other CORS headers remain intact.
|
|
57
|
+
assert.strictEqual(res.headers.get('access-control-allow-origin'), 'https://evil.example.com');
|
|
58
|
+
assert.ok(res.headers.get('access-control-allow-methods'));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
45
61
|
// ── Basic presigned URL round-trip ──────────────────────────────────────────
|
|
46
62
|
describe('file-server: basic GET/PUT', () => {
|
|
47
63
|
test('PUT then GET via presigned URLs', async () => {
|
package/dist/index.cdk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAC7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,iBAAiB,EAA2B,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAGhG,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAC7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,iBAAiB,EAA2B,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAGhG,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAgB9K,qBAAa,UAAU,CAAC,CAAC,SAAS,iBAAiB,GAAG,iBAAiB,CAAE,SAAQ,KAAK;IACrF,OAAO,CAAC,MAAM,CAAa;IAE3B;;;;OAIG;IACH,MAAM,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,iBAAiB;gBAI9C,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;CAwJvD"}
|
package/dist/index.cdk.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
4
|
-
import * as cdk from 'aws-cdk-lib';
|
|
5
4
|
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
|
|
5
|
+
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
|
|
6
6
|
import { Scope } from '@aws-blocks/core/cdk';
|
|
7
7
|
import { validateBucketName } from './bucket-name.js';
|
|
8
8
|
export { FileBucketErrors } from './errors.js';
|
|
@@ -13,6 +13,10 @@ const httpMethodMap = {
|
|
|
13
13
|
DELETE: s3.HttpMethods.DELETE,
|
|
14
14
|
HEAD: s3.HttpMethods.HEAD,
|
|
15
15
|
};
|
|
16
|
+
/** Default number of days after which noncurrent object versions expire. */
|
|
17
|
+
const DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS = 90;
|
|
18
|
+
/** HTTP methods that mutate bucket state; unsafe to expose to wildcard origins. */
|
|
19
|
+
const MUTATING_CORS_METHODS = ['PUT', 'POST', 'DELETE'];
|
|
16
20
|
export class FileBucket extends Scope {
|
|
17
21
|
bucket;
|
|
18
22
|
/**
|
|
@@ -32,28 +36,120 @@ export class FileBucket extends Scope {
|
|
|
32
36
|
this.bucket.grantReadWrite(this.executionRole);
|
|
33
37
|
return;
|
|
34
38
|
}
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// Resolve durability from the per-block option (a `'destroy'|'retain'`
|
|
40
|
+
// string, normalized to a CDK RemovalPolicy) falling back to the
|
|
41
|
+
// stack-wide `defaults`. This replaces the old `sandboxMode` context
|
|
42
|
+
// read — the sandbox posture now flows in through the chosen preset,
|
|
43
|
+
// exactly like bb-kv-store. Explicit `removalPolicy` from the customer
|
|
44
|
+
// still takes precedence. `autoDeleteObjects: true` is only valid paired
|
|
45
|
+
// with DESTROY (CDK validates this at construct time), so we derive the
|
|
46
|
+
// two from the same resolved policy.
|
|
47
|
+
const removalPolicy = options?.removalPolicy === 'destroy'
|
|
48
|
+
? RemovalPolicy.DESTROY
|
|
49
|
+
: options?.removalPolicy === 'retain'
|
|
50
|
+
? RemovalPolicy.RETAIN
|
|
51
|
+
: this.defaults.removalPolicy;
|
|
52
|
+
const destroy = removalPolicy === RemovalPolicy.DESTROY;
|
|
42
53
|
// Bucket name is derived from the scope chain. Validate against S3's
|
|
43
54
|
// naming rules at synth so an invalid name fails here rather than at
|
|
44
55
|
// `cdk deploy` (where CloudFormation rejects it with a cryptic error).
|
|
56
|
+
// Run this first: an unusable bucket name is the most fundamental synth
|
|
57
|
+
// error, so surface it before the option-level guards below.
|
|
45
58
|
validateBucketName(this.fullId);
|
|
59
|
+
// Reject unsafe CORS at synth: a wildcard origin ('*') combined with a
|
|
60
|
+
// mutating method (PUT/POST/DELETE) lets any site issue state-changing
|
|
61
|
+
// cross-origin requests. Fail loud here rather than deploying it.
|
|
62
|
+
for (const rule of options?.corsRules ?? []) {
|
|
63
|
+
if (rule.allowedOrigins.includes('*')) {
|
|
64
|
+
const mutating = rule.allowedMethods.filter(m => MUTATING_CORS_METHODS.includes(m));
|
|
65
|
+
if (mutating.length > 0) {
|
|
66
|
+
throw new Error(`FileBucket "${this.fullId}": CORS rule with wildcard origin '*' must not allow mutating method(s) ${mutating.join(', ')}. ` +
|
|
67
|
+
`Specify explicit allowedOrigins (e.g. 'https://app.example.com') for ${mutating.join(', ')} instead of '*'.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Reject a non-positive or non-integer noncurrent-version expiration at
|
|
72
|
+
// synth whenever the option is provided. A zero, negative, or fractional
|
|
73
|
+
// value would produce a degenerate lifecycle expiration
|
|
74
|
+
// (Duration.days(0) / negative) that only surfaces at deploy. The FORMAT
|
|
75
|
+
// is validated regardless of `versioned` so a malformed value is caught
|
|
76
|
+
// even when versioning is off; the rule itself is only APPLIED when
|
|
77
|
+
// versioning is on (see the main-bucket lifecycle rules below).
|
|
78
|
+
if (options?.noncurrentVersionExpirationDays !== undefined) {
|
|
79
|
+
const days = options.noncurrentVersionExpirationDays;
|
|
80
|
+
if (!Number.isInteger(days) || days <= 0) {
|
|
81
|
+
throw new Error(`FileBucket "${this.fullId}": noncurrentVersionExpirationDays must be a positive integer (got ${days}). ` +
|
|
82
|
+
`Omit it to use the default of ${DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS} days.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Versioning stays on by default (secure default): a posture-driven
|
|
86
|
+
// `versioned` default would require a new `BlocksDefaults` field in
|
|
87
|
+
// core, which is out of scope for this change, so we keep the
|
|
88
|
+
// default-on and bound its cost with a noncurrent-version expiration
|
|
89
|
+
// below.
|
|
90
|
+
const versioned = options?.versioned ?? true;
|
|
91
|
+
// Opt-in server access logging: provision a dedicated, locked-down log
|
|
92
|
+
// bucket and expire its logs after the framework retention. Kept
|
|
93
|
+
// separate from the data bucket so log delivery can't loop back on it.
|
|
94
|
+
// Resolves from the stack `defaults.accessLogging` when no per-block
|
|
95
|
+
// option is given, so a production-postured stack opts every FileBucket
|
|
96
|
+
// in without a per-block flag.
|
|
97
|
+
const accessLogging = options?.accessLogging ?? this.defaults.accessLogging;
|
|
98
|
+
let serverAccessLogsBucket;
|
|
99
|
+
if (accessLogging) {
|
|
100
|
+
// The access-log lifecycle expiry derives from the framework-wide
|
|
101
|
+
// `logRetention` default (a `RetentionDays` enum). `RetentionDays`
|
|
102
|
+
// is a numeric enum whose member value IS the day count
|
|
103
|
+
// (ONE_WEEK === 7, ONE_YEAR === 365), so it maps directly to
|
|
104
|
+
// `Duration.days(...)`. The one non-day member is INFINITE (=== 9999,
|
|
105
|
+
// "retain forever"): for it we omit the lifecycle rule so logs are
|
|
106
|
+
// never expired, rather than expiring them at a spurious 9999 days.
|
|
107
|
+
const logRetention = this.defaults.logRetention;
|
|
108
|
+
const logLifecycleRules = logRetention === RetentionDays.INFINITE
|
|
109
|
+
? undefined
|
|
110
|
+
: [{ id: 'expire-access-logs', expiration: Duration.days(logRetention) }];
|
|
111
|
+
serverAccessLogsBucket = new s3.Bucket(this, 'access-logs', {
|
|
112
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
113
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
114
|
+
enforceSSL: true,
|
|
115
|
+
removalPolicy,
|
|
116
|
+
autoDeleteObjects: destroy,
|
|
117
|
+
lifecycleRules: logLifecycleRules,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Main-bucket lifecycle rules: the noncurrent-version expiration (only
|
|
121
|
+
// when versioning is on, to bound version-storage growth) merged with
|
|
122
|
+
// any customer-supplied lifecycle rules into a single array.
|
|
123
|
+
const lifecycleRules = [];
|
|
124
|
+
if (versioned) {
|
|
125
|
+
lifecycleRules.push({
|
|
126
|
+
id: 'ExpireNoncurrentVersions',
|
|
127
|
+
enabled: true,
|
|
128
|
+
noncurrentVersionExpiration: Duration.days(options?.noncurrentVersionExpirationDays ?? DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
for (const rule of options?.lifecycleRules ?? []) {
|
|
132
|
+
lifecycleRules.push({
|
|
133
|
+
prefix: rule.prefix,
|
|
134
|
+
expiration: rule.expirationDays ? Duration.days(rule.expirationDays) : undefined,
|
|
135
|
+
transitions: rule.transitionToIaDays ? [{
|
|
136
|
+
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
|
|
137
|
+
transitionAfter: Duration.days(rule.transitionToIaDays),
|
|
138
|
+
}] : undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
46
141
|
this.bucket = new s3.Bucket(this, 'bucket', {
|
|
47
142
|
bucketName: this.fullId,
|
|
48
143
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
49
144
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: undefined,
|
|
145
|
+
// All FileBucket traffic (SDK calls + presigned URLs) is HTTPS, so
|
|
146
|
+
// enforce TLS to close the in-transit exposure gap unconditionally.
|
|
147
|
+
enforceSSL: true,
|
|
148
|
+
versioned,
|
|
149
|
+
removalPolicy,
|
|
56
150
|
autoDeleteObjects: destroy,
|
|
151
|
+
serverAccessLogsBucket,
|
|
152
|
+
serverAccessLogsPrefix: serverAccessLogsBucket ? 'access-logs/' : undefined,
|
|
57
153
|
cors: options?.corsRules?.map((rule) => ({
|
|
58
154
|
allowedOrigins: rule.allowedOrigins,
|
|
59
155
|
allowedMethods: rule.allowedMethods.map(m => httpMethodMap[m]),
|
|
@@ -61,14 +157,7 @@ export class FileBucket extends Scope {
|
|
|
61
157
|
exposedHeaders: rule.exposedHeaders,
|
|
62
158
|
maxAge: rule.maxAge,
|
|
63
159
|
})),
|
|
64
|
-
lifecycleRules:
|
|
65
|
-
prefix: rule.prefix,
|
|
66
|
-
expiration: rule.expirationDays ? Duration.days(rule.expirationDays) : undefined,
|
|
67
|
-
transitions: rule.transitionToIaDays ? [{
|
|
68
|
-
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
|
|
69
|
-
transitionAfter: Duration.days(rule.transitionToIaDays),
|
|
70
|
-
}] : undefined,
|
|
71
|
-
})),
|
|
160
|
+
lifecycleRules: lifecycleRules.length > 0 ? lifecycleRules : undefined,
|
|
72
161
|
});
|
|
73
162
|
this.bucket.grantReadWrite(this.executionRole);
|
|
74
163
|
}
|