diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-02-15 09:45:54 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-02-15 09:45:54 +0100 |
| commit | b6c066c8bc86deaf6b1d80296bdbd9e3fd3c5c77 (patch) | |
| tree | 00ed023257bd6d064ab425ecb320a378cf02f58b | |
| parent | 85c7801a87e99af8e698a6847604510d7233cd7a (diff) | |
| parent | 31149d6acc62c52cd0bad3e1f80f7f86151d6487 (diff) | |
| download | mullvadvpn-b6c066c8bc86deaf6b1d80296bdbd9e3fd3c5c77.tar.xz mullvadvpn-b6c066c8bc86deaf6b1d80296bdbd9e3fd3c5c77.zip | |
Merge branch 'improve-ip-validation'
| -rw-r--r-- | gui/package-lock.json | 14 | ||||
| -rw-r--r-- | gui/package.json | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettings.tsx | 26 | ||||
| -rw-r--r-- | gui/src/renderer/lib/ip.ts | 278 | ||||
| -rw-r--r-- | gui/test/ip.spec.ts | 174 |
5 files changed, 464 insertions, 30 deletions
diff --git a/gui/package-lock.json b/gui/package-lock.json index e8c2721c45..64c8311241 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -18782,15 +18782,6 @@ "hoist-non-react-statics": "^3.3.0" } }, - "@types/ip": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.0.tgz", - "integrity": "sha512-dwNe8gOoF70VdL6WJBwVHtQmAX4RMd62M+mAB9HQFjG1/qiCLM/meRy95Pd14FYBbEDwCq7jgJs89cHpLBu4HQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/json-schema": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", @@ -26355,11 +26346,6 @@ "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", "dev": true }, - "ip": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", - "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" - }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", diff --git a/gui/package.json b/gui/package.json index c92a4a88d5..baf7717269 100644 --- a/gui/package.json +++ b/gui/package.json @@ -17,7 +17,6 @@ "d3-geo": "^1.12.1", "gettext-parser": "^4.0.3", "google-protobuf": "^4.0.0-rc.2", - "ip": "^1.1.5", "linux-app-list": "^1.0.1", "moment": "^2.24.0", "node-gettext": "^3.0.0", @@ -45,7 +44,6 @@ "@types/gettext-parser": "^4.0.0", "@types/google-protobuf": "^3.7.2", "@types/history": "^4.7.8", - "@types/ip": "^1.1.0", "@types/mkdirp": "^1.0.0", "@types/mocha": "^5.2.6", "@types/node": "^10.12.3", diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 3f840f4492..b24b849b5f 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -1,4 +1,3 @@ -import ip from 'ip'; import * as React from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../config.json'; @@ -10,6 +9,7 @@ import { } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import consumePromise from '../../shared/promise'; +import { IpAddress } from '../lib/ip'; import { WgKeyState } from '../redux/settings/reducers'; import { StyledButtonCellGroup, @@ -575,21 +575,19 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { }; private addDnsAddress = async (address: string, confirmed?: boolean) => { - if (ip.isV4Format(address) || ip.isV6Format(address)) { - if (ip.isPublic(address) && !confirmed) { - this.setState({ publicDnsIpToConfirm: address }); + try { + const ipAddress = IpAddress.fromString(address); + + if (ipAddress.isLocal() || confirmed) { + await this.props.setDnsOptions({ + custom: this.props.dns.custom || this.state.showAddCustomDns, + addresses: [...this.props.dns.addresses, address], + }); + this.hideAddCustomDnsRow(); } else { - try { - await this.props.setDnsOptions({ - custom: this.props.dns.custom || this.state.showAddCustomDns, - addresses: [...this.props.dns.addresses, address], - }); - this.hideAddCustomDnsRow(); - } catch (_e) { - this.setState({ invalidDnsIp: true }); - } + this.setState({ publicDnsIpToConfirm: address }); } - } else { + } catch (e) { this.setState({ invalidDnsIp: true }); } }; diff --git a/gui/src/renderer/lib/ip.ts b/gui/src/renderer/lib/ip.ts new file mode 100644 index 0000000000..369d66a479 --- /dev/null +++ b/gui/src/renderer/lib/ip.ts @@ -0,0 +1,278 @@ +// Number of groups for each IP format +type IPv4Octets = [number, number, number, number]; +type IPv6Groups = [number, number, number, number, number, number, number, number]; + +// Number of bits in each group for each IP format +const IPv4OctetSize = 8; +const IPv6GroupSize = 16; + +// Abstract class representing an IP address +export abstract class IpAddress<G extends number[]> { + public constructor(public readonly groups: G) {} + + public abstract isLocal(): boolean; + + public static fromString(ip: string): IPv4Address | IPv6Address { + try { + return IPv4Address.fromString(ip); + } catch (e) { + return IPv6Address.fromString(ip); + } + } +} + +// Abstract class representing an IP range or subnet +export abstract class IpRange<G extends number[]> { + public constructor(public readonly groups: G, public readonly prefixSize: number) {} + + // Returns whether or not this subnet includes the privided IP + protected includes<T extends IpAddress<G>>(ip: T, groupSize: number): boolean { + return IpRange.match(groupSize, ip.groups, [this.groups, this.prefixSize]); + } + + // Matches each group of the ip/subnet from left to right to determine if they match + private static match( + groupSize: number, + [ipGroup, ...ipGroups]: number[], + [[subnetGroup, ...subnetGroups], prefixSize]: [number[], number], + ): boolean { + if (prefixSize >= groupSize) { + // If the current group is part of the prefix the only needed check is if they are equal + return ( + ipGroup === subnetGroup && + IPv4Range.match(groupSize, ipGroups, [subnetGroups, prefixSize - groupSize]) + ); + } else { + // If the group (or parts of the group) isn't part of the prefix the non-prefix part needs to + // be compared + // variableBits contains the maximum value that the non-prefix bits can have + const variableBits = getBitsMax(groupSize - prefixSize); + // Calculate smallest IP in the subnet + const subnetMin = subnetGroup & (getBitsMax(groupSize) - variableBits); + // Calculate greatest IP in the subnet + const subnetMax = subnetGroup | variableBits; + // Check if the provided ip is between subnetMin/-Max + return ipGroup >= subnetMin && ipGroup <= subnetMax; + } + } +} + +export class IPv4Address extends IpAddress<IPv4Octets> { + public constructor(octets: IPv4Octets) { + super(octets); + + // Ensure that each octets is the correct number of bits + if (octets.some((octets) => !isNumberOfBits(octets, IPv4OctetSize))) { + throw new Error(`Invalid ip: ${octets.join('.')}`); + } + } + + public isLocal(): boolean { + const localSubnets = [...IPV4_LAN_SUBNETS, IPV4_LOOPBACK_SUBNET]; + return localSubnets.some((subnet) => subnet.includes(this)); + } + + // Parses an ip address from a string of the quad-dotted format, e.g. 127.0.0.1 + public static fromString(ip: string): IPv4Address { + try { + const octets = IPv4Address.octetsFromString(ip); + return new IPv4Address(octets); + } catch (e) { + throw new Error(`Invalid ip: ${ip}`); + } + } + + public static octetsFromString(ip: string): IPv4Octets { + try { + const octets = ip.split('.'); + if (octets.every((octet) => /^\d{1,3}$/.test(octet))) { + const parsedOctets = octets.map((octet) => parseInt(octet, 10)); + if (IPv4Address.isIPv4Octets(parsedOctets)) { + return parsedOctets; + } + } + } catch (e) { + // no-op + } + + throw new Error(`Invalid ip: ${ip}`); + } + + public static isValid(ip: string): boolean { + try { + IPv4Address.fromString(ip); + return true; + } catch (e) { + return false; + } + } + + // Makes sure that the number of octets is correct and values where parsed correctly + private static isIPv4Octets(octets: number[]): octets is IPv4Octets { + return octets.length === 4 && octets.every((octet) => !isNaN(octet)); + } +} + +export class IPv4Range extends IpRange<IPv4Octets> { + public constructor(octets: IPv4Octets, prefixSize: number) { + super(octets, prefixSize); + + // Makes sure that the prefix is within the correct range + if (prefixSize < 0 || prefixSize > 32) { + throw new Error(`Invalid ip: ${octets.join('.')}/${prefixSize}`); + } + } + + public static fromString(subnet: string): IPv4Range { + try { + // In addition to parsing the ip the subnet-mask also needs to be parsed + const parts = subnet.split('/'); + if (/^\d{1,2}$/.test(parts[1])) { + const octets = IPv4Address.octetsFromString(parts[0]); + const prefixSize = parseInt(parts[1]); + return new IPv4Range(octets, prefixSize); + } + } catch (e) { + // no-op + } + + throw new Error(`Invalid ip: ${subnet}`); + } + + public includes(ip: IPv4Address): boolean { + return super.includes(ip, IPv4OctetSize); + } +} + +export class IPv6Address extends IpAddress<IPv6Groups> { + public constructor(groups: IPv6Groups) { + super(groups); + + // Ensure that each group is the correct number of bits + if (groups.some((group) => !isNumberOfBits(group, 16))) { + throw new Error(`Invalid ip: ${groups.join(':')}`); + } + } + + public isLocal(): boolean { + const localSubnets = [...IPV6_LAN_SUBNETS, IPV6_LOOPBACK_SUBNET]; + return localSubnets.some((subnet) => subnet.includes(this)); + } + + // Parses IPv6 addresses where the groups are seperated by ':' and supports shortened addresses. + public static fromString(ip: string): IPv6Address { + try { + const groups = IPv6Address.groupsFromString(ip); + return new IPv6Address(groups); + } catch (e) { + throw new Error(`Invalid ip: ${ip}`); + } + } + + public static groupsFromString(ip: string): IPv6Groups { + try { + // Split on shortening separator and make sure there's only one separator + const shortened = ip.split('::'); + if (shortened.length <= 2) { + // Split each part of the shortened address into groups and remove any empty groups, such as + // the one before the separator in ::1 + const parts = shortened.map((groups) => groups.split(':').filter((group) => group !== '')); + + let groups: string[]; + if (parts.length === 2) { + // If the address contained the shortening separator the parts are concatenated with empty + // groups in between + const shortened = Array(8 - parts[0].length - parts[1].length).fill(0x0); + groups = [...parts[0], ...shortened, ...parts[1]]; + } else { + // If it wasn't shortened all groups are used as is + groups = parts.flat(); + } + + if (groups.every((group) => /^[0-9a-fA-F]{1,4}$/.test(group))) { + const parsedGroups = groups.map((group) => parseInt(group, 16)); + + if (IPv6Address.isIPv6Groups(parsedGroups)) { + return parsedGroups; + } + } + } + } catch (e) { + // no-op + } + + throw new Error(`Invalid ip: ${ip}`); + } + + public static isValid(ip: string): boolean { + try { + IPv6Address.fromString(ip); + return true; + } catch (e) { + return false; + } + } + + // Makes sure that the number of groups is correct and values where parsed correctly + private static isIPv6Groups(groups: number[]): groups is IPv6Groups { + return groups.length === 8 && groups.every((group) => !isNaN(group)); + } +} + +export class IPv6Range extends IpRange<IPv6Groups> { + public constructor(groups: IPv6Groups, prefixSize: number) { + super(groups, prefixSize); + + // Makes sure that the prefix is within the correct range + if (prefixSize < 0 || prefixSize > 128) { + throw new Error(`Invalid subnet: ${groups.join(':')}/${prefixSize}`); + } + } + + public static fromString(subnet: string): IPv6Range { + try { + // In addition to parsing the ip the subnet-mask also needs to be parsed + const parts = subnet.split('/'); + if (/^\d{1,3}$/.test(parts[1])) { + const groups = IPv6Address.groupsFromString(parts[0]); + const prefixSize = parseInt(parts[1], 10); + return new IPv6Range(groups, prefixSize); + } + } catch (e) { + // no-op + } + + throw new Error(`Invalid subnet: ${subnet}`); + } + + public includes(ip: IPv6Address): boolean { + return super.includes(ip, IPv6GroupSize); + } +} + +// Returns the maximum value possible with the provided size +function getBitsMax(bits: number): number { + return Math.pow(2, bits) - 1; +} + +// Returns whether or not a number is possible to represent as an unsigned in of the provided size +function isNumberOfBits(value: number, bits: number): boolean { + return value >= 0 && value < Math.pow(2, bits); +} + +// IPv4 addresses reserved for local networks +const IPV4_LAN_SUBNETS = [ + new IPv4Range([10, 0, 0, 0], 8), + new IPv4Range([172, 16, 0, 0], 12), + new IPv4Range([192, 168, 0, 0], 16), + new IPv4Range([169, 254, 0, 0], 16), +]; + +// IPv6 addresses reserved for local networks +const IPV6_LAN_SUBNETS = [ + new IPv6Range([0xfe80, 0, 0, 0, 0, 0, 0, 0], 10), + new IPv6Range([0xfc00, 0, 0, 0, 0, 0, 0, 0], 7), +]; + +const IPV4_LOOPBACK_SUBNET = new IPv4Range([127, 0, 0, 0], 8); +const IPV6_LOOPBACK_SUBNET = new IPv6Range([0, 0, 0, 0, 0, 0, 0, 1], 128); diff --git a/gui/test/ip.spec.ts b/gui/test/ip.spec.ts new file mode 100644 index 0000000000..b430e4d2b3 --- /dev/null +++ b/gui/test/ip.spec.ts @@ -0,0 +1,174 @@ +import { expect } from 'chai'; +import { it, describe } from 'mocha'; +import * as ip from '../src/renderer/lib/ip'; + +const validIpv4Addresses = [ + '127.0.0.1', + '10.255.255.255', + '192.168.1.1', + '192.168.0.10', + '192.168.1.254', + '192.168.254.254', + '10.0.0.1', + '10.90.90.90', + '1.1.1.1', + '193.138.218.74', +]; + +const validIpv6Addresses = [ + '0:1:2:3:4:5:6:7', + '00:11:22:33:44:55:66:77', + '000:111:222:333:444:555:666:777', + '0000:1111:2222:3333:4444:5555:6666:7777', + 'ffff::', + '::ff:2233', + 'fee::ff:2233', +]; + +const invalidIpv4Addresses = [ + '127.0.0.0.1', + '10.0.0.256', + '192.168.1', + '0.0.0.a1', + '0.0.0.', + '0.0..0', + '0. 0.0.0', + '0.a.0.0.0', +]; + +const invalidIpv6Addresses = [ + '00:11:22:33:44:55:66:77:88', + '00:11:22:33:44:55:66', + 'ff::ff::ff', + 'ff::ff::', + '::ff::', + '00:11:22:33:44:55:66:gg', + '13245:11:22:33:44:55:66:77', + '::1g', + 'gg:11:22:33:44:55:66:77:88', +]; + +const validIpv4Subnets = ['10.0.0.0/0', '10.0.0.0/8', '10.0.0.0/32']; +const invalidIpv4Subnets = ['10.0.0.0', '10.0.0.0/', '10.0.0.0/-1', '10.0.0.0/33']; + +const validIpv6Subnets = ['::1/0', 'fe::/128', '1:1::1/12', '0:1:2:3:4:5:6:7/64']; +const invalidIpv6Subnets = ['::1', 'fe::/', '0:0:0:0:0:0:0:0/-1', '::1/129']; + +const localIpAddresses = [ + '10.0.0.0', + '10.255.255.255', + '172.16.0.0', + '172.31.255.255', + '192.168.0.0', + '192.168.255.255', +]; + +const publicIpAddresses = [ + '1.1.1.1', + '193.138.218.74', + '9.255.255.255', + '11.0.0.0', + '172.15.0.0', + '172.15.255.255', + '172.32.0.0', + '192.167.0.0', + '192.167.255.255', + '192.169.0.0', +]; + +describe('Logging', () => { + it('should detect that valid IPv4 addresses are valid', () => { + validIpv4Addresses.forEach((ipAddress) => { + const valid = ip.IPv4Address.isValid(ipAddress); + expect(valid).to.be.true; + expect(() => ip.IPv4Address.fromString(ipAddress)).to.not.throw(); + }); + }); + + it('should detect that invalid IPv4 addresses are invalid', () => { + invalidIpv4Addresses.forEach((ipAddress) => { + const valid = ip.IPv4Address.isValid(ipAddress); + expect(valid).to.be.false; + expect(() => ip.IPv4Address.fromString(ipAddress)).to.throw(); + }); + }); + + it('should detect that valid IPv6 addresses are valid', () => { + validIpv6Addresses.forEach((ipAddress) => { + const valid = ip.IPv6Address.isValid(ipAddress); + expect(valid).to.be.true; + expect(() => ip.IPv6Address.fromString(ipAddress)).to.not.throw(); + }); + }); + + it('should detect that invalid IPv6 addresses are invalid', () => { + invalidIpv6Addresses.forEach((ipAddress) => { + const valid = ip.IPv6Address.isValid(ipAddress); + expect(valid).to.be.false; + expect(() => ip.IPv6Address.fromString(ipAddress)).to.throw(); + }); + }); + + it('should detect that valid IPv4 subnets are valid', () => { + validIpv4Subnets.forEach((subnet) => { + expect(() => ip.IPv4Range.fromString(subnet)).to.not.throw(); + }); + }); + + it('should detect that invalid IPv4 subnets are invalid', () => { + invalidIpv4Subnets.forEach((subnet) => { + expect(() => ip.IPv4Range.fromString(subnet)).to.throw(); + }); + }); + + it('should detect that valid IPv6 subnets are valid', () => { + validIpv6Subnets.forEach((subnet) => { + expect(() => ip.IPv6Range.fromString(subnet)).to.not.throw(); + }); + }); + + it('should detect that invalid IPv6 subnets are invalid', () => { + invalidIpv6Subnets.forEach((subnet) => { + expect(() => ip.IPv6Range.fromString(subnet)).to.throw(); + }); + }); + + it('should detect that IP addresses are local', () => { + localIpAddresses.forEach((ipAddress) => { + expect(ip.IpAddress.fromString(ipAddress).isLocal()).to.be.true; + }); + }); + + it('should detect that IP addresses are public', () => { + publicIpAddresses.forEach((ipAddress) => { + expect(ip.IpAddress.fromString(ipAddress).isLocal()).to.be.false; + }); + }); + + it('should correctly parse IP addresses', () => { + expect(ip.IpAddress.fromString('127.0.0.1').groups).to.deep.equal([127, 0, 0, 1]); + expect(ip.IpAddress.fromString('1.1.1.1').groups).to.deep.equal([1, 1, 1, 1]); + expect(ip.IpAddress.fromString('252.253.254.255').groups).to.deep.equal([252, 253, 254, 255]); + + const ip1 = ip.IpAddress.fromString('0:1:2:3:4:5:6:7').groups; + expect(ip1).to.deep.equal([0, 1, 2, 3, 4, 5, 6, 7]); + + const ip2 = ip.IpAddress.fromString('ffff::').groups; + expect(ip2).to.deep.equal([0xffff, 0, 0, 0, 0, 0, 0, 0]); + + const ip3 = ip.IpAddress.fromString('::1').groups; + expect(ip3).to.deep.equal([0, 0, 0, 0, 0, 0, 0, 1]); + + const ip4 = ip.IpAddress.fromString('ffff:1::1').groups; + expect(ip4).to.deep.equal([0xffff, 1, 0, 0, 0, 0, 0, 1]); + }); + + it('should correctly parse IP range prefix sizes', () => { + expect(ip.IPv4Range.fromString('127.0.0.1/0').prefixSize).to.equal(0); + expect(ip.IPv4Range.fromString('1.1.1.1/32').prefixSize).to.equal(32); + + expect(ip.IPv6Range.fromString('0:1:2:3:4:5:6:7/0').prefixSize).to.equal(0); + expect(ip.IPv6Range.fromString('ffff::/128').prefixSize).to.equal(128); + expect(ip.IPv6Range.fromString('::1/32').prefixSize).to.equal(32); + }); +}); |
