TOCTOU Race Condition CVSS 7.5 High Vendor won't fix

Racing Dub's Async Quota Counter for Unlimited Free Links

CVE ID
Pending MITRE assignment
GHSA
GHSA-gg99-cq42-5x7h
CVSS
7.5 High (AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H)
CWE
CWE-367 (Time-of-Check Time-of-Use Race Condition)
Vendor
Dub Technologies, Inc.
Product
dubinc/dub
Affected
All current releases and the hosted dub.co service
Vendor position
Closed as intentional tradeoff, no patch planned
Reporter
Jackson Mittag (0dayscyber)

Summary

Dub enforces per-workspace link limits by reading a cached linksUsage counter, comparing it to linksLimit, and then creating the link. The counter is not incremented in the same transaction. It is updated asynchronously through a Redis stream reconciled by a cron job. Any request that lands during the window between check and reconciliation reads the same stale value that every other in-flight request reads. All of them pass validation, all of them create links, and the workspace ends up well past its enforced limit.

The primitive is trivial to run at scale. A free-tier workspace with a 25-link cap ends up with 53 links after 30 concurrent POST /api/links requests, from an initial recorded usage of 1. No stale link is later removed. Paid tiers with higher caps have the same exposure.

Affected component

Vulnerability details

Vulnerable flow

The request path splits check from increment, and the increment happens after the response returns:

  1. Request reads workspace.linksUsage (cached) and compares to workspace.linksLimit.
  2. N concurrent requests all read the same stale value.
  3. Every request passes the guard.
  4. Every request writes a link row.
  5. Usage increments are queued on a Redis stream.
  6. A cron job later reconciles linksUsage against the actual link count.

Step 6 catches nothing. Reconciliation observes the over-limit count, records it, and does not prune. The workspace is now permanently over its cap.

Root cause

The enforcement check and the state update that would prevent a duplicate check are not atomic. The database is not asked to reject the write when it would take the workspace over its limit. Enforcement is expressed against a cache that lags real state, and the reconciliation loop has no rollback path.

Proof of concept

Reproduced on the hosted dub.co service from a free-tier account. The workspace limit was 25 links. Initial recorded usage on the workspace was 1 (stale). Thirty concurrent POST /api/links requests landed. All 30 succeeded. Final link count for the workspace was 53. The workspace is 2.1x over its enforced cap. No rollback fired, no throttle intervened, and the link rows remained in place after the reconciliation window closed. Dub's own dashboard billing view reports the workspace as over-quota after the fact.

Concurrent exploitation PoC
import asyncio
import aiohttp
import json

COOKIE = "<session_cookie>"
BASE = "https://app.dub.co"
WS = "workspace_id"

async def create(i):
    async with aiohttp.ClientSession() as s:
        async with s.post(
            f"{BASE}/api/links?workspaceId={WS}",
            headers={"Cookie": COOKIE},
            json={"url": f"https://example.com/{i}", "domain": "dub.sh"}
        ) as r:
            return await r.text()

async def main():
    tasks = [create(i) for i in range(30)]
    results = await asyncio.gather(*tasks)
    print(len(results))

asyncio.run(main())
Dub usage-limit email showing the affected workspace at 220% of its monthly link quota after the race-condition PoC ran.
Dub's own billing notification for the affected workspace after the PoC: 220% of the monthly link quota. That number is not the ceiling, only what this run happened to produce.

Confirmed observations on the same workspace after the run:

MetricValue
Workspace planFree (25-link cap)
Initial cached usage1
Concurrent requests30
Successful creations30
Final link count53
Over cap by2.1x

Impact

Vendor response

The advisory was reported privately via GitHub Security Advisory GHSA-gg99-cq42-5x7h on 2026-05-03 and closed by the vendor on 2026-05-05 as a known limitation. The vendor's stated position is that the tradeoff is intentional to keep POST /links latency low, and that rate limiting and abuse monitoring are relied on as compensating controls.

Vendor comment, 2026-05-05 “This is a known limitation - we intentionally chose this tradeoff to ensure low latencies in the POST /links endpoint, which wouldn't be possible if we ran an atomic operation to increment usage + create link at the same time. We also constantly monitor any abuse cases + have rate limits in place to guard against resource exhaustion as well.”

The published proof of concept ran to completion against the hosted service and left the workspace persistently over its enforced cap. No rollback was observed. No throttle intervened during the 30 concurrent creations. The over-limit state was still present after the reconciliation cron had a chance to run.

Remediation

Two things need to change to make the primitive unprofitable. First, replace the async-only reconciliation with a database-enforced constraint that rejects the write when it would take the workspace past its cap. Second, if step one is a latency non-starter, add a cheap catch-up job that prunes over-cap links so the primitive cannot leave a persistent benefit for the attacker.

Fix 1: enforce atomically in the database

Increment and check in the same statement. The write itself is rejected when it would take the counter past the cap, and only then is the link row created. No race window.

-- Postgres: conditional increment
UPDATE "Project"
SET "linksUsage" = "linksUsage" + 1
WHERE id = $1
  AND "linksUsage" < "linksLimit"
RETURNING "linksUsage";

-- If the UPDATE returns zero rows, reject the create.
-- If it returns a row, proceed to write the Link row inside
-- the same transaction.

Fix 2: transactional check-and-write

If keeping the async counter is a hard requirement, at minimum wrap the read of the current live count and the write of the new link in a single serializable transaction, so concurrent creates serialize and observe each other's increments.

BEGIN ISOLATION LEVEL SERIALIZABLE;

WITH usage AS (
  SELECT COUNT(*) AS n FROM "Link" WHERE "projectId" = $1
)
INSERT INTO "Link" (...)
SELECT ...
FROM usage
WHERE usage.n < (SELECT "linksLimit" FROM "Project" WHERE id = $1);

COMMIT;

Fix 3: catch-up job that removes over-cap links

If neither of the above is acceptable, run a fast reconciliation task on the same cron that already updates linksUsage. When the live count exceeds the cap, delete the most recent links back down to the cap and email the workspace owner. This does not prevent the race, but it makes the primitive worthless to an attacker: the excess links vanish before they can be used. The compute cost is negligible because the task is a no-op for the >99% of workspaces that are not over cap.

-- On the existing reconciliation tick
DELETE FROM "Link"
WHERE id IN (
  SELECT id FROM "Link"
  WHERE "projectId" = $1
  ORDER BY "createdAt" DESC
  LIMIT GREATEST(0, (
    SELECT COUNT(*) - (SELECT "linksLimit" FROM "Project" WHERE id = $1)
    FROM "Link" WHERE "projectId" = $1
  ))
);

Timeline

DateEvent
Vulnerability discovered by Jackson Mittag (0dayscyber)
Reported privately via GitHub Security Advisory GHSA-gg99-cq42-5x7h
Vendor closed the advisory as an intentional latency tradeoff, no patch planned
Researcher proposed a compensating catch-up job that would neutralise the primitive without adding latency. No response.
Public disclosure and MITRE CVE submission

References