openvibers/openvibe.tools Commits
Tools: an audio job's format can no longer write outside the temp directory; the gateway refuses backslashes in next (#29)
Changes
@@@ -129,6 +129,8 @@ function defineJobs(system) {return `Tool "${id}" takes exactly one file`;}for (const [k, v] of Object.entries(input)) if (v != null && typeof v === 'object') return `Option ${k} must be a string or a number`;++ // The output format becomes the file extension (ffmpeg-helper tmpFile): a short alphanumeric name only.++ if (input.format != null && !/^[a-z0-9]{1,8}$/i.test(String(input.format))) return 'format must be a short audio format name such as mp3, wav or flac';return null;},async run({ input, files, progress, signal }) {@@@ -16,10 +16,16 @@ const tmpDir = path.resolve(config.dataDir, 'tmp');if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });/**-- * Generate a temp file path with the given extension.++ * Generate a temp file path with the given extension. The extension often comes from a request (an output `format`),++ * so only a short alphanumeric one is accepted: anything with a path separator, a dot or `..` would write outside++ * tmpDir. The resolved path is checked to stay inside tmpDir as well.*/function tmpFile(ext) {-- return path.join(tmpDir, `${crypto.randomBytes(16).toString('hex')}.${ext}`);++ const e = String(ext == null ? '' : ext).toLowerCase();++ if (!/^[a-z0-9]{1,8}$/.test(e)) throw Object.assign(new Error('unsupported output format'), { status: 400, code: 'tools.bad_format' });++ const file = path.join(tmpDir, `${crypto.randomBytes(16).toString('hex')}.${e}`);++ if (path.dirname(file) !== tmpDir) throw Object.assign(new Error('unsupported output format'), { status: 400, code: 'tools.bad_format' });++ return file;}/**nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++/**++ * The output format becomes a temp file's extension, so it can never name a path: tmpFile() takes only a short++ * alphanumeric extension and stays inside tmpDir, and the audio.process job refuses any other format at submit.++ * (Security review 2026-10-10: `format: "../../x"` through POST /api/v1/jobs wrote outside the temp directory.)++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-audio-format-'));++const { tmpFile, tmpDir } = require('../server/tools/ffmpeg-helper');++const { defineJobs } = require('../server/process');++++assert.strictEqual(path.dirname(tmpFile('mp3')), tmpDir);++assert.ok(tmpFile('FLAC').endsWith('.flac'));++for (const bad of ['../../etc/x', '../x', 'mp3/../../x', 'a.b', '', 'x'.repeat(9), '..', 'm p3']) {++ assert.throws(() => tmpFile(bad), (e) => e.status === 400 && e.code === 'tools.bad_format', `${JSON.stringify(bad)} is refused`);++}++++let job = null;++defineJobs({ define: (d) => { job = d; } });++const file = [{ path: '/tmp/a.mp3', name: 'a.mp3', size: 1 }];++assert.strictEqual(job.validate({ tool: 'convert', format: 'wav' }, file), null);++assert.match(job.validate({ tool: 'convert', format: '../../x' }, file) || '', /format must be a short audio format name/);++assert.match(job.validate({ tool: 'extract', format: 'mp3/../../../tmp/pwned' }, file) || '', /format must be/);++++fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });++console.log('audio format path: only short alphanumeric formats, inside the temp directory');@@@ -245,6 +245,8 @@ function createAuthRoutes(config, auth) {*/function sanitizeNext(next) {if (!next || typeof next !== 'string') return '/';++ // Browsers drop tabs and newlines and read \ as /, so "/\evil.com" or "/<TAB>/evil.com" would leave the site.++ if (/[\u0000-\u001f\u007f\\]/.test(next)) return '/';if (/^\/(?!\/)/.test(next)) return next; // relative path, not protocol-relativetry {const u = new URL(next);
@@ -129,6 +129,8 @@ function defineJobs(system) {
@@ -129,6 +129,8 @@ function defineJobs(system) {
return `Tool "${id}" takes exactly one file`;
return `Tool "${id}" takes exactly one file`;
}
}
for (const [k, v] of Object.entries(input)) if (v != null && typeof v === 'object') return `Option ${k} must be a string or a number`;
for (const [k, v] of Object.entries(input)) if (v != null && typeof v === 'object') return `Option ${k} must be a string or a number`;
+ // The output format becomes the file extension (ffmpeg-helper tmpFile): a short alphanumeric name only.
+ if (input.format != null && !/^[a-z0-9]{1,8}$/i.test(String(input.format))) return 'format must be a short audio format name such as mp3, wav or flac';
return null;
return null;
},
},
async run({ input, files, progress, signal }) {
async run({ input, files, progress, signal }) {
@@ -16,10 +16,16 @@ const tmpDir = path.resolve(config.dataDir, 'tmp');
@@ -16,10 +16,16 @@ const tmpDir = path.resolve(config.dataDir, 'tmp');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
/**
/**
- * Generate a temp file path with the given extension.
+ * Generate a temp file path with the given extension. The extension often comes from a request (an output `format`),
+ * so only a short alphanumeric one is accepted: anything with a path separator, a dot or `..` would write outside
+ * tmpDir. The resolved path is checked to stay inside tmpDir as well.
*/
*/
function tmpFile(ext) {
function tmpFile(ext) {
- return path.join(tmpDir, `${crypto.randomBytes(16).toString('hex')}.${ext}`);
+ const e = String(ext == null ? '' : ext).toLowerCase();
+ if (!/^[a-z0-9]{1,8}$/.test(e)) throw Object.assign(new Error('unsupported output format'), { status: 400, code: 'tools.bad_format' });
+ const file = path.join(tmpDir, `${crypto.randomBytes(16).toString('hex')}.${e}`);
+ if (path.dirname(file) !== tmpDir) throw Object.assign(new Error('unsupported output format'), { status: 400, code: 'tools.bad_format' });
+ return file;
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+/**
+ * The output format becomes a temp file's extension, so it can never name a path: tmpFile() takes only a short
+ * alphanumeric extension and stays inside tmpDir, and the audio.process job refuses any other format at submit.
+ * (Security review 2026-10-10: `format: "../../x"` through POST /api/v1/jobs wrote outside the temp directory.)
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-audio-format-'));
+const { tmpFile, tmpDir } = require('../server/tools/ffmpeg-helper');
+const { defineJobs } = require('../server/process');
+
+assert.strictEqual(path.dirname(tmpFile('mp3')), tmpDir);
+assert.ok(tmpFile('FLAC').endsWith('.flac'));
+for (const bad of ['../../etc/x', '../x', 'mp3/../../x', 'a.b', '', 'x'.repeat(9), '..', 'm p3']) {
+ assert.throws(() => tmpFile(bad), (e) => e.status === 400 && e.code === 'tools.bad_format', `${JSON.stringify(bad)} is refused`);
+}
+
+let job = null;
+defineJobs({ define: (d) => { job = d; } });
+const file = [{ path: '/tmp/a.mp3', name: 'a.mp3', size: 1 }];
+assert.strictEqual(job.validate({ tool: 'convert', format: 'wav' }, file), null);
+assert.match(job.validate({ tool: 'convert', format: '../../x' }, file) || '', /format must be a short audio format name/);
+assert.match(job.validate({ tool: 'extract', format: 'mp3/../../../tmp/pwned' }, file) || '', /format must be/);
+
+fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
+console.log('audio format path: only short alphanumeric formats, inside the temp directory');
@@ -245,6 +245,8 @@ function createAuthRoutes(config, auth) {
@@ -245,6 +245,8 @@ function createAuthRoutes(config, auth) {
*/
*/
function sanitizeNext(next) {
function sanitizeNext(next) {
if (!next || typeof next !== 'string') return '/';
if (!next || typeof next !== 'string') return '/';
+ // Browsers drop tabs and newlines and read \ as /, so "/\evil.com" or "/<TAB>/evil.com" would leave the site.
+ if (/[\u0000-\u001f\u007f\\]/.test(next)) return '/';
if (/^\/(?!\/)/.test(next)) return next; // relative path, not protocol-relative
if (/^\/(?!\/)/.test(next)) return next; // relative path, not protocol-relative
try {
try {
const u = new URL(next);
const u = new URL(next);