diff --git a/package-lock.json b/package-lock.json index f733050b21..6c61f501db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "graphql-relay": "0.10.0", "graphql-tag": "2.12.6", "intersect": "1.0.1", - "ip-range-check": "0.2.0", "jsonwebtoken": "9.0.0", "jwks-rsa": "2.1.5", "ldapjs": "2.3.3", @@ -9303,22 +9302,6 @@ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" }, - "node_modules/ip-range-check": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/ip-range-check/-/ip-range-check-0.2.0.tgz", - "integrity": "sha512-oaM3l/3gHbLlt/tCWLvt0mj1qUaI+STuRFnUvARGCujK9vvU61+2JsDpmkMzR4VsJhuFXWWgeKKVnwwoFfzCqw==", - "dependencies": { - "ipaddr.js": "^1.0.1" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -16837,6 +16820,14 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ps-node": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/ps-node/-/ps-node-0.1.6.tgz", @@ -27668,19 +27659,6 @@ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" }, - "ip-range-check": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/ip-range-check/-/ip-range-check-0.2.0.tgz", - "integrity": "sha512-oaM3l/3gHbLlt/tCWLvt0mj1qUaI+STuRFnUvARGCujK9vvU61+2JsDpmkMzR4VsJhuFXWWgeKKVnwwoFfzCqw==", - "requires": { - "ipaddr.js": "^1.0.1" - } - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -33335,6 +33313,13 @@ "requires": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" + }, + "dependencies": { + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + } } }, "ps-node": { diff --git a/package.json b/package.json index 6f7d8cd6aa..871a0a95a2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "graphql-relay": "0.10.0", "graphql-tag": "2.12.6", "intersect": "1.0.1", - "ip-range-check": "0.2.0", "jsonwebtoken": "9.0.0", "jwks-rsa": "2.1.5", "ldapjs": "2.3.3", diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 636e7809f9..571aa4c564 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -27,6 +27,7 @@ describe('middlewares', () => { expect(fakeReq.headers['content-type']).toEqual(undefined); const contentType = 'image/jpeg'; fakeReq.body._ContentType = contentType; + fakeReq.ip = '127.0.0.1'; middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeReq.headers['content-type']).toEqual(contentType); expect(fakeReq.body._ContentType).toEqual(undefined); @@ -151,7 +152,92 @@ describe('middlewares', () => { ); }); - it('should not succeed if the ip does not belong to masterKeyIps list', async () => { + it('should match address', () => { + const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + const anotherIpv6 = '::ffff:101.10.0.1'; + const ipv4 = '192.168.0.101'; + const localhostV6 = '::1'; + const localhostV62 = '::ffff:127.0.0.1'; + const localhostV4 = '127.0.0.1'; + + const v6 = [ipv6, anotherIpv6]; + v6.forEach(ip => { + expect(middlewares.checkIpRanges(ip, ['::/0'])).toBe(true); + expect(middlewares.checkIpRanges(ip, ['::'])).toBe(true); + expect(middlewares.checkIpRanges(ip, ['0.0.0.0'])).toBe(false); + expect(middlewares.checkIpRanges(ip, ['123.123.123.123'])).toBe(false); + }); + + expect(middlewares.checkIpRanges(ipv6, [anotherIpv6])).toBe(false); + expect(middlewares.checkIpRanges(ipv6, [ipv6])).toBe(true); + expect(middlewares.checkIpRanges(ipv6, ['2001:db8:85a3:0:0:8a2e:0:0/100'])).toBe(true); + + expect(middlewares.checkIpRanges(ipv4, ['::'])).toBe(false); + expect(middlewares.checkIpRanges(ipv4, ['::/0'])).toBe(true); + expect(middlewares.checkIpRanges(ipv4, ['0.0.0.0'])).toBe(true); + expect(middlewares.checkIpRanges(ipv4, ['123.123.123.123'])).toBe(false); + expect(middlewares.checkIpRanges(ipv4, [ipv4])).toBe(true); + expect(middlewares.checkIpRanges(ipv4, ['192.168.0.0/24'])).toBe(true); + + expect(middlewares.checkIpRanges(localhostV4, ['::1'])).toBe(false); + expect(middlewares.checkIpRanges(localhostV6, ['::1'])).toBe(true); + // ::ffff:127.0.0.1 is a padded ipv4 address but not ::1 + expect(middlewares.checkIpRanges(localhostV62, ['::1'])).toBe(false); + // ::ffff:127.0.0.1 is a padded ipv4 address and is a match for 127.0.0.1 + expect(middlewares.checkIpRanges(localhostV62, ['127.0.0.1'])).toBe(true); + }); + + it('can allow all with masterKeyIPs', async () => { + const combinations = [ + { + masterKeyIps: ['::/0'], + ips: ['::ffff:192.168.0.101', '192.168.0.101'], + id: 'allowAllIpV6', + }, + { + masterKeyIps: ['0.0.0.0'], + ips: ['192.168.0.101'], + id: 'allowAllIpV4', + }, + ]; + for (const combination of combinations) { + AppCache.put(combination.id, { + masterKey: 'masterKey', + masterKeyIps: combination.masterKeyIps, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + for (const ip of combination.ips) { + fakeReq = { + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { + _ApplicationId: combination.id, + }, + headers: {}, + get: key => { + return fakeReq.headers[key.toLowerCase()]; + }, + }; + fakeReq.ip = ip; + fakeReq.headers['x-parse-master-key'] = 'masterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(true); + } + } + }); + + it('can allow localhost with masterKeyIPs', async () => { + AppCache.put(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['::'], + }); + fakeReq.ip = '::ffff:127.0.0.1'; + fakeReq.headers['x-parse-master-key'] = 'masterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(true); + }); + + it('should not succeed if the ip does not belong to masterKeyIps list (ipv4)', async () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', masterKeyIps: ['10.0.0.1'], @@ -162,6 +248,17 @@ describe('middlewares', () => { expect(fakeReq.auth.isMaster).toBe(false); }); + it('should not succeed if the ip does not belong to masterKeyIps list (ipv6)', async () => { + AppCache.put(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['::1'], + }); + fakeReq.ip = '::ffff:101.10.0.1'; + fakeReq.headers['x-parse-master-key'] = 'masterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(false); + }); + it('should not succeed if the ip does not belong to maintenanceKeyIps list', async () => { const logger = require('../lib/logger').logger; spyOn(logger, 'error').and.callFake(() => {}); diff --git a/spec/index.spec.js b/spec/index.spec.js index 66654aaec4..6c20f4d487 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -450,13 +450,12 @@ describe('server', () => { reconfigureServer({ revokeSessionOnPasswordReset: 'non-bool' }).catch(done); }); - it('fails if you provides invalid ip in masterKeyIps', done => { - reconfigureServer({ masterKeyIps: ['invalidIp', '1.2.3.4'] }).catch(error => { - expect(error).toEqual( - 'The Parse Server option "masterKeyIps" contains an invalid IP address "invalidIp".' - ); - done(); - }); + it('fails if you provides invalid ip in masterKeyIps', async () => { + await expectAsync( + reconfigureServer({ masterKeyIps: ['1.2.3.4/0', 'invalidIp'] }) + ).toBeRejectedWith( + 'The Parse Server option "masterKeyIps" contains an invalid IP address "invalidIp".' + ); }); it('should succeed if you provide valid ip in masterKeyIps', done => { diff --git a/src/middlewares.js b/src/middlewares.js index a7e309b0cc..a467ec7a4d 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -10,9 +10,9 @@ import PostgresStorageAdapter from './Adapters/Storage/Postgres/PostgresStorageA import rateLimit from 'express-rate-limit'; import { RateLimitOptions } from './Options/Definitions'; import { pathToRegexp } from 'path-to-regexp'; -import ipRangeCheck from 'ip-range-check'; import RedisStore from 'rate-limit-redis'; import { createClient } from 'redis'; +import { BlockList, isIPv4 } from 'net'; export const DEFAULT_ALLOWED_HEADERS = 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control'; @@ -23,6 +23,27 @@ const getMountForRequest = function (req) { return req.protocol + '://' + req.get('host') + mountPath; }; +export const checkIpRanges = (ip, ranges = []) => { + const getType = address => (isIPv4(address) ? 'ipv4' : 'ipv6'); + const clientType = getType(ip); + const blocklist = new BlockList(); + for (const range of ranges) { + if ((range === '::/0' || range === '::') && clientType === 'ipv6') { + return true; + } + if (range === '0.0.0.0' && clientType === 'ipv4') { + return true; + } + const [addr, prefix] = range.split('/'); + if (prefix) { + blocklist.addSubnet(addr, Number(prefix), getType(addr)); + } else { + blocklist.addAddress(addr, getType(addr)); + } + } + return blocklist.check(ip, clientType); +}; + // Checks that the request is authorized for this app and checks user // auth too. // The bodyparser should run before this middleware. @@ -183,7 +204,7 @@ export function handleParseHeaders(req, res, next) { const isMaintenance = req.config.maintenanceKey && info.maintenanceKey === req.config.maintenanceKey; if (isMaintenance) { - if (ipRangeCheck(clientIp, req.config.maintenanceKeyIps || [])) { + if (checkIpRanges(clientIp, req.config.maintenanceKeyIps)) { req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, @@ -199,7 +220,7 @@ export function handleParseHeaders(req, res, next) { } let isMaster = info.masterKey === req.config.masterKey; - if (isMaster && !ipRangeCheck(clientIp, req.config.masterKeyIps || [])) { + if (isMaster && !checkIpRanges(clientIp, req.config.masterKeyIps)) { const log = req.config?.loggerController || defaultLogger; log.error( `Request using master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'masterKeyIps'.`