openvibers/openvibe.mediahub Commits
MediaHub form reader: text fields capped at 64 KB, one file per form, a cut-off body is refused; the write-error listener is added once
Changes
@@@ -16,6 +16,8 @@ const crypto = require('crypto');const fs = require('fs');const CRLF = '\r\n';++// A text field is a name or a choice: anything longer than this is not a form we serve, and it would sit in memory.++const FIELD_MAX = 64 * 1024;/** The boundary of a multipart body, or null. */function boundaryOf(contentType) {@@@ -72,12 +74,16 @@ function readFormData(req, { boundary, filePath, maxBytes }) {const take = (data) => {if (!data || !data.length) return;if (!part || part.filename == null) {-- if (!part) part = { field: null, filename: null, contentType: null, data: [] };++ if (!part) part = { field: null, filename: null, contentType: null, data: [], size: 0 };++ part.size = (part.size || 0) + data.length;++ if (part.size > FIELD_MAX) return fail('bad_request', 'a form field is longer than this form takes');part.data.push(data);return;}-- if (!sink) sink = fs.createWriteStream(filePath, { flags: 'w' });-- sink.on('error', (err) => fail('write_failed', (err && err.message) || 'the upload could not be written'));++ if (!sink) {++ sink = fs.createWriteStream(filePath, { flags: 'w' });++ sink.on('error', (err) => fail('write_failed', (err && err.message) || 'the upload could not be written'));++ }file.size += data.length;hash.update(data);if (!sink.write(data)) { req.pause(); sink.once('drain', () => req.resume()); }@@@ -120,7 +126,11 @@ function readFormData(req, { boundary, filePath, maxBytes }) {contentType: type ? type.slice(type.indexOf(':') + 1).trim() : null,data: [],};-- if (part.filename != null && !file) file = { field: null, filename: null, contentType: null, path: filePath, size: 0 };++ if (part.filename != null) {++ // One file per form: a second file part would otherwise be appended to the first.++ if (file) return fail('bad_request', 'one file per upload');++ file = { field: null, filename: null, contentType: null, path: filePath, size: 0 };++ }buf = buf.subarray(end + 4);state = 'body';continue;@@@ -155,10 +165,12 @@ function readFormData(req, { boundary, filePath, maxBytes }) {return collected;};-- // The sink is closed once, whether the last boundary arrived or the request simply ended.++ // The sink is closed once, when the last boundary arrived. A body that ends before it is a cut-off upload,++ // not a shorter file: it is refused rather than stored truncated.let closing = false;const wrapUp = () => {if (closing || settled) return;++ if (state !== 'done') return fail('bad_request', 'the upload ended before the form did');closing = true;endPart();if (sink) sink.end(() => finish(collect()));@@@ -176,7 +188,6 @@ function readFormData(req, { boundary, filePath, maxBytes }) {}req.on('data', onData);-- // A body that ends without its last boundary is still the form we were sent: what arrived is used.req.on('end', wrapUp);req.on('aborted', () => fail('aborted', 'the upload was interrupted'));req.on('error', () => fail('aborted', 'the upload was interrupted'));nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The no-JavaScript upload form's reader (server/http/form-upload.js): a file streams to disk with its hash, and the++ * forms it refuses — a text field too long to hold, a second file, a body cut off before its last boundary.++ */++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { PassThrough } = require('stream');++const { readFormData } = require('../server/http/form-upload');++++const BOUNDARY = 'xYzBoundary123';++const tmp = (n) => path.join(os.tmpdir(), `mh-form-${process.pid}-${n}`);++++function body(parts, { close = true } = {}) {++ const chunks = [];++ for (const p of parts) {++ chunks.push(Buffer.from(`--${BOUNDARY}\r\n`));++ const disp = p.filename != null ? `form-data; name="${p.name}"; filename="${p.filename}"` : `form-data; name="${p.name}"`;++ chunks.push(Buffer.from(`Content-Disposition: ${disp}\r\n${p.type ? `Content-Type: ${p.type}\r\n` : ''}\r\n`));++ chunks.push(Buffer.isBuffer(p.value) ? p.value : Buffer.from(p.value));++ chunks.push(Buffer.from('\r\n'));++ }++ if (close) chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`));++ return Buffer.concat(chunks);++}++++/** Feed a body in small chunks, as a socket would, and read it. */++async function read(buf, n) {++ const req = new PassThrough();++ const out = readFormData(req, { boundary: BOUNDARY, filePath: tmp(n), maxBytes: 10 * 1024 * 1024 });++ for (let i = 0; i < buf.length; i += 4096) req.write(buf.subarray(i, i + 4096));++ req.end();++ return out;++}++++(async () => {++ const data = crypto.randomBytes(200_000);++ let r = await read(body([{ name: 'folder', value: 'fld_1' }, { name: 'file', filename: 'photo.png', type: 'image/png', value: data }]), 1);++ assert.ok(!r.error, JSON.stringify(r));++ assert.strictEqual(r.fields.folder, 'fld_1');++ assert.strictEqual(r.file.filename, 'photo.png');++ assert.strictEqual(r.file.size, data.length);++ assert.strictEqual(r.file.sha256, crypto.createHash('sha256').update(data).digest('hex'));++ assert.ok(fs.readFileSync(r.file.path).equals(data), 'the bytes on disk are the bytes sent');++ fs.unlinkSync(r.file.path);++++ r = await read(body([{ name: 'note', value: 'x'.repeat(70 * 1024) }]), 2);++ assert.strictEqual(r.error, 'bad_request', 'a text field past 64 KB is refused, not held in memory');++++ r = await read(body([{ name: 'a', filename: 'one.bin', value: 'one' }, { name: 'b', filename: 'two.bin', value: 'two' }]), 3);++ assert.strictEqual(r.error, 'bad_request', 'a second file is refused, not appended to the first');++ assert.ok(!fs.existsSync(tmp(3)), 'and nothing is left on disk');++++ r = await read(body([{ name: 'file', filename: 'cut.bin', value: data }], { close: false }), 4);++ assert.strictEqual(r.error, 'bad_request', 'a body cut off before its last boundary is not stored as a shorter file');++ assert.ok(!fs.existsSync(tmp(4)));++++ console.log('form-upload: ok');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -16,6 +16,8 @@ const crypto = require('crypto');
@@ -16,6 +16,8 @@ const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
const CRLF = '\r\n';
const CRLF = '\r\n';
+// A text field is a name or a choice: anything longer than this is not a form we serve, and it would sit in memory.
+const FIELD_MAX = 64 * 1024;
/** The boundary of a multipart body, or null. */
/** The boundary of a multipart body, or null. */
function boundaryOf(contentType) {
function boundaryOf(contentType) {
@@ -72,12 +74,16 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
@@ -72,12 +74,16 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
const take = (data) => {
const take = (data) => {
if (!data || !data.length) return;
if (!data || !data.length) return;
if (!part || part.filename == null) {
if (!part || part.filename == null) {
- if (!part) part = { field: null, filename: null, contentType: null, data: [] };
+ if (!part) part = { field: null, filename: null, contentType: null, data: [], size: 0 };
+ part.size = (part.size || 0) + data.length;
+ if (part.size > FIELD_MAX) return fail('bad_request', 'a form field is longer than this form takes');
part.data.push(data);
part.data.push(data);
return;
return;
}
}
- if (!sink) sink = fs.createWriteStream(filePath, { flags: 'w' });
- sink.on('error', (err) => fail('write_failed', (err && err.message) || 'the upload could not be written'));
+ if (!sink) {
+ sink = fs.createWriteStream(filePath, { flags: 'w' });
+ sink.on('error', (err) => fail('write_failed', (err && err.message) || 'the upload could not be written'));
+ }
file.size += data.length;
file.size += data.length;
hash.update(data);
hash.update(data);
if (!sink.write(data)) { req.pause(); sink.once('drain', () => req.resume()); }
if (!sink.write(data)) { req.pause(); sink.once('drain', () => req.resume()); }
@@ -120,7 +126,11 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
@@ -120,7 +126,11 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
contentType: type ? type.slice(type.indexOf(':') + 1).trim() : null,
contentType: type ? type.slice(type.indexOf(':') + 1).trim() : null,
data: [],
data: [],
};
};
- if (part.filename != null && !file) file = { field: null, filename: null, contentType: null, path: filePath, size: 0 };
+ if (part.filename != null) {
+ // One file per form: a second file part would otherwise be appended to the first.
+ if (file) return fail('bad_request', 'one file per upload');
+ file = { field: null, filename: null, contentType: null, path: filePath, size: 0 };
+ }
buf = buf.subarray(end + 4);
buf = buf.subarray(end + 4);
state = 'body';
state = 'body';
continue;
continue;
@@ -155,10 +165,12 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
@@ -155,10 +165,12 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
return collected;
return collected;
};
};
- // The sink is closed once, whether the last boundary arrived or the request simply ended.
+ // The sink is closed once, when the last boundary arrived. A body that ends before it is a cut-off upload,
+ // not a shorter file: it is refused rather than stored truncated.
let closing = false;
let closing = false;
const wrapUp = () => {
const wrapUp = () => {
if (closing || settled) return;
if (closing || settled) return;
+ if (state !== 'done') return fail('bad_request', 'the upload ended before the form did');
closing = true;
closing = true;
endPart();
endPart();
if (sink) sink.end(() => finish(collect()));
if (sink) sink.end(() => finish(collect()));
@@ -176,7 +188,6 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
@@ -176,7 +188,6 @@ function readFormData(req, { boundary, filePath, maxBytes }) {
}
}
req.on('data', onData);
req.on('data', onData);
- // A body that ends without its last boundary is still the form we were sent: what arrived is used.
req.on('end', wrapUp);
req.on('end', wrapUp);
req.on('aborted', () => fail('aborted', 'the upload was interrupted'));
req.on('aborted', () => fail('aborted', 'the upload was interrupted'));
req.on('error', () => fail('aborted', 'the upload was interrupted'));
req.on('error', () => fail('aborted', 'the upload was interrupted'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The no-JavaScript upload form's reader (server/http/form-upload.js): a file streams to disk with its hash, and the
+ * forms it refuses — a text field too long to hold, a second file, a body cut off before its last boundary.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { PassThrough } = require('stream');
+const { readFormData } = require('../server/http/form-upload');
+
+const BOUNDARY = 'xYzBoundary123';
+const tmp = (n) => path.join(os.tmpdir(), `mh-form-${process.pid}-${n}`);
+
+function body(parts, { close = true } = {}) {
+ const chunks = [];
+ for (const p of parts) {
+ chunks.push(Buffer.from(`--${BOUNDARY}\r\n`));
+ const disp = p.filename != null ? `form-data; name="${p.name}"; filename="${p.filename}"` : `form-data; name="${p.name}"`;
+ chunks.push(Buffer.from(`Content-Disposition: ${disp}\r\n${p.type ? `Content-Type: ${p.type}\r\n` : ''}\r\n`));
+ chunks.push(Buffer.isBuffer(p.value) ? p.value : Buffer.from(p.value));
+ chunks.push(Buffer.from('\r\n'));
+ }
+ if (close) chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`));
+ return Buffer.concat(chunks);
+}
+
+/** Feed a body in small chunks, as a socket would, and read it. */
+async function read(buf, n) {
+ const req = new PassThrough();
+ const out = readFormData(req, { boundary: BOUNDARY, filePath: tmp(n), maxBytes: 10 * 1024 * 1024 });
+ for (let i = 0; i < buf.length; i += 4096) req.write(buf.subarray(i, i + 4096));
+ req.end();
+ return out;
+}
+
+(async () => {
+ const data = crypto.randomBytes(200_000);
+ let r = await read(body([{ name: 'folder', value: 'fld_1' }, { name: 'file', filename: 'photo.png', type: 'image/png', value: data }]), 1);
+ assert.ok(!r.error, JSON.stringify(r));
+ assert.strictEqual(r.fields.folder, 'fld_1');
+ assert.strictEqual(r.file.filename, 'photo.png');
+ assert.strictEqual(r.file.size, data.length);
+ assert.strictEqual(r.file.sha256, crypto.createHash('sha256').update(data).digest('hex'));
+ assert.ok(fs.readFileSync(r.file.path).equals(data), 'the bytes on disk are the bytes sent');
+ fs.unlinkSync(r.file.path);
+
+ r = await read(body([{ name: 'note', value: 'x'.repeat(70 * 1024) }]), 2);
+ assert.strictEqual(r.error, 'bad_request', 'a text field past 64 KB is refused, not held in memory');
+
+ r = await read(body([{ name: 'a', filename: 'one.bin', value: 'one' }, { name: 'b', filename: 'two.bin', value: 'two' }]), 3);
+ assert.strictEqual(r.error, 'bad_request', 'a second file is refused, not appended to the first');
+ assert.ok(!fs.existsSync(tmp(3)), 'and nothing is left on disk');
+
+ r = await read(body([{ name: 'file', filename: 'cut.bin', value: data }], { close: false }), 4);
+ assert.strictEqual(r.error, 'bad_request', 'a body cut off before its last boundary is not stored as a shorter file');
+ assert.ok(!fs.existsSync(tmp(4)));
+
+ console.log('form-upload: ok');
+})().catch((err) => { console.error(err); process.exit(1); });