Skip to content

Commit 76de95b

Browse files
Mossakaclaude
andauthored
fix(security): disable IPv6 via sysctl when ip6tables unavailable (#1154)
When ip6tables is not available, IPv6 traffic could bypass all firewall filtering rules. Instead of just logging a warning, now disable IPv6 completely via sysctl (net.ipv6.conf.all.disable_ipv6=1) to prevent unfiltered bypass. IPv6 is re-enabled on cleanup. Changes: - host-iptables.ts: disable IPv6 via sysctl when ip6tables unavailable, re-enable on cleanup via enableIpv6ViaSysctl() - setup-iptables.sh: disable IPv6 via sysctl in container when ip6tables unavailable - host-iptables.test.ts: add 3 tests for sysctl disable/enable behavior Fixes #245 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c6047f commit 76de95b

File tree

3 files changed

+135
-6
lines changed

3 files changed

+135
-6
lines changed

containers/agent/setup-iptables.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ if has_ip6tables; then
3232
IP6TABLES_AVAILABLE=true
3333
echo "[iptables] ip6tables is available"
3434
else
35-
echo "[iptables] WARNING: ip6tables is not available, IPv6 rules will be skipped"
35+
echo "[iptables] WARNING: ip6tables is not available, disabling IPv6 via sysctl to prevent unfiltered bypass"
36+
sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null || echo "[iptables] WARNING: failed to disable IPv6 (net.ipv6.conf.all.disable_ipv6)"
37+
sysctl -w net.ipv6.conf.default.disable_ipv6=1 2>/dev/null || echo "[iptables] WARNING: failed to disable IPv6 (net.ipv6.conf.default.disable_ipv6)"
3638
fi
3739

3840
# Get Squid proxy configuration from environment

src/host-iptables.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ensureFirewallNetwork, setupHostIptables, cleanupHostIptables, cleanupFirewallNetwork } from './host-iptables';
1+
import { ensureFirewallNetwork, setupHostIptables, cleanupHostIptables, cleanupFirewallNetwork, _resetIpv6State } from './host-iptables';
22
import execa from 'execa';
33

44
// Mock execa
@@ -19,6 +19,7 @@ jest.mock('./logger', () => ({
1919
describe('host-iptables', () => {
2020
beforeEach(() => {
2121
jest.clearAllMocks();
22+
_resetIpv6State();
2223
});
2324

2425
describe('ensureFirewallNetwork', () => {
@@ -490,6 +491,48 @@ describe('host-iptables', () => {
490491
]);
491492
});
492493

494+
it('should disable IPv6 via sysctl when ip6tables unavailable', async () => {
495+
// Make ip6tables unavailable
496+
mockedExeca
497+
.mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any)
498+
// iptables -L DOCKER-USER permission check
499+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any)
500+
// chain existence check (doesn't exist)
501+
.mockResolvedValueOnce({ exitCode: 1 } as any);
502+
503+
// All subsequent calls succeed (except ip6tables)
504+
mockedExeca.mockImplementation(((cmd: string, _args: string[]) => {
505+
if (cmd === 'ip6tables') {
506+
return Promise.reject(new Error('ip6tables not found'));
507+
}
508+
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
509+
}) as any);
510+
511+
await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']);
512+
513+
// Verify sysctl was called to disable IPv6
514+
expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=1']);
515+
expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=1']);
516+
});
517+
518+
it('should not disable IPv6 via sysctl when ip6tables is available', async () => {
519+
mockedExeca
520+
// Mock getNetworkBridgeName
521+
.mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any)
522+
// Mock iptables -L DOCKER-USER (permission check)
523+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any)
524+
// Mock chain existence check (doesn't exist)
525+
.mockResolvedValueOnce({ exitCode: 1 } as any);
526+
527+
mockedExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any);
528+
529+
await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']);
530+
531+
// Verify sysctl was NOT called to disable IPv6
532+
expect(mockedExeca).not.toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=1']);
533+
expect(mockedExeca).not.toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=1']);
534+
});
535+
493536
it('should not create IPv6 chain when no IPv6 DNS servers', async () => {
494537
mockedExeca
495538
// Mock getNetworkBridgeName
@@ -541,6 +584,39 @@ describe('host-iptables', () => {
541584
expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-X', 'FW_WRAPPER_V6'], { reject: false });
542585
});
543586

587+
it('should re-enable IPv6 via sysctl on cleanup if it was disabled', async () => {
588+
// First, simulate setup that disabled IPv6
589+
mockedExeca
590+
.mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any)
591+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any)
592+
.mockResolvedValueOnce({ exitCode: 1 } as any);
593+
594+
// Make ip6tables unavailable to trigger sysctl disable
595+
mockedExeca.mockImplementation(((cmd: string) => {
596+
if (cmd === 'ip6tables') {
597+
return Promise.reject(new Error('ip6tables not found'));
598+
}
599+
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
600+
}) as any);
601+
602+
await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8']);
603+
604+
// Now run cleanup
605+
jest.clearAllMocks();
606+
mockedExeca.mockImplementation(((cmd: string) => {
607+
if (cmd === 'ip6tables') {
608+
return Promise.reject(new Error('ip6tables not found'));
609+
}
610+
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
611+
}) as any);
612+
613+
await cleanupHostIptables();
614+
615+
// Verify IPv6 was re-enabled via sysctl
616+
expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=0']);
617+
expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=0']);
618+
});
619+
544620
it('should not throw on errors (best-effort cleanup)', async () => {
545621
mockedExeca.mockRejectedValue(new Error('iptables error'));
546622

src/host-iptables.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ const NETWORK_SUBNET = '172.30.0.0/24';
1111
// Cache for ip6tables availability check (only checked once per run)
1212
let ip6tablesAvailableCache: boolean | null = null;
1313

14+
// Track whether IPv6 was disabled via sysctl (so we can re-enable on cleanup)
15+
let ipv6DisabledViaSysctl = false;
16+
17+
/**
18+
* Resets internal IPv6 state (for testing only).
19+
*/
20+
export function _resetIpv6State(): void {
21+
ip6tablesAvailableCache = null;
22+
ipv6DisabledViaSysctl = false;
23+
}
24+
1425
/**
1526
* Gets the bridge interface name for the firewall network
1627
*/
@@ -52,6 +63,38 @@ async function isIp6tablesAvailable(): Promise<boolean> {
5263
}
5364
}
5465

66+
/**
67+
* Disables IPv6 via sysctl when ip6tables is unavailable.
68+
* This prevents IPv6 from becoming an unfiltered bypass path.
69+
*/
70+
async function disableIpv6ViaSysctl(): Promise<void> {
71+
try {
72+
await execa('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=1']);
73+
await execa('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=1']);
74+
ipv6DisabledViaSysctl = true;
75+
logger.info('IPv6 disabled via sysctl (ip6tables unavailable)');
76+
} catch (error) {
77+
logger.warn('Failed to disable IPv6 via sysctl:', error);
78+
}
79+
}
80+
81+
/**
82+
* Re-enables IPv6 via sysctl if it was previously disabled.
83+
*/
84+
async function enableIpv6ViaSysctl(): Promise<void> {
85+
if (!ipv6DisabledViaSysctl) {
86+
return;
87+
}
88+
try {
89+
await execa('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=0']);
90+
await execa('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=0']);
91+
ipv6DisabledViaSysctl = false;
92+
logger.debug('IPv6 re-enabled via sysctl');
93+
} catch (error) {
94+
logger.debug('Failed to re-enable IPv6 via sysctl:', error);
95+
}
96+
}
97+
5598
/**
5699
* Creates the dedicated firewall network if it doesn't exist
57100
* Returns the Squid and agent IPs
@@ -309,13 +352,17 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS
309352
]);
310353
}
311354

355+
// Check ip6tables availability and disable IPv6 if unavailable
356+
const ip6tablesAvailable = await isIp6tablesAvailable();
357+
if (!ip6tablesAvailable) {
358+
logger.warn('ip6tables is not available, disabling IPv6 via sysctl to prevent unfiltered bypass');
359+
await disableIpv6ViaSysctl();
360+
}
361+
312362
// Add IPv6 DNS server rules using ip6tables
313363
if (ipv6DnsServers.length > 0) {
314-
// Check if ip6tables is available before setting up IPv6 rules
315-
const ip6tablesAvailable = await isIp6tablesAvailable();
316364
if (!ip6tablesAvailable) {
317-
logger.warn('ip6tables is not available, IPv6 DNS servers will not be configured at the host level');
318-
logger.warn(' IPv6 traffic may not be properly filtered');
365+
logger.warn('IPv6 DNS servers configured but ip6tables not available; IPv6 has been disabled');
319366
} else {
320367
// Set up IPv6 chain if we have IPv6 DNS servers
321368
await setupIpv6Chain(bridgeName);
@@ -614,6 +661,10 @@ export async function cleanupHostIptables(): Promise<void> {
614661
} else {
615662
logger.debug('ip6tables not available, skipping IPv6 cleanup');
616663
}
664+
665+
// Re-enable IPv6 if it was disabled via sysctl
666+
await enableIpv6ViaSysctl();
667+
617668
logger.debug('Host-level iptables rules cleaned up');
618669
} catch (error) {
619670
logger.debug('Error cleaning up iptables rules:', error);

0 commit comments

Comments
 (0)