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.coservice - 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
- Endpoint:
POST /api/linksinapps/web - Workspace usage tracking: cached
project.linksUsage, updated via Redis stream and a periodic cron reconciliation job - Enforcement site: workspace link-creation guard that reads the cached counter before writing the new link
Vulnerability details
Vulnerable flow
The request path splits check from increment, and the increment happens after the response returns:
- Request reads
workspace.linksUsage(cached) and compares toworkspace.linksLimit. - N concurrent requests all read the same stale value.
- Every request passes the guard.
- Every request writes a link row.
- Usage increments are queued on a Redis stream.
- A cron job later reconciles
linksUsageagainst 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.
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())
Confirmed observations on the same workspace after the run:
| Metric | Value |
|---|---|
| Workspace plan | Free (25-link cap) |
| Initial cached usage | 1 |
| Concurrent requests | 30 |
| Successful creations | 30 |
| Final link count | 53 |
| Over cap by | 2.1x |
Impact
- Complete bypass of workspace link limits. Any workspace of any plan can be pushed arbitrarily far past its enforced cap by running the primitive with more concurrency.
- Free-tier quota enforcement failure. Free accounts obtain paid-tier link volume without upgrading.
- Paid-tier revenue leakage. Customers on Pro can obtain Business-tier volumes, and Business can obtain Enterprise-tier volumes, without paying the delta.
- Persistent inconsistency window in usage tracking. The reconciliation job records the over-limit count as the new truth. There is no rollback, so the over-limit state persists indefinitely.
- Downstream cost driver. Every excess link is a permanent row in the Postgres primary and an entry in the Redis-backed link redirect layer. The unit cost of the excess creation is borne by the vendor.
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
| Date | Event |
|---|---|
| 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 |