fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 8m39s

Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON
`{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf`
was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the
"signed PDF" — every signer got a corrupt attachment ("Adobe could not open …
damaged") and the corrupt file was filed against the deal in the CRM.

Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the
JSON and follow `downloadUrl` to fetch the actual file; validate the result is a
real PDF before returning. Backward-compatible with older v1 servers that return
the PDF directly. This also protects the CRM deposit + email fan-out, since both
consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
This commit is contained in:
2026-06-24 23:55:16 +02:00
parent 1c91d76c52
commit af05bb18dc
2 changed files with 125 additions and 2 deletions

View File

@@ -1060,8 +1060,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
});
}
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
// Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
// (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes
// directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it
// doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file.
// Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file
// that got emailed to every signer + filed in the CRM (audit 2026-06-24).
const firstBuf = Buffer.from(await res.arrayBuffer());
if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') {
return firstBuf;
}
let downloadUrl: string | undefined;
try {
downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl;
} catch {
/* body was neither a PDF nor JSON */
}
if (!downloadUrl) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`,
});
}
let pdfRes: Response;
try {
pdfRes = await fetchWithTimeout(downloadUrl, {});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!pdfRes.ok) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`,
});
}
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`,
});
}
return pdfBuf;
}
/** Convenience health-check used by the admin "Test connection" button.