diff --git a/docs/transports.md b/docs/transports.md index 3fcb2cbe7..f976db0d9 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -106,6 +106,7 @@ looking for daily log rotation see [DailyRotateFile](#dailyrotatefile-transport) * __silent:__ Boolean flag indicating whether to suppress output (default false). * __eol:__ Line-ending character to use. (default: `os.EOL`). * __lazy:__ If true, log files will be created on demand, not at the initialization time. +* __allowSymlinks:__ Boolean flag indicating whether to allow symlinks (default true). * __filename:__ The filename of the logfile to write output to. * __maxsize:__ Max size in bytes of the logfile, if the size is exceeded then a new file is created, a counter will become a suffix of the log file. * __maxFiles:__ Limit the number of files created when the size of the logfile is exceeded. diff --git a/lib/winston/transports/file.js b/lib/winston/transports/file.js index d8d7d4a43..389cfc46f 100644 --- a/lib/winston/transports/file.js +++ b/lib/winston/transports/file.js @@ -80,6 +80,7 @@ module.exports = class File extends TransportStream { this.eol = (typeof options.eol === 'string') ? options.eol : os.EOL; this.tailable = options.tailable || false; this.lazy = options.lazy || false; + this.allowSymlinks = (typeof options.allowSymlinks === 'boolean') ? options.allowSymlinks : true; // Internal state variables representing the number of files this instance // has created and the current size (in bytes) of the current logfile. @@ -610,7 +611,21 @@ module.exports = class File extends TransportStream { const fullpath = path.join(this.dirname, this.filename); debug('create stream start', fullpath, this.options); - const dest = fs.createWriteStream(fullpath, this.options) + + const streamOptions = Object.assign({}, this.options); + if (!this.allowSymlinks) { + /* eslint-disable no-bitwise */ + if (streamOptions.flags === 'a') { + streamOptions.flags = fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW; + } else if (streamOptions.flags === 'w') { + streamOptions.flags = fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW; + } else if (typeof streamOptions.flags === 'number') { + streamOptions.flags |= fs.constants.O_NOFOLLOW; + } + /* eslint-enable no-bitwise */ + } + + const dest = fs.createWriteStream(fullpath, streamOptions) // TODO: What should we do with errors here? .on('error', err => debug(err)) .on('close', () => debug('close', dest.path, dest.bytesWritten)) diff --git a/lib/winston/transports/index.d.ts b/lib/winston/transports/index.d.ts index 76902a060..a8d907094 100644 --- a/lib/winston/transports/index.d.ts +++ b/lib/winston/transports/index.d.ts @@ -36,6 +36,7 @@ declare namespace winston { eol?: string; tailable?: boolean; lazy?: boolean; + allowSymlinks?: boolean; } interface FileTransportInstance extends Transport { diff --git a/test/unit/winston/transports/file.test.js b/test/unit/winston/transports/file.test.js index 6fabceee0..e9928da6f 100644 --- a/test/unit/winston/transports/file.test.js +++ b/test/unit/winston/transports/file.test.js @@ -478,6 +478,153 @@ describe('File Transport', function () { }); }); + describe('Symlink Option', function () { + it('should not write to a symlink when allowSymlinks is false', async function () { + const targetFilename = 'target.log'; + const symlinkFilename = 'symlink.log'; + const targetPath = getFilePath(targetFilename); + const symlinkPath = getFilePath(symlinkFilename); + + // Create target file + fs.writeFileSync(targetPath, 'initial content'); + + // Create symlink + try { + fs.symlinkSync(targetPath, symlinkPath); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + + const transport = new winston.transports.File({ + dirname: testLogFixturesPath, + filename: symlinkFilename, + allowSymlinks: false + }); + + // We expect this to fail silently or emit an error, but definitely NOT write to target + // Using a small amount of data to avoid filling the buffer and hanging + await logToTransport(transport, { kbytes: 1 }); + + // Give it a moment to potentially flush (or fail) + await new Promise(resolve => setTimeout(resolve, 100)); + + const content = fs.readFileSync(targetPath, 'utf8'); + assert.strictEqual( + content, + 'initial content', + 'Target file should not be modified' + ); + }); + + it('should not write to a symlink when allowSymlinks is false and flags is "w"', async function () { + const targetFilename = 'target_w.log'; + const symlinkFilename = 'symlink_w.log'; + const targetPath = getFilePath(targetFilename); + const symlinkPath = getFilePath(symlinkFilename); + + // Create target file + fs.writeFileSync(targetPath, 'initial content'); + + // Create symlink + try { + fs.symlinkSync(targetPath, symlinkPath); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + + const transport = new winston.transports.File({ + dirname: testLogFixturesPath, + filename: symlinkFilename, + allowSymlinks: false, + options: { flags: 'w' } + }); + + await logToTransport(transport, { kbytes: 1 }); + + // Give it a moment to potentially flush (or fail) + await new Promise(resolve => setTimeout(resolve, 100)); + + const content = fs.readFileSync(targetPath, 'utf8'); + assert.strictEqual( + content, + 'initial content', + 'Target file should not be modified' + ); + }); + + it('should not write to a symlink when allowSymlinks is false and flags is number', async function () { + const targetFilename = 'target_num.log'; + const symlinkFilename = 'symlink_num.log'; + const targetPath = getFilePath(targetFilename); + const symlinkPath = getFilePath(symlinkFilename); + + // Create target file + fs.writeFileSync(targetPath, 'initial content'); + + // Create symlink + try { + fs.symlinkSync(targetPath, symlinkPath); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + + const transport = new winston.transports.File({ + dirname: testLogFixturesPath, + filename: symlinkFilename, + allowSymlinks: false, + /* eslint-disable no-bitwise */ + options: { flags: fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY } + /* eslint-enable no-bitwise */ + }); + + await logToTransport(transport, { kbytes: 1 }); + + // Give it a moment to potentially flush (or fail) + await new Promise(resolve => setTimeout(resolve, 100)); + + const content = fs.readFileSync(targetPath, 'utf8'); + assert.strictEqual( + content, + 'initial content', + 'Target file should not be modified' + ); + }); + + it('should write to a symlink when allowSymlinks is true (default)', async function () { + const targetFilename = 'target_default.log'; + const symlinkFilename = 'symlink_default.log'; + const targetPath = getFilePath(targetFilename); + const symlinkPath = getFilePath(symlinkFilename); + + // Create target file + fs.writeFileSync(targetPath, 'initial content'); + + // Create symlink + try { + fs.symlinkSync(targetPath, symlinkPath); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + + const transport = new winston.transports.File({ + dirname: testLogFixturesPath, + filename: symlinkFilename + // allowSymlinks: true is default + }); + + await logToTransport(transport, { kbytes: 1 }); + + // Wait for write + await new Promise(resolve => setTimeout(resolve, 500)); + + const content = fs.readFileSync(targetPath, 'utf8'); + assert.notStrictEqual( + content, + 'initial content', + 'Target file should be modified' + ); + }); + }); // TODO: Reintroduce these tests //