From c83684f9b0bee3df8db575538f12b499f2155349 Mon Sep 17 00:00:00 2001 From: vaibhavmashal Date: Sun, 6 Sep 2026 20:53:52 +0530 Subject: [PATCH] feat: add followSymlinks option to restrict symlink traversal (#297) --- index.js | 48 ++++++++++++++++++++++++++ test/send.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/index.js b/index.js index 1655053..c1b0440 100644 --- a/index.js +++ b/index.js @@ -123,6 +123,10 @@ function SendStream (req, path, options) { ? normalizeList(opts.extensions, 'extensions option') : [] + this._followSymlinks = opts.followSymlinks !== undefined + ? Boolean(opts.followSymlinks) + : true + this._immutable = opts.immutable !== undefined ? Boolean(opts.immutable) : false @@ -488,6 +492,50 @@ SendStream.prototype.pipe = function pipe (res) { */ SendStream.prototype.send = function send (path, stat) { + var self = this + var root = this._root + + if (this.res.headersSent) { + // impossible to send now + this.headersAlreadySent() + return + } + + if (!this._followSymlinks && root !== null) { + debug('check symlink "%s"', path) + fs.realpath(path, function onrealpath (err, realPath) { + if (err) { + return self.onStatError(err) + } + + fs.realpath(root, function onrealroot (err, realRoot) { + if (err) { + return self.onStatError(err) + } + + if (realPath !== realRoot && !realPath.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)) { + debug('symlink target outside root "%s"', realPath) + return self.error(403) + } + + self._send(path, stat) + }) + }) + return + } + + this._send(path, stat) +} + +/** + * Transfer `path` to response. + * + * @param {String} path + * @param {Object} stat + * @api private + */ + +SendStream.prototype._send = function _send (path, stat) { var len = stat.size var options = this.options var opts = {} diff --git a/test/send.js b/test/send.js index b824282..25bfe95 100644 --- a/test/send.js +++ b/test/send.js @@ -919,6 +919,101 @@ describe('send(file, options)', function () { }) }) + describe('followSymlinks', function () { + var petsDir = path.join(fixtures, 'pets') + var linkDir = path.join(petsDir, 'outside-dir') + var linkFile = path.join(petsDir, 'outside-file.txt') + var linkInside = path.join(fixtures, 'inside-symlink.txt') + var hasFileSymlink = false + var hasDirSymlink = false + var hasInsideSymlink = false + + before(function () { + try { + fs.symlinkSync(path.join(fixtures, 'name.txt'), linkFile) + hasFileSymlink = true + } catch (e) { + // file symlink creation may not be permitted on Windows without privileges + } + + try { + fs.symlinkSync(path.join(fixtures, 'name.txt'), linkInside) + hasInsideSymlink = true + } catch (e) { + // file symlink creation may not be permitted on Windows without privileges + } + + try { + var linkType = process.platform === 'win32' ? 'junction' : 'dir' + fs.symlinkSync(fixtures, linkDir, linkType) + hasDirSymlink = true + } catch (e) { + // ignore + } + }) + + after(function () { + if (hasFileSymlink) { + try { + fs.unlinkSync(linkFile) + } catch (e) { + try { fs.rmdirSync(linkFile) } catch (e) {} + } + } + if (hasDirSymlink) { + try { + fs.unlinkSync(linkDir) + } catch (e) { + try { fs.rmdirSync(linkDir) } catch (e) {} + } + } + if (hasInsideSymlink) { + try { + fs.unlinkSync(linkInside) + } catch (e) { + try { fs.rmdirSync(linkInside) } catch (e) {} + } + } + }) + + it('should default to true', function (done) { + if (!hasFileSymlink && !hasDirSymlink) return this.skip() + var targetUrl = hasFileSymlink ? '/outside-file.txt' : '/outside-dir/name.txt' + request(createServer({ root: petsDir })) + .get(targetUrl) + .expect(200, 'tobi', done) + }) + + it('should allow symlinks pointing outside root when true', function (done) { + if (!hasFileSymlink && !hasDirSymlink) return this.skip() + var targetUrl = hasFileSymlink ? '/outside-file.txt' : '/outside-dir/name.txt' + request(createServer({ followSymlinks: true, root: petsDir })) + .get(targetUrl) + .expect(200, 'tobi', done) + }) + + it('should 403 for symlinks pointing outside root when false', function (done) { + if (!hasFileSymlink && !hasDirSymlink) return this.skip() + var targetUrl = hasFileSymlink ? '/outside-file.txt' : '/outside-dir/name.txt' + request(createServer({ followSymlinks: false, root: petsDir })) + .get(targetUrl) + .expect(403, done) + }) + + it('should allow symlinks pointing inside root when false', function (done) { + if (!hasInsideSymlink) return this.skip() + request(createServer({ followSymlinks: false, root: fixtures })) + .get('/inside-symlink.txt') + .expect(200, 'tobi', done) + }) + + it('should allow regular files within root when false', function (done) { + request(createServer({ followSymlinks: false, root: petsDir })) + .get('/index.html') + .expect(200, done) + }) + }) + describe('lastModified', function () { it('should support disabling last-modified', function (done) { request(createServer({ lastModified: false, root: fixtures }))