Skip to content

[P2][BUG] TOCTOU race in Bifrost port allocation: free port used between getFreePort() and spawn() #9

Description

@pratik-saptarshi

Summary

src/bifrost.ts uses a two-phase port allocation strategy: (1) bind a TCP server to port 0 to get a free OS-assigned port, (2) immediately close the server, (3) pass that port number to the spawned Bifrost Go binary via -port N. Between steps 2 and 3, the OS may reassign that port to another process. The Bifrost binary then fails to bind with EADDRINUSE, or silently connects to the wrong service.

Panel verdict: P2, VERIFIED, EXISTING_DEFECT, consensus.


Affected Files

File Lines Issue
src/bifrost.ts L23–L38 getFreePort() closes the server before spawning — TOCTOU window
src/bifrost.ts L76–L89 Port passed to spawn() may be taken by the time Bifrost binds

Root Cause — Code Evidence

getFreePort() closes before use (src/bifrost.ts, lines 23–38):

function getFreePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = net.createServer();
    srv.unref();
    srv.on('error', reject);
    srv.listen(0, '127.0.0.1', () => {
      const addr = srv.address();
      if (addr && typeof addr === 'object') {
        const p = addr.port;
        srv.close(() => resolve(p));  // ← port freed here
        //                               ↑ TOCTOU: another process can grab this port
        //                                 before Bifrost binds to it
      } else {
        srv.close(() => reject(new Error('no port')));
      }
    });
  });
}

Port passed to spawn (src/bifrost.ts, lines 78–89):

const child: ChildProcess = spawn(
  process.execPath,
  [launcher, '-port', String(port)],  // ← port may be taken by now
  { ... }
);

Impact

  • Startup failure: Bifrost fails to bind and the scan cannot proceed, producing a confusing error.
  • Silent proxy misdirection: If another service grabbed the port, Bifrost may error while the Probus client connects to the wrong service — causing API key leakage to the unintended service.
  • Reproducibility on loaded systems: More likely under CI/CD runners or developer machines with many concurrent services.

Remediation

Option A — Let Bifrost bind to port 0 (OS-assigned) and report back

If the Bifrost binary supports binding to port 0 and reporting the bound port via stdout, use that approach:

// Pass port=0 and read the actual bound port from stdout
const child = spawn(process.execPath, [launcher, '-port', '0'], { ... });
const actualPort = await readPortFromStdout(child);

Option B — Keep the listener socket open and pass the fd (Unix only)

// Keep the socket open and pass it as a pre-bound fd:
const srv = net.createServer();
await new Promise<void>((res, rej) => { srv.listen(0, '127.0.0.1', res); srv.on('error', rej); });
const port = (srv.address() as net.AddressInfo).port;
// Don't close srv — pass the socket fd to the child process
const child = spawn(process.execPath, [launcher, '-port', String(port)], {
  stdio: ['ignore', 'pipe', 'pipe', srv._handle]  // platform-specific
});
srv.close(); // Now safe to close since child inherited the fd

Option C — Retry with exponential backoff (simplest)

async function spawnBifrostWithRetry(port: number, maxRetries = 3): Promise<ChildProcess> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const p = attempt === 0 ? port : await getFreePort();
      const child = spawnBifrost(p);
      await waitForPort(p, 60_000);
      return child;
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
      await new Promise(r => setTimeout(r, 100 * Math.pow(2, attempt)));
    }
  }
  throw new Error('Failed to spawn Bifrost after retries');
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions