s3-test-server 1.0.1 → 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 +139 -41
- package/lib/cli.js +7 -1
- package/lib/controllers/bucket.js +105 -20
- package/lib/index.d.ts +25 -4
- package/lib/index.js +23 -2
- package/lib/models/policy.js +124 -0
- package/lib/routes.js +37 -1
- package/lib/signature/v4.js +20 -0
- package/package.json +9 -1
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,44 +108,141 @@ 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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
176
|
+
|
|
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:
|
|
180
|
+
|
|
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.
|
|
188
|
+
|
|
189
|
+
Set a bucket's ACL any of the usual ways:
|
|
190
|
+
|
|
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
|
+
```
|
|
200
|
+
|
|
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
|
+
```
|
|
239
|
+
|
|
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.
|
|
149
246
|
|
|
150
247
|
## Supported operations
|
|
151
248
|
|
|
@@ -158,7 +255,8 @@ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
|
|
|
158
255
|
Get/Put/DeleteBucketTagging
|
|
159
256
|
- GetBucketVersioning, GetBucketEncryption, GetBucketNotification,
|
|
160
257
|
GetBucketRequestPayment, GetBucketLogging, GetBucketAccelerate
|
|
161
|
-
- Bucket ACLs (
|
|
258
|
+
- Bucket ACLs (canned, enforced for anonymous requests)
|
|
259
|
+
- Bucket policies (anonymous-access subset): Get/Put/DeleteBucketPolicy
|
|
162
260
|
|
|
163
261
|
### Objects
|
|
164
262
|
|
|
@@ -188,7 +286,7 @@ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
|
|
|
188
286
|
- Index and error documents, folder redirects
|
|
189
287
|
- `x-amz-website-redirect-location` and `RedirectAllRequestsTo`
|
|
190
288
|
- Served for anonymous GET/HEAD requests on buckets with a website
|
|
191
|
-
configuration
|
|
289
|
+
configuration; make the bucket `public-read`, as on real S3
|
|
192
290
|
|
|
193
291
|
### CORS
|
|
194
292
|
|
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>
|
|
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
|
|
22
|
-
'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
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);
|
package/lib/signature/v4.js
CHANGED
|
@@ -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": "
|
|
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": {
|