Skip to content

Commit 45cde65

Browse files
Mossakaclaude
andcommitted
fix: use exclusive file creation for chroot-hosts to address CWE-377
Build complete chroot-hosts content in memory before writing to avoid TOCTOU races (CWE-367). Create the file atomically with O_CREAT|O_EXCL to prevent symlink attacks (CWE-377). This addresses CodeQL alerts for js/insecure-temporary-file and js/file-system-race. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fdd5713 commit 45cde65

1 file changed

Lines changed: 17 additions & 8 deletions

File tree

src/docker-manager.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -500,18 +500,20 @@ export function generateDockerCompose(
500500
// 1. Pre-resolve allowed domains using the host's DNS stack (supports Tailscale MagicDNS,
501501
// split DNS, and other custom resolvers not available inside the container)
502502
// 2. Inject host.docker.internal when --enable-host-access is set
503+
// Build the complete chroot hosts file content in memory before writing,
504+
// to avoid TOCTOU races on the temp file (CWE-367, CWE-377).
503505
const chrootHostsPath = path.join(config.workDir, 'chroot-hosts');
506+
let hostsContent = '127.0.0.1 localhost\n';
504507
try {
505-
fs.copyFileSync('/etc/hosts', chrootHostsPath);
508+
hostsContent = fs.readFileSync('/etc/hosts', 'utf-8');
506509
} catch {
507-
fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n');
510+
// /etc/hosts not readable, use minimal fallback
508511
}
509512

510-
// Pre-resolve allowed domains on the host and inject into /etc/hosts
513+
// Pre-resolve allowed domains on the host and append to hosts content.
511514
// This is critical for domains that rely on custom DNS (e.g., Tailscale MagicDNS
512515
// at 100.100.100.100) which is unreachable from inside the Docker container's
513516
// network namespace. Resolution runs on the host where all DNS resolvers are available.
514-
const hostsContent = fs.readFileSync(chrootHostsPath, 'utf-8');
515517
for (const domain of config.allowedDomains) {
516518
// Skip patterns that aren't resolvable hostnames
517519
if (domain.startsWith('*.') || domain.startsWith('.') || domain.includes('*')) continue;
@@ -523,7 +525,7 @@ export function generateDockerCompose(
523525
const parts = stdout.trim().split(/\s+/);
524526
const ip = parts[0];
525527
if (ip) {
526-
fs.appendFileSync(chrootHostsPath, `${ip}\t${domain}\n`);
528+
hostsContent += `${ip}\t${domain}\n`;
527529
logger.debug(`Pre-resolved ${domain} -> ${ip} for chroot /etc/hosts`);
528530
}
529531
} catch {
@@ -532,7 +534,7 @@ export function generateDockerCompose(
532534
}
533535
}
534536

535-
// Add host.docker.internal when host access is enabled
537+
// Add host.docker.internal when host access is enabled.
536538
// Docker only adds this to the container's /etc/hosts via extra_hosts, but the
537539
// chroot uses the host's /etc/hosts which lacks this entry. MCP servers need it
538540
// to connect to the MCP gateway running on the host.
@@ -544,15 +546,22 @@ export function generateDockerCompose(
544546
]);
545547
const hostGatewayIp = stdout.trim();
546548
if (hostGatewayIp) {
547-
fs.appendFileSync(chrootHostsPath, `${hostGatewayIp}\thost.docker.internal\n`);
549+
hostsContent += `${hostGatewayIp}\thost.docker.internal\n`;
548550
logger.debug(`Added host.docker.internal (${hostGatewayIp}) to chroot-hosts`);
549551
}
550552
} catch (err) {
551553
logger.debug(`Could not resolve Docker bridge gateway: ${err}`);
552554
}
553555
}
554556

555-
fs.chmodSync(chrootHostsPath, 0o644);
557+
// Write the complete content atomically via a securely-created file
558+
const fd = fs.openSync(
559+
chrootHostsPath,
560+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
561+
0o644,
562+
);
563+
fs.writeSync(fd, hostsContent);
564+
fs.closeSync(fd);
556565
agentVolumes.push(`${chrootHostsPath}:/host/etc/hosts:ro`);
557566

558567
// SECURITY: Hide Docker socket to prevent firewall bypass via 'docker run'

0 commit comments

Comments
 (0)