Skip to content
Open
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@
? 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']
Expand Down Expand Up @@ -598,7 +602,7 @@
* @api private
*/
SendStream.prototype.sendFile = function sendFile (path) {
var i = 0

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
var self = this

debug('stat "%s"', path)
Expand All @@ -611,6 +615,16 @@
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)
})
Expand All @@ -625,9 +639,19 @@
var p = path + '.' + self._extensions[i++]

debug('stat "%s"', p)
fs.stat(p, function (err, stat) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
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)
})
Expand All @@ -653,9 +677,19 @@
var p = join(path, self._index[i])

debug('stat "%s"', p)
fs.stat(p, function (err, stat) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
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)
})
Expand All @@ -664,6 +698,45 @@
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

Check failure on line 710 in index.js

View workflow job for this annotation

GitHub Actions / Lint

'self' is assigned a value but never used
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.
*
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/symlinks/external-link.txt
1 change: 1 addition & 0 deletions test/fixtures/symlinks/internal-link.txt
1 change: 1 addition & 0 deletions test/fixtures/symlinks/safe.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
safe content
52 changes: 52 additions & 0 deletions test/send.js
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,58 @@
})
})
})

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')

Check failure on line 1340 in test/send.js

View workflow job for this annotation

GitHub Actions / Lint

'indexFixtures' is assigned a value but never used
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) {
Expand Down
Loading