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)
Changes
@@@ -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 };@@@ -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)})`);@@@ -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 };});@@@ -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');@@@ -110,7 +110,7 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /beinawait 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 decryptedassert.strictEqual(doc.getPageCount(), 3);
@@ -53,9 +53,10 @@ const pdfinfo = createBinary({ name: 'pdfinfo', candidates: ['pdfinfo'], envVar:
@@ -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.
* @file) readable only by this user, so passwords are not visible in the process list.
* → { code, stdout, stderr }
* → { 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) => {
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;
let stdout = '', stderr = '', timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
child.stdout.on('data', d => { if (stdout.length < maxOutput) stdout += d; });
child.stdout.on('data', d => { if (stdout.length < maxOutput) stdout += d; });
@@ -75,18 +76,21 @@ async function withTempDir(fn) {
@@ -75,18 +76,21 @@ async function withTempDir(fn) {
try { return await fn(dir); } finally { fs.rm(dir, { recursive: true, force: true }, () => {}); }
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');
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). */
/** qpdf's page count (with a password for encrypted files). */
async function qpdfPages(bin, dir, input, password) {
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 };
if (r.code !== 0 && r.code !== 3) return { error: r.stderr || r.stdout };
return { pages: parseInt(r.stdout.trim(), 10) };
return { pages: parseInt(r.stdout.trim(), 10) };
}
}
@@ -134,4 +138,4 @@ function zip(entries) {
@@ -134,4 +138,4 @@ function zip(entries) {
return Buffer.concat([...locals, cd, end]);
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 };
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const fsp = require('fs/promises');
const fsp = require('fs/promises');
const path = require('path');
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
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 = {}) {
@@ -45,11 +45,11 @@ async function protect(buffer, options = {}) {
}
}
checkPages(count.pages);
checkPages(count.pages);
- const args = await writeArgFile(dir, [
+ const stdin = stdinArgs([
'--encrypt', `--user-password=${userPassword}`, `--owner-password=${ownerPassword}`, '--bits=256',
'--encrypt', `--user-password=${userPassword}`, `--owner-password=${ownerPassword}`, '--bits=256',
`--print=${allowPrint ? 'full' : 'none'}`, `--extract=${allowCopy ? 'y' : 'n'}`, '--',
`--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).
// 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)})`);
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)})`);
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
const fsp = require('fs/promises');
const fsp = require('fs/promises');
const path = require('path');
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
* @param {Buffer} buffer - PDF buffer
@@ -36,13 +36,12 @@ async function unlock(buffer, options = {}) {
@@ -36,13 +36,12 @@ async function unlock(buffer, options = {}) {
const count = await qpdfPages(bin, dir, input, password);
const count = await qpdfPages(bin, dir, input, password);
if (count.error) {
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.');
throw refuse('This file could not be read as a PDF.');
}
}
checkPages(count.pages);
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.');
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 };
return { buffer: await fsp.readFile(output), ext: 'pdf', mime: 'application/pdf', pageCount: count.pages };
});
});
@@ -18,7 +18,9 @@ async function watermark(buffer, options = {}) {
@@ -18,7 +18,9 @@ async function watermark(buffer, options = {}) {
const text = options.text || 'WATERMARK';
const text = options.text || 'WATERMARK';
const fontSize = parseInt(options.fontSize) || 48;
const fontSize = parseInt(options.fontSize) || 48;
const opacity = Math.min(1, Math.max(0.01, parseFloat(options.opacity) || 0.15));
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)
// Parse color (hex to rgb)
const color = parseColor(options.color || '#888888');
const color = parseColor(options.color || '#888888');
@@ -110,7 +110,7 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /bein
@@ -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', locked.buffer, { password: 'again' }), /already password-protected/);
await assert.rejects(run('protect', three, {}), (e) => e.status === 400);
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 open = await run('unlock', locked.buffer, { password: 's3cret pass' });
const doc = await PDFDocument.load(open.buffer); // no ignoreEncryption: really decrypted
const doc = await PDFDocument.load(open.buffer); // no ignoreEncryption: really decrypted
assert.strictEqual(doc.getPageCount(), 3);
assert.strictEqual(doc.getPageCount(), 3);