Skip to content

getDocument cloud function discloses full document metadata, all signers' PII, and a working document-download token to any unauthenticated caller who knows only the document ID #2218

Description

@geo-chen

Issue Description

Affected Versions: confirmed on tag v2.41.0 (commit f2ada47), default IsEnableOTP document configuration
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5 High)
CWE: CWE-862 Missing Authorization

Summary

OpenSign's guest-signer link is /guest/{docId}-{...}-{contactId}, and the frontend resolves it by calling the Parse cloud function getDocument with only docId (apps/OpenSign/src/pages/GuestLogin.jsx). The contactId segment is never sent to getDocument. Server-side, getDocument.js runs the document lookup with useMasterKey: true (bypassing Parse ACLs) and, whenever the document's IsEnableOTP flag is false — the coded default for every document created through the normal product flow — returns the entire document object with zero authentication and zero verification that the caller is any of the invited signers. This exposes every signer's name and email, the sender's account identity, the tenant/company name, the audit trail once populated, and a working signed download token for the underlying PDF, to anyone who obtains just the docId (the leading, most-exposed segment of any one signer's link — e.g. via referrer headers, forwarded emails, browser history, or simply because it is shared identically across every recipient on a multi-signer document).

Details

apps/OpenSign/src/pages/GuestLogin.jsx:

const navigateToDoc = async (docId, contactId) => {
  try {
    const docDetails = await Parse.Cloud.run("getDocument", {
      docId: docId
    });
    ...

contactId is only used afterward to build the client-side navigation route — it is never sent to the cloud function that actually returns the document.

apps/OpenSignServer/cloud/parsefunction/getDocument.js:

const query = new Parse.Query('contracts_Document');
query.equalTo('objectId', docId);
query.include('ExtUserPtr');
query.include('ExtUserPtr.TenantId');
query.include('CreatedBy');
query.include('Signers');
query.include('AuditTrail.UserPtr');
query.include('Placeholders');
query.include('DeclineBy');
query.notEqualTo('IsArchive', true);
const res = await query.first({ useMasterKey: true });   // bypasses ACL
if (res) {
  const IsEnableOTP = res?.get('IsEnableOTP') || false;
  const document = JSON.parse(JSON.stringify(res));
  delete document.ExtUserPtr.TenantId.FileAdapters;
  delete document?.ExtUserPtr?.TenantId?.PfxFile;
  if (!IsEnableOTP) {
    return document;                                    // <-- returned to ANY caller
  } else {
    // only here does it check sessiontoken -> user -> ACL.getReadAccess(userId)
    ...
  }
}

There is no comparison against contactId anywhere in this function — the docId alone (with IsEnableOTP off) is sufficient. IsEnableOTP defaults to false in every document-creation path:

  • apps/OpenSign/src/pages/Form.jsx:73: IsEnableOTP: "false" (initial form state)
  • apps/OpenSignServer/cloud/parsefunction/createDocumentFromApp.js:17: const IsEnableOTP = doc?.IsEnableOTP !== undefined ? doc?.IsEnableOTP : false;

As a side effect of a global contracts_Document afterFind hook (apps/OpenSignServer/cloud/parsefunction/DocumentAfterFind.js, registered in main.js as Parse.Cloud.afterFind('contracts_Document', DocumentBeforeFind)), every contracts_Document fetch — including this one — automatically mints a fresh signed download token for the document's URL/SignedUrl/CertificateUrl fields regardless of who is asking. So the same unauthenticated getDocument response also hands out a working token to download the actual PDF content.

Sibling comparison confirming this is an inconsistency, not general behavior: the function that actually finalizes a signature, signPdf (Parse.Cloud.define('signPdf', PDF)apps/OpenSignServer/cloud/parsefunction/pdf/PDF.js), does bind to the caller-supplied reqUserId (the contactId): it looks up _resDoc.Signers.find(x => x.objectId === reqUserId) and the whole request fails once that lookup misses, because the resulting signUser is undefined and the very next line reads signUser.Name. getDocument performs no equivalent check at all.

PoC

Tested end to end against a from-source build of tag v2.41.0 (apps/OpenSignServer/Dockerhubfile + apps/OpenSign/Dockerhubfile, mongo:6, USE_LOCAL=true, APP_ID=opensigntest01).

  1. Created a sender account and logged in through the product's own signup/login flow, obtaining a genuine session token.
  2. As the sender, created a two-signer document (Alice, Bob) through the createdocumentfromapp cloud function, leaving IsEnableOTP unset (its coded default, false). Server returned objectId: "sq15daIVou".
  3. From a fresh, completely unauthenticated request — no cookie, no session token, no contactId, only the app ID and the docId:
POST /app/functions/getDocument HTTP/1.1
Host: localhost:8080
X-Parse-Application-Id: opensigntest01
Content-Type: application/json

{"docId":"sq15daIVou"}

Response (truncated to the sensitive fields; full document metadata was also returned):

{
  "result": {
    "Signers": [
      { "Name": "Alice Signer", "Email": "alice@example.test", "objectId": "2FPhs8s9E5", "className": "contracts_Contactbook" },
      { "Name": "Bob Signer",   "Email": "bob@example.test",   "objectId": "pZchmZk7sm", "className": "contracts_Contactbook" }
    ],
    "Name": "Confidential NDA - Project Falcon",
    "Note": "Please review and sign the attached confidential NDA.",
    "URL": "http://localhost:8080/files/opensigntest01/dummy.pdf?token=eyJhbGciOiJIUzI1NiIs...<valid signed download token>...",
    "ExtUserPtr": {
      "Name": "Sender Test", "Email": "sender@example.test", "Company": "Test Co",
      "TenantId": { "TenantName": "Test Co", "EmailAddress": "sender@example.test", "IsActive": true }
    },
    "CreatedBy": { "username": "sender@example.test", "email": "sender@example.test", "name": "Sender Test" },
    "objectId": "sq15daIVou"
  }
}

This proves the caller obtained not just Alice's own record but Bob's full PII too, plus the sender's identity, tenant/company name, and a working file-download token — none of which either signer explicitly shared with an unrelated third party, and none of which required any contactId at all.

  1. Positive control: created a second document with IsEnableOTP: true explicitly and repeated the identical unauthenticated request:
{"result":{"error":"You don't have access of this document!"}}

confirming IsEnableOTP is the only gate on this code path.

  1. Sibling-function control: called POST /app/functions/signPdf on the non-OTP document with a bogus userId (contactId) value:
{"code":141,"error":"Cannot read properties of undefined (reading 'Name')"}

confirming that the signing-completion function does key off contactId (it crashes when it doesn't match a signer), unlike getDocument.

Impact

Any party who obtains the docId portion of one signer's guest link — which is the leading, identically-shared segment of every recipient's individual link on a multi-signer document, and thus more likely to leak via referrer headers to embedded third-party scripts on the signing page, forwarded emails, browser history, or screenshots than the full compound token — can, with zero authentication, read:

  • every other signer's full name and email on that document,
  • the sending user's account identity and tenant/company name,
  • the complete audit trail once populated,
  • document metadata (title, note, expiry), and
  • a valid, working download token for the actual PDF content.

This is the default, out-of-the-box state for every document created through OpenSign's normal signing flow, since IsEnableOTP is not enabled unless a sender actively turns it on. For a product whose core function is legally-significant document signing, this discloses the PII of every counterparty on a contract, plus the contract content itself, to anyone who can recover a single docId.

Suggested fix: getDocument should require the caller to supply the contactId from their own link and verify it matches an entry in the document's Signers/Placeholders, independent of IsEnableOTP — the same binding signPdf already (if inelegantly) enforces — rather than treating IsEnableOTP as the sole authorization gate for reading document data.

Expected Behavior

No response

Current Behavior

No response

Steps to reproduce

No response

Screenshots of the issue(optional)

No response

Operating System [e.g. MacOS Sonoma 14.1, Windows 11]

No response

What browsers are you seeing the problem on?

Chrome

What version of OpenSign™ are you seeing this issue on? [e.g. 1.0.6]

2.41.0

What environment are you seeing the problem on?

Dev (localhost or vercel)

Please check the boxes that apply to this issue report.

  • I have searched the existing issues & discussions to make sure that this is not a duplicate.

Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I have searched the existing issues & discussions to make sure that this is not a duplicate.

Metadata

Metadata

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions