One missing await: How OpenHack hacked Papermark and got unlimited uploads

TL;DR
- OpenHack found an authentication bypass in Papermark's resumable file-upload endpoint.
- The bug was one missing
awaitin front ofgetServerSession(). - Because a JavaScript
Promiseis always truthy, the endpoint treated every request as authenticated—even requests with no cookie or token. - An unauthenticated attacker could create and complete as many uploads as they wanted, with a hard limit of 2 GiB per file. The client also supplied the
teamId, and the server did not verify team membership. - OpenHack did more than flag the line: it traced the request into the TUS server and backing object store, worked out the real impact, and verified the bypass with an unauthenticated upload.
- We reported the issue to Papermark on March 1, 2026. Papermark patched it in PR #2096, merged on March 2.
One keyword. Five characters. That was the difference between an authenticated upload route and a public pipe into Papermark's object storage.
OpenHack found it in its first week.
We pointed the agent at Papermark's codebase and let it work through the application as an attacker would. It mapped the externally reachable routes, identified the resumable upload endpoint as a high-value boundary, followed the authentication logic, and stopped on a small block near the bottom of the handler. At first glance, the code looked completely ordinary: fetch the current session, reject the request if there is no session, and otherwise hand the request to the upload server.
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const session = getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ message: "Unauthorized" });
}
return tusServer.handle(req, res);
}The problem is that getServerSession() is asynchronous. Without await, session is not a session. It is a Promise that may eventually resolve to a session—or to null.
JavaScript does not wait to find out which. A Promise object is truthy, so !session is always false. The 401 branch is dead code. Every request falls through to tusServer.handle().
That is the whole bug.
How OpenHack found it
The interesting part of this story is not that a language model can spot a missing keyword. Plenty of tools can point at unusual code. The interesting part is that OpenHack turned five missing characters into a complete, verified security finding without being handed the vulnerable file or told what class of bug to look for.
OpenHack works in stages. On Papermark, each stage added a piece that the previous one could not establish on its own.
Reconnaissance mapped the attack surface. OpenHack first built a model of the application: a Next.js API route at /api/file/tus, a NextAuth session check in front of it, a TUS server behind it, and an S3-compatible datastore at the end of the path. That context made the route worth investigating. This was not a forgotten debug endpoint; it was the boundary controlling large document uploads.
The hunter reasoned about the runtime value. The code looked protected to a text-based scan because it contained getServerSession, an authorization guard, and a 401. OpenHack checked the API semantics instead. getServerSession() returns a promise. Since the call was not awaited, the condition was checking the promise object—not the eventual session—and a promise object is always truthy.
Validation followed the fallthrough. A missing await is only interesting if it changes something an attacker can reach. OpenHack traced the false guard into tusServer.handle(), found the 2 GiB per-file ceiling, and saw that teamId came directly from upload metadata. It also confirmed there was no independent membership or document-quota check before the object-store write. That turned “possibly incorrect async code” into “unauthenticated, repeatable uploads to tenant-scoped storage.”
Verification made the exploit real. OpenHack's verifier constructed the TUS flow with no cookie and no token. Instead of returning 401 Unauthorized, the vulnerable application created an upload and accepted the follow-up file data. The agent did not stop at a plausible code finding; it produced the behavioral proof that separated a real vulnerability from a scanner alert.
That progression is the point of OpenHack. Reconnaissance found the security boundary. The hunter formed the hypothesis. Validation established impact. Verification proved exploitability. By the time the finding reached us, it was not “line 104 might need an await.” It was a report we could reproduce, explain, and send to Papermark.
A lock painted on the door
This bug is easy to skim past because the code has all the visual furniture of an authentication check. There is a session call. There is an if (!session). There is a 401 Unauthorized response. A reviewer reading quickly can see the shape of secure code and move on.
At runtime, though, the check is equivalent to this:
const session = Promise.resolve(null);
if (!session) {
// Never reached. The Promise object itself is truthy.
}The eventual value of the promise does not matter because nothing ever observes it. Even a request with no session cookie at all gets handed to the upload server.
This is exactly the sort of vulnerability that is obvious once somebody points at it and surprisingly difficult to catch with shallow pattern matching. The code contains the expected auth function and the expected rejection branch. Finding the bug requires reasoning about the value that actually exists at the moment of the check.
Why the upload route mattered
Papermark is an open-source document-sharing and virtual data-room product. Its upload path used TUS, a protocol designed for large, resumable uploads.
A TUS upload normally happens in two stages:
- A client sends a
POSTto create an upload and declares its length and metadata. - The server returns a unique upload URL. The client sends one or more
PATCHrequests to that URL until every byte has been transferred.
That is a good fit for large documents and unreliable connections. It also means the server must enforce authorization at the boundary and throughout the upload lifecycle. The TUS specification deliberately leaves authentication and authorization to each implementation.
Papermark's handler configured the server like this:
const tusServer = new Server({
path: "/api/file/tus",
maxSize: 1024 * 1024 * 1024 * 2, // 2 GiB
respectForwardedHeaders: true,
locker,
datastore: new MultiRegionS3Store(),
// ...
});The maxSize setting limited one upload to 2 GiB. It did not limit how many uploads a caller could create. Once the session check failed open, an unauthenticated caller could repeatedly create new uploads and stream data into the backing S3-compatible store.
The route also trusted client-supplied metadata when constructing the object key:
namingFunction(req, metadata) {
const { teamId, fileName } = metadata as {
teamId: string;
fileName: string;
};
const docId = newId("doc");
const { name, ext } = path.parse(fileName);
return `${teamId}/${docId}/${slugify(name)}${ext}`;
}There was no server-side check that the caller belonged to teamId. In fact, there could not be one: because the promise was never awaited, the handler never had an authenticated user to compare against the requested team.
So “unlimited uploads” needs one small but important qualifier. An individual file could not exceed 2 GiB. The number of files and the aggregate storage consumed, however, were not tied to an authenticated user, a team membership, or a team's document quota at this endpoint. A caller could just start another upload.
Reproducing the bypass
We did not need a complicated exploit chain. The most useful test was also the simplest: make a normal TUS creation request without sending a session cookie or an authorization token.
Against a local or otherwise authorized vulnerable deployment, the request shape was:
POST /api/file/tus HTTP/1.1
Host: papermark.test
Tus-Resumable: 1.0.0
Upload-Length: 12
Upload-Metadata: fileName cG9jLnR4dA==,contentType dGV4dC9wbGFpbg==,teamId PHJlZGFjdGVkPg==
Content-Length: 0A correctly protected endpoint should reject that request with 401 Unauthorized. The vulnerable handler instead passed it to TUS, which created an upload and returned 201 Created with a Location header. A subsequent unauthenticated PATCH to that location could supply the file bytes.
No race condition was required. No malformed token was required. No browser state was required. The absence of credentials was enough.
We are intentionally showing the protocol flow against a test host, not pointing a copy-paste exploit at Papermark's production service. The upstream issue has been fixed, but that distinction matters when publishing security research.
How long it was there
The vulnerable route was introduced in commit 37a20633 on July 12, 2024. The missing await was present in the first version of the TUS handler.
Based on Papermark's public tag history, the affected tagged releases run from v0.14.0 through v0.22.0. Deployments built directly from the repository may also have been affected if they included the route before the March 2026 fix.
| Date | Event |
|---|---|
| July 12, 2024 | TUS upload handler introduced with the missing await |
| March 1, 2026 | OpenHack discovered and reported the vulnerability |
| March 2, 2026, 06:00 UTC | Papermark opened the public fix PR |
| March 2, 2026, 06:43 UTC | Papermark merged the fix into main |
Papermark moved quickly once the issue was in front of them. The public pull request was open for 43 minutes before merge.
The fix was bigger than one keyword
The first necessary change was exactly what you would expect:
-export default function handler(req, res) {
- const session = getServerSession(req, res, authOptions);
- if (!session) {
+export default async function handler(req, res) {
+ const session = await getServerSession(req, res, authOptions);
+ const userId = session?.user?.id;
+ if (!userId) {
return res.status(401).json({ message: "Unauthorized" });
}Papermark correctly went further. The merged patch also:
- attached the authenticated user ID to the request before handing it to TUS;
- checked team membership when an upload was created;
- protected follow-up
HEAD,PATCH, andDELETErequests, not only the initialPOST; - enforced team document quotas and paused-team restrictions;
- applied plan-aware file-size limits; and
- rejected missing or invalid upload metadata and lengths.
That broader patch matters. Adding await repairs authentication, but authentication alone does not answer the authorization question: is this user allowed to upload to this particular team? Nor does it enforce the business rules around document counts and file size. Papermark's fix moved those decisions to the server-side upload lifecycle, where they belonged.
The complete patch is public in PR #2096 and merge commit c322c1a.
Why ordinary review misses bugs like this
Most authentication bugs do not announce themselves with a function named skipAuthentication(). They look like authentication code with one wrong assumption.
Here, every token a scanner might search for was present:
getServerSession- a null check
- a
401response - an early return
A grep-based rule looking for “sensitive route without session check” would probably consider this route covered. The interesting question is semantic: what type and value does session have before the promise resolves, and what does JavaScript do when that value is used as a boolean?
This is where OpenHack earns its keep. It did not match a suspicious line and hand a human a maybe. It followed the endpoint into the framework API, reasoned about the missing asynchronous boundary, connected that failure to the TUS server and storage layer behind it, and turned the observation into a working unauthenticated upload test. The vulnerable line was small; OpenHack established why it mattered.
What developers should take from this
The obvious lesson is “remember to await asynchronous authentication calls.” That is true, but it is not enough to prevent the next version of this bug.
Security-sensitive async code should fail closed. Treat the resolved identity—not the existence of a promise—as the precondition for continuing. In TypeScript projects, enable lint rules such as @typescript-eslint/no-floating-promises and @typescript-eslint/no-misused-promises, and make authentication helpers return types difficult to misuse. Add unauthenticated integration tests for every protected route; a single request with an empty cookie jar would have caught this bug immediately.
File-upload systems also need controls at more than one layer:
- authenticate every upload operation, including resume and delete requests;
- authorize the user against server-owned tenant or team state;
- never trust a client-supplied tenant identifier by itself;
- enforce per-file, per-user, per-team, and aggregate quotas server-side;
- rate-limit creation attempts; and
- monitor orphaned or incomplete objects in backing storage.
Finally, review code for runtime behavior, not for the visual presence of a security check. A lock painted on a door is still just paint.
Disclosure notes
OpenHack reported the issue before publishing technical details, and Papermark fixed it before this write-up. This post describes the vulnerable open-source code and the public patch. It does not claim that Papermark customer documents were accessed or that the vulnerability was abused in the wild; we have no evidence of either.
CVE-2026-36755 has been reserved for this issue. The public CVE record has not yet been published, so the identifier may not appear in CVE or NVD searches until the record is made public. We also track the vulnerability as OpenHack advisory OH-2026-001.
If you self-host Papermark and your build contains the old TUS handler, update to a revision containing c322c1a or a later patched revision.
References
- Papermark PR #2096: File upload authentication bypass
- Papermark fix commit
c322c1a - Vulnerable handler in Papermark v0.22.0
- Original TUS handler commit
37a20633 - TUS resumable upload protocol
OpenHack found this vulnerability autonomously, validated the impact, and verified the exploit end to end. If you want to see what it finds in your own application, install it and point it at your codebase.
