s3-test-server 1.0.1 → 2.1.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 +175 -53
- package/lib/cli.js +7 -1
- package/lib/controllers/bucket.js +105 -20
- package/lib/index.d.ts +83 -4
- package/lib/index.js +167 -6
- 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
|
@@ -2,18 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A lightweight Amazon S3 emulator for local development and testing.
|
|
4
4
|
|
|
5
|
-
Zero runtime dependencies
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- **AWS SDK for JavaScript v2**, SigV4 and legacy SigV2 (`signatureVersion: 's3'`)
|
|
12
|
-
- **Any other S3 client** that signs with Signature V2 or V4, header-based or
|
|
13
|
-
presigned, path-style or virtual-host-style
|
|
14
|
-
|
|
15
|
-
Data is stored on the filesystem (a temp directory by default), so it survives
|
|
16
|
-
restarts when you point it at a directory of your own.
|
|
5
|
+
Zero runtime dependencies and wire-level compatibility with old and new S3
|
|
6
|
+
clients: AWS SDK for JavaScript v3 (flexible checksums, `aws-chunked`
|
|
7
|
+
streaming, multipart), v2 (SigV4 and legacy SigV2), and any other client that
|
|
8
|
+
signs with Signature V2/V4, header-based or presigned, path-style or
|
|
9
|
+
virtual-host-style. Objects are stored on the filesystem and persist across
|
|
10
|
+
restarts when you point `directory` at a folder of your own.
|
|
17
11
|
|
|
18
12
|
## Quick start
|
|
19
13
|
|
|
@@ -82,7 +76,7 @@ map to 127.0.0.1):
|
|
|
82
76
|
```js
|
|
83
77
|
const server = new S3TestServer({
|
|
84
78
|
address: 'aws-s3.local',
|
|
85
|
-
configureBuckets: [{ name: 'reports' }],
|
|
79
|
+
configureBuckets: [{ name: 'reports', acl: 'public-read' }],
|
|
86
80
|
});
|
|
87
81
|
|
|
88
82
|
beforeAll(() => server.run()); // http://aws-s3.local:4568
|
|
@@ -108,44 +102,141 @@ multipart thresholds, this test fails, exactly as it would against AWS.
|
|
|
108
102
|
All options are optional. `new S3TestServer()` starts an HTTP server on
|
|
109
103
|
`localhost:4568` backed by a temporary directory.
|
|
110
104
|
|
|
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
|
-
|
|
105
|
+
```js
|
|
106
|
+
// bucket configuration documents used below
|
|
107
|
+
const corsXml = `<CORSConfiguration>
|
|
108
|
+
<CORSRule>
|
|
109
|
+
<AllowedOrigin>https://app.example.com</AllowedOrigin>
|
|
110
|
+
<AllowedMethod>GET</AllowedMethod>
|
|
111
|
+
<AllowedMethod>PUT</AllowedMethod>
|
|
112
|
+
<AllowedHeader>*</AllowedHeader>
|
|
113
|
+
</CORSRule>
|
|
114
|
+
</CORSConfiguration>`;
|
|
115
|
+
|
|
116
|
+
const websiteXml = `<WebsiteConfiguration>
|
|
117
|
+
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
|
118
|
+
<ErrorDocument><Key>error.html</Key></ErrorDocument>
|
|
119
|
+
</WebsiteConfiguration>`;
|
|
120
|
+
|
|
121
|
+
const policyJson = JSON.stringify({
|
|
122
|
+
Version: '2012-10-17',
|
|
123
|
+
Statement: [
|
|
124
|
+
{
|
|
125
|
+
Effect: 'Allow',
|
|
126
|
+
Principal: '*',
|
|
127
|
+
Action: 's3:GetObject',
|
|
128
|
+
Resource: 'arn:aws:s3:::site/public/*', // anonymous reads under public/ only
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const server = new S3TestServer({
|
|
134
|
+
// server
|
|
135
|
+
address: 'localhost', // hostname or IP the HTTP server binds to (default)
|
|
136
|
+
port: 4568, // listening port (default); 0 picks a free ephemeral port
|
|
137
|
+
key: fs.readFileSync('./key.pem'), // TLS private key; give key AND cert to
|
|
138
|
+
cert: fs.readFileSync('./cert.pem'), // serve HTTPS; omit both for HTTP (default)
|
|
139
|
+
silent: false, // default; true suppresses request and startup logging
|
|
140
|
+
|
|
141
|
+
// storage
|
|
142
|
+
directory: './s3-data', // where buckets and objects are stored, persisted
|
|
143
|
+
// across restarts; default: a fresh temporary directory per server
|
|
144
|
+
resetOnStart: false, // default; true empties the directory on every run()
|
|
145
|
+
resetOnClose: false, // default; true deletes all bucket data on close()
|
|
146
|
+
configureBuckets: [
|
|
147
|
+
// buckets created before the server starts; default: none
|
|
148
|
+
{ name: 'assets', acl: 'public-read' }, // canned ACL (default: defaultBucketAcl)
|
|
149
|
+
{ name: 'site', configs: [corsXml, websiteXml, policyJson] }, // raw CORS or
|
|
150
|
+
// website XML documents and bucket-policy JSON, applied at startup
|
|
151
|
+
],
|
|
152
|
+
|
|
153
|
+
// credentials and addressing
|
|
154
|
+
accessKeyId: 'S3TEST', // access key id clients must sign with (default)
|
|
155
|
+
secretAccessKey: 'S3TEST', // matching SigV2/SigV4 secret key (default)
|
|
156
|
+
region: 'eu-west-1', // pinning a region rejects requests signed for others;
|
|
157
|
+
// omit to accept any region (reported as us-east-1)
|
|
158
|
+
defaultBucketAcl: 'private', // default, like real S3; ACL for buckets without
|
|
159
|
+
// one: 'private', 'public-read' (anonymous GETs) or 'public-read-write'
|
|
160
|
+
vhostBuckets: true, // default; resolve bucket.localhost and
|
|
161
|
+
// bucket.s3.<region>.amazonaws.com style Host headers to the bucket
|
|
162
|
+
serviceEndpoint: 'amazonaws.com', // default; root domain recognized for
|
|
163
|
+
// virtual-host-style addressing
|
|
164
|
+
allowMismatchedSignatures: false, // default; true accepts well-formed but
|
|
165
|
+
// incorrect signatures (useful with exotic clients)
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Public and private buckets
|
|
170
|
+
|
|
171
|
+
Like real S3, buckets are private by default. Signed requests are verified
|
|
172
|
+
against `accessKeyId`/`secretAccessKey` and have full access. Anonymous
|
|
173
|
+
(unsigned) requests are checked against the bucket's canned ACL:
|
|
174
|
+
|
|
175
|
+
- `private` or `authenticated-read` (the default): all anonymous requests
|
|
176
|
+
get `403 AccessDenied`.
|
|
177
|
+
- `public-read`: anonymous GET/HEAD only; anonymous writes get
|
|
178
|
+
`403 AccessDenied`.
|
|
179
|
+
- `public-read-write`: anonymous reads and writes, like a fully public
|
|
180
|
+
bucket. Set `defaultBucketAcl: 'public-read-write'` to restore the 1.x
|
|
181
|
+
behavior where unsigned `fetch`/`curl` works everywhere.
|
|
182
|
+
|
|
183
|
+
Set a bucket's ACL any of the usual ways:
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
// at startup
|
|
187
|
+
new S3TestServer({
|
|
188
|
+
configureBuckets: [
|
|
189
|
+
{ name: 'assets', acl: 'public-read' },
|
|
190
|
+
{ name: 'internal' }, // private, the default
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// through the API
|
|
197
|
+
await client.send(
|
|
198
|
+
new CreateBucketCommand({ Bucket: 'assets', ACL: 'public-read' })
|
|
199
|
+
);
|
|
200
|
+
await client.send(
|
|
201
|
+
new PutBucketAclCommand({ Bucket: 'assets', ACL: 'private' })
|
|
202
|
+
);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`GetBucketAcl` reflects the canned ACL (an `AllUsers` grant appears for the
|
|
206
|
+
public ones). Object-level ACLs are accepted but not enforced.
|
|
207
|
+
|
|
208
|
+
### Per-prefix access with bucket policies
|
|
209
|
+
|
|
210
|
+
For finer control than canned ACLs, the server evaluates a subset of bucket
|
|
211
|
+
policies against anonymous requests: `Effect` Allow/Deny, `Principal` `"*"`,
|
|
212
|
+
`Action` wildcards (`s3:GetObject`, `s3:ListBucket`, `s3:PutObject`,
|
|
213
|
+
`s3:DeleteObject`, `s3:*`) and `Resource` ARNs with wildcards. Signed
|
|
214
|
+
requests always have full access; an explicit `Deny` only narrows anonymous
|
|
215
|
+
access. Set policies with `PutBucketPolicy`, or as a JSON document in
|
|
216
|
+
`configureBuckets` (detected by its leading `{`):
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
// only keys under public/ are readable without signing
|
|
220
|
+
const policy = JSON.stringify({
|
|
221
|
+
Version: '2012-10-17',
|
|
222
|
+
Statement: [
|
|
223
|
+
{
|
|
224
|
+
Effect: 'Allow',
|
|
225
|
+
Principal: '*',
|
|
226
|
+
Action: 's3:GetObject',
|
|
227
|
+
Resource: 'arn:aws:s3:::site/public/*',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
new S3TestServer({ configureBuckets: [{ name: 'site', configs: [policy] }] });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Anonymous `GET /site/public/logo.png` then succeeds while
|
|
235
|
+
`GET /site/internal/report.csv` and all anonymous writes return
|
|
236
|
+
`403 AccessDenied`. `GetBucketPolicy` returns the stored document and
|
|
237
|
+
`DeleteBucketPolicy` removes it. `NotAction`, `NotResource`, `NotPrincipal`
|
|
238
|
+
and `Condition` are rejected as `NotImplemented` rather than silently
|
|
239
|
+
ignored.
|
|
149
240
|
|
|
150
241
|
## Supported operations
|
|
151
242
|
|
|
@@ -158,7 +249,8 @@ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
|
|
|
158
249
|
Get/Put/DeleteBucketTagging
|
|
159
250
|
- GetBucketVersioning, GetBucketEncryption, GetBucketNotification,
|
|
160
251
|
GetBucketRequestPayment, GetBucketLogging, GetBucketAccelerate
|
|
161
|
-
- Bucket ACLs (
|
|
252
|
+
- Bucket ACLs (canned, enforced for anonymous requests)
|
|
253
|
+
- Bucket policies (anonymous-access subset): Get/Put/DeleteBucketPolicy
|
|
162
254
|
|
|
163
255
|
### Objects
|
|
164
256
|
|
|
@@ -188,7 +280,7 @@ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
|
|
|
188
280
|
- Index and error documents, folder redirects
|
|
189
281
|
- `x-amz-website-redirect-location` and `RedirectAllRequestsTo`
|
|
190
282
|
- Served for anonymous GET/HEAD requests on buckets with a website
|
|
191
|
-
configuration
|
|
283
|
+
configuration; make the bucket `public-read`, as on real S3
|
|
192
284
|
|
|
193
285
|
### CORS
|
|
194
286
|
|
|
@@ -216,6 +308,33 @@ server.on('event', (notification) => {
|
|
|
216
308
|
Emitted for `ObjectCreated:Put`, `ObjectCreated:Copy`, `ObjectCreated:Post`,
|
|
217
309
|
`ObjectCreated:CompleteMultipartUpload` and `ObjectRemoved:Delete`.
|
|
218
310
|
|
|
311
|
+
## Test helpers
|
|
312
|
+
|
|
313
|
+
Three helpers make assertions and fixtures easy without building an S3
|
|
314
|
+
client; they talk to the store directly:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
// seed fixtures (bucket is created if missing; no events are emitted)
|
|
318
|
+
await server.putObjects('reports', [
|
|
319
|
+
{ key: 'public/a.csv', body: 'a,b,c', contentType: 'text/csv' },
|
|
320
|
+
{ key: 'internal/b.csv', body: Buffer.from('d,e,f') },
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
// inspect bucket contents in assertions
|
|
324
|
+
const all = await server.getObjects('reports');
|
|
325
|
+
const csvs = await server.getObjects('reports', {
|
|
326
|
+
prefix: 'public/',
|
|
327
|
+
bodies: true, // each summary gets a `body` Buffer
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// wait for background uploads from the code under test;
|
|
331
|
+
// multipart uploads only count once completed
|
|
332
|
+
const files = await server.waitForObjects('reports', 2, { timeout: 5000 });
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
After `run()` resolves, `server.hostPort` (`"localhost:4568"`) and
|
|
336
|
+
`server.endpoint` (`"http://localhost:4568"`) are also set on the instance.
|
|
337
|
+
|
|
219
338
|
## Using as middleware
|
|
220
339
|
|
|
221
340
|
```js
|
|
@@ -234,6 +353,9 @@ http.createServer(s3.callback()).listen(4568);
|
|
|
234
353
|
- `callback()` / `getMiddleware()` returns a request handler
|
|
235
354
|
- `configureBuckets()` provisions `options.configureBuckets` without the API
|
|
236
355
|
- `reset()` deletes all buckets and configurations
|
|
356
|
+
- `getObjects(bucket, { prefix, bodies })` lists completed objects
|
|
357
|
+
- `waitForObjects(bucket, count, { prefix, timeout })` awaits uploads
|
|
358
|
+
- `putObjects(bucket, objects)` seeds fixtures without the HTTP API
|
|
237
359
|
|
|
238
360
|
TypeScript definitions ship with the package (`lib/index.d.ts`).
|
|
239
361
|
|
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
|
@@ -11,6 +11,12 @@ declare class S3TestServer extends EventEmitter {
|
|
|
11
11
|
/** The underlying HTTP(S) server, available after `run()` resolves. */
|
|
12
12
|
httpServer: HttpServer | null;
|
|
13
13
|
|
|
14
|
+
/** `host:port` the server listens on; set once `run()` resolves. */
|
|
15
|
+
hostPort: string | null;
|
|
16
|
+
|
|
17
|
+
/** Full endpoint URL (e.g. `http://localhost:4568`); set once `run()` resolves. */
|
|
18
|
+
endpoint: string | null;
|
|
19
|
+
|
|
14
20
|
/** Starts the server. */
|
|
15
21
|
run(): Promise<S3TestServer.RunAddress>;
|
|
16
22
|
run(
|
|
@@ -32,6 +38,34 @@ declare class S3TestServer extends EventEmitter {
|
|
|
32
38
|
/** Deletes all buckets, objects and configurations in the store. */
|
|
33
39
|
reset(): Promise<void>;
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Lists the completed objects in a bucket, sorted by key. With
|
|
43
|
+
* `bodies: true`, each summary also carries its content as a Buffer.
|
|
44
|
+
*/
|
|
45
|
+
getObjects(
|
|
46
|
+
bucket: string,
|
|
47
|
+
options?: { prefix?: string; bodies?: boolean }
|
|
48
|
+
): Promise<S3TestServer.ObjectSummary[]>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves with the bucket's objects once at least `count` exist
|
|
52
|
+
* (optionally under `prefix`); rejects after `timeout` ms (default 10000).
|
|
53
|
+
*/
|
|
54
|
+
waitForObjects(
|
|
55
|
+
bucket: string,
|
|
56
|
+
count: number,
|
|
57
|
+
options?: { prefix?: string; timeout?: number }
|
|
58
|
+
): Promise<S3TestServer.ObjectSummary[]>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Seeds objects directly into the store (the bucket is created when
|
|
62
|
+
* missing). No S3 event notifications are emitted for seeded objects.
|
|
63
|
+
*/
|
|
64
|
+
putObjects(
|
|
65
|
+
bucket: string,
|
|
66
|
+
objects: S3TestServer.SeedObject[]
|
|
67
|
+
): Promise<S3TestServer.ObjectSummary[]>;
|
|
68
|
+
|
|
35
69
|
on(
|
|
36
70
|
event: 'event',
|
|
37
71
|
listener: (notification: S3TestServer.S3EventNotification) => void
|
|
@@ -48,14 +82,21 @@ declare namespace S3TestServer {
|
|
|
48
82
|
interface BucketConfiguration {
|
|
49
83
|
/** Bucket name (must be a valid S3 bucket name). */
|
|
50
84
|
name: string;
|
|
85
|
+
/** Canned ACL applied to the bucket. Default: the server's `defaultBucketAcl`. */
|
|
86
|
+
acl?: CannedBucketAcl;
|
|
51
87
|
/**
|
|
52
|
-
* Raw
|
|
53
|
-
*
|
|
54
|
-
*
|
|
88
|
+
* Raw configuration documents applied to the bucket at startup.
|
|
89
|
+
* XML documents are recognized by their root element: CORS
|
|
90
|
+
* (`<CORSConfiguration>`), website (`<WebsiteConfiguration>`) and tagging
|
|
91
|
+
* (`<Tagging>`). A JSON document (leading `{`) is stored as the bucket
|
|
92
|
+
* policy and evaluated for anonymous requests.
|
|
55
93
|
*/
|
|
56
94
|
configs?: Array<string | Buffer>;
|
|
57
95
|
}
|
|
58
96
|
|
|
97
|
+
type CannedBucketAcl =
|
|
98
|
+
'private' | 'public-read' | 'public-read-write' | 'authenticated-read';
|
|
99
|
+
|
|
59
100
|
interface S3TestServerOptions {
|
|
60
101
|
/** Hostname or IP to bind to. Default: `"localhost"`. */
|
|
61
102
|
address?: string;
|
|
@@ -95,8 +136,20 @@ declare namespace S3TestServer {
|
|
|
95
136
|
accessKeyId?: string;
|
|
96
137
|
/** Secret access key clients must sign requests with. Default: `"S3TEST"`. */
|
|
97
138
|
secretAccessKey?: string;
|
|
98
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Region reported in responses and event records. Default: `"us-east-1"`.
|
|
141
|
+
* When set explicitly, the server acts like a regional endpoint and
|
|
142
|
+
* rejects SigV4 requests signed for other regions; when omitted, any
|
|
143
|
+
* region is accepted.
|
|
144
|
+
*/
|
|
99
145
|
region?: string;
|
|
146
|
+
/**
|
|
147
|
+
* ACL for buckets without an explicit one. `'private'` (the default, like
|
|
148
|
+
* real S3) rejects anonymous requests; `'public-read'` allows anonymous
|
|
149
|
+
* GET/HEAD only; `'public-read-write'` restores the fully public 1.x
|
|
150
|
+
* behavior.
|
|
151
|
+
*/
|
|
152
|
+
defaultBucketAcl?: CannedBucketAcl;
|
|
100
153
|
/** Buckets to create (and configure) before the server starts. */
|
|
101
154
|
configureBuckets?: BucketConfiguration[];
|
|
102
155
|
}
|
|
@@ -116,14 +169,37 @@ declare namespace S3TestServer {
|
|
|
116
169
|
| 'accessKeyId'
|
|
117
170
|
| 'secretAccessKey'
|
|
118
171
|
| 'region'
|
|
172
|
+
| 'defaultBucketAcl'
|
|
119
173
|
>
|
|
120
174
|
>;
|
|
121
175
|
|
|
176
|
+
interface ObjectSummary {
|
|
177
|
+
key: string;
|
|
178
|
+
size: number;
|
|
179
|
+
/** Hex ETag without quotes. */
|
|
180
|
+
etag: string;
|
|
181
|
+
lastModified: Date;
|
|
182
|
+
contentType?: string;
|
|
183
|
+
/** User metadata (`x-amz-meta-*`), with the prefix stripped. */
|
|
184
|
+
metadata: Record<string, string>;
|
|
185
|
+
/** Present when requested with `bodies: true`. */
|
|
186
|
+
body?: Buffer;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface SeedObject {
|
|
190
|
+
key: string;
|
|
191
|
+
body: string | Buffer;
|
|
192
|
+
contentType?: string;
|
|
193
|
+
metadata?: Record<string, string>;
|
|
194
|
+
}
|
|
195
|
+
|
|
122
196
|
interface RunAddress {
|
|
123
197
|
/** Bound address, as reported by `net.Server#address()`. */
|
|
124
198
|
address: string;
|
|
125
199
|
family: string;
|
|
126
200
|
port: number;
|
|
201
|
+
/** `host:port` the server listens on. */
|
|
202
|
+
hostPort: string;
|
|
127
203
|
/** `"http"` or `"https"`. */
|
|
128
204
|
protocol: string;
|
|
129
205
|
/** Convenience endpoint URL, e.g. `"http://localhost:4568"`. */
|
|
@@ -178,6 +254,9 @@ declare namespace S3TestServer {
|
|
|
178
254
|
|
|
179
255
|
export {
|
|
180
256
|
S3TestServer,
|
|
257
|
+
ObjectSummary,
|
|
258
|
+
SeedObject,
|
|
259
|
+
CannedBucketAcl,
|
|
181
260
|
BucketConfiguration,
|
|
182
261
|
S3TestServerOptions,
|
|
183
262
|
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);
|
|
@@ -43,6 +52,8 @@ class S3TestServer extends EventEmitter {
|
|
|
43
52
|
};
|
|
44
53
|
this.httpServer = null;
|
|
45
54
|
this.store = null;
|
|
55
|
+
this.hostPort = null;
|
|
56
|
+
this.endpoint = null;
|
|
46
57
|
this._middleware = null;
|
|
47
58
|
this._sequencer = 0;
|
|
48
59
|
}
|
|
@@ -101,13 +112,14 @@ class S3TestServer extends EventEmitter {
|
|
|
101
112
|
});
|
|
102
113
|
const address = this.httpServer.address();
|
|
103
114
|
const protocol = this.options.key && this.options.cert ? 'https' : 'http';
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
);
|
|
115
|
+
this.hostPort = `${this.options.address}:${address.port}`;
|
|
116
|
+
this.endpoint = `${protocol}://${this.hostPort}`;
|
|
117
|
+
this.logger.info(`S3 test server listening on ${this.endpoint}`);
|
|
107
118
|
return {
|
|
108
119
|
...address,
|
|
109
120
|
protocol,
|
|
110
|
-
|
|
121
|
+
hostPort: this.hostPort,
|
|
122
|
+
endpoint: this.endpoint,
|
|
111
123
|
};
|
|
112
124
|
})();
|
|
113
125
|
if (typeof callback === 'function') {
|
|
@@ -157,16 +169,28 @@ class S3TestServer extends EventEmitter {
|
|
|
157
169
|
parseCorsConfig,
|
|
158
170
|
parseWebsiteConfig,
|
|
159
171
|
parseTaggingConfig,
|
|
172
|
+
validateCannedAcl,
|
|
160
173
|
} = require('./controllers/bucket');
|
|
161
174
|
const { validateBucketName } = require('./utils');
|
|
162
|
-
for (const { name, configs = [] } of this.options.configureBuckets ||
|
|
175
|
+
for (const { name, configs = [], acl } of this.options.configureBuckets ||
|
|
176
|
+
[]) {
|
|
163
177
|
validateBucketName(name);
|
|
164
178
|
await this.store.createBucket(name);
|
|
179
|
+
if (acl) {
|
|
180
|
+
await this.store.putBucketConfig(name, 'acl', validateCannedAcl(acl));
|
|
181
|
+
}
|
|
165
182
|
for (const rawConfig of configs) {
|
|
166
183
|
const xml = Buffer.isBuffer(rawConfig)
|
|
167
184
|
? rawConfig.toString('utf8')
|
|
168
185
|
: String(rawConfig);
|
|
169
|
-
if (
|
|
186
|
+
if (/^\s*\{/.test(xml)) {
|
|
187
|
+
const { parseBucketPolicy } = require('./models/policy');
|
|
188
|
+
await this.store.putBucketConfig(
|
|
189
|
+
name,
|
|
190
|
+
'policy',
|
|
191
|
+
parseBucketPolicy(xml)
|
|
192
|
+
);
|
|
193
|
+
} else if (/<\s*(\w+:)?CORSConfiguration[\s>]/.test(xml)) {
|
|
170
194
|
await this.store.putBucketConfig(name, 'cors', parseCorsConfig(xml));
|
|
171
195
|
} else if (/<\s*(\w+:)?WebsiteConfiguration[\s>]/.test(xml)) {
|
|
172
196
|
await this.store.putBucketConfig(
|
|
@@ -196,6 +220,143 @@ class S3TestServer extends EventEmitter {
|
|
|
196
220
|
await this.store.reset();
|
|
197
221
|
}
|
|
198
222
|
|
|
223
|
+
// ---------------------------------------------------------------- helpers
|
|
224
|
+
|
|
225
|
+
_toSummary(meta) {
|
|
226
|
+
return {
|
|
227
|
+
key: meta.key,
|
|
228
|
+
size: meta.size,
|
|
229
|
+
etag: meta.etag,
|
|
230
|
+
lastModified: new Date(meta.lastModified),
|
|
231
|
+
contentType: meta.contentType,
|
|
232
|
+
metadata: { ...(meta.userMetadata || {}) },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Lists the completed objects in a bucket (multipart parts in flight are
|
|
238
|
+
* never included), sorted by key. With `bodies: true`, each entry also
|
|
239
|
+
* carries its content as a Buffer.
|
|
240
|
+
*/
|
|
241
|
+
async getObjects(bucket, { prefix = '', bodies = false } = {}) {
|
|
242
|
+
this._ensureStore();
|
|
243
|
+
if (!(await this.store.getBucket(bucket))) {
|
|
244
|
+
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist', {
|
|
245
|
+
BucketName: bucket,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const metas = (await this.store.listObjects(bucket)).filter(
|
|
249
|
+
(meta) => !prefix || meta.key.startsWith(prefix)
|
|
250
|
+
);
|
|
251
|
+
const summaries = metas.map((meta) => this._toSummary(meta));
|
|
252
|
+
if (bodies) {
|
|
253
|
+
for (const summary of summaries) {
|
|
254
|
+
summary.body = await this._readObject(bucket, summary.key);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return summaries;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_readObject(bucket, key) {
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const chunks = [];
|
|
263
|
+
this.store
|
|
264
|
+
.createObjectReadStream(bucket, key)
|
|
265
|
+
.on('data', (chunk) => chunks.push(chunk))
|
|
266
|
+
.on('end', () => resolve(Buffer.concat(chunks)))
|
|
267
|
+
.on('error', reject);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves with the bucket's objects once at least `count` of them exist
|
|
273
|
+
* (optionally under `prefix`). Useful when the code under test uploads in
|
|
274
|
+
* the background. A bucket that does not exist yet simply counts as empty.
|
|
275
|
+
*/
|
|
276
|
+
async waitForObjects(bucket, count, { prefix = '', timeout = 10000 } = {}) {
|
|
277
|
+
this._ensureStore();
|
|
278
|
+
const matching = async () => {
|
|
279
|
+
if (!(await this.store.getBucket(bucket))) return null;
|
|
280
|
+
const metas = (await this.store.listObjects(bucket)).filter(
|
|
281
|
+
(meta) => !prefix || meta.key.startsWith(prefix)
|
|
282
|
+
);
|
|
283
|
+
return metas.length >= count
|
|
284
|
+
? metas.map((meta) => this._toSummary(meta))
|
|
285
|
+
: null;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const found = await matching();
|
|
289
|
+
if (found) return found;
|
|
290
|
+
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
let interval;
|
|
293
|
+
let timer;
|
|
294
|
+
const check = async () => {
|
|
295
|
+
const objects = await matching().catch(() => null);
|
|
296
|
+
if (objects) {
|
|
297
|
+
cleanup();
|
|
298
|
+
resolve(objects);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const cleanup = () => {
|
|
302
|
+
clearInterval(interval);
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
this.removeListener('event', check);
|
|
305
|
+
};
|
|
306
|
+
this.on('event', check);
|
|
307
|
+
// Poll as well, so out-of-band writes (e.g. putObjects) are seen too.
|
|
308
|
+
interval = setInterval(check, 150);
|
|
309
|
+
timer = setTimeout(() => {
|
|
310
|
+
cleanup();
|
|
311
|
+
reject(
|
|
312
|
+
new Error(
|
|
313
|
+
`Timed out after ${timeout}ms waiting for ${count} object(s) in "${bucket}"` +
|
|
314
|
+
(prefix ? ` under "${prefix}"` : '')
|
|
315
|
+
)
|
|
316
|
+
);
|
|
317
|
+
}, timeout);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Seeds objects directly into the store, bypassing the HTTP API. The
|
|
323
|
+
* bucket is created when missing. Each entry is
|
|
324
|
+
* `{ key, body, contentType?, metadata? }` with a string or Buffer body.
|
|
325
|
+
* No S3 event notifications are emitted for seeded objects.
|
|
326
|
+
*/
|
|
327
|
+
async putObjects(bucket, objects) {
|
|
328
|
+
this._ensureStore();
|
|
329
|
+
await this.store.initialize();
|
|
330
|
+
const { validateBucketName } = require('./utils');
|
|
331
|
+
validateBucketName(bucket);
|
|
332
|
+
await this.store.createBucket(bucket);
|
|
333
|
+
const { Readable } = require('stream');
|
|
334
|
+
const results = [];
|
|
335
|
+
for (const object of objects) {
|
|
336
|
+
if (!object || typeof object.key !== 'string') {
|
|
337
|
+
throw new TypeError('putObjects entries need a string `key`');
|
|
338
|
+
}
|
|
339
|
+
const body = Buffer.isBuffer(object.body)
|
|
340
|
+
? object.body
|
|
341
|
+
: Buffer.from(object.body ?? '');
|
|
342
|
+
const staged = await this.store.stageObject(
|
|
343
|
+
bucket,
|
|
344
|
+
Readable.from([body]),
|
|
345
|
+
['CRC64NVME']
|
|
346
|
+
);
|
|
347
|
+
const meta = await staged.commit(object.key, {
|
|
348
|
+
contentType: object.contentType || 'binary/octet-stream',
|
|
349
|
+
userMetadata: { ...(object.metadata || {}) },
|
|
350
|
+
tagging: {},
|
|
351
|
+
checksums: staged.digests.checksums,
|
|
352
|
+
checksumAlgorithm: 'CRC64NVME',
|
|
353
|
+
checksumType: 'FULL_OBJECT',
|
|
354
|
+
});
|
|
355
|
+
results.push(this._toSummary(meta));
|
|
356
|
+
}
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
|
|
199
360
|
_emitEvent(eventName, bucket, objectMeta) {
|
|
200
361
|
if (!objectMeta) return;
|
|
201
362
|
this._sequencer += 1;
|
|
@@ -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": "1.0
|
|
3
|
+
"version": "2.1.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": {
|