diff --git a/README.md b/README.md index 7cce428..1d01a63 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,20 @@ all methods. The default value is `true`. +##### followsymlinks + +Determines how serve-static will handle how files or paths containing symlinks +are handled. Setting `followsymlinks` to `false` will cause serve-static to +reject requests for files that have a symlink in their path. + +The default value is `true`. + +Note that setting `followsymlinks` to `false` also causes the the module +to resolve any symbolic links in the root path during startup. This means +that if your root path does contain symlinks, changes to those symlinks after +application startup will not be noticed. + + ##### immutable Enable or disable the `immutable` directive in the `Cache-Control` response diff --git a/index.js b/index.js index b7d3984..8dfb143 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,8 @@ var encodeUrl = require('encodeurl') var escapeHtml = require('escape-html') var parseUrl = require('parseurl') var resolve = require('path').resolve +var fs = require('fs') +var constants = fs.constants || require('constants') // eslint-disable-line node/no-deprecated-api var send = require('send') var url = require('url') @@ -50,6 +52,10 @@ function serveStatic (root, options) { // fall-though var fallthrough = opts.fallthrough !== false + // handle symlinks + var realroot + var followsymlinks = true + // default redirect var redirect = opts.redirect !== false @@ -64,6 +70,16 @@ function serveStatic (root, options) { opts.maxage = opts.maxage || opts.maxAge || 0 opts.root = resolve(root) + // only set followsymlinks to false if it was explicitly set + if (opts.followsymlinks === false) { + followsymlinks = false + realroot = fs.realpathSync(root) + opts.flags = constants.O_RDONLY | constants.O_NOFOLLOW + // if followsymlinks is disabled, we need the fully resolved + // (un-symlink'd) root to start + opts.root = realroot + } + // construct directory listener var onDirectory = redirect ? createRedirectDirectoryListener() @@ -86,12 +102,35 @@ function serveStatic (root, options) { var forwardError = !fallthrough var originalUrl = parseUrl.original(req) var path = parseUrl(req).pathname + var fullpath, realpath // make sure redirect occurs at mount if (path === '/' && originalUrl.pathname.substr(-1) !== '/') { path = '' } + if (followsymlinks === false) { + fullpath = realroot + path + try { + realpath = fs.realpathSync(realroot + path) + } catch (e) { + realpath = undefined + } + // if the full path and the real path are not the same, + // then there is a symlink somewhere along the way + if (fullpath !== realpath) { + if (fallthrough) { + return next() + } + + // forbidden on symlinks + res.statusCode = 403 + res.setHeader('Content-Length', '0') + res.end() + return + } + } + // create send stream var stream = send(req, path, opts) diff --git a/test/fixtures/members b/test/fixtures/members new file mode 120000 index 0000000..aa7fdfe --- /dev/null +++ b/test/fixtures/members @@ -0,0 +1 @@ +users/ \ No newline at end of file diff --git a/test/fixtures/symroot b/test/fixtures/symroot new file mode 120000 index 0000000..6a04314 --- /dev/null +++ b/test/fixtures/symroot @@ -0,0 +1 @@ +./ \ No newline at end of file diff --git a/test/fixtures/users/william.txt b/test/fixtures/users/william.txt new file mode 120000 index 0000000..5540581 --- /dev/null +++ b/test/fixtures/users/william.txt @@ -0,0 +1 @@ +tobi.txt \ No newline at end of file diff --git a/test/test.js b/test/test.js index 94a6c6a..790f05f 100644 --- a/test/test.js +++ b/test/test.js @@ -3,6 +3,7 @@ var assert = require('assert') var Buffer = require('safe-buffer').Buffer var http = require('http') var path = require('path') +var fs = require('fs') var request = require('supertest') var serveStatic = require('..') @@ -10,6 +11,10 @@ var fixtures = path.join(__dirname, '/fixtures') var relative = path.relative(process.cwd(), fixtures) var skipRelative = ~relative.indexOf('..') || path.resolve(relative) === relative +var skipSymlinks = true +try { + skipSymlinks = fs.realpathSync(fixtures + '/users/tobi.txt') !== fs.realpathSync(fixtures + '/members/tobi.txt') +} catch (e) {} describe('serveStatic()', function () { describe('basic operations', function () { @@ -759,6 +764,89 @@ describe('serveStatic()', function () { .get('/todo/') .expect(404, done) }) + }); + + (skipSymlinks ? describe.skip : describe)('symlink tests', function () { + describe('when followsymlinks is false', function () { + var server + before(function () { + server = createServer(fixtures, { followsymlinks: false, fallthrough: false }) + }) + + it('accessing a real file works', function (done) { + request(server) + .get('/users/tobi.txt') + .expect(200, 'ferret', done) + }) + + it('should 403 on nonexistant file', function (done) { + request(server) + .get('/users/bob.txt') + .expect(403, done) + }) + + it('should 403 on a symlink in the path', function (done) { + request(server) + .get('/members/tobi.txt') + .expect(403, done) + }) + + it('should 403 on a symlink as the file', function (done) { + request(server) + .get('/users/william.txt') + .expect(403, done) + }) + + it('should fail on nested root symlink', function (done) { + request(server) + .get('/symroot/users/tobi.txt') + .expect(403, done) + }) + }) + + describe('when followsymlinks is false and root had symlinks', function () { + var server + before(function () { + server = createServer(fixtures + '/symroot', { followsymlinks: false, fallthrough: false }) + }) + + it('accessing a real file works', function (done) { + request(server) + .get('/users/tobi.txt') + .expect(200, 'ferret', done) + }) + + it('should 403 on a symlink in the path', function (done) { + request(server) + .get('/members/tobi.txt') + .expect(403, done) + }) + + it('should 403 on a symlink as the file', function (done) { + request(server) + .get('/users/william.txt') + .expect(403, done) + }) + }) + + describe('when followsymlinks is false and fallthrough is true', function () { + var server + before(function () { + server = createServer(fixtures, { followsymlinks: false, fallthrough: true }) + }) + + it('accessing a real file works', function (done) { + request(server) + .get('/users/tobi.txt') + .expect(200, 'ferret', done) + }) + + it('should 404 on a symlink', function (done) { + request(server) + .get('/members/tobi.txt') + .expect(404, done) + }) + }) }) describe('when responding non-2xx or 304', function () {