Browse Source

added comments to firewall rules and updated tests

pull/2418/head
Ian Foster 5 months ago
parent
commit
ed3cf89dad
  1. 33
      src/server/utils/firewall.ts
  2. 2
      src/server/utils/types.ts
  3. 152
      src/test/unit/firewall.spec.ts

33
src/server/utils/firewall.ts

@ -21,6 +21,18 @@ type ParsedEntry = {
proto?: 'tcp' | 'udp' | 'both'; proto?: 'tcp' | 'udp' | 'both';
}; };
/**
* Sanitize a client identifier for use in an iptables comment.
* Strips all characters except ASCII alphanumeric, space, underscore, hyphen, and dot.
* Combines with client ID for a safe, identifiable comment.
* Truncates to 256 bytes (iptables comment module limit).
*/
function sanitizeComment(clientId: number, clientName: string): string {
const safe = clientName.replace(/[^a-zA-Z0-9 _.-]/g, '');
const comment = `client ${clientId}: ${safe}`;
return comment.slice(0, 256);
}
/** /**
* Parse a firewall entry string into its components. * Parse a firewall entry string into its components.
* Supports formats: * Supports formats:
@ -109,22 +121,28 @@ function parseFirewallEntry(entry: string): ParsedEntry {
function generateRuleArgs( function generateRuleArgs(
clientIp: string, clientIp: string,
entry: ParsedEntry, entry: ParsedEntry,
comment?: string,
action: 'A' | 'D' = 'A' action: 'A' | 'D' = 'A'
): string[] { ): string[] {
const rules: string[] = []; const rules: string[] = [];
const commentArg = comment ? ` -m comment --comment "${comment}"` : '';
const baseArgs = `-${action} ${CHAIN_NAME} -s ${clientIp} -d ${entry.ip}`; const baseArgs = `-${action} ${CHAIN_NAME} -s ${clientIp} -d ${entry.ip}`;
if (entry.port) { if (entry.port) {
// Port-specific rules // Port-specific rules
if (entry.proto === 'tcp' || entry.proto === 'both') { if (entry.proto === 'tcp' || entry.proto === 'both') {
rules.push(`${baseArgs} -p tcp --dport ${entry.port} -j ACCEPT`); rules.push(
`${baseArgs} -p tcp --dport ${entry.port}${commentArg} -j ACCEPT`
);
} }
if (entry.proto === 'udp' || entry.proto === 'both') { if (entry.proto === 'udp' || entry.proto === 'both') {
rules.push(`${baseArgs} -p udp --dport ${entry.port} -j ACCEPT`); rules.push(
`${baseArgs} -p udp --dport ${entry.port}${commentArg} -j ACCEPT`
);
} }
} else { } else {
// No port - allow all traffic to destination // No port - allow all traffic to destination
rules.push(`${baseArgs} -j ACCEPT`); rules.push(`${baseArgs}${commentArg} -j ACCEPT`);
} }
return rules; return rules;
@ -181,19 +199,21 @@ export const firewall = {
`Applying firewall rules for client ${client.name} (${client.id}): ${effectiveIps.join(', ')}` `Applying firewall rules for client ${client.name} (${client.id}): ${effectiveIps.join(', ')}`
); );
const comment = sanitizeComment(client.id, client.name);
for (const ipEntry of effectiveIps) { for (const ipEntry of effectiveIps) {
const parsed = parseFirewallEntry(ipEntry); const parsed = parseFirewallEntry(ipEntry);
const destIsIpv6 = isIPv6(parsed.ip.split('/')[0]); // Handle CIDR by checking base IP const destIsIpv6 = isIPv6(parsed.ip.split('/')[0]); // Handle CIDR by checking base IP
if (destIsIpv6) { if (destIsIpv6) {
if (enableIpv6) { if (enableIpv6) {
const rules = generateRuleArgs(client.ipv6Address, parsed); const rules = generateRuleArgs(client.ipv6Address, parsed, comment);
for (const rule of rules) { for (const rule of rules) {
await exec(`ip6tables ${rule}`); await exec(`ip6tables ${rule}`);
} }
} }
} else { } else {
const rules = generateRuleArgs(client.ipv4Address, parsed); const rules = generateRuleArgs(client.ipv4Address, parsed, comment);
for (const rule of rules) { for (const rule of rules) {
await exec(`iptables ${rule}`); await exec(`iptables ${rule}`);
} }
@ -324,7 +344,8 @@ export const firewall = {
}, },
}; };
export const testExports = { export const firewallTestExports = {
parseFirewallEntry, parseFirewallEntry,
generateRuleArgs, generateRuleArgs,
sanitizeComment,
}; };

2
src/server/utils/types.ts

@ -259,4 +259,4 @@ export function assertUnreachable(_: never): never {
throw new Error("Didn't expect to get here"); throw new Error("Didn't expect to get here");
} }
export const testExports = { FirewallIpEntrySchema }; export const typesTestExports = { FirewallIpEntrySchema };

152
src/test/unit/firewall.spec.ts

@ -1,103 +1,107 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { testExports } from '../../server/utils/firewall'; import { firewallTestExports } from '../../server/utils/firewall';
import { testExports as typesTextExports } from '../../server/utils/types'; import { typesTestExports } from '../../server/utils/types';
describe('firewall', () => { describe('firewall', () => {
describe('isValidFirewallEntry', () => { describe('isValidFirewallEntry', () => {
test('invalid ips', () => { test('invalid ips', () => {
expect(() => typesTextExports.FirewallIpEntrySchema.parse('')).toThrow(); expect(() => typesTestExports.FirewallIpEntrySchema.parse('')).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('255.255.255.256') typesTestExports.FirewallIpEntrySchema.parse('255.255.255.256')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.256') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.256')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1.1') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1.1')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('[]:443/udp') typesTestExports.FirewallIpEntrySchema.parse('[]:443/udp')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('[]:443') typesTestExports.FirewallIpEntrySchema.parse('[]:443')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('[::1]/32') typesTestExports.FirewallIpEntrySchema.parse('[::1]/32')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('[1.1.1.1]/32') typesTestExports.FirewallIpEntrySchema.parse('[1.1.1.1]/32')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('[::g]/32') typesTestExports.FirewallIpEntrySchema.parse('[::g]/32')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('2001:dbx::1') typesTestExports.FirewallIpEntrySchema.parse('2001:dbx::1')
).toThrow(); ).toThrow();
}); });
test('invalid port, protocol or cidr', () => { test('invalid port, protocol or cidr', () => {
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1:80/tcpp') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1:80/tcpp')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1:65536') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1:65536')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1:0') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1:0')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1/33') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1/33')
).toThrow(); ).toThrow();
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1/32:0') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1/32:0')
).toThrow(); ).toThrow();
}); });
test('protocol without port', () => { test('protocol without port', () => {
expect(() => expect(() =>
typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1/tcp') typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1/tcp')
).toThrow(); ).toThrow();
}); });
test('valid entries', () => { test('valid entries', () => {
expect(typesTextExports.FirewallIpEntrySchema.parse('1.1.1.1')).toBe( expect(typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1')).toBe(
'1.1.1.1' '1.1.1.1'
); );
expect(typesTextExports.FirewallIpEntrySchema.parse('::/0')).toBe('::/0'); expect(typesTestExports.FirewallIpEntrySchema.parse('::/0')).toBe('::/0');
expect(typesTextExports.FirewallIpEntrySchema.parse('::0/0')).toBe( expect(typesTestExports.FirewallIpEntrySchema.parse('::0/0')).toBe(
'::0/0' '::0/0'
); );
expect(typesTextExports.FirewallIpEntrySchema.parse('2001:db8::1')).toBe( expect(typesTestExports.FirewallIpEntrySchema.parse('2001:db8::1')).toBe(
'2001:db8::1' '2001:db8::1'
); );
expect(typesTextExports.FirewallIpEntrySchema.parse('::1')).toBe('::1'); expect(typesTestExports.FirewallIpEntrySchema.parse('::1')).toBe('::1');
expect( expect(
typesTextExports.FirewallIpEntrySchema.parse('2001:db8::1/32') typesTestExports.FirewallIpEntrySchema.parse('2001:db8::1/32')
).toBe('2001:db8::1/32'); ).toBe('2001:db8::1/32');
expect(typesTextExports.FirewallIpEntrySchema.parse('[::1]')).toBe( expect(typesTestExports.FirewallIpEntrySchema.parse('[::1]')).toBe(
'[::1]' '[::1]'
); );
expect(testExports.parseFirewallEntry('[::1]')).toEqual({ expect(firewallTestExports.parseFirewallEntry('[::1]')).toEqual({
ip: '::1', ip: '::1',
}); });
}); });
}); });
describe('parseFirewallEntry', () => { describe('parseFirewallEntry', () => {
test('IPv4 with port and protocol', () => { test('IPv4 with port and protocol', () => {
expect(() => testExports.parseFirewallEntry('1.1.1.1/tcp')).toThrow(); expect(() =>
expect(() => testExports.parseFirewallEntry('1.1.1.1/udp')).toThrow(); firewallTestExports.parseFirewallEntry('1.1.1.1/tcp')
expect(testExports.parseFirewallEntry('1.1.1.1')).toEqual({ ).toThrow();
expect(() =>
firewallTestExports.parseFirewallEntry('1.1.1.1/udp')
).toThrow();
expect(firewallTestExports.parseFirewallEntry('1.1.1.1')).toEqual({
ip: '1.1.1.1', ip: '1.1.1.1',
}); });
expect(testExports.parseFirewallEntry('1.1.1.1:80')).toEqual({ expect(firewallTestExports.parseFirewallEntry('1.1.1.1:80')).toEqual({
ip: '1.1.1.1', ip: '1.1.1.1',
port: 80, port: 80,
proto: 'both', proto: 'both',
}); });
expect(testExports.parseFirewallEntry('1.1.1.1:80/tcp')).toEqual({ expect(firewallTestExports.parseFirewallEntry('1.1.1.1:80/tcp')).toEqual({
ip: '1.1.1.1', ip: '1.1.1.1',
port: 80, port: 80,
proto: 'tcp', proto: 'tcp',
}); });
expect(testExports.parseFirewallEntry('1.1.1.1:80/udp')).toEqual({ expect(firewallTestExports.parseFirewallEntry('1.1.1.1:80/udp')).toEqual({
ip: '1.1.1.1', ip: '1.1.1.1',
port: 80, port: 80,
proto: 'udp', proto: 'udp',
@ -106,34 +110,102 @@ describe('firewall', () => {
test('IPv6 with port and protocol', () => { test('IPv6 with port and protocol', () => {
expect(() => expect(() =>
testExports.parseFirewallEntry('[2001:db8::1]/tcp') firewallTestExports.parseFirewallEntry('[2001:db8::1]/tcp')
).toThrow(); ).toThrow();
expect(() => expect(() =>
testExports.parseFirewallEntry('[2001:db8::1]/udp') firewallTestExports.parseFirewallEntry('[2001:db8::1]/udp')
).toThrow(); ).toThrow();
expect(testExports.parseFirewallEntry('[2001:db8::1]')).toEqual({ expect(firewallTestExports.parseFirewallEntry('[2001:db8::1]')).toEqual({
ip: '2001:db8::1', ip: '2001:db8::1',
}); });
expect(testExports.parseFirewallEntry('[2001:db8::1]:443')).toEqual({ expect(
firewallTestExports.parseFirewallEntry('[2001:db8::1]:443')
).toEqual({
ip: '2001:db8::1', ip: '2001:db8::1',
port: 443, port: 443,
proto: 'both', proto: 'both',
}); });
expect(testExports.parseFirewallEntry('[2001:db8::1]:443/tcp')).toEqual({ expect(
firewallTestExports.parseFirewallEntry('[2001:db8::1]:443/tcp')
).toEqual({
ip: '2001:db8::1', ip: '2001:db8::1',
port: 443, port: 443,
proto: 'tcp', proto: 'tcp',
}); });
expect(testExports.parseFirewallEntry('[2001:db8::1]:443/udp')).toEqual({ expect(
firewallTestExports.parseFirewallEntry('[2001:db8::1]:443/udp')
).toEqual({
ip: '2001:db8::1', ip: '2001:db8::1',
port: 443, port: 443,
proto: 'udp', proto: 'udp',
}); });
expect(testExports.parseFirewallEntry('::0/0')).toEqual({ expect(firewallTestExports.parseFirewallEntry('::0/0')).toEqual({
ip: '::0/0', ip: '::0/0',
}); });
expect(() => testExports.parseFirewallEntry('::0/0/tcp')).toThrow(); expect(() =>
firewallTestExports.parseFirewallEntry('::0/0/tcp')
).toThrow();
});
});
describe('sanitizeComment', () => {
test('basic ASCII name', () => {
expect(firewallTestExports.sanitizeComment(1, 'My Laptop')).toBe(
'client 1: My Laptop'
);
});
test('strips non-ASCII and shell metacharacters', () => {
expect(firewallTestExports.sanitizeComment(42, 'café')).toBe(
'client 42: caf'
);
expect(firewallTestExports.sanitizeComment(5, 'a"; rm -rf /')).toBe(
'client 5: a rm -rf '
);
expect(firewallTestExports.sanitizeComment(7, 'test$(cmd)`id`')).toBe(
'client 7: testcmdid'
);
});
test('preserves allowed punctuation', () => {
expect(firewallTestExports.sanitizeComment(3, 'phone-2.lan_home')).toBe(
'client 3: phone-2.lan_home'
);
});
test('truncates to 256 bytes', () => {
const longName = 'a'.repeat(300);
const result = firewallTestExports.sanitizeComment(1, longName);
expect(result.length).toBeLessThanOrEqual(256);
expect(result).toBe('client 1: ' + 'a'.repeat(246));
});
});
describe('generateRuleArgs', () => {
test('includes comment when provided', () => {
const rules = firewallTestExports.generateRuleArgs(
'10.8.0.2',
{ ip: '10.0.0.1' },
'client 1: test'
);
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -m comment --comment "client 1: test" -j ACCEPT',
]);
});
test('omits comment when not provided', () => {
const rules = firewallTestExports.generateRuleArgs('10.8.0.2', {
ip: '10.0.0.1',
});
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -j ACCEPT',
]);
});
test('comment with port generates two rules for both proto', () => {
const rules = firewallTestExports.generateRuleArgs(
'10.8.0.2',
{ ip: '10.0.0.1', port: 443, proto: 'both' },
'client 2: phone'
);
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
]);
}); });
}); });
}); });

Loading…
Cancel
Save