openvibers/openvibe.tools Commits

Docs: Protect, Unlock and the password page count give qpdf its password arguments on stdin (@-) instead of an @file argument file. qpdf 12.3.2 on the host did not expand @file (it opened '@/tmp/…/args' as a PDF), so every Protect PDF in production failed with 'The PDF could not be encrypted ()'; the tests passed against an older qpdf. Verified on the host's qpdf 12.3.2: AES-256 (R=6) output, correct password unlocks. Passwords still never appear on the command line. A wrong password answers 422 tools.pdf.wrong_password (the contract's status; was 400). Watermark: a missing rotation now defaults to -45 degrees (parseInt gave NaN, so ?? never applied)

OP OpenVibers committed 24 Sept 2026 · 13b79ce0a5 · 5 files, +24 −19


Changes

ddiff --git a/apps/docs/server/tools/pdf.js b/apps/docs/server/tools/pdf.js
iindex 5c1ec36..e6cb905 100644
---- a/apps/docs/server/tools/pdf.js
++++ b/apps/docs/server/tools/pdf.js
@@@ -53,9 +53,10 @@ const pdfinfo = createBinary({ name: 'pdfinfo', candidates: ['pdfinfo'], envVar:
* @file) readable only by this user, so passwords are not visible in the process list.
* → { code, stdout, stderr }
*/
--function run(bin, args, { timeoutMs = 120_000, cwd, maxOutput = 64 * 1024 } = {}) {
++function run(bin, args, { timeoutMs = 120_000, cwd, maxOutput = 64 * 1024, stdin = null } = {}) {
return new Promise((resolve, reject) => {
-- const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
++ const child = spawn(bin, args, { cwd, stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
++ if (stdin != null) { child.stdin.on('error', () => {}); child.stdin.end(stdin); }
let stdout = '', stderr = '', timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
child.stdout.on('data', d => { if (stdout.length < maxOutput) stdout += d; });
@@@ -75,18 +76,21 @@ async function withTempDir(fn) {
try { return await fn(dir); } finally { fs.rm(dir, { recursive: true, force: true }, () => {}); }
}
--/** One argument per line for qpdf's @file (a line break inside an argument cannot be represented). */
--async function writeArgFile(dir, args) {
++/**
++ * Arguments that carry a password go to qpdf on stdin (`@-`, one per line), never on the command line
++ * where any local user could read them in the process list. (qpdf 12.3's `@file` argument files did
++ * not expand reliably on the host; `@-` does, in any position.) A line break inside an argument
++ * cannot be represented, so it is refused.
++ */
++function stdinArgs(args) {
for (const a of args) if (/[\r\n\0]/.test(a)) throw refuse('Passwords cannot contain line breaks');
-- const file = path.join(dir, 'args');
-- await fsp.writeFile(file, args.join('\n') + '\n', { mode: 0o600 });
-- return file;
++ return args.join('\n') + '\n';
}
/** qpdf's page count (with a password for encrypted files). */
async function qpdfPages(bin, dir, input, password) {
-- const args = password != null ? [`@${await writeArgFile(dir, [`--password=${password}`])}`] : [];
-- const r = await run(bin, [...args, '--show-npages', input], { timeoutMs: 60_000 });
++ const stdin = password != null ? stdinArgs([`--password=${password}`]) : null;
++ const r = await run(bin, [...(stdin != null ? ['@-'] : []), '--show-npages', input], { timeoutMs: 60_000, stdin });
if (r.code !== 0 && r.code !== 3) return { error: r.stderr || r.stdout };
return { pages: parseInt(r.stdout.trim(), 10) };
}
@@@ -134,4 +138,4 @@ function zip(entries) {
return Buffer.concat([...locals, cd, end]);
}
--module.exports = { MAX_PAGES, checkPages, loadPdf, refuse, qpdf, pdftoppm, pdfinfo, run, withTempDir, writeArgFile, qpdfPages, zip, crc32 };
++module.exports = { MAX_PAGES, checkPages, loadPdf, refuse, qpdf, pdftoppm, pdfinfo, run, withTempDir, stdinArgs, qpdfPages, zip, crc32 };
ddiff --git a/apps/docs/server/tools/protect.js b/apps/docs/server/tools/protect.js
iindex d4c98c5..95c926c 100644
---- a/apps/docs/server/tools/protect.js
++++ b/apps/docs/server/tools/protect.js
@@@ -10,7 +10,7 @@
const crypto = require('crypto');
const fsp = require('fs/promises');
const path = require('path');
--const { qpdf, run, withTempDir, writeArgFile, qpdfPages, checkPages, refuse } = require('./pdf');
++const { qpdf, run, withTempDir, stdinArgs, qpdfPages, checkPages, refuse } = require('./pdf');
const MAX_PASSWORD_BYTES = 127; // AES-256 (R6) passwords are UTF-8, at most 127 bytes
@@@ -45,11 +45,11 @@ async function protect(buffer, options = {}) {
}
checkPages(count.pages);
-- const args = await writeArgFile(dir, [
++ const stdin = stdinArgs([
'--encrypt', `--user-password=${userPassword}`, `--owner-password=${ownerPassword}`, '--bits=256',
`--print=${allowPrint ? 'full' : 'none'}`, `--extract=${allowCopy ? 'y' : 'n'}`, '--',
]);
-- const r = await run(bin, [`@${args}`, input, output]);
++ const r = await run(bin, ['@-', input, output], { stdin });
// 0 = done, 3 = done with warnings (qpdf repaired something on the way).
if (r.code !== 0 && r.code !== 3) throw refuse(`The PDF could not be encrypted (${(r.stderr || '').split('\n')[0].replace(/^qpdf:\s*/, '').replace(dir, '').slice(0, 160)})`);
ddiff --git a/apps/docs/server/tools/unlock.js b/apps/docs/server/tools/unlock.js
iindex 7f92d83..18a4c9f 100644
---- a/apps/docs/server/tools/unlock.js
++++ b/apps/docs/server/tools/unlock.js
@@@ -9,7 +9,7 @@
const fsp = require('fs/promises');
const path = require('path');
--const { qpdf, run, withTempDir, writeArgFile, qpdfPages, checkPages, refuse } = require('./pdf');
++const { qpdf, run, withTempDir, stdinArgs, qpdfPages, checkPages, refuse } = require('./pdf');
/**
* @param {Buffer} buffer - PDF buffer
@@@ -36,13 +36,12 @@ async function unlock(buffer, options = {}) {
const count = await qpdfPages(bin, dir, input, password);
if (count.error) {
-- if (/invalid password/i.test(count.error)) throw refuse(password ? 'The password is incorrect.' : 'This PDF needs its password to be unlocked.', 400, 'tools.pdf.wrong_password');
++ if (/invalid password/i.test(count.error)) throw refuse(password ? 'The password is incorrect.' : 'This PDF needs its password to be unlocked.', 422, 'tools.pdf.wrong_password');
throw refuse('This file could not be read as a PDF.');
}
checkPages(count.pages);
-- const args = await writeArgFile(dir, [`--password=${password}`, '--decrypt']);
-- const r = await run(bin, [`@${args}`, input, output]);
++ const r = await run(bin, ['@-', input, output], { stdin: stdinArgs([`--password=${password}`, '--decrypt']) });
if (r.code !== 0 && r.code !== 3) throw refuse('The PDF could not be unlocked.');
return { buffer: await fsp.readFile(output), ext: 'pdf', mime: 'application/pdf', pageCount: count.pages };
});
ddiff --git a/apps/docs/server/tools/watermark.js b/apps/docs/server/tools/watermark.js
iindex 09309cf..a3b5eaf 100644
---- a/apps/docs/server/tools/watermark.js
++++ b/apps/docs/server/tools/watermark.js
@@@ -18,7 +18,9 @@ async function watermark(buffer, options = {}) {
const text = options.text || 'WATERMARK';
const fontSize = parseInt(options.fontSize) || 48;
const opacity = Math.min(1, Math.max(0.01, parseFloat(options.opacity) || 0.15));
-- const rotation = parseInt(options.rotation) ?? -45;
++ // parseInt of a missing value is NaN (never null), so `?? -45` never applied the default.
++ const rotationIn = parseInt(options.rotation, 10);
++ const rotation = Number.isFinite(rotationIn) ? rotationIn : -45;
// Parse color (hex to rgb)
const color = parseColor(options.color || '#888888');
ddiff --git a/apps/docs/test/pdf-tools.test.js b/apps/docs/test/pdf-tools.test.js
iindex 12e1693..793a6f6 100644
---- a/apps/docs/test/pdf-tools.test.js
++++ b/apps/docs/test/pdf-tools.test.js
@@@ -110,7 +110,7 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /bein
await assert.rejects(run('protect', locked.buffer, { password: 'again' }), /already password-protected/);
await assert.rejects(run('protect', three, {}), (e) => e.status === 400);
-- await assert.rejects(run('unlock', locked.buffer, { password: 'wrong' }), (e) => e.status === 400 && e.code === 'tools.pdf.wrong_password');
++ await assert.rejects(run('unlock', locked.buffer, { password: 'wrong' }), (e) => e.status === 422 && e.code === 'tools.pdf.wrong_password');
const open = await run('unlock', locked.buffer, { password: 's3cret pass' });
const doc = await PDFDocument.load(open.buffer); // no ignoreEncryption: really decrypted
assert.strictEqual(doc.getPageCount(), 3);