From c6da2a4b0c5afabe4ea9a5bed21c8d6f093f46e4 Mon Sep 17 00:00:00 2001 From: DebuggingMax Date: Tue, 24 Feb 2026 10:05:48 +0000 Subject: [PATCH] feat: add followSymlinks option to restrict symlink traversal Add a new `followSymlinks` option that allows restricting symlink traversal to stay within the configured root directory. When `followSymlinks` is set to `false`, the module will use `fs.realpath()` to resolve the canonical path of files and reject requests (with 403) if the resolved path falls outside the root directory. This provides an explicit opt-in safeguard for applications that serve user-writable directories. - Default: `true` (backward compatible - current behavior) - When `false`: reject symlinks pointing outside root - Requires `root` option to be set for the check to apply Closes #297 --- README.md | 10 ++++ index.js | 73 ++++++++++++++++++++++++ test/fixtures/symlinks/external-link.txt | 1 + test/fixtures/symlinks/internal-link.txt | 1 + test/fixtures/symlinks/safe.txt | 1 + test/send.js | 52 +++++++++++++++++ 6 files changed, 138 insertions(+) create mode 120000 test/fixtures/symlinks/external-link.txt create mode 120000 test/fixtures/symlinks/internal-link.txt create mode 100644 test/fixtures/symlinks/safe.txt diff --git a/README.md b/README.md index 350fccd5..4da25035 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,16 @@ in the given order. By default, this is disabled (set to `false`). An example value that will serve extension-less HTML files: `['html', 'htm']`. This is skipped if the requested file already has an extension. +##### followSymlinks + +Enable or disable following symbolic links, defaults to `true`. When set to +`false`, if the resolved path (after following symlinks via `fs.realpath`) +falls outside of the `root` directory, the request is rejected with a 403 +status code. This option requires the `root` option to be set. + + - `true` - Follow symlinks (current/default behavior). + - `false` - Reject requests for symlinks that resolve outside `root`. + ##### immutable Enable or disable the `immutable` directive in the `Cache-Control` response diff --git a/index.js b/index.js index 1655053d..eed5e716 100644 --- a/index.js +++ b/index.js @@ -127,6 +127,10 @@ function SendStream (req, path, options) { ? Boolean(opts.immutable) : false + this._followSymlinks = opts.followSymlinks !== undefined + ? Boolean(opts.followSymlinks) + : true + this._index = opts.index !== undefined ? normalizeList(opts.index, 'index option') : ['index.html'] @@ -611,6 +615,16 @@ SendStream.prototype.sendFile = function sendFile (path) { if (err) return self.onStatError(err) if (stat.isDirectory()) return self.redirect(path) if (pathEndsWithSep) return self.error(404) + + // symlink check + if (!self._followSymlinks && self._root) { + return self.checkSymlink(path, stat, function (err) { + if (err) return self.error(403) + self.emit('file', path, stat) + self.send(path, stat) + }) + } + self.emit('file', path, stat) self.send(path, stat) }) @@ -628,6 +642,16 @@ SendStream.prototype.sendFile = function sendFile (path) { fs.stat(p, function (err, stat) { if (err) return next(err) if (stat.isDirectory()) return next() + + // symlink check + if (!self._followSymlinks && self._root) { + return self.checkSymlink(p, stat, function (err) { + if (err) return self.error(403) + self.emit('file', p, stat) + self.send(p, stat) + }) + } + self.emit('file', p, stat) self.send(p, stat) }) @@ -656,6 +680,16 @@ SendStream.prototype.sendIndex = function sendIndex (path) { fs.stat(p, function (err, stat) { if (err) return next(err) if (stat.isDirectory()) return next() + + // symlink check + if (!self._followSymlinks && self._root) { + return self.checkSymlink(p, stat, function (err) { + if (err) return self.error(403) + self.emit('file', p, stat) + self.send(p, stat) + }) + } + self.emit('file', p, stat) self.send(p, stat) }) @@ -664,6 +698,45 @@ SendStream.prototype.sendIndex = function sendIndex (path) { next() } +/** + * Check if the file path resolves to a location within root. + * + * @param {String} path + * @param {Object} stat + * @param {Function} callback + * @api private + */ +SendStream.prototype.checkSymlink = function checkSymlink (path, stat, callback) { + var self = this + var root = this._root + + fs.realpath(path, function (err, realPath) { + if (err) { + debug('realpath error "%s": %s', path, err.message) + return callback(err) + } + + // Resolve root to its realpath as well for consistent comparison + fs.realpath(root, function (err, realRoot) { + if (err) { + debug('realpath error for root "%s": %s', root, err.message) + return callback(err) + } + + // Check if realPath is within realRoot + var isWithinRoot = realPath === realRoot || + realPath.startsWith(realRoot + sep) + + if (!isWithinRoot) { + debug('symlink "%s" points outside root "%s"', path, root) + return callback(new Error('Symlink target outside root')) + } + + callback(null) + }) + }) +} + /** * Stream `path` to the response. * diff --git a/test/fixtures/symlinks/external-link.txt b/test/fixtures/symlinks/external-link.txt new file mode 120000 index 00000000..3594e94c --- /dev/null +++ b/test/fixtures/symlinks/external-link.txt @@ -0,0 +1 @@ +/etc/passwd \ No newline at end of file diff --git a/test/fixtures/symlinks/internal-link.txt b/test/fixtures/symlinks/internal-link.txt new file mode 120000 index 00000000..d27dbd55 --- /dev/null +++ b/test/fixtures/symlinks/internal-link.txt @@ -0,0 +1 @@ +../name.txt \ No newline at end of file diff --git a/test/fixtures/symlinks/safe.txt b/test/fixtures/symlinks/safe.txt new file mode 100644 index 00000000..50ccf490 --- /dev/null +++ b/test/fixtures/symlinks/safe.txt @@ -0,0 +1 @@ +safe content diff --git a/test/send.js b/test/send.js index b8242821..798425f6 100644 --- a/test/send.js +++ b/test/send.js @@ -1303,6 +1303,58 @@ describe('send(file, options)', function () { }) }) }) + + describe('followSymlinks', function () { + it('should default to true (follow symlinks)', function (done) { + request(createServer({ root: path.join(fixtures, 'symlinks') })) + .get('/internal-link.txt') + .expect(200, 'tobi', done) + }) + + it('should allow symlinks within root when false', function (done) { + request(createServer({ followSymlinks: false, root: fixtures })) + .get('/symlinks/internal-link.txt') + .expect(200, 'tobi', done) + }) + + it('should reject symlinks outside root when false', function (done) { + request(createServer({ followSymlinks: false, root: path.join(fixtures, 'symlinks') })) + .get('/external-link.txt') + .expect(403, done) + }) + + it('should serve regular files when false', function (done) { + request(createServer({ followSymlinks: false, root: path.join(fixtures, 'symlinks') })) + .get('/safe.txt') + .expect(200, 'safe content\n', done) + }) + + it('should work with extensions option', function (done) { + // Create a symlink without extension for testing + request(createServer({ followSymlinks: false, root: fixtures, extensions: ['txt'] })) + .get('/name') + .expect(200, 'tobi', done) + }) + + it('should work with index files', function (done) { + var indexFixtures = path.join(fixtures, 'symlinks') + request(createServer({ followSymlinks: false, root: fixtures, index: ['safe.txt'] })) + .get('/symlinks/') + .expect(200, 'safe content\n', done) + }) + + it('should require root option', function (done) { + // followSymlinks without root should not trigger the check + var app = http.createServer(function (req, res) { + send(req, path.join(fixtures, 'symlinks', req.url), { followSymlinks: false }) + .pipe(res) + }) + + request(app) + .get('/safe.txt') + .expect(200, 'safe content\n', done) + }) + }) }) function createServer (opts, fn) {