s3-test-server 1.0.0 → 2.0.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/README.md CHANGED
@@ -82,7 +82,7 @@ map to 127.0.0.1):
82
82
  ```js
83
83
  const server = new S3TestServer({
84
84
  address: 'aws-s3.local',
85
- configureBuckets: [{ name: 'reports' }],
85
+ configureBuckets: [{ name: 'reports', acl: 'public-read' }],
86
86
  });
87
87
 
88
88
  beforeAll(() => server.run()); // http://aws-s3.local:4568
@@ -108,72 +108,197 @@ multipart thresholds, this test fails, exactly as it would against AWS.
108
108
  All options are optional. `new S3TestServer()` starts an HTTP server on
109
109
  `localhost:4568` backed by a temporary directory.
110
110
 
111
- ### Server
111
+ ```js
112
+ // bucket configuration documents used below
113
+ const corsXml = `<CORSConfiguration>
114
+ <CORSRule>
115
+ <AllowedOrigin>https://app.example.com</AllowedOrigin>
116
+ <AllowedMethod>GET</AllowedMethod>
117
+ <AllowedMethod>PUT</AllowedMethod>
118
+ <AllowedHeader>*</AllowedHeader>
119
+ </CORSRule>
120
+ </CORSConfiguration>`;
121
+
122
+ const websiteXml = `<WebsiteConfiguration>
123
+ <IndexDocument><Suffix>index.html</Suffix></IndexDocument>
124
+ <ErrorDocument><Key>error.html</Key></ErrorDocument>
125
+ </WebsiteConfiguration>`;
126
+
127
+ const policyJson = JSON.stringify({
128
+ Version: '2012-10-17',
129
+ Statement: [
130
+ {
131
+ Effect: 'Allow',
132
+ Principal: '*',
133
+ Action: 's3:GetObject',
134
+ Resource: 'arn:aws:s3:::site/public/*', // anonymous reads under public/ only
135
+ },
136
+ ],
137
+ });
138
+
139
+ const server = new S3TestServer({
140
+ // server
141
+ address: 'localhost', // hostname or IP the HTTP server binds to (default)
142
+ port: 4568, // listening port (default); 0 picks a free ephemeral port
143
+ key: fs.readFileSync('./key.pem'), // TLS private key; give key AND cert to
144
+ cert: fs.readFileSync('./cert.pem'), // serve HTTPS; omit both for HTTP (default)
145
+ silent: false, // default; true suppresses request and startup logging
146
+
147
+ // storage
148
+ directory: './s3-data', // where buckets and objects are stored, persisted
149
+ // across restarts; default: a fresh temporary directory per server
150
+ resetOnStart: false, // default; true empties the directory on every run()
151
+ resetOnClose: false, // default; true deletes all bucket data on close()
152
+ configureBuckets: [
153
+ // buckets created before the server starts; default: none
154
+ { name: 'assets', acl: 'public-read' }, // canned ACL (default: defaultBucketAcl)
155
+ { name: 'site', configs: [corsXml, websiteXml, policyJson] }, // raw CORS or
156
+ // website XML documents and bucket-policy JSON, applied at startup
157
+ ],
158
+
159
+ // credentials and addressing
160
+ accessKeyId: 'S3TEST', // access key id clients must sign with (default)
161
+ secretAccessKey: 'S3TEST', // matching SigV2/SigV4 secret key (default)
162
+ region: 'eu-west-1', // pinning a region rejects requests signed for others;
163
+ // omit to accept any region (reported as us-east-1)
164
+ defaultBucketAcl: 'private', // default, like real S3; ACL for buckets without
165
+ // one: 'private', 'public-read' (anonymous GETs) or 'public-read-write'
166
+ vhostBuckets: true, // default; resolve bucket.localhost and
167
+ // bucket.s3.<region>.amazonaws.com style Host headers to the bucket
168
+ serviceEndpoint: 'amazonaws.com', // default; root domain recognized for
169
+ // virtual-host-style addressing
170
+ allowMismatchedSignatures: false, // default; true accepts well-formed but
171
+ // incorrect signatures (useful with exotic clients)
172
+ });
173
+ ```
174
+
175
+ ## Public and private buckets
112
176
 
113
- | Option | Type | Default | Description |
114
- | -------------- | ------------------ | ------------- | ------------------------------------------------- |
115
- | `address` | `string` | `'localhost'` | Hostname or IP to bind to. |
116
- | `port` | `number` | `4568` | Port to listen on. Use `0` for an ephemeral port. |
117
- | `key` / `cert` | `string \| Buffer` | none | TLS key and certificate. Provide both for HTTPS. |
118
- | `silent` | `boolean` | `false` | Suppress log output. |
177
+ Like real S3, buckets are private by default. Signed requests are verified
178
+ against `accessKeyId`/`secretAccessKey` and have full access. Anonymous
179
+ (unsigned) requests are checked against the bucket's canned ACL:
119
180
 
120
- ### Storage
181
+ - `private` or `authenticated-read` (the default): all anonymous requests
182
+ get `403 AccessDenied`.
183
+ - `public-read`: anonymous GET/HEAD only; anonymous writes get
184
+ `403 AccessDenied`.
185
+ - `public-read-write`: anonymous reads and writes, like a fully public
186
+ bucket. Set `defaultBucketAcl: 'public-read-write'` to restore the 1.x
187
+ behavior where unsigned `fetch`/`curl` works everywhere.
121
188
 
122
- | Option | Type | Default | Description |
123
- | ------------------ | ----------------------- | -------- | ------------------------------------------------------------------------------------------ |
124
- | `directory` | `string` | temp dir | Where buckets and objects are stored. Data persists across restarts. |
125
- | `resetOnStart` | `boolean` | `false` | Clear the data directory on every start, so a persistent `directory` begins empty. |
126
- | `resetOnClose` | `boolean` | `false` | Delete all bucket data when `close()` is called. |
127
- | `configureBuckets` | `BucketConfiguration[]` | `[]` | Buckets to create at startup, each with optional raw XML configs (CORS, website, tagging). |
189
+ Set a bucket's ACL any of the usual ways:
128
190
 
129
- ### Credentials and addressing
191
+ ```js
192
+ // at startup
193
+ new S3TestServer({
194
+ configureBuckets: [
195
+ { name: 'assets', acl: 'public-read' },
196
+ { name: 'internal' }, // private, the default
197
+ ],
198
+ });
199
+ ```
130
200
 
131
- | Option | Type | Default | Description |
132
- | --------------------------- | --------- | ----------------- | --------------------------------------------------------------------------------------- |
133
- | `accessKeyId` | `string` | `'S3TEST'` | Access key clients must sign with. |
134
- | `secretAccessKey` | `string` | `'S3TEST'` | Matching secret key. |
135
- | `region` | `string` | `'us-east-1'` | Region reported in responses and event records. |
136
- | `vhostBuckets` | `boolean` | `true` | Virtual-host-style addressing (`bucket.localhost`, `bucket.s3.<region>.amazonaws.com`). |
137
- | `serviceEndpoint` | `string` | `'amazonaws.com'` | Root domain recognized for virtual-host-style access. |
138
- | `allowMismatchedSignatures` | `boolean` | `false` | Accept well-formed but incorrect signatures. |
201
+ ```js
202
+ // through the API
203
+ await client.send(
204
+ new CreateBucketCommand({ Bucket: 'assets', ACL: 'public-read' })
205
+ );
206
+ await client.send(
207
+ new PutBucketAclCommand({ Bucket: 'assets', ACL: 'private' })
208
+ );
209
+ ```
210
+
211
+ `GetBucketAcl` reflects the canned ACL (an `AllUsers` grant appears for the
212
+ public ones). Object-level ACLs are accepted but not enforced.
213
+
214
+ ### Per-prefix access with bucket policies
215
+
216
+ For finer control than canned ACLs, the server evaluates a subset of bucket
217
+ policies against anonymous requests: `Effect` Allow/Deny, `Principal` `"*"`,
218
+ `Action` wildcards (`s3:GetObject`, `s3:ListBucket`, `s3:PutObject`,
219
+ `s3:DeleteObject`, `s3:*`) and `Resource` ARNs with wildcards. Signed
220
+ requests always have full access; an explicit `Deny` only narrows anonymous
221
+ access. Set policies with `PutBucketPolicy`, or as a JSON document in
222
+ `configureBuckets` (detected by its leading `{`):
223
+
224
+ ```js
225
+ // only keys under public/ are readable without signing
226
+ const policy = JSON.stringify({
227
+ Version: '2012-10-17',
228
+ Statement: [
229
+ {
230
+ Effect: 'Allow',
231
+ Principal: '*',
232
+ Action: 's3:GetObject',
233
+ Resource: 'arn:aws:s3:::site/public/*',
234
+ },
235
+ ],
236
+ });
237
+ new S3TestServer({ configureBuckets: [{ name: 'site', configs: [policy] }] });
238
+ ```
139
239
 
140
- Anonymous (unsigned) requests are accepted, as on a public bucket, which is
141
- handy for plain `fetch`/`curl` in tests. Signed requests are verified against
142
- `accessKeyId`/`secretAccessKey`.
240
+ Anonymous `GET /site/public/logo.png` then succeeds while
241
+ `GET /site/internal/report.csv` and all anonymous writes return
242
+ `403 AccessDenied`. `GetBucketPolicy` returns the stored document and
243
+ `DeleteBucketPolicy` removes it. `NotAction`, `NotResource`, `NotPrincipal`
244
+ and `Condition` are rejected as `NotImplemented` rather than silently
245
+ ignored.
143
246
 
144
247
  ## Supported operations
145
248
 
146
- **Service / buckets**: ListBuckets, Create/Delete/HeadBucket,
147
- GetBucketLocation, ListObjects (v1), ListObjectsV2, ListObjectVersions
148
- (unversioned mapping), Get/Put/DeleteBucketCors, Get/Put/DeleteBucketWebsite,
149
- Get/Put/DeleteBucketTagging, GetBucketVersioning, GetBucketEncryption,
150
- GetBucketNotification/RequestPayment/Logging/Accelerate, ACLs (dummy),
151
- ListMultipartUploads.
152
-
153
- **Objects**: Put/Get/Head/DeleteObject, DeleteObjects, CopyObject
154
- (COPY/REPLACE directives, copy conditions), POST form uploads
155
- (`multipart/form-data`, `${filename}` substitution, `success_action_*`),
156
- Get/Put/DeleteObjectTagging, GetObjectAttributes, object ACLs (dummy),
157
- Range requests, conditional reads (`If-Match`, `If-None-Match`,
158
- `If-(Un)Modified-Since`) and **conditional writes** (`If-None-Match: *`,
159
- `If-Match`), `response-*` header overrides, `Content-MD5` and
160
- `x-amz-checksum-*` validation (headers or `aws-chunked` trailers).
161
-
162
- **Multipart**: Create/Abort/CompleteMultipartUpload, UploadPart,
163
- UploadPartCopy (with `x-amz-copy-source-range`), ListParts, correct
164
- multipart ETags (`md5-of-md5s-N`), composite and full-object checksums.
165
-
166
- **Static website hosting**: index/error documents, folder redirects,
167
- `x-amz-website-redirect-location`, `RedirectAllRequestsTo`; served for
168
- anonymous GET/HEAD requests on buckets with a website configuration.
169
-
170
- **CORS**: preflight (`OPTIONS`) evaluation and response headers, driven by
171
- the bucket's CORS configuration.
172
-
173
- Not implemented: bucket versioning (`PutBucketVersioning` with `Enabled`
174
- returns `NotImplemented`), object locking, lifecycle execution, bucket
175
- policies/IAM. Per-chunk signatures inside `aws-chunked` bodies are not
176
- re-verified (the seed signature is).
249
+ ### Service and buckets
250
+
251
+ - ListBuckets, CreateBucket, DeleteBucket, HeadBucket, GetBucketLocation
252
+ - ListObjects (v1), ListObjectsV2, ListObjectVersions (unversioned mapping)
253
+ - ListMultipartUploads
254
+ - Get/Put/DeleteBucketCors, Get/Put/DeleteBucketWebsite,
255
+ Get/Put/DeleteBucketTagging
256
+ - GetBucketVersioning, GetBucketEncryption, GetBucketNotification,
257
+ GetBucketRequestPayment, GetBucketLogging, GetBucketAccelerate
258
+ - Bucket ACLs (canned, enforced for anonymous requests)
259
+ - Bucket policies (anonymous-access subset): Get/Put/DeleteBucketPolicy
260
+
261
+ ### Objects
262
+
263
+ - PutObject, GetObject, HeadObject, DeleteObject, DeleteObjects
264
+ - CopyObject with COPY/REPLACE directives and copy conditions
265
+ - POST form uploads: `multipart/form-data`, `${filename}` substitution,
266
+ `success_action_*`
267
+ - Get/Put/DeleteObjectTagging, GetObjectAttributes, object ACLs (dummy)
268
+ - Range requests (`206 Partial Content`)
269
+ - Conditional reads: `If-Match`, `If-None-Match`, `If-(Un)Modified-Since`
270
+ - Conditional writes: `If-None-Match: *`, `If-Match`
271
+ - `response-*` header overrides on GET
272
+ - Integrity validation: `Content-MD5` and `x-amz-checksum-*`
273
+ (CRC32, CRC32C, CRC64NVME, SHA1, SHA256), from headers or `aws-chunked`
274
+ trailers
275
+
276
+ ### Multipart uploads
277
+
278
+ - CreateMultipartUpload, UploadPart, UploadPartCopy (with
279
+ `x-amz-copy-source-range`), CompleteMultipartUpload, AbortMultipartUpload,
280
+ ListParts
281
+ - Correct multipart ETags (`md5-of-md5s-N`)
282
+ - Composite and full-object checksums
283
+
284
+ ### Static website hosting
285
+
286
+ - Index and error documents, folder redirects
287
+ - `x-amz-website-redirect-location` and `RedirectAllRequestsTo`
288
+ - Served for anonymous GET/HEAD requests on buckets with a website
289
+ configuration; make the bucket `public-read`, as on real S3
290
+
291
+ ### CORS
292
+
293
+ - Preflight (`OPTIONS`) evaluation and response headers, driven by the
294
+ bucket's CORS configuration
295
+
296
+ ### Not implemented
297
+
298
+ - Bucket versioning (`PutBucketVersioning` with `Enabled` returns
299
+ `NotImplemented`), object locking, lifecycle execution, bucket policies/IAM
300
+ - Per-chunk signatures inside `aws-chunked` bodies are not re-verified (the
301
+ seed signature is)
177
302
 
178
303
  ## Events
179
304
 
package/lib/cli.js CHANGED
@@ -14,7 +14,10 @@ Options:
14
14
  -d, --directory <path> Data directory (default: a temporary directory)
15
15
  --access-key-id <id> Access key clients must sign with (default: S3TEST)
16
16
  --secret-access-key <key> Matching secret access key (default: S3TEST)
17
- --region <region> Region reported by the server (default: us-east-1)
17
+ --region <region> Pin the server's region; requests signed for other
18
+ regions are rejected (default: accept any, report us-east-1)
19
+ --default-bucket-acl <acl> ACL for buckets without one: private (default),
20
+ public-read, public-read-write, authenticated-read
18
21
  --key <path> Path to a private key file for HTTPS
19
22
  --cert <path> Path to a certificate file for HTTPS
20
23
  --service-endpoint <host> Root domain for virtual-host-style access
@@ -69,6 +72,9 @@ function parseArgs(argv) {
69
72
  case '--region':
70
73
  options.region = next();
71
74
  break;
75
+ case '--default-bucket-acl':
76
+ options.defaultBucketAcl = next();
77
+ break;
72
78
  case '--key':
73
79
  options.key = fs.readFileSync(next());
74
80
  break;
@@ -18,15 +18,58 @@ const {
18
18
  DUMMY_OWNER,
19
19
  } = require('../utils');
20
20
 
21
- const dummyAclXml = () =>
22
- '<?xml version="1.0" encoding="UTF-8"?>\n' +
23
- `<AccessControlPolicy xmlns="${S3_XMLNS}">` +
24
- `<Owner><ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Owner>` +
25
- '<AccessControlList><Grant>' +
26
- '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="CanonicalUser">' +
27
- `<ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Grantee>` +
28
- '<Permission>FULL_CONTROL</Permission>' +
29
- '</Grant></AccessControlList></AccessControlPolicy>';
21
+ const CANNED_ACLS = new Set([
22
+ 'private',
23
+ 'public-read',
24
+ 'public-read-write',
25
+ 'authenticated-read',
26
+ ]);
27
+
28
+ const ALL_USERS_URI = 'http://acs.amazonaws.com/groups/global/AllUsers';
29
+
30
+ function groupGrant(permission) {
31
+ return (
32
+ '<Grant>' +
33
+ '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="Group">' +
34
+ `<URI>${ALL_USERS_URI}</URI></Grantee>` +
35
+ `<Permission>${permission}</Permission>` +
36
+ '</Grant>'
37
+ );
38
+ }
39
+
40
+ /** Renders an AccessControlPolicy document reflecting a canned ACL. */
41
+ function aclXml(acl = 'private') {
42
+ let grants =
43
+ '<Grant>' +
44
+ '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="CanonicalUser">' +
45
+ `<ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Grantee>` +
46
+ '<Permission>FULL_CONTROL</Permission>' +
47
+ '</Grant>';
48
+ if (acl === 'public-read' || acl === 'public-read-write') {
49
+ grants += groupGrant('READ');
50
+ }
51
+ if (acl === 'public-read-write') {
52
+ grants += groupGrant('WRITE');
53
+ }
54
+ return (
55
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
56
+ `<AccessControlPolicy xmlns="${S3_XMLNS}">` +
57
+ `<Owner><ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Owner>` +
58
+ `<AccessControlList>${grants}</AccessControlList></AccessControlPolicy>`
59
+ );
60
+ }
61
+
62
+ const dummyAclXml = () => aclXml('private');
63
+
64
+ function validateCannedAcl(acl) {
65
+ if (!CANNED_ACLS.has(acl)) {
66
+ throw new S3Error('InvalidArgument', 'The ACL you provided is not valid', {
67
+ ArgumentName: 'x-amz-acl',
68
+ ArgumentValue: acl,
69
+ });
70
+ }
71
+ return acl;
72
+ }
30
73
 
31
74
  async function requireBucket(ctx) {
32
75
  const meta = await ctx.store.getBucket(ctx.bucket);
@@ -56,8 +99,8 @@ async function getBucket(ctx) {
56
99
  return;
57
100
  }
58
101
  if (query.has('acl')) {
59
- await requireBucket(ctx);
60
- sendXml(res, 200, dummyAclXml());
102
+ const meta = await requireBucket(ctx);
103
+ sendXml(res, 200, aclXml(meta.configs.acl || ctx.options.defaultBucketAcl));
61
104
  return;
62
105
  }
63
106
  if (query.has('cors')) {
@@ -110,14 +153,23 @@ async function getBucket(ctx) {
110
153
  return;
111
154
  }
112
155
  if (query.has('policy')) {
113
- await requireBucket(ctx);
114
- throw new S3Error(
115
- 'NoSuchBucketPolicy',
116
- 'The bucket policy does not exist',
117
- {
118
- BucketName: ctx.bucket,
119
- }
156
+ const meta = await requireBucket(ctx);
157
+ if (!meta.configs.policy) {
158
+ throw new S3Error(
159
+ 'NoSuchBucketPolicy',
160
+ 'The bucket policy does not exist',
161
+ {
162
+ BucketName: ctx.bucket,
163
+ }
164
+ );
165
+ }
166
+ send(
167
+ res,
168
+ 200,
169
+ { 'Content-Type': 'application/json' },
170
+ meta.configs.policy.json
120
171
  );
172
+ return;
121
173
  }
122
174
  if (query.has('lifecycle')) {
123
175
  await requireBucket(ctx);
@@ -486,9 +538,26 @@ async function putBucket(ctx) {
486
538
  if (query.has('acl')) {
487
539
  await requireBucket(ctx);
488
540
  await readBody(ctx.req);
541
+ const acl = ctx.req.headers['x-amz-acl'];
542
+ if (acl) {
543
+ await ctx.store.putBucketConfig(
544
+ ctx.bucket,
545
+ 'acl',
546
+ validateCannedAcl(String(acl))
547
+ );
548
+ }
489
549
  send(res, 200, {});
490
550
  return;
491
551
  }
552
+ if (query.has('policy')) {
553
+ await requireBucket(ctx);
554
+ const body = await readBody(ctx.req);
555
+ const { parseBucketPolicy } = require('../models/policy');
556
+ const policy = parseBucketPolicy(body.toString('utf8'));
557
+ await ctx.store.putBucketConfig(ctx.bucket, 'policy', policy);
558
+ send(res, 204, {});
559
+ return;
560
+ }
492
561
  if (query.has('versioning')) {
493
562
  await requireBucket(ctx);
494
563
  const body = await readBody(ctx.req);
@@ -512,7 +581,6 @@ async function putBucket(ctx) {
512
581
  }
513
582
  const knownUnsupported = [
514
583
  'lifecycle',
515
- 'policy',
516
584
  'encryption',
517
585
  'object-lock',
518
586
  'publicAccessBlock',
@@ -553,10 +621,26 @@ async function putBucket(ctx) {
553
621
  );
554
622
  }
555
623
  }
624
+ if (
625
+ ctx.options.enforceRegion &&
626
+ location &&
627
+ location !== ctx.options.region
628
+ ) {
629
+ throw new S3Error(
630
+ 'IllegalLocationConstraintException',
631
+ `The ${location} location constraint is incompatible for the region specific endpoint this request was sent to.`,
632
+ { statusCode: 400 }
633
+ );
634
+ }
635
+ const acl = ctx.req.headers['x-amz-acl'];
636
+ if (acl) validateCannedAcl(String(acl));
556
637
  const { created } = await ctx.store.createBucket(ctx.bucket);
557
638
  if (created && location) {
558
639
  await ctx.store.putBucketConfig(ctx.bucket, 'location', location);
559
640
  }
641
+ if (created && acl) {
642
+ await ctx.store.putBucketConfig(ctx.bucket, 'acl', String(acl));
643
+ }
560
644
  // Matching us-east-1 behavior: re-creating a bucket you own succeeds.
561
645
  send(ctx.res, 200, { Location: `/${ctx.bucket}` });
562
646
  }
@@ -570,7 +654,7 @@ async function deleteBucket(ctx) {
570
654
  website: 'website',
571
655
  tagging: 'tagging',
572
656
  lifecycle: null,
573
- policy: null,
657
+ policy: 'policy',
574
658
  encryption: null,
575
659
  publicAccessBlock: null,
576
660
  replication: null,
@@ -618,6 +702,7 @@ module.exports = {
618
702
  deleteBucket,
619
703
  headBucket,
620
704
  dummyAclXml,
705
+ validateCannedAcl,
621
706
  parseCorsConfig,
622
707
  parseWebsiteConfig,
623
708
  parseTaggingConfig,
package/lib/index.d.ts CHANGED
@@ -48,14 +48,21 @@ declare namespace S3TestServer {
48
48
  interface BucketConfiguration {
49
49
  /** Bucket name (must be a valid S3 bucket name). */
50
50
  name: string;
51
+ /** Canned ACL applied to the bucket. Default: the server's `defaultBucketAcl`. */
52
+ acl?: CannedBucketAcl;
51
53
  /**
52
- * Raw XML configuration documents applied to the bucket at startup.
53
- * CORS (`<CORSConfiguration>`), website (`<WebsiteConfiguration>`) and
54
- * tagging (`<Tagging>`) documents are recognized by their root element.
54
+ * Raw configuration documents applied to the bucket at startup.
55
+ * XML documents are recognized by their root element: CORS
56
+ * (`<CORSConfiguration>`), website (`<WebsiteConfiguration>`) and tagging
57
+ * (`<Tagging>`). A JSON document (leading `{`) is stored as the bucket
58
+ * policy and evaluated for anonymous requests.
55
59
  */
56
60
  configs?: Array<string | Buffer>;
57
61
  }
58
62
 
63
+ type CannedBucketAcl =
64
+ 'private' | 'public-read' | 'public-read-write' | 'authenticated-read';
65
+
59
66
  interface S3TestServerOptions {
60
67
  /** Hostname or IP to bind to. Default: `"localhost"`. */
61
68
  address?: string;
@@ -95,8 +102,20 @@ declare namespace S3TestServer {
95
102
  accessKeyId?: string;
96
103
  /** Secret access key clients must sign requests with. Default: `"S3TEST"`. */
97
104
  secretAccessKey?: string;
98
- /** Region reported in responses and event records. Default: `"us-east-1"`. */
105
+ /**
106
+ * Region reported in responses and event records. Default: `"us-east-1"`.
107
+ * When set explicitly, the server acts like a regional endpoint and
108
+ * rejects SigV4 requests signed for other regions; when omitted, any
109
+ * region is accepted.
110
+ */
99
111
  region?: string;
112
+ /**
113
+ * ACL for buckets without an explicit one. `'private'` (the default, like
114
+ * real S3) rejects anonymous requests; `'public-read'` allows anonymous
115
+ * GET/HEAD only; `'public-read-write'` restores the fully public 1.x
116
+ * behavior.
117
+ */
118
+ defaultBucketAcl?: CannedBucketAcl;
100
119
  /** Buckets to create (and configure) before the server starts. */
101
120
  configureBuckets?: BucketConfiguration[];
102
121
  }
@@ -116,6 +135,7 @@ declare namespace S3TestServer {
116
135
  | 'accessKeyId'
117
136
  | 'secretAccessKey'
118
137
  | 'region'
138
+ | 'defaultBucketAcl'
119
139
  >
120
140
  >;
121
141
 
@@ -178,6 +198,7 @@ declare namespace S3TestServer {
178
198
 
179
199
  export {
180
200
  S3TestServer,
201
+ CannedBucketAcl,
181
202
  BucketConfiguration,
182
203
  S3TestServerOptions,
183
204
  ResolvedOptions,
package/lib/index.js CHANGED
@@ -26,6 +26,9 @@ const DEFAULT_OPTIONS = {
26
26
  accessKeyId: 'S3TEST',
27
27
  secretAccessKey: 'S3TEST',
28
28
  region: 'us-east-1',
29
+ // Like real S3, buckets are private by default: anonymous requests get
30
+ // 403 AccessDenied unless the bucket's ACL makes it public.
31
+ defaultBucketAcl: 'private',
29
32
  configureBuckets: [],
30
33
  };
31
34
 
@@ -33,6 +36,12 @@ class S3TestServer extends EventEmitter {
33
36
  constructor(options = {}) {
34
37
  super();
35
38
  this.options = { ...DEFAULT_OPTIONS, ...options };
39
+ // When the user pins a region, behave like a regional endpoint and
40
+ // reject requests signed for other regions. Otherwise accept any.
41
+ this.options.enforceRegion = Object.prototype.hasOwnProperty.call(
42
+ options,
43
+ 'region'
44
+ );
36
45
  this.logger = {
37
46
  info: (...args) => {
38
47
  if (!this.options.silent) console.log(...args);
@@ -157,16 +166,28 @@ class S3TestServer extends EventEmitter {
157
166
  parseCorsConfig,
158
167
  parseWebsiteConfig,
159
168
  parseTaggingConfig,
169
+ validateCannedAcl,
160
170
  } = require('./controllers/bucket');
161
171
  const { validateBucketName } = require('./utils');
162
- for (const { name, configs = [] } of this.options.configureBuckets || []) {
172
+ for (const { name, configs = [], acl } of this.options.configureBuckets ||
173
+ []) {
163
174
  validateBucketName(name);
164
175
  await this.store.createBucket(name);
176
+ if (acl) {
177
+ await this.store.putBucketConfig(name, 'acl', validateCannedAcl(acl));
178
+ }
165
179
  for (const rawConfig of configs) {
166
180
  const xml = Buffer.isBuffer(rawConfig)
167
181
  ? rawConfig.toString('utf8')
168
182
  : String(rawConfig);
169
- if (/<\s*(\w+:)?CORSConfiguration[\s>]/.test(xml)) {
183
+ if (/^\s*\{/.test(xml)) {
184
+ const { parseBucketPolicy } = require('./models/policy');
185
+ await this.store.putBucketConfig(
186
+ name,
187
+ 'policy',
188
+ parseBucketPolicy(xml)
189
+ );
190
+ } else if (/<\s*(\w+:)?CORSConfiguration[\s>]/.test(xml)) {
170
191
  await this.store.putBucketConfig(name, 'cors', parseCorsConfig(xml));
171
192
  } else if (/<\s*(\w+:)?WebsiteConfiguration[\s>]/.test(xml)) {
172
193
  await this.store.putBucketConfig(
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const { S3Error } = require('./error');
4
+
5
+ /**
6
+ * A small subset of S3 bucket policies, used to grant anonymous access per
7
+ * key prefix (e.g. make only `public/*` readable without signing).
8
+ *
9
+ * Supported: Effect Allow/Deny, Principal "*" (or {"AWS": "*"}), Action
10
+ * strings with wildcards (s3:GetObject, s3:ListBucket, s3:PutObject,
11
+ * s3:DeleteObject, s3:*), Resource ARNs with * and ? wildcards.
12
+ *
13
+ * Statements with other principals are stored but never match anonymous
14
+ * requests. Signed requests always have full access, so policies only widen
15
+ * or narrow what anonymous callers may do.
16
+ */
17
+
18
+ function asList(value) {
19
+ if (value === undefined || value === null) return [];
20
+ return Array.isArray(value) ? value : [value];
21
+ }
22
+
23
+ function malformed(message) {
24
+ return new S3Error('MalformedPolicy', message, { statusCode: 400 });
25
+ }
26
+
27
+ const UNSUPPORTED_FIELDS = [
28
+ 'NotAction',
29
+ 'NotResource',
30
+ 'NotPrincipal',
31
+ 'Condition',
32
+ ];
33
+
34
+ function parseBucketPolicy(text) {
35
+ let doc;
36
+ try {
37
+ doc = JSON.parse(text);
38
+ } catch {
39
+ throw malformed('Policies must be valid JSON and the first byte must be {');
40
+ }
41
+ const rawStatements = asList(doc.Statement);
42
+ if (rawStatements.length === 0) {
43
+ throw malformed('Missing required field Statement');
44
+ }
45
+ const statements = rawStatements.map((statement) => {
46
+ if (typeof statement !== 'object' || statement === null) {
47
+ throw malformed('Statement must be an object or an array of objects');
48
+ }
49
+ for (const field of UNSUPPORTED_FIELDS) {
50
+ if (statement[field] !== undefined) {
51
+ throw new S3Error(
52
+ 'NotImplemented',
53
+ `Bucket policy element ${field} is not supported by this server`
54
+ );
55
+ }
56
+ }
57
+ const effect = statement.Effect;
58
+ if (effect !== 'Allow' && effect !== 'Deny') {
59
+ throw malformed('Statement Effect must be Allow or Deny');
60
+ }
61
+ let principals;
62
+ if (statement.Principal === '*') {
63
+ principals = ['*'];
64
+ } else if (statement.Principal && typeof statement.Principal === 'object') {
65
+ principals = asList(statement.Principal.AWS).map(String);
66
+ } else if (statement.Principal === undefined) {
67
+ throw malformed('Missing required field Principal');
68
+ } else {
69
+ principals = [String(statement.Principal)];
70
+ }
71
+ const actions = asList(statement.Action).map((action) =>
72
+ String(action).toLowerCase()
73
+ );
74
+ if (actions.length === 0) {
75
+ throw malformed('Missing required field Action');
76
+ }
77
+ const resources = asList(statement.Resource).map(String);
78
+ if (resources.length === 0) {
79
+ throw malformed('Missing required field Resource');
80
+ }
81
+ return { effect, principals, actions, resources };
82
+ });
83
+ return {
84
+ json: typeof text === 'string' ? text : JSON.stringify(doc),
85
+ statements,
86
+ };
87
+ }
88
+
89
+ function wildcardMatch(pattern, value) {
90
+ const regex = new RegExp(
91
+ '^' +
92
+ String(pattern)
93
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
94
+ .replace(/\*/g, '.*')
95
+ .replace(/\?/g, '.') +
96
+ '$'
97
+ );
98
+ return regex.test(value);
99
+ }
100
+
101
+ /**
102
+ * Evaluates a parsed policy for an anonymous request.
103
+ * Returns 'deny', 'allow' or 'none' (no matching statement).
104
+ */
105
+ function evaluateBucketPolicy(policy, action, resource) {
106
+ const wantedAction = action.toLowerCase();
107
+ let allowed = false;
108
+ for (const statement of policy.statements) {
109
+ if (!statement.principals.includes('*')) continue;
110
+ const actionMatches = statement.actions.some((pattern) =>
111
+ wildcardMatch(pattern, wantedAction)
112
+ );
113
+ const resourceMatches = statement.resources.some((pattern) =>
114
+ wildcardMatch(pattern, resource)
115
+ );
116
+ if (actionMatches && resourceMatches) {
117
+ if (statement.effect === 'Deny') return 'deny';
118
+ allowed = true;
119
+ }
120
+ }
121
+ return allowed ? 'allow' : 'none';
122
+ }
123
+
124
+ module.exports = { parseBucketPolicy, evaluateBucketPolicy };
package/lib/routes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { S3Error } = require('./models/error');
4
+ const { evaluateBucketPolicy } = require('./models/policy');
4
5
  const { authenticate } = require('./signature');
5
6
  const { generateRequestId, generateId2 } = require('./utils');
6
7
  const { resolveVhostBucket } = require('./middleware/vhost');
@@ -82,9 +83,44 @@ async function handleRequest(server, req, res) {
82
83
  }
83
84
  applyCorsHeaders(ctx, corsConfig);
84
85
 
85
- // Authentication (anonymous requests are allowed, like public buckets)
86
+ // Authentication (anonymous requests are allowed on public buckets)
86
87
  ctx.auth = authenticate({ req, rawPath, query, vhostBucket, options });
87
88
 
89
+ // Bucket access control: anonymous requests are checked against the
90
+ // bucket's canned ACL, then against its bucket policy (which can widen
91
+ // access per key prefix, or deny it). Signed requests have full access.
92
+ if (bucket && bucketMeta && ctx.auth.mode === 'anonymous') {
93
+ const acl = bucketMeta.configs.acl || options.defaultBucketAcl;
94
+ const isRead = req.method === 'GET' || req.method === 'HEAD';
95
+ let allowed =
96
+ acl === 'public-read-write' || (acl === 'public-read' && isRead);
97
+ const policy = bucketMeta.configs.policy;
98
+ if (policy) {
99
+ let action = null;
100
+ let resource;
101
+ if (key) {
102
+ resource = `arn:aws:s3:::${bucket}/${key}`;
103
+ if (isRead) action = 's3:GetObject';
104
+ else if (req.method === 'DELETE') action = 's3:DeleteObject';
105
+ else action = 's3:PutObject';
106
+ } else if (isRead) {
107
+ resource = `arn:aws:s3:::${bucket}`;
108
+ action = 's3:ListBucket';
109
+ } else if (req.method === 'POST') {
110
+ resource = `arn:aws:s3:::${bucket}/*`;
111
+ action = query.has('delete') ? 's3:DeleteObject' : 's3:PutObject';
112
+ }
113
+ if (action) {
114
+ const verdict = evaluateBucketPolicy(policy, action, resource);
115
+ if (verdict === 'allow') allowed = true;
116
+ else if (verdict === 'deny') allowed = false;
117
+ }
118
+ }
119
+ if (!allowed) {
120
+ throw new S3Error('AccessDenied', 'Access Denied');
121
+ }
122
+ }
123
+
88
124
  // Static website hosting
89
125
  if (isWebsiteRequest(ctx, bucketMeta)) {
90
126
  await serveWebsite(ctx, bucketMeta.configs.website);
@@ -93,6 +93,13 @@ function verifyV4Header({ req, rawPath, query, authHeader, options }) {
93
93
  }
94
94
  const [accessKeyId, credDate, region] = credParts;
95
95
  checkAccessKey(accessKeyId, options);
96
+ if (options.enforceRegion && region !== options.region) {
97
+ throw new S3Error(
98
+ 'AuthorizationHeaderMalformed',
99
+ `The authorization header is malformed; the region '${region}' is wrong; expecting '${options.region}'`,
100
+ { Region: options.region }
101
+ );
102
+ }
96
103
 
97
104
  const amzDate = req.headers['x-amz-date'];
98
105
  const dateMs = amzDate
@@ -175,7 +182,20 @@ function verifyV4Query({ req, rawPath, query, options }) {
175
182
  );
176
183
  }
177
184
  const [accessKeyId, credDate, region] = credParts;
185
+ if (credParts[3] !== 's3' || credParts[4] !== 'aws4_request') {
186
+ throw new S3Error(
187
+ 'AuthorizationQueryParametersError',
188
+ 'Error parsing the X-Amz-Credential parameter; the credential scope is incorrect.'
189
+ );
190
+ }
178
191
  checkAccessKey(accessKeyId, options);
192
+ if (options.enforceRegion && region !== options.region) {
193
+ throw new S3Error(
194
+ 'AuthorizationQueryParametersError',
195
+ `Error parsing the X-Amz-Credential parameter; the region '${region}' is wrong; expecting '${options.region}'`,
196
+ { Region: options.region }
197
+ );
198
+ }
179
199
 
180
200
  const dateMs = parseAmzDate(amzDate);
181
201
  if (Number.isNaN(dateMs)) {
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "s3-test-server",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "A modern, lightweight Amazon S3 emulator for local development and testing. Compatible with AWS SDK v2 and v3, Signature V2/V4, path-style and virtual-host-style addressing, streaming (aws-chunked) uploads and flexible checksums.",
5
5
  "license": "MIT",
6
6
  "author": "Israel Alagbe <israelalagbe53@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/israelalagbe/s3-test-server.git"
10
+ },
11
+ "homepage": "https://github.com/israelalagbe/s3-test-server#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/israelalagbe/s3-test-server/issues"
14
+ },
7
15
  "main": "lib/index.js",
8
16
  "types": "lib/index.d.ts",
9
17
  "bin": {