Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Safe default filename #689

Merged
merged 34 commits into from
Mar 18, 2021
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
777a04f
feat: allow to use filename option without keepExtension
GrosSacASac Feb 18, 2021
5531a57
feat: can use uploadDir option and filename together
GrosSacASac Feb 18, 2021
33dd6c4
refactor: rename
GrosSacASac Feb 18, 2021
ebb7a18
feat: by default prevent directory traversal attacks
GrosSacASac Feb 18, 2021
cb3d359
refactor: make octet stream less divergent
GrosSacASac Feb 18, 2021
aafebea
refactor: pass through, avoid renaming variable names
GrosSacASac Feb 18, 2021
0a5bad4
refactor: prefer Object.assign
GrosSacASac Feb 18, 2021
032e66b
feat: pass newName
GrosSacASac Feb 18, 2021
ad1664a
feat: give createFileWriteStream more, including newName
GrosSacASac Feb 18, 2021
c178080
docs: update
GrosSacASac Feb 18, 2021
f600f32
docs: update examples
GrosSacASac Feb 18, 2021
3ca6382
fix: fix missing variables
GrosSacASac Feb 18, 2021
df66b4f
feat: remove duplicate fix tests
GrosSacASac Feb 18, 2021
e0576e1
lint: lint
GrosSacASac Feb 18, 2021
59a9b2b
refactor: rename mime into mimetype
GrosSacASac Feb 19, 2021
2ca0b78
refactor: explicit this.options.keepExtensions !== true
GrosSacASac Feb 19, 2021
e4b7fe6
tests: reverse expectation order
GrosSacASac Feb 19, 2021
3481057
tests: rename to xname to avoid confusion
GrosSacASac Feb 19, 2021
bef35f2
refactor: inline old _uploadPath
GrosSacASac Feb 19, 2021
45c9213
refactor: rename newName into newFileName
GrosSacASac Feb 19, 2021
7edf4bb
refactor: rename filename into originalFilename
GrosSacASac Feb 19, 2021
8340df2
refactor: direcly filepath
GrosSacASac Feb 26, 2021
63402a1
fix: test
GrosSacASac Feb 26, 2021
9e54b19
refactor: split hash into hashAlgorithm and hash
GrosSacASac Feb 26, 2021
4f09883
feat: this.lastModifiedDate = null remains
GrosSacASac Feb 26, 2021
2d85616
refactor: finalpath: filepath
GrosSacASac Feb 26, 2021
48f25fc
fix: change order
GrosSacASac Feb 26, 2021
3c14493
refactor: better be explicit
GrosSacASac Feb 26, 2021
58e4a61
feat: display more in toString
GrosSacASac Feb 26, 2021
4ff8447
docs: update changelog
GrosSacASac Mar 2, 2021
fec82d3
chore: update version
GrosSacASac Mar 2, 2021
738ff5c
Merge branch 'master' into safe-default-filename
GrosSacASac Mar 3, 2021
4bce5f7
fix: revert, renamed too much
GrosSacASac Mar 4, 2021
97181fe
fix: _flush is more appropriate
GrosSacASac Mar 5, 2021
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

### Unreleased (`canary` & `dev` dist-tags)

* rename option hash into hashAlgorithm
* rename file.path into file.filepath
* rename file.type into file.mimetype
* split file.name into file.newFilename and file.originalFilename
* prevent directory traversal attacks by default
* options.fileWriteStreamHandler receives the entire file instance (including file.newFilename)
* Add fileWriteStreamHandler option
* Add allowEmptyFiles and minFileSize options
* Test only on Node.js >= v10. Support only Node LTS and latest ([#515](https://github.com/node-formidable/node-formidable/pull/515))
Expand Down
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js)
- `options.maxFieldsSize` **{number}** - default `20 * 1024 * 1024` (20mb);
limit the amount of memory all fields together (except files) can allocate in
bytes.
- `options.hash` **{string | false}** - default `false`; include checksums calculated
- `options.hashAlgorithm` **{string | false}** - default `false`; include checksums calculated
for incoming files, set this to some hash algorithm, see
[crypto.createHash](https://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm_options)
for available algorithms
Expand All @@ -354,6 +354,10 @@ See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js)
files for inputs which submit multiple files using the HTML5 `multiple`
attribute. Also, the `fields` argument will contain arrays of values for
fields that have names ending with '[]'.
- `options.filename` **{function}** - default `undefined` Use it to control
newFilename. Must return a string. Will be joined with options.uploadDir.

#### `options.filename` **{function}** function (name, ext, part, form) -> string

_**Note:** If this size of combined fields, or size of some file is exceeded, an
`'error'` event is fired._
Expand Down Expand Up @@ -528,7 +532,7 @@ you can remove it from the `options.enabledPlugins`, like so
const { Formidable } = require('formidable');

const form = new Formidable({
hash: 'sha1',
hashAlgorithm: 'sha1',
enabledPlugins: ['octetstream', 'querystring', 'json'],
});
```
Expand Down Expand Up @@ -565,9 +569,9 @@ const form = formidable();

form.onPart = function (part) {
// let formidable handle only non-file parts
if (part.filename === '' || !part.mime) {
if (part.originalFilename === '' || !part.mimetype) {
// used internally, please do not override!
form.handlePart(part);
form._handlePart(part);
}
};
```
Expand All @@ -583,7 +587,7 @@ export interface File {

// The path this file is being written to. You can modify this in the `'fileBegin'` event in
// case you are unhappy with the way formidable generates a temporary path for your files.
file.path: string;
file.filepath: string;

// The name this file had according to the uploading client.
file.name: string | null;
Expand All @@ -595,8 +599,9 @@ export interface File {
// Mostly here for compatibility with the [W3C File API Draft](http://dev.w3.org/2006/webapi/FileAPI/).
file.lastModifiedDate: Date | null;

// If `options.hash` calculation was set, you can read the hex digest out of this var.
file.hash: string | 'sha1' | 'md5' | 'sha256' | null;
file.hashAlgorithm: false | |'sha1' | 'md5' | 'sha256'
// If `options.hashAlgorithm` calculation was set, you can read the hex digest out of this var.
file.hash: string | object | null;
}
```

Expand Down Expand Up @@ -634,10 +639,11 @@ file system.
```js
form.on('fileBegin', (formName, file) => {
// accessible here
// formName the name in the form (<input name="thisname" type="file">)
// file.name http filename or null if there was a parsing error
// file.path default pathnme as per options.uploadDir and options.filename
// file.path = CUSTOM_PATH // to change the final path
// formName the name in the form (<input name="thisname" type="file">) or http filename for octetstream
// file.originalFilename http filename or null if there was a parsing error
// file.newFilename generated hexoid or what options.filename returned
// file.filepath default pathnme as per options.uploadDir and options.filename
// file.filepath = CUSTOM_PATH // to change the final path
});
```

Expand All @@ -649,7 +655,7 @@ Emitted whenever a field / file pair has been received. `file` is an instance of
```js
form.on('file', (formname, file) => {
// same as fileBegin, except
// it is too late to change file.path
// it is too late to change file.filepath
// file.hash is available if options.hash was used
});
```
Expand Down
2 changes: 1 addition & 1 deletion examples/log-file-content-to-console.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const server = http.createServer((req, res) => {
if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') {
// parse a file upload
const form = formidable({
fileWriteStreamHandler: () => {
fileWriteStreamHandler: (/* file */) => {
const writable = Writable();
// eslint-disable-next-line no-underscore-dangle
writable._write = (chunk, enc, next) => {
Expand Down
4 changes: 2 additions & 2 deletions examples/store-files-on-s3.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ const s3Client = new AWS.S3({
},
});

const uploadStream = (filename) => {
const uploadStream = (file) => {
const pass = PassThrough();
s3Client.upload(
{
Bucket: 'demo-bucket',
Key: filename,
Key: file.newName,
Body: pass,
},
(err, data) => {
Expand Down
23 changes: 15 additions & 8 deletions examples/with-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@ const formidable = require('../src/index');
const server = http.createServer((req, res) => {
if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') {
// parse a file upload
const form = formidable({ multiples: true });

form.on('fileBegin', (formName, file) => {
if (file.name === null) {
// todo change lint rules because that is what we recommend
// eslint-disable-next-line
file.name = 'invalid-characters';
}
const form = formidable({
multiples: true,
uploadDir: `uploads`,
keepExtensions: true,
filename(/*name, ext, part, form*/) {
/* name basename of the http originalFilename
ext with the dot ".txt" only if keepExtension is true
*/
// slugify to avoid invalid filenames
// substr to define a maximum length
// return `${slugify(name).${slugify(ext, separator: '')}`.substr(0, 100);
return 'yo.txt'; // or completly different name
// return 'z/yo.txt'; // subdirectory
},
});

form.parse(req, (err, fields, files) => {
if (err) {
console.error(err);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "formidable",
"version": "2.0.0-canary.20210216",
"version": "2.0.0-canary.20210302",
"license": "MIT",
"description": "A node.js module for parsing form data, especially file uploads.",
"homepage": "https://github.com/node-formidable/formidable",
Expand Down
98 changes: 62 additions & 36 deletions src/Formidable.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ const DEFAULT_OPTIONS = {
allowEmptyFiles: true,
keepExtensions: false,
encoding: 'utf-8',
hash: false,
hashAlgorithm: false,
uploadDir: os.tmpdir(),
multiples: false,
enabledPlugins: ['octetstream', 'querystring', 'multipart', 'json'],
fileWriteStreamHandler: null,
defaultInvalidName: 'invalid-name',
};

const PersistentFile = require('./PersistentFile');
Expand All @@ -46,7 +47,9 @@ class IncomingForm extends EventEmitter {

this.options = { ...DEFAULT_OPTIONS, ...options };

const dir = this.options.uploadDir || this.options.uploaddir || os.tmpdir();
const dir = path.resolve(
this.options.uploadDir || this.options.uploaddir || os.tmpdir(),
);

this.uploaddir = dir;
this.uploadDir = dir;
Expand Down Expand Up @@ -266,27 +269,27 @@ class IncomingForm extends EventEmitter {
}

_handlePart(part) {
if (part.filename && typeof part.filename !== 'string') {
if (part.originalFilename && typeof part.originalFilename !== 'string') {
this._error(
new FormidableError(
`the part.filename should be string when it exists`,
`the part.originalFilename should be string when it exists`,
errors.filenameNotString,
),
);
return;
}

// This MUST check exactly for undefined. You can not change it to !part.filename.
// This MUST check exactly for undefined. You can not change it to !part.originalFilename.

// todo: uncomment when switch tests to Jest
// console.log(part);

// ? NOTE(@tunnckocore): no it can be any falsey value, it most probably depends on what's returned
// from somewhere else. Where recently I changed the return statements
// and such thing because code style
// ? NOTE(@tunnckocore): or even better, if there is no mime, then it's for sure a field
// ? NOTE(@tunnckocore): filename is an empty string when a field?
if (!part.mime) {
// ? NOTE(@tunnckocore): or even better, if there is no mimetype, then it's for sure a field
// ? NOTE(@tunnckocore): originalFilename is an empty string when a field?
if (!part.mimetype) {
let value = '';
const decoder = new StringDecoder(
part.transferEncoding || this.options.encoding,
Expand Down Expand Up @@ -315,10 +318,13 @@ class IncomingForm extends EventEmitter {

this._flushing += 1;

const newFilename = this._getNewName(part);
const filepath = this._joinDirectoryName(newFilename);
const file = this._newFile({
path: this._rename(part),
filename: part.filename,
mime: part.mime,
newFilename,
filepath,
originalFilename: part.originalFilename,
mimetype: part.mimetype,
});
file.on('error', (err) => {
this._error(err);
Expand Down Expand Up @@ -475,19 +481,22 @@ class IncomingForm extends EventEmitter {
return new MultipartParser(this.options);
}

_newFile({ path: filePath, filename: name, mime: type }) {
_newFile({ filepath, originalFilename, mimetype, newFilename }) {
return this.options.fileWriteStreamHandler
? new VolatileFile({
name,
type,
newFilename,
filepath,
originalFilename,
mimetype,
createFileWriteStream: this.options.fileWriteStreamHandler,
hash: this.options.hash,
hashAlgorithm: this.options.hashAlgorithm,
})
: new PersistentFile({
path: filePath,
name,
type,
hash: this.options.hash,
newFilename,
filepath,
originalFilename,
mimetype,
hashAlgorithm: this.options.hashAlgorithm,
});
}

Expand All @@ -499,13 +508,13 @@ class IncomingForm extends EventEmitter {
if (!m) return null;

const match = m[2] || m[3] || '';
let filename = match.substr(match.lastIndexOf('\\') + 1);
filename = filename.replace(/%22/g, '"');
filename = filename.replace(/&#([\d]{4});/g, (_, code) =>
let originalFilename = match.substr(match.lastIndexOf('\\') + 1);
originalFilename = originalFilename.replace(/%22/g, '"');
originalFilename = originalFilename.replace(/&#([\d]{4});/g, (_, code) =>
String.fromCharCode(code),
);

return filename;
return originalFilename;
}

_getExtension(str) {
Expand All @@ -525,28 +534,45 @@ class IncomingForm extends EventEmitter {
return basename.slice(firstDot, lastDot) + extname;
}

_uploadPath(part, fp) {
const name = fp || `${this.uploadDir}${path.sep}${toHexoId()}`;

if (part && this.options.keepExtensions) {
const filename = typeof part === 'string' ? part : part.filename;
return `${name}${this._getExtension(filename)}`;

_joinDirectoryName(name) {
const newPath = path.join(this.uploadDir, name);

// prevent directory traversal attacks
if (!newPath.startsWith(this.uploadDir)) {
return path.join(this.uploadDir, this.options.defaultInvalidName);
}

return name;
return newPath;
}

_setUpRename() {
const hasRename = typeof this.options.filename === 'function';

if (this.options.keepExtensions === true && hasRename) {
this._rename = (part) => {
const resultFilepath = this.options.filename.call(this, part, this);

return this._uploadPath(part, resultFilepath);
if (hasRename) {
this._getNewName = (part) => {
let ext = '';
let name = this.options.defaultInvalidName;
if (part.originalFilename) {
// can be null
({ ext, name } = path.parse(part.originalFilename));
if (this.options.keepExtensions !== true) {
ext = '';
}
}
return this.options.filename.call(this, name, ext, part, this);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path's return of ext isn't handling well filenames with multiple dots, that's why i did the _getExtension method, maybe use it here too.

this._getNewName = (part) => {
  let filename = this.options.defaultInvalidName
  let extname = '';

  if (part.mimetype) {
    filename = part.originalFilename;
    if (this.options.keepExtensions === true) {
      extname = this._getExtename(part.originalFilename);
    }
  }

  return this.options.filename.call(this, filename, extname, part, this);
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but the problem is that there would be duplicated parts. For example x.y.z.w would call
this.options.filename.call(this, name, ext, part, this);
with name = 'x.y.z' and ext = '.y.z.w'

};
} else {
this._rename = (part) => this._uploadPath(part);
this._getNewName = (part) => {
const name = toHexoId();

if (part && this.options.keepExtensions) {
const originalFilename = typeof part === 'string' ? part : part.originalFilename;
return `${name}${this._getExtension(originalFilename)}`;
}

return name;
}
}
}

Expand Down
1 change: 0 additions & 1 deletion src/FormidableError.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable no-plusplus */


const missingPlugin = 1000;
const pluginFunction = 1001;
const aborted = 1002;
Expand Down
Loading