ROCK-9041 Stop SignNowDownload leaking the temp PDF file handle - #321
Conversation
Read the downloaded SignNow PDF into a MemoryStream instead of assigning
an open FileStream to BinaryFile.ContentStream. The Azure provider did not
dispose the stream on save, so the temp file stayed locked and the
File.Delete afterwards threw. Using a MemoryStream removes the dependency
on the provider's dispose behavior entirely.
Also persist the updated content when the BinaryFile already exists (the
else branch assigned a stream but never called SaveChanges), and delete
the actual downloaded path ({name}.pdf) rather than {name}, which left
temp PDFs behind on every pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Ensure temporary PDFs are deleted on failure and update the stale README documentation.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Updates SignNowDownload to prevent PDF stream leaks, persist re-downloaded documents, and clean up temporary files.
Changes:
- Reads PDFs into memory before storage.
- Saves updates to existing binary files.
- Deletes the correct
.pdftemporary path.
| File | Description |
|---|---|
Plugins/org.secc.SignNowWorkflow/Workflows/SignNowDownload.cs |
Fixes stream handling, persistence, and temporary-file cleanup. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Download into a per-call temp directory, check the SDK result, and delete the directory before any database work so concurrent runs can't store each other's PDF and cleanup can't throw after the save. Fall back to the Default file type so new files keep their content, return errors instead of throwing on SignNow error responses, add the .pdf extension, and remove dead code. Update the README to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jwakefield-secc
left a comment
There was a problem hiding this comment.
Review notes
Verified against RestSharp 105.2.3, SignNowSDK Document.cs, and Rock 1.16.12.1 BinaryFile.SaveHook / GetFile.ashx. The core fix (MemoryStream + per-call temp dir + finally cleanup) is sound.
New in this PR
-
SignNowDownload.cs:117
Problem: RestSharp 105 never throwsWebException.RestClient.Executeswallows every exception intoErrorExceptionand returnsContent = "", so the SDK returnsnulland the action logs"SignNow Download Error: "with nothing after it. Theex is System.Net.WebExceptionfilter is dead. Same blank message when SignNow returns a JSON-array error body, sinceas JObjectyields null.
Fix: Drop theWebExceptionclause. Add a specific message whenresultis null ("No response from SignNow"). Stringify the raw dynamic result before theJObjectcast so array bodies are preserved in the error text. -
SignNowDownload.cs:126
Problem:finallycallsDirectory.Deleteunconditionally. IfDirectory.CreateDirectoryitself threw, the delete throwsDirectoryNotFoundExceptionand a misleading forced "Could not delete" log entry lands on top of the real error.
Fix: Guard the delete withif ( Directory.Exists( tempDirectory ) ).
Pre-existing, but in the path this PR rewrites / claims to harden
-
SignNowDownload.cs:109
Problem:SignNowSDK.Document.Downloadsends the request twice and writes the second response'sRawBytestosigned.pdfwith no status check. A 401/429 on the second call saves the JSON error body as the PDF; the newFile.Existscheck passes, the bytes are stored, andPDF Signedis set toTrue, so the workflow stops polling. A transport failure leavesRawBytesnull andFile.WriteAllBytes(path, null)throwsArgumentNullException, outside the catch filter.
Fix: AfterReadAllBytes, checksignedPdfBytes.Length >= 5and that the first bytes are%PDF-; otherwise add an error message and return false. AddArgumentNullExceptionto the catch filter. -
SignNowDownload.cs:75
Problem:Document.Getuses the same SDK pattern but sits outside the new catch. A non-JSON body (HTML gateway page) throwsJsonReaderException; a JSON-array body throwsRuntimeBinderExceptionon the dynamic-to-JObjectassignment. Thesignatures == nullguard only covers object-shaped error bodies.
Fix: Wrap theGetcall in the sametry/catch, assign todynamicfirst, and useas JObjectbefore readingsignatures. -
SignNowDownload.cs:187
Problem: The existing-file branch renames to.pdfand replaces the bytes but leavesMimeTypeunchanged.GetFile.ashxsendsbinaryFile.MimeTypeasContent-Type, so a DOCX-sourced document (theDocumentattribute allowsFileFieldType, and SignNow converts on upload) comes back as PDF bytes served as Word.
Fix: SetsignedPDF.MimeType = "application/pdf"in the existing-file branch as well.
Out of scope, noting for a follow-up
SignNowCreate.cs:90
Problem:Directory.Deleteruns only on the success path. The error return at line 87 and any SDK exception leave the unsigned PDF in a random temp directory on every retry, the same class of leak ROCK-9041 targets.
Fix: Move the delete into afinallyaround theDocument.Createcall, matching the shape this PR adds to Download.
…temp files RestSharp 105 never throws, so the SDK returns null or the raw error body. Treat Document.Get/Download/Create results as untyped, report a clear message for null, and catch non-JSON bodies. Reject downloads that don't start with %PDF-, since the SDK saves its second response without a status check. Guard the temp directory delete, reset MimeType on the existing-file branch, and move SignNowCreate's temp cleanup into a finally. Update the README to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@jwakefield-secc Thanks. I checked each point against the SDK source and all six hold up, so they are all fixed in 3250b07:
The plugin builds clean (0 errors, 0 warnings). The README and PR description are updated. A runtime check on dev is still needed. |
Keeps #321's temp-directory handling (finally cleanup, SDK error handling) and only replaces the ContentStream.CopyTo read with the DevLib helper, so the cached stream on the tracked BinaryFile is never disposed or left open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keeps #321's temp-directory handling (finally cleanup, SDK error handling) and only replaces the ContentStream.CopyTo read with the DevLib helper, so the cached stream on the tracked BinaryFile is never disposed or left open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…findings) (#323) * ROCK-9041: Make ConnectionCards utilities storage-provider agnostic ConvertPDFToImage and ChopImage wrote content only to DatabaseData, so under any non-Database provider no content was ever saved, and ChopImage read DatabaseData.Content directly (NullReferenceException for files in Azure). All three utilities now read through ContentStream into memory, dispose the provider stream before the caller deletes the source file, and assign a fresh MemoryStream to ContentStream so the provider gets the content. RotateImage also stops handing the provider a stream positioned at its end, which uploaded a zero-byte blob under Azure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider stream in FaceCrop Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider stream when loading LeagueApps certificate Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider streams in PDFCombine and PDFFormMerge Read the template bytes into memory and dispose the storage provider stream instead of handing iText a provider stream it never closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider streams in SafetyAndSecurity PDF merges Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider streams in SignNowTest and WellrightRedirect Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Add BinaryFile.ReadContentBytes to DevLib Reads a BinaryFile's bytes through a fresh storage-provider stream and disposes it, leaving the entity's cached ContentStream untouched. Disposing ContentStream directly breaks later reads of the same tracked BinaryFile on Azure: the getter only re-fetches when CanSeek is false, and the Azure blob stream still reports CanSeek after Dispose. Null or empty content throws a named InvalidOperationException. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Read BinaryFile content via ReadContentBytes in PDF, SafetyAndSecurity, Imaging, LeagueApps, SignNowTest Replaces the using-around-ContentStream blocks, which left a disposed stream cached on the entity under Azure. Missing or empty files now fail with a named error everywhere instead of a NullReferenceException in some paths. LeagueApps no longer returns a null certificate and re-queries on every access. SignNowTest writes with File.WriteAllBytes, which truncates, so a leftover temp file can't leave trailing bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Fix ConnectionCards chop bounds and bitmap disposal; read via ReadContentBytes ChopImage now iterates exactly cols x rows cells and clamps each inset rectangle to the bitmap. The old step-until-edge loop added a partial column/row when the size wasn't evenly divisible, and Clone threw OutOfMemoryException. All bitmaps are disposed. Drops the private ReadAllBytes helper and corrects the RotateImage comment (the old code leaked the provider stream and Image; it did not upload a zero-byte blob). README updated for provider-agnostic reads and the save-hook resize caveat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Read SignNowCreate's rendered PDF via ReadContentBytes Keeps #321's temp-directory handling (finally cleanup, SDK error handling) and only replaces the ContentStream.CopyTo read with the DevLib helper, so the cached stream on the tracked BinaryFile is never disposed or left open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Stop persisting Wellright signing keys per request; read via ReadContentBytes The PFX was imported with PersistKeySet on every page load and never disposed, leaving a new private-key file in the machine key store each time. It now imports without PersistKeySet and disposes the certificate after signing. Exportable stays because CertificateUtility exports the key. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Document storage-provider support in PDF and SafetyAndSecurity READMEs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Reject bad chop grids and zero-page PDFs in ConnectionCards instead of losing the scan ChopImage silently skipped cells when rows/columns made a cell 4px or smaller, so the block deleted the source scan, showed success and launched no workflows (the Rows/Columns NumberUpDowns had no bounds). It now throws InvalidOperationException for fewer than 1 row/column or cells of 4px or less, before anything is saved. The block shows the message and keeps the scan. Rows/Columns get Minimum="1". The no-op Rectangle.Intersect is removed. ConvertPDFToImage returns null for a PDF with no pages (an empty BinaryFile made the save hook throw on its null MimeType), and the block shows an error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Document that ReadContentBytes reads stored bytes (save before reading) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Document ConnectionCards grid validation and zero-page handling Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* ROCK-9041 Stop SignNowDownload leaking the temp PDF file handle (#321) * ROCK-9041: Stop SignNowDownload leaking the temp PDF file handle Read the downloaded SignNow PDF into a MemoryStream instead of assigning an open FileStream to BinaryFile.ContentStream. The Azure provider did not dispose the stream on save, so the temp file stayed locked and the File.Delete afterwards threw. Using a MemoryStream removes the dependency on the provider's dispose behavior entirely. Also persist the updated content when the BinaryFile already exists (the else branch assigned a stream but never called SaveChanges), and delete the actual downloaded path ({name}.pdf) rather than {name}, which left temp PDFs behind on every pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041 Harden SignNowDownload temp-file handling per review Download into a per-call temp directory, check the SDK result, and delete the directory before any database work so concurrent runs can't store each other's PDF and cleanup can't throw after the save. Fall back to the Default file type so new files keep their content, return errors instead of throwing on SignNow error responses, add the .pdf extension, and remove dead code. Update the README to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041 Handle SignNowSDK error results and clean up SignNowCreate temp files RestSharp 105 never throws, so the SDK returns null or the raw error body. Treat Document.Get/Download/Create results as untyped, report a clear message for null, and catch non-JSON bodies. Reject downloads that don't start with %PDF-, since the SDK saves its second response without a status check. Guard the temp directory delete, reset MimeType on the existing-file branch, and move SignNowCreate's temp cleanup into a finally. Update the README to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041 Fix storage-provider stream handling across plugins (audit findings) (#323) * ROCK-9041: Make ConnectionCards utilities storage-provider agnostic ConvertPDFToImage and ChopImage wrote content only to DatabaseData, so under any non-Database provider no content was ever saved, and ChopImage read DatabaseData.Content directly (NullReferenceException for files in Azure). All three utilities now read through ContentStream into memory, dispose the provider stream before the caller deletes the source file, and assign a fresh MemoryStream to ContentStream so the provider gets the content. RotateImage also stops handing the provider a stream positioned at its end, which uploaded a zero-byte blob under Azure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider stream in FaceCrop Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider stream when loading LeagueApps certificate Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider streams in PDFCombine and PDFFormMerge Read the template bytes into memory and dispose the storage provider stream instead of handing iText a provider stream it never closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider streams in SafetyAndSecurity PDF merges Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Dispose provider streams in SignNowTest and WellrightRedirect Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041: Add BinaryFile.ReadContentBytes to DevLib Reads a BinaryFile's bytes through a fresh storage-provider stream and disposes it, leaving the entity's cached ContentStream untouched. Disposing ContentStream directly breaks later reads of the same tracked BinaryFile on Azure: the getter only re-fetches when CanSeek is false, and the Azure blob stream still reports CanSeek after Dispose. Null or empty content throws a named InvalidOperationException. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Read BinaryFile content via ReadContentBytes in PDF, SafetyAndSecurity, Imaging, LeagueApps, SignNowTest Replaces the using-around-ContentStream blocks, which left a disposed stream cached on the entity under Azure. Missing or empty files now fail with a named error everywhere instead of a NullReferenceException in some paths. LeagueApps no longer returns a null certificate and re-queries on every access. SignNowTest writes with File.WriteAllBytes, which truncates, so a leftover temp file can't leave trailing bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Fix ConnectionCards chop bounds and bitmap disposal; read via ReadContentBytes ChopImage now iterates exactly cols x rows cells and clamps each inset rectangle to the bitmap. The old step-until-edge loop added a partial column/row when the size wasn't evenly divisible, and Clone threw OutOfMemoryException. All bitmaps are disposed. Drops the private ReadAllBytes helper and corrects the RotateImage comment (the old code leaked the provider stream and Image; it did not upload a zero-byte blob). README updated for provider-agnostic reads and the save-hook resize caveat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Read SignNowCreate's rendered PDF via ReadContentBytes Keeps #321's temp-directory handling (finally cleanup, SDK error handling) and only replaces the ContentStream.CopyTo read with the DevLib helper, so the cached stream on the tracked BinaryFile is never disposed or left open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Stop persisting Wellright signing keys per request; read via ReadContentBytes The PFX was imported with PersistKeySet on every page load and never disposed, leaving a new private-key file in the machine key store each time. It now imports without PersistKeySet and disposes the certificate after signing. Exportable stays because CertificateUtility exports the key. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Document storage-provider support in PDF and SafetyAndSecurity READMEs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Reject bad chop grids and zero-page PDFs in ConnectionCards instead of losing the scan ChopImage silently skipped cells when rows/columns made a cell 4px or smaller, so the block deleted the source scan, showed success and launched no workflows (the Rows/Columns NumberUpDowns had no bounds). It now throws InvalidOperationException for fewer than 1 row/column or cells of 4px or less, before anything is saved. The block shows the message and keeps the scan. Rows/Columns get Minimum="1". The no-op Rectangle.Intersect is removed. ConvertPDFToImage returns null for a PDF with no pages (an empty BinaryFile made the save hook throw on its null MimeType), and the block shows an error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Document that ReadContentBytes reads stored bytes (save before reading) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * ROCK-9041: Document ConnectionCards grid validation and zero-page handling Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041 Address review findings on storage-provider fixes - ReadContentBytes: decide "unsaved" by Id == 0, not StorageProvider != null, so a new file that already has a storage type is read from ContentStream. - SignNowDownload: a Document attribute whose file type no longer exists is an error, not a silent fallback to the Default type; resolve the destination before downloading; accept the %PDF- header anywhere in the first 1024 bytes. - SignNowCreate: report a missing document, temp-file write failures, and failed signer/token/invite calls as error messages instead of throwing. - CertificateUtility: dispose the RSACryptoServiceProvider per request. - SignNowTest: per-request temp directory removed in a finally. - ConnectionCardEntry: keep the scan and show a message on any ChopImage failure. - FaceCrop: dispose the rotated source and target bitmaps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ROCK-9041 Update plugin READMEs for the DevLib dependency and behavior changes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

Summary
Plugin-side companion to the core fork patch for ROCK-9041 (secc/Rock#18). The ticket's plugin audit flagged
SignNowDownloadas the same open-FileStream-then-delete pattern that broke the Process Signature Documents job on the Azure provider. Review follow-ups also harden the rest of the download path.Changes in
org.secc.SignNowWorkflow/Workflows/SignNowDownload.csFile.ReadAllBytes→MemoryStream) instead of assigning an openFileStreamtoBinaryFile.ContentStream. No file handle is held, so the fix does not depend on whichever storage provider happens to dispose the stream.%TEMP%\{document_name}.pdf, and document names repeat (the PDF merge template name, people with the same name), so two workflows running at once could store each other's signed PDF. The download now goes toPath.GetTempPath()+Path.GetRandomFileName(), following the patternSignNowCreatealready uses.filepath when SignNow does not return 200. The action now adds an error message rather than throwingFileNotFoundExceptionor reading a stale file. It also catches non-JSON error bodies and I/O failures.finallyright after the bytes are read. A failed delete is logged to the action log and does not fail the action. Before, cleanup deleted{name}while the SDK wrote{name}.pdf, so temp PDFs piled up on every poll.SaveChanges()too. For persisted workflows,WorkflowService.Processalready saved this content at the end of processing; the explicit save covers non-persisted workflows and surfaces storage-provider errors inside the action. After the save only attribute values are set, so no file I/O can throw after the commit.BinaryFilewith noBinaryFileTypeIdhas its content silently dropped byBinaryFile's save hook. If the Document attribute has nobinaryFileTypequalifier, or it names a missing type, the action now uses the Default file type. A missingDocumentattribute or file type now returns an error instead of throwing an NRE.Document.Get. A missingsignaturesarray (deleted document, rejected token) now returns an error instead of throwingArgumentNullException..pdfextension is now added to the stored file name.PersonAliasService, a redundant null check, an unusedGuid.Emptyassignment, and a stray comment.Review follow-ups (SignNowSDK / RestSharp 105 behavior)
RestSharp 105 never throws on transport failures, so the SDK returns
nullor the raw error body instead of the expected object.Document.Get,Download, andCreateresults are treated as untyped. Each is read withas JObject,nullreports "No response from SignNow.", and array or string error bodies are kept in the error text.GetandCreateare now wrapped in aJsonExceptioncatch for non-JSON bodies (e.g. gateway error pages). The deadWebExceptionfilter is removed.Document.Downloadsends the request twice and saves the second body without a status check. A 401 or 429 on that call used to be stored as the signed document with PDF Signed = True. The action now requires the file to start with%PDF-; otherwise it errors so the workflow keeps polling.ArgumentNullException(the SDK saving a null body after a transport failure) is caught too.Directory.Exists, so a failedCreateDirectorydoesn't add a misleading log entry.MimeTypetoapplication/pdf, so a DOCX-sourced document isn't served as Word after its bytes become a PDF.Changes in
org.secc.SignNowWorkflow/Workflows/SignNowCreate.csfinally, so error returns and SDK exceptions no longer leave the unsigned PDF behind on each retry. The access token is now fetched before the file is written.Document.Createresult handled like the others:nullor a non-object body is an error message instead of aNullReferenceException, and non-JSON bodies are caught.README Observations and the Components row are updated to match (Copilot comment). Last updated line added.
Follow-up, not in this PR:
SignNowSDK.Document.Downloadalready has the bytes (DownloadData) and sends the request twice. A byte[]-returning method in the secc/Rock SDK fork would remove the temp file entirely.Testing
org.secc.SignNowWorkflow.csprojbuilds clean with standalone MSBuild (0 errors, 0 warnings)..pdfand has a file type, and no leftover directories remain in the web server temp directory after either action.🤖 Generated with Claude Code