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

OP OpenVibers committed 8 Oct 2026 · 2cbbbe8efd · 2 files, +79 −6


Changes

ddiff --git a/server/http/form-upload.js b/server/http/form-upload.js
iindex 81219ce..d0f3922 100644
---- a/server/http/form-upload.js
++++ b/server/http/form-upload.js
@@@ -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'));
ddiff --git a/test/form-upload.test.js b/test/form-upload.test.js
nnew file mode 100644
iindex 0000000..b253eae
---- /dev/null
++++ b/test/form-upload.test.js
@@@ -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); });