VYPR
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.

PackageAffected versionsPatched versions
outraynpm
< 0.1.50.1.5

Affected products

1

Patches

2
73e8a0957575

fix(subdomains): improve subdomain counting with row-level locking

https://github.com/outray-tunnel/outrayakinloluwamiJan 13, 2026via ghsa
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 {
    
08c614957613

fix(tunnel): add transaction locking to prevent race conditions during registration

https://github.com/outray-tunnel/outrayakinloluwamiJan 13, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.