Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for S3 CORS configuration policies #63

Merged
merged 7 commits into from
Feb 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 46 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,43 +34,45 @@ The goal of S3rver is to minimise runtime dependencies and be more of a developm
Install s3rver:

```bash
npm install s3rver -g
$ npm install s3rver -g
```
You will now have a command on your path called *s3rver*

Executing this command for the various options:

```bash
s3rver --help
$ s3rver --help
```

## Supported clients

Please see [Fake S3s wiki page](https://github.com/jubos/fake-s3/wiki/Supported-Clients) for a list of supported clients.
When listening on HTTPS with a self-signed certificate, the AWS SDK in a Node.js environment will need ```httpOptions: { agent: new https.Agent({ rejectUnauthorized: false }) }``` in order to allow interaction.
When listening on HTTPS with a self-signed certificate, the AWS SDK in a Node.js environment will need `httpOptions: { agent: new https.Agent({ rejectUnauthorized: false }) }` in order to allow interaction.

Please test, if you encounter any problems please do not hesitate to open an issue :)

## Static Website Hosting

If you specify an *indexDocument* then ```get``` requests will serve the *indexDocument* if it is found, simulating the static website mode of AWS S3. An *errorDocument* can also be set, to serve a custom 404 page.
If you specify an *indexDocument* then `GET` requests will serve the *indexDocument* if it is found, simulating the static website mode of AWS S3. An *errorDocument* can also be set, to serve a custom 404 page.

### Hostname Resolution

By default a bucket name needs to be given. So for a bucket called ```mysite.local```, with an indexDocument of ```index.html```. Visiting ```http://localhost:4568/mysite.local/``` in your browser will display the ```index.html``` file uploaded to the bucket.
By default a bucket name needs to be given. So for a bucket called `mysite.local`, with an indexDocument of `index.html`. Visiting `http://localhost:4568/mysite.local/` in your browser will display the `index.html` file uploaded to the bucket.

However you can also setup a local hostname in your /etc/hosts file pointing at 127.0.0.1
```
localhost 127.0.0.1
mysite.local 127.0.0.1
```
Now you can access the served content at ```http://mysite.local:4568/```
Now you can access the served content at `http://mysite.local:4568/`

## Tests

The tests should be run by one of the active LTS versions. The CI Server runs the tests on version `4.x`, `6.x` and `8.x`.
I recommend using [NVM](https://github.com/creationix/nvm) to manage your node versions.

The tests should be run by one of the active LTS versions. The CI Server runs the tests on the latest `6.x` and `8.x` releases.

Be wary that Windows is not fully supported due to the way its filesystem works, so most tests that involve deleting objects will fail.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@specialkk Do you have any more info about this? You made a comment that you fixed all the tests for Windows?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR. If you try running tests on Windows without the changes here, you'd run into issues because of either a) permission issues trying to create a test data directory at /tmp or b) ENOENT errors when resetting the data because Windows seems to have trouble recursively deleting all the folders created by the previous test. I partially fixed these by using os.tmpdir() and by not having the before hook fail if a bucket already exists.

I have a commit that updates syntax to ES2015 across the project and another that has a pretty major refactor of the file system store back end. Incidentally I think I fixed whatever file lock trouble Windows had before in the process.

Use Linux Subsystem for Windows to test if it's your primary environment.

To run the test suite, first install the dependencies, then run `npm test`:

```bash
Expand All @@ -84,34 +86,60 @@ You can also run s3rver programmatically.

> This is particularly useful if you want to integrate s3rver into another projects tests that depends on access to an s3 environment

### new S3rver([options])

Creates a S3rver instance

| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| port | `number` | `4578` | Port of the mock S3 server |
| hostname | `string` | `localhost` | Host/IP to bind to |
| key | `string` \| `Buffer` | | Private key for running with TLS |
| cert | `string` \| `Buffer` | | Certificate for running with TLS |
| silent | `boolean` | `false` | Suppress log messages |
| directory | `string` | | Data directory |
| cors | `string` \| `Buffer` | [S3 Sample policy](test/resources/cors_sample_policy.xml) | Raw XML string or Buffer of CORS policy |
| indexDocument | `string` | | Index document for static web hosting |
| errorDocument | `string` | | Error document for static web hosting |
| removeBucketsOnClose | `boolean` | `false` | Remove all bucket data on server close |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎖 hero


### s3rver.run(callback)
Starts the server on the configured port and host

Example in mocha:

```
var S3rver = require('s3rver');
var client;
```javascript
const S3rver = require('s3rver');
let instance;

before(function (done) {
client = new S3rver({
instance = new S3rver({
port: 4569,
hostname: 'localhost',
silent: false,
directory: '/tmp/s3rver_test_directory'
}).run(function (err, host, port) {
}).run((err, host, port) => {
if(err) {
return done(err);
return done(err);
}
done();
});
});

after(function (done) {
client.close(done);
instance.close(done);
});
```

### s3rver.callback() ⇒ `function (req, res)`
*Also aliased as* **s3rver.getMiddleware()**
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


Creates and returns a callback that can be passed into `http.createServer()` or mounted in an Express app.

## Using [s3fs-fuse](https://github.com/s3fs-fuse/s3fs-fuse) with S3rver

You can connect to s3rver and mount a bucket to your local file system by using the following command:

```
s3fs bucket1 /tmp/3 -o url="http://localhost:4568" -o use_path_request_style -d -f -o f2 -o curldbg
```bash
$ s3fs bucket1 /tmp/3 -o url="http://localhost:4568" -o use_path_request_style -d -f -o f2 -o curldbg
```
12 changes: 8 additions & 4 deletions bin/s3rver.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ const fs = require('fs-extra');
const S3rver = require('../lib');

program.version(version, '--version');
program.option('-h, --hostname [value]', 'Set the host name or ip for the server', 'localhost')
program.option('-h, --hostname [value]', 'Set the host name or IP to bind to', 'localhost')
.option('-p, --port <n>', 'Set the port of the http server', 4568)
.option('-s, --silent', 'Suppress log messages', false)
.option('-i, --indexDocument [path]', 'Index Document for Static Web Hosting', '')
.option('-e, --errorDocument [path]', 'Custom Error Document for Static Web Hosting', '')
.option('-i, --indexDocument [path]', 'Index Document for Static Web Hosting')
.option('-e, --errorDocument [path]', 'Custom Error Document for Static Web Hosting')
.option('-d, --directory [path]', 'Data directory')
.option('-c, --cors', 'Enable CORS', false)
.option('-c, --cors [path]', 'Path to S3 CORS configuration XML file')
.option('--key [path]', 'Path to private key file for running with TLS')
.option('--cert [path]', 'Path to certificate file for running with TLS')
.parse(process.argv);
Expand All @@ -34,6 +34,10 @@ catch (e) {
process.exit();
}

if (program.cors) {
program.cors = fs.readFileSync(program.cors);
}

if (program.key && program.cert) {
program.key = fs.readFileSync(program.key);
program.cert = fs.readFileSync(program.cert);
Expand Down
38 changes: 12 additions & 26 deletions lib/app.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
'use strict';

const express = require('express');
const fs = require('fs-extra');
const morgan = require('morgan');
const path = require('path');

const Controllers = require('./controllers');
const cors = require('./cors');
const createLogger = require('./logger');
const path = require('path');
const https = require('https');
const morgan = require('morgan');

module.exports = function (options) {
const app = express();
const logger = createLogger(options.silent);
const controllers = new Controllers(options.directory, logger, options.indexDocument, options.errorDocument);

/**
* Log all requests
*/
app.use(morgan('tiny', {
'stream': {
write: function (message) {
logger.info(message.slice(0, -1));
app.logger.info(message.slice(0, -1));
}
}
}));
Expand All @@ -34,22 +34,17 @@ module.exports = function (options) {
req.url = path.join('/', host, req.url);
}

if (options.cors) {
if (req.method === 'OPTIONS') {
if (req.headers['access-control-request-headers'])
res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
if (req.headers['access-control-request-method'])
res.header('Access-Control-Allow-Methods', req.headers['access-control-request-method']);
}
if (req.headers.origin)
res.header('Access-Control-Allow-Origin', '*');
}

next();
});

app.use(cors(options.cors));

app.disable('x-powered-by');

// Don't register logger until app is successfully set up
app.logger = createLogger(options.silent);
const controllers = new Controllers(options.directory, app.logger, options.indexDocument, options.errorDocument);

/**
* Routes for the application
*/
Expand All @@ -64,14 +59,5 @@ module.exports = function (options) {
app.delete('/:bucket/:key(*)', controllers.bucketExists, controllers.deleteObject);
app.post('/:bucket', controllers.bucketExists, controllers.genericPost);

app.serve = function (done) {
const server = ((options.key && options.cert) || options.pfx) ? https.createServer(options, app) : app;
return server.listen(options.port, options.hostname, function (err) {
return done(err, options.hostname, options.port, options.directory);
}).on('error', function (err) {
return done(err);
});
};

return app;
};
1 change: 0 additions & 1 deletion lib/controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ module.exports = function (rootDirectory, logger, indexDocument, errorDocument)
};

const buildResponse = function (req, res, status, object, data) {
res.header('Access-Control-Allow-Origin', '*');
res.header('Etag', '"' + object.md5 + '"');
res.header('Last-Modified', new Date(object.modifiedDate).toUTCString());
res.setHeader('Content-Type', object.contentType);
Expand Down
112 changes: 112 additions & 0 deletions lib/cors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use strict';

const { escapeRegExp, includes } = require('lodash');
const xml2js = require('xml2js');

const templateBuilder = require('./xml-template-builder');

function createWildcardRegExp(str, flags = '') {
const parts = str.split('*');
if (parts.length > 2) throw new Error(`"${str}" can not have more than one wildcard.`);
return new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, flags);
}

// See https://docs.aws.amazon.com/AmazonS3/latest/dev/cors.html
module.exports = function cors(config) {
// parse and validate config
let CORSConfiguration;
if (config) {
xml2js.parseString(config, { async: false }, (err, parsed) => {
if (!err) ({ CORSConfiguration } = parsed);
});
if (!CORSConfiguration || !CORSConfiguration.CORSRule) {
throw new Error('The CORS configuration XML you provided was not well-formed or did not validate');
}
for (const rule of CORSConfiguration.CORSRule) {
if (!rule.AllowedOrigin || !rule.AllowedMethod) {
throw new Error('CORSRule must have at least one AllowedOrigin and AllowedMethod');
}

// Keep track if the rule has the plain wildcard '*' origin since S3 responds with '*'
// instead of echoing back the request origin in this case
rule.hasWildcardOrigin = rule.AllowedOrigin.includes('*');
rule.AllowedOrigin = rule.AllowedOrigin.map(o => createWildcardRegExp(o));
rule.AllowedHeader = (rule.AllowedHeader || []).map(h => createWildcardRegExp(h, 'i'));
}
}

return function (req, res, next) {
// Prefer the Access-Control-Request-Method header if supplied
const method = req.get('access-control-request-method') || req.method;
const matchedRule = CORSConfiguration.CORSRule.find((rule) => {
return rule.AllowedOrigin.some(pattern => pattern.test(req.get('origin')))
&& includes(rule.AllowedMethod, method);
});

if (req.method === 'OPTIONS') {
let template;

if (!req.get('origin')) {
template = templateBuilder.buildError('BadRequest',
'Insufficient information. Origin request header needed.');
res.header('Content-Type', 'application/xml');
return res.status(403).send(template);
}

if (!req.get('access-control-request-method')) {
template = templateBuilder.buildError('BadRequest',
'Invalid Access-Control-Request-Method: null');
res.header('Content-Type', 'application/xml');
return res.status(403).send(template);
}

// S3 only checks if CORS is enabled *after* checking the existence of access control headers
if (!CORSConfiguration) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user does not specify a CORS configuration, but their client still sends a preflight OPTIONS request? The user would get a 403 where I think we would expect it to be lenient (we want to make integration tests easier wherever we allow users to omit preconditions). Should there be a check to determine if a config was specified before running this logical branch under if (req.method === 'OPTIONS')?

If this is how S3 behaves when no CORS config is specified, or if we have a default applied when none is specified, then leave a note and keep this as-is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 403 response is correct based on what I was testing, also documented here. I'm not sure if it would get in the way of integration tests since the default sample policy should be lenient enough.

template = templateBuilder.buildError('CORSResponse',
'CORS is not enabled for this bucket.');
res.header('Content-Type', 'application/xml');
return res.status(403).send(template);
}

const requestHeaders = req.get('access-control-request-headers') || [];
const allowedHeaders = matchedRule
? requestHeaders
.filter(header => matchedRule.AllowedHeader.some(pattern => pattern.test(header)))
.map(header => header.toLowercase())
: [];

if (!matchedRule || allowedHeaders.length < requestHeaders.length) {
template = templateBuilder.buildError('CORSResponse',
'This CORS request is not allowed. ' +
'This is usually because the evalution of Origin, ' +
'request method / Access-Control-Request-Method or Access-Control-Request-Headers ' +
'are not whitelisted by the resource\'s CORS spec.')
res.header('Content-Type', 'application/xml');
return res.status(403).send(template);
}

res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', matchedRule.AllowedMethod.join(', '));
if (req.get('access-control-request-headers')) {
res.header('Access-Control-Allow-Headers', allowedHeaders.join(', '));
}

res.header('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method');

return res.status(200).send();
} else if (CORSConfiguration && req.get('origin')) {
if (matchedRule) {
res.header('Access-Control-Allow-Origin', matchedRule.hasWildcardOrigin ? '*' : req.get('origin'));
if (matchedRule.ExposeHeader) {
res.header('Access-Control-Expose-Headers', matchedRule.ExposeHeader.join(', '));
}
if (matchedRule.MaxAgeSeconds) {
res.header('Access-Control-Max-Age', matchedRule.MaxAgeSeconds[0]);
}
res.header('Access-Control-Allow-Credentials', true);
res.header('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method');
}
}
next();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work on this middleware, it looks fantastic and fairly easy to extend/maintain

}
}
Loading