summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-02-15 09:45:54 +0100
committerOskar Nyberg <oskar@mullvad.net>2021-02-15 09:45:54 +0100
commitb6c066c8bc86deaf6b1d80296bdbd9e3fd3c5c77 (patch)
tree00ed023257bd6d064ab425ecb320a378cf02f58b
parent85c7801a87e99af8e698a6847604510d7233cd7a (diff)
parent31149d6acc62c52cd0bad3e1f80f7f86151d6487 (diff)
downloadmullvadvpn-b6c066c8bc86deaf6b1d80296bdbd9e3fd3c5c77.tar.xz
mullvadvpn-b6c066c8bc86deaf6b1d80296bdbd9e3fd3c5c77.zip
Merge branch 'improve-ip-validation'
-rw-r--r--gui/package-lock.json14
-rw-r--r--gui/package.json2
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx26
-rw-r--r--gui/src/renderer/lib/ip.ts278
-rw-r--r--gui/test/ip.spec.ts174
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);
+ });
+});