Moderate severityOSV Advisory· Published Jan 14, 2026· Updated Jan 14, 2026
Outray has a Race Condition in main/apps/web/src/routes/api/$orgSlug/subdomains/index.ts
CVE-2026-22819
Description
Outray openSource ngrok alternative. Prior to 0.1.5, this vulnerability allows a user i.e a free plan user to get more than the desired subdomains due to lack of db transaction lock mechanisms in main/apps/web/src/routes/api/$orgSlug/subdomains/index.ts. This vulnerability is fixed in 0.1.5.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
outraynpm | < 0.1.5 | 0.1.5 |
Affected products
1Patches
273e8a0957575fix(subdomains): improve subdomain counting with row-level locking
1 file changed · +4 −4
apps/web/src/routes/api/$orgSlug/subdomains/index.ts+4 −4 modified@@ -65,14 +65,14 @@ export const Route = createFileRoute("/api/$orgSlug/subdomains/")({ const planLimits = getPlanLimits(currentPlan as any); const subdomainLimit = planLimits.maxSubdomains; - // Count existing subdomains with a locked read to prevent phantom reads - const [countResult] = await tx - .select({ count: sql<number>`count(*)::int` }) + // Count existing subdomains - lock the rows to prevent race conditions + const existingSubdomains = await tx + .select({ id: subdomains.id }) .from(subdomains) .where(eq(subdomains.organizationId, organization.id)) .for("update"); - const existingCount = countResult?.count ?? 0; + const existingCount = existingSubdomains.length; if (subdomainLimit !== -1 && existingCount >= subdomainLimit) { return {
08c614957613fix(tunnel): add transaction locking to prevent race conditions during registration
1 file changed · +82 −76
apps/web/src/routes/api/tunnel/register.ts+82 −76 modified@@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { json } from "@tanstack/react-start"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import { db } from "../../../db"; import { tunnels } from "../../../db/app-schema"; @@ -73,97 +73,103 @@ export const Route = createFileRoute("/api/tunnel/register")({ tunnelId = providedTunnelId!; } - // Check subscription limits - const [subscription] = await db - .select() - .from(subscriptions) - .where(eq(subscriptions.organizationId, organizationId)); + // Use the URL passed from the tunnel server + const tunnelUrl = url; - const currentPlan = subscription?.plan || "free"; - const planLimits = getPlanLimits(currentPlan as any); - const tunnelLimit = planLimits.maxTunnels; + // Use a transaction with row-level locking to prevent race conditions + const result = await db.transaction(async (tx) => { + // Lock the organization's subscription row to serialize concurrent requests + const [subscription] = await tx + .select() + .from(subscriptions) + .where(eq(subscriptions.organizationId, organizationId)) + .for("update"); - const setKey = `org:${organizationId}:online_tunnels`; + const currentPlan = subscription?.plan || "free"; + const planLimits = getPlanLimits(currentPlan as any); + const tunnelLimit = planLimits.maxTunnels; - // Use the URL passed from the tunnel server - const tunnelUrl = url; + const setKey = `org:${organizationId}:online_tunnels`; + + // Check if tunnel already exists in database (with lock) + const [existingTunnel] = await tx + .select() + .from(tunnels) + .where(eq(tunnels.url, tunnelUrl)) + .for("update"); + + const isReconnection = !!existingTunnel; - // Check if tunnel already exists in database - const [existingTunnel] = await db - .select() - .from(tunnels) - .where(eq(tunnels.url, tunnelUrl)); - - const isReconnection = !!existingTunnel; - - console.log( - `[TUNNEL LIMIT CHECK] Org: ${organizationId}, Tunnel: ${tunnelId}`, - ); - console.log( - `[TUNNEL LIMIT CHECK] Is Reconnection: ${isReconnection}`, - ); - console.log( - `[TUNNEL LIMIT CHECK] Plan: ${currentPlan}, Limit: ${tunnelLimit}`, - ); - - // Check limits only for NEW tunnels (not reconnections) - if (!isReconnection) { - // Count active tunnels from Redis SET - const activeCount = await redis.scard(setKey); console.log( - `[TUNNEL LIMIT CHECK] Active count in Redis: ${activeCount}`, + `[TUNNEL LIMIT CHECK] Org: ${organizationId}, Tunnel: ${tunnelId}`, + ); + console.log( + `[TUNNEL LIMIT CHECK] Is Reconnection: ${isReconnection}`, + ); + console.log( + `[TUNNEL LIMIT CHECK] Plan: ${currentPlan}, Limit: ${tunnelLimit}`, ); - // The current tunnel is NOT yet in the online_tunnels set (added after successful registration) - // So we check if activeCount >= limit (not >) - if (activeCount >= tunnelLimit) { + // Check limits only for NEW tunnels (not reconnections) + if (!isReconnection) { + // Count active tunnels from Redis SET + const activeCount = await redis.scard(setKey); console.log( - `[TUNNEL LIMIT CHECK] REJECTED - ${activeCount} >= ${tunnelLimit}`, + `[TUNNEL LIMIT CHECK] Active count in Redis: ${activeCount}`, ); - return json( - { + + // The current tunnel is NOT yet in the online_tunnels set (added after successful registration) + // So we check if activeCount >= limit (not >) + if (activeCount >= tunnelLimit) { + console.log( + `[TUNNEL LIMIT CHECK] REJECTED - ${activeCount} >= ${tunnelLimit}`, + ); + return { error: `Tunnel limit reached. The ${currentPlan} plan allows ${tunnelLimit} active tunnel${tunnelLimit > 1 ? "s" : ""}.`, - }, - { status: 403 }, + status: 403, + }; + } + console.log( + `[TUNNEL LIMIT CHECK] ALLOWED - ${activeCount} < ${tunnelLimit}`, ); + } else { + console.log(`[TUNNEL LIMIT CHECK] SKIPPED - Reconnection detected`); } - console.log( - `[TUNNEL LIMIT CHECK] ALLOWED - ${activeCount} < ${tunnelLimit}`, - ); - } else { - console.log(`[TUNNEL LIMIT CHECK] SKIPPED - Reconnection detected`); - } - if (existingTunnel) { - // Tunnel with this URL already exists, update lastSeenAt - await db - .update(tunnels) - .set({ lastSeenAt: new Date() }) - .where(eq(tunnels.id, existingTunnel.id)); - - return json({ - success: true, - tunnelId: existingTunnel.id, - }); - } + if (existingTunnel) { + // Tunnel with this URL already exists, update lastSeenAt + await tx + .update(tunnels) + .set({ lastSeenAt: new Date() }) + .where(eq(tunnels.id, existingTunnel.id)); - // Create new tunnel record - const tunnelRecord = { - id: randomUUID(), - url: tunnelUrl, - userId, - organizationId, - name: name || null, - protocol, - remotePort: remotePort || null, - lastSeenAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }; + return { success: true, tunnelId: existingTunnel.id }; + } - await db.insert(tunnels).values(tunnelRecord); + // Create new tunnel record + const tunnelRecord = { + id: randomUUID(), + url: tunnelUrl, + userId, + organizationId, + name: name || null, + protocol, + remotePort: remotePort || null, + lastSeenAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + await tx.insert(tunnels).values(tunnelRecord); + + return { success: true, tunnelId: tunnelRecord.id }; + }); + + if ("error" in result) { + return json({ error: result.error }, { status: result.status }); + } - return json({ success: true, tunnelId: tunnelRecord.id }); + return json({ success: true, tunnelId: result.tunnelId }); } catch (error) { console.error("Tunnel registration error:", error); return json({ error: "Internal server error" }, { status: 500 });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-45hj-9x76-wp9gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-22819ghsaADVISORY
- github.com/akinloluwami/outray/security/advisories/GHSA-45hj-9x76-wp9gghsaWEB
- github.com/outray-tunnel/outray/commit/08c61495761349e7fd2965229c3faa8d7b1c1581ghsaWEB
- github.com/outray-tunnel/outray/commit/73e8a09575754fb4c395438680454b2ec064d1d6ghsax_refsource_MISCWEB
- github.com/outray-tunnel/outray/security/advisories/GHSA-45hj-9x76-wp9gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.