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

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 CHANGED
@@ -70,7 +70,8 @@ Creates a single S3 bucket:
70
70
  - `versions/{key}/{versionId}` (+ `.json` sidecars, `__deleted__` marker) — version history.
71
71
  - Path mapping for both the mock and the dev file-server is centralized in `paths.ts` so they stay in lockstep.
72
72
  - Data persists across dev server restarts. Customers can wipe with `rm -rf .bb-data`.
73
- - Presigned URLs are served by the dev file-server at `/.bb-file-bucket/{scope.fullId}/{path}?token=...`. The path segments are URL-encoded; the server decodes them and validates an HMAC token scoped to method, path, and expiry.
73
+ - Presigned URLs are served by the dev file-server at `/.bb-file-bucket/{scope.fullId}/{path}?token=...`. The path segments are URL-encoded; the server decodes them and validates an HMAC token scoped to method, path, and expiry. The HMAC secret (`LOCAL_FILE_SECRET` in `tokens.ts`) is a **per-process random value** — the token-minting mock and the validating dev file-server share the same in-process module instance, so tokens are unforgeable without being a hardcoded, source-visible literal.
74
+ - Downloads are served with `X-Content-Type-Options: nosniff` and `Content-Disposition: attachment`. The stored body and its `Content-Type` are caller-controlled, so serving them inline would make the dev file-server a stored-XSS vector (an uploaded `text/html`/SVG payload executing in the app origin). Forcing a download + disabling MIME sniffing keeps local dev no weaker than S3-behind-CloudFront.
74
75
  - `scan()` recursively walks only the `content/` root and yields every file it finds — no marker-based filtering — so user keys are unrestricted.
75
76
  - The dev file-server's PUT handler delegates to the registered `FileBucket` instance (via a process-global registry) so uploads get versioning, key validation, and metadata. There is no direct-write fallback; an unregistered bucket fails loud with a 500.
76
77
  - Key length validated against S3's 1,024-byte limit (warns, does not reject).
@@ -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,QAkJxC"}
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,QAiKxC"}
@@ -116,7 +116,22 @@ export function attach(httpServer) {
116
116
  catch { }
117
117
  }
118
118
  const body = readFileSync(readPath);
119
- res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': body.length.toString() });
119
+ // The stored object body and its Content-Type are attacker-controlled
120
+ // (any client with a presigned PUT can upload arbitrary bytes under an
121
+ // arbitrary content type). Serving that back inline turns the dev file
122
+ // server into a stored-XSS vector: an uploaded `text/html` (or sniffed
123
+ // HTML/SVG) payload would execute in the origin of the local app.
124
+ // `nosniff` stops the browser from MIME-sniffing octet-streams into
125
+ // HTML, and `Content-Disposition: attachment` forces a download rather
126
+ // than inline rendering — so an uploaded document can never run as a
127
+ // page. Real S3 objects served through CloudFront are hardened the same
128
+ // way; this keeps local dev from being weaker than production.
129
+ res.writeHead(200, {
130
+ 'Content-Type': contentType,
131
+ 'Content-Length': body.length.toString(),
132
+ 'X-Content-Type-Options': 'nosniff',
133
+ 'Content-Disposition': 'attachment',
134
+ });
120
135
  res.end(body);
121
136
  }
122
137
  else if (req.method === 'PUT') {
@@ -60,6 +60,28 @@ describe('file-server: basic GET/PUT', () => {
60
60
  assert.strictEqual(getRes.status, 200, `GET failed: ${getRes.status}`);
61
61
  assert.strictEqual(await getRes.text(), 'hello world');
62
62
  });
63
+ test('GET hardens against stored XSS: nosniff + attachment disposition', async () => {
64
+ // A client uploads an HTML payload under a text/html content type — the
65
+ // classic stored-XSS setup. When the dev server serves it back it must
66
+ // never let the browser render it inline in the app's origin.
67
+ const bucket = new FileBucket(scope, 'fs-xss');
68
+ const putUrl = await bucket.putUrl('payload.html', { contentType: 'text/html' });
69
+ const adjustedPut = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
70
+ const putRes = await fetch(adjustedPut, {
71
+ method: 'PUT',
72
+ body: '<script>alert(document.domain)</script>',
73
+ headers: { 'Content-Type': 'text/html' },
74
+ });
75
+ assert.strictEqual(putRes.status, 200, `PUT failed: ${putRes.status}`);
76
+ const getUrl = await bucket.getUrl('payload.html');
77
+ const adjustedGet = getUrl.replace(/localhost:\d+/, `localhost:${port}`);
78
+ const getRes = await fetch(adjustedGet);
79
+ assert.strictEqual(getRes.status, 200, `GET failed: ${getRes.status}`);
80
+ assert.strictEqual(getRes.headers.get('x-content-type-options'), 'nosniff', 'GET must send X-Content-Type-Options: nosniff');
81
+ assert.match(getRes.headers.get('content-disposition') ?? '', /^attachment/, 'GET must force download via Content-Disposition: attachment');
82
+ // consume the body so the socket closes cleanly
83
+ await getRes.arrayBuffer();
84
+ });
63
85
  test('GET non-existent file returns 404', async () => {
64
86
  const bucket = new FileBucket(scope, 'fs-404');
65
87
  const url = await bucket.getUrl('missing.txt');
@@ -72,6 +94,21 @@ describe('file-server: basic GET/PUT', () => {
72
94
  const res = await fetch(url);
73
95
  assert.strictEqual(res.status, 403);
74
96
  });
97
+ test('a token forged with the former hardcoded secret is rejected', async () => {
98
+ // The signing secret used to be a fixed, source-visible literal, so anyone
99
+ // could mint a valid token offline for any fullId/path without ever calling
100
+ // getUrl()/putUrl(). It is now a per-process random secret. A token signed
101
+ // with the old literal must no longer validate.
102
+ const bucket = new FileBucket(scope, 'fs-forge');
103
+ const putUrl = await bucket.putUrl('secret.txt', { contentType: 'text/plain' });
104
+ const adjustedPut = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
105
+ await fetch(adjustedPut, { method: 'PUT', body: 'data', headers: { 'Content-Type': 'text/plain' } });
106
+ const forged = mintFileToken('fsrv-fs-forge', 'secret.txt', 'GET', 3600, '__blocks_file_bucket_dev_secret__');
107
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-forge/secret.txt?token=${forged}`;
108
+ const res = await fetch(url);
109
+ await res.arrayBuffer();
110
+ assert.strictEqual(res.status, 403, 'a token forged with the old hardcoded secret must be rejected');
111
+ });
75
112
  test('PUT for an unregistered bucket fails loud (500), no silent write', async () => {
76
113
  // Mint a structurally valid token for a fullId that has no FileBucket
77
114
  // instance registered. The server must refuse rather than fall back to
package/dist/tokens.d.ts CHANGED
@@ -1,4 +1,20 @@
1
- export declare const LOCAL_FILE_SECRET = "__blocks_file_bucket_dev_secret__";
1
+ /**
2
+ * Per-process HMAC secret for signing local presigned-URL tokens.
3
+ *
4
+ * The mock bucket (which mints tokens) and the dev file server (which validates
5
+ * them) both import this module and run in the *same* dev-server process, so a
6
+ * value generated once at module load is shared between them via the ESM module
7
+ * cache — no configuration needed.
8
+ *
9
+ * This deliberately replaces a previously hardcoded literal. A fixed, source-
10
+ * visible secret let anyone forge a valid token for any `fullId`/path/method and
11
+ * hit the dev file server without ever calling `getUrl()`/`putUrl()`, defeating
12
+ * the point of signing. A random per-process secret makes tokens unforgeable
13
+ * while keeping the local round-trip working, since both ends share this value.
14
+ * Tokens do not need to survive a dev-server restart (presigned URLs are short-
15
+ * lived and re-minted on demand), so per-process randomness is sufficient.
16
+ */
17
+ export declare const LOCAL_FILE_SECRET: string;
2
18
  interface FileTokenPayload {
3
19
  fullId: string;
4
20
  path: string;
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,iBAAiB,sCAAsC,CAAC;AAErE,UAAU,gBAAgB;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,KAAK,GAAG,KAAK,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,aAAa,CAC5B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,KAAK,GAAG,KAAK,EACrB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,MAAM,GAClB,MAAM,CAWR;AAED,wBAAgB,iBAAiB,CAChC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,KAAK,GAAG,KAAK,GAC3B,gBAAgB,GAAG,IAAI,CAgBzB"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iBAAiB,QAAwC,CAAC;AAEvE,UAAU,gBAAgB;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,KAAK,GAAG,KAAK,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,aAAa,CAC5B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,KAAK,GAAG,KAAK,EACrB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,MAAM,GAClB,MAAM,CAWR;AAED,wBAAgB,iBAAiB,CAChC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,KAAK,GAAG,KAAK,GAC3B,gBAAgB,GAAG,IAAI,CAgBzB"}
package/dist/tokens.js CHANGED
@@ -1,9 +1,25 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { createHmac } from 'node:crypto';
3
+ import { createHmac, randomBytes } from 'node:crypto';
4
4
  import { constantTimeEquals } from '@aws-blocks/core/bb-utils';
5
5
  // ── Token helpers ───────────────────────────────────────────────────────────
6
- export const LOCAL_FILE_SECRET = '__blocks_file_bucket_dev_secret__';
6
+ /**
7
+ * Per-process HMAC secret for signing local presigned-URL tokens.
8
+ *
9
+ * The mock bucket (which mints tokens) and the dev file server (which validates
10
+ * them) both import this module and run in the *same* dev-server process, so a
11
+ * value generated once at module load is shared between them via the ESM module
12
+ * cache — no configuration needed.
13
+ *
14
+ * This deliberately replaces a previously hardcoded literal. A fixed, source-
15
+ * visible secret let anyone forge a valid token for any `fullId`/path/method and
16
+ * hit the dev file server without ever calling `getUrl()`/`putUrl()`, defeating
17
+ * the point of signing. A random per-process secret makes tokens unforgeable
18
+ * while keeping the local round-trip working, since both ends share this value.
19
+ * Tokens do not need to survive a dev-server restart (presigned URLs are short-
20
+ * lived and re-minted on demand), so per-process randomness is sufficient.
21
+ */
22
+ export const LOCAL_FILE_SECRET = randomBytes(32).toString('base64url');
7
23
  export function mintFileToken(fullId, path, method, expiresIn, secret, contentType) {
8
24
  const payload = {
9
25
  fullId,
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export declare const BB_NAME = "FileBucket";
2
- export declare const BB_VERSION = "0.1.2";
2
+ export declare const BB_VERSION = "0.1.4";
3
3
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'FileBucket';
3
- export const BB_VERSION = '0.1.2';
3
+ export const BB_VERSION = '0.1.4';
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@aws-blocks/bb-file-bucket",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
7
+ "directory": "packages/bb-file-bucket"
8
+ },
9
+ "homepage": "https://github.com/aws-devtools-labs/aws-blocks/tree/main/packages/bb-file-bucket#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/aws-devtools-labs/aws-blocks/issues"
12
+ },
4
13
  "author": "Amazon Web Services",
5
14
  "license": "Apache-2.0",
6
15
  "type": "module",
@@ -41,8 +50,8 @@
41
50
  "test": "node --test dist/index.test.js dist/index.cdk.test.js dist/scan.test.js dist/file-server.test.js dist/url-encoding.test.js dist/path-containment.test.js dist/bucket-name.test.js"
42
51
  },
43
52
  "dependencies": {
44
- "@aws-blocks/bb-logger": "^0.1.2",
45
- "@aws-blocks/core": "^0.1.1",
53
+ "@aws-blocks/bb-logger": "^0.1.4",
54
+ "@aws-blocks/core": "^0.2.0",
46
55
  "@aws-sdk/client-s3": "^3.0.0",
47
56
  "@aws-sdk/s3-request-presigner": "^3.0.0"
48
57
  },
@@ -72,6 +72,38 @@ describe('file-server: basic GET/PUT', () => {
72
72
  assert.strictEqual(await getRes.text(), 'hello world');
73
73
  });
74
74
 
75
+ test('GET hardens against stored XSS: nosniff + attachment disposition', async () => {
76
+ // A client uploads an HTML payload under a text/html content type — the
77
+ // classic stored-XSS setup. When the dev server serves it back it must
78
+ // never let the browser render it inline in the app's origin.
79
+ const bucket = new FileBucket(scope, 'fs-xss');
80
+ const putUrl = await bucket.putUrl('payload.html', { contentType: 'text/html' });
81
+ const adjustedPut = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
82
+ const putRes = await fetch(adjustedPut, {
83
+ method: 'PUT',
84
+ body: '<script>alert(document.domain)</script>',
85
+ headers: { 'Content-Type': 'text/html' },
86
+ });
87
+ assert.strictEqual(putRes.status, 200, `PUT failed: ${putRes.status}`);
88
+
89
+ const getUrl = await bucket.getUrl('payload.html');
90
+ const adjustedGet = getUrl.replace(/localhost:\d+/, `localhost:${port}`);
91
+ const getRes = await fetch(adjustedGet);
92
+ assert.strictEqual(getRes.status, 200, `GET failed: ${getRes.status}`);
93
+ assert.strictEqual(
94
+ getRes.headers.get('x-content-type-options'),
95
+ 'nosniff',
96
+ 'GET must send X-Content-Type-Options: nosniff',
97
+ );
98
+ assert.match(
99
+ getRes.headers.get('content-disposition') ?? '',
100
+ /^attachment/,
101
+ 'GET must force download via Content-Disposition: attachment',
102
+ );
103
+ // consume the body so the socket closes cleanly
104
+ await getRes.arrayBuffer();
105
+ });
106
+
75
107
  test('GET non-existent file returns 404', async () => {
76
108
  const bucket = new FileBucket(scope, 'fs-404');
77
109
  const url = await bucket.getUrl('missing.txt');
@@ -87,6 +119,23 @@ describe('file-server: basic GET/PUT', () => {
87
119
  assert.strictEqual(res.status, 403);
88
120
  });
89
121
 
122
+ test('a token forged with the former hardcoded secret is rejected', async () => {
123
+ // The signing secret used to be a fixed, source-visible literal, so anyone
124
+ // could mint a valid token offline for any fullId/path without ever calling
125
+ // getUrl()/putUrl(). It is now a per-process random secret. A token signed
126
+ // with the old literal must no longer validate.
127
+ const bucket = new FileBucket(scope, 'fs-forge');
128
+ const putUrl = await bucket.putUrl('secret.txt', { contentType: 'text/plain' });
129
+ const adjustedPut = putUrl.replace(/localhost:\d+/, `localhost:${port}`);
130
+ await fetch(adjustedPut, { method: 'PUT', body: 'data', headers: { 'Content-Type': 'text/plain' } });
131
+
132
+ const forged = mintFileToken('fsrv-fs-forge', 'secret.txt', 'GET', 3600, '__blocks_file_bucket_dev_secret__');
133
+ const url = `http://localhost:${port}/.bb-file-bucket/fsrv-fs-forge/secret.txt?token=${forged}`;
134
+ const res = await fetch(url);
135
+ await res.arrayBuffer();
136
+ assert.strictEqual(res.status, 403, 'a token forged with the old hardcoded secret must be rejected');
137
+ });
138
+
90
139
  test('PUT for an unregistered bucket fails loud (500), no silent write', async () => {
91
140
  // Mint a structurally valid token for a fullId that has no FileBucket
92
141
  // instance registered. The server must refuse rather than fall back to
@@ -141,7 +141,22 @@ export function attach(httpServer: Server) {
141
141
  }
142
142
 
143
143
  const body = readFileSync(readPath);
144
- res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': body.length.toString() });
144
+ // The stored object body and its Content-Type are attacker-controlled
145
+ // (any client with a presigned PUT can upload arbitrary bytes under an
146
+ // arbitrary content type). Serving that back inline turns the dev file
147
+ // server into a stored-XSS vector: an uploaded `text/html` (or sniffed
148
+ // HTML/SVG) payload would execute in the origin of the local app.
149
+ // `nosniff` stops the browser from MIME-sniffing octet-streams into
150
+ // HTML, and `Content-Disposition: attachment` forces a download rather
151
+ // than inline rendering — so an uploaded document can never run as a
152
+ // page. Real S3 objects served through CloudFront are hardened the same
153
+ // way; this keeps local dev from being weaker than production.
154
+ res.writeHead(200, {
155
+ 'Content-Type': contentType,
156
+ 'Content-Length': body.length.toString(),
157
+ 'X-Content-Type-Options': 'nosniff',
158
+ 'Content-Disposition': 'attachment',
159
+ });
145
160
  res.end(body);
146
161
  } else if (req.method === 'PUT') {
147
162
  const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'PUT');
package/src/tokens.ts CHANGED
@@ -1,12 +1,28 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { createHmac } from 'node:crypto';
4
+ import { createHmac, randomBytes } from 'node:crypto';
5
5
  import { constantTimeEquals } from '@aws-blocks/core/bb-utils';
6
6
 
7
7
  // ── Token helpers ───────────────────────────────────────────────────────────
8
8
 
9
- export const LOCAL_FILE_SECRET = '__blocks_file_bucket_dev_secret__';
9
+ /**
10
+ * Per-process HMAC secret for signing local presigned-URL tokens.
11
+ *
12
+ * The mock bucket (which mints tokens) and the dev file server (which validates
13
+ * them) both import this module and run in the *same* dev-server process, so a
14
+ * value generated once at module load is shared between them via the ESM module
15
+ * cache — no configuration needed.
16
+ *
17
+ * This deliberately replaces a previously hardcoded literal. A fixed, source-
18
+ * visible secret let anyone forge a valid token for any `fullId`/path/method and
19
+ * hit the dev file server without ever calling `getUrl()`/`putUrl()`, defeating
20
+ * the point of signing. A random per-process secret makes tokens unforgeable
21
+ * while keeping the local round-trip working, since both ends share this value.
22
+ * Tokens do not need to survive a dev-server restart (presigned URLs are short-
23
+ * lived and re-minted on demand), so per-process randomness is sufficient.
24
+ */
25
+ export const LOCAL_FILE_SECRET = randomBytes(32).toString('base64url');
10
26
 
11
27
  interface FileTokenPayload {
12
28
  fullId: string;
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'FileBucket';
3
- export const BB_VERSION = '0.1.2';
3
+ export const BB_VERSION = '0.1.4';