diff --git a/src/apps.js b/src/apps.js index e5942b902..c0b9a0fa3 100644 --- a/src/apps.js +++ b/src/apps.js @@ -49,7 +49,6 @@ exports = module.exports = { PORT_TYPE_UDP: 'udp', // exported for testing - _validateHostname: validateHostname, _validatePortBindings: validatePortBindings, _validateAccessRestriction: validateAccessRestriction, _translatePortBindings: translatePortBindings @@ -85,7 +84,6 @@ var appdb = require('./appdb.js'), split = require('split'), superagent = require('superagent'), taskmanager = require('./taskmanager.js'), - tld = require('tldjs'), TransformStream = require('stream').Transform, updateChecker = require('./updatechecker.js'), url = require('url'), @@ -127,40 +125,6 @@ AppsError.BILLING_REQUIRED = 'Billing Required'; AppsError.ACCESS_DENIED = 'Access denied'; AppsError.BAD_CERTIFICATE = 'Invalid certificate'; -// Hostname validation comes from RFC 1123 (section 2.1) -// Domain name validation comes from RFC 2181 (Name syntax) -// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names -// We are validating the validity of the location-fqdn as host name (and not dns name) -function validateHostname(location, domain, hostname) { - assert.strictEqual(typeof location, 'string'); - assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof hostname, 'string'); - - const RESERVED_LOCATIONS = [ - constants.API_LOCATION, - constants.SMTP_LOCATION, - constants.IMAP_LOCATION - ]; - if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new AppsError(AppsError.BAD_FIELD, location + ' is reserved'); - - if (hostname === config.adminFqdn()) return new AppsError(AppsError.BAD_FIELD, location + ' is reserved'); - - // workaround https://github.com/oncletom/tld.js/issues/73 - var tmp = hostname.replace('_', '-'); - if (!tld.isValid(tmp)) return new AppsError(AppsError.BAD_FIELD, 'Hostname is not a valid domain name'); - - if (hostname.length > 253) return new AppsError(AppsError.BAD_FIELD, 'Hostname length exceeds 253 characters'); - - if (location) { - // label validation - if (location.split('.').some(function (p) { return p.length > 63 || p.length < 1; })) return new AppsError(AppsError.BAD_FIELD, 'Invalid subdomain length'); - if (location.match(/^[A-Za-z0-9-.]+$/) === null) return new AppsError(AppsError.BAD_FIELD, 'Subdomain can only contain alphanumeric, hyphen and dot'); - if (/^[-.]/.test(location)) return new AppsError(AppsError.BAD_FIELD, 'Subdomain cannot start or end with hyphen or dot'); - } - - return null; -} - // validate the port bindings function validatePortBindings(portBindings, manifest) { assert.strictEqual(typeof portBindings, 'object'); @@ -595,12 +559,11 @@ function install(data, auditSource, callback) { if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain')); if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message)); - var fqdn = domains.fqdn(location, domainObject); - - error = validateHostname(location, domain, fqdn); - if (error) return callback(error); + error = domains.validateHostname(location, domainObject); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message)); if (cert && key) { + let fqdn = domains.fqdn(location, domain, domainObject); error = reverseProxy.validateCertificate(fqdn, cert, key); if (error) return callback(new AppsError(AppsError.BAD_CERTIFICATE, error.message)); } @@ -642,6 +605,7 @@ function install(data, auditSource, callback) { // save cert to boxdata/certs if (cert && key) { + let fqdn = domains.fqdn(location, domain, domainObject); if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, fqdn + '.user.cert'), cert)) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving cert: ' + safe.error.message)); if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, fqdn + '.user.key'), key)) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving key: ' + safe.error.message)); } @@ -731,13 +695,13 @@ function configure(appId, data, auditSource, callback) { if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain')); if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message)); - var fqdn = domains.fqdn(location, domainObject); - - error = validateHostname(location, domain, fqdn); - if (error) return callback(error); + error = domains.validateHostname(location, domainObject); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message)); // save cert to boxdata/certs. TODO: move this to apptask when we have a real task queue if ('cert' in data && 'key' in data) { + let fqdn = domains.fqdn(location, domain, domainObject); + if (data.cert && data.key) { error = reverseProxy.validateCertificate(fqdn, data.cert, data.key); if (error) return callback(new AppsError(AppsError.BAD_CERTIFICATE, error.message)); @@ -994,8 +958,8 @@ function clone(appId, data, auditSource, callback) { if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.EXTERNAL_ERROR, 'No such domain')); if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message)); - error = validateHostname(location, domain, domains.fqdn(location, domainObject)); - if (error) return callback(error); + error = domains.validateHostname(location, domainObject); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message)); var newAppId = uuid.v4(), manifest = backupInfo.manifest; diff --git a/src/domains.js b/src/domains.js index 481ac08ca..f37b751d3 100644 --- a/src/domains.js +++ b/src/domains.js @@ -19,12 +19,15 @@ module.exports = exports = { removePrivateFields: removePrivateFields, removeRestrictedFields: removeRestrictedFields, + validateHostname: validateHostname, + DomainsError: DomainsError }; var assert = require('assert'), caas = require('./caas.js'), config = require('./config.js'), + constants = require('./constants.js'), DatabaseError = require('./databaseerror.js'), debug = require('debug')('box:domains'), domaindb = require('./domaindb.js'), @@ -104,6 +107,45 @@ function verifyDnsConfig(config, domain, zoneName, provider, ip, callback) { api(provider).verifyDnsConfig(config, domain, zoneName, ip, callback); } +function fqdn(location, domainObject) { + return location + (location ? (domainObject.config.hyphenatedSubdomains ? '-' : '.') : '') + domainObject.domain; +} + +// Hostname validation comes from RFC 1123 (section 2.1) +// Domain name validation comes from RFC 2181 (Name syntax) +// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names +// We are validating the validity of the location-fqdn as host name (and not dns name) +function validateHostname(location, domainObject) { + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof domainObject, 'object'); + + const hostname = fqdn(location, domainObject); + + const RESERVED_LOCATIONS = [ + constants.API_LOCATION, + constants.SMTP_LOCATION, + constants.IMAP_LOCATION + ]; + if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new DomainsError(DomainsError.BAD_FIELD, location + ' is reserved'); + + if (hostname === config.adminFqdn()) return new DomainsError(DomainsError.BAD_FIELD, location + ' is reserved'); + + // workaround https://github.com/oncletom/tld.js/issues/73 + var tmp = hostname.replace('_', '-'); + if (!tld.isValid(tmp)) return new DomainsError(DomainsError.BAD_FIELD, 'Hostname is not a valid domain name'); + + if (hostname.length > 253) return new DomainsError(DomainsError.BAD_FIELD, 'Hostname length exceeds 253 characters'); + + if (location) { + // label validation + if (location.split('.').some(function (p) { return p.length > 63 || p.length < 1; })) return new DomainsError(DomainsError.BAD_FIELD, 'Invalid subdomain length'); + if (location.match(/^[A-Za-z0-9-.]+$/) === null) return new DomainsError(DomainsError.BAD_FIELD, 'Subdomain can only contain alphanumeric, hyphen and dot'); + if (/^[-.]/.test(location)) return new DomainsError(DomainsError.BAD_FIELD, 'Subdomain cannot start or end with hyphen or dot'); + } + + return null; +} + function add(domain, zoneName, provider, config, fallbackCertificate, tlsConfig, callback) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof zoneName, 'string'); @@ -375,10 +417,6 @@ function setAdmin(domain, callback) { }); } -function fqdn(location, domainObject) { - return location + (location ? (domainObject.config.hyphenatedSubdomains ? '-' : '.') : '') + domainObject.domain; -} - // removes all fields that are strictly private and should never be returned by API calls function removePrivateFields(domain) { var result = _.pick(domain, 'domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate'); diff --git a/src/test/apps-test.js b/src/test/apps-test.js index 44f24088d..5c22247fe 100644 --- a/src/test/apps-test.js +++ b/src/test/apps-test.js @@ -176,44 +176,6 @@ describe('Apps', function () { ], done); }); - describe('validateHostname', function () { - it('does not allow admin subdomain', function () { - expect(apps._validateHostname('my', DOMAIN_0.domain, 'my.' + DOMAIN_0.domain)).to.be.an(Error); - }); - - it('cannot have >63 length subdomains', function () { - var s = Array(64).fill('s').join(''); - expect(apps._validateHostname(s, 'example.com', s + '.example.com')).to.be.an(Error); - expect(apps._validateHostname(`dev.${s}`, 'example.com', `dev.${s}.example.com`)).to.be.an(Error); - }); - - it('allows only alphanumerics and hypen', function () { - expect(apps._validateHostname('#2r', 'example.com', '#2r.example.com')).to.be.an(Error); - expect(apps._validateHostname('a%b', 'example.com', 'a%b.example.com')).to.be.an(Error); - expect(apps._validateHostname('ab_', 'example.com', 'ab_.example.com')).to.be.an(Error); - expect(apps._validateHostname('ab.', 'example.com', 'ab.example.com')).to.be.an(Error); - expect(apps._validateHostname('ab..c', 'example.com', 'ab..c.example.com')).to.be.an(Error); - expect(apps._validateHostname('.ab', 'example.com', '.ab.example.com')).to.be.an(Error); - expect(apps._validateHostname('-ab', 'example.com', '-ab.example.com')).to.be.an(Error); - expect(apps._validateHostname('ab-', 'example.com', 'ab-.example.com')).to.be.an(Error); - }); - - it('total length cannot exceed 255', function () { - var s = ''; - for (var i = 0; i < (255 - 'example.com'.length); i++) s += 's'; - - expect(apps._validateHostname(s, 'example.com', s + '.example.com')).to.be.an(Error); - }); - - it('allow valid domains', function () { - expect(apps._validateHostname('a', 'example.com', 'a.example.com')).to.be(null); - expect(apps._validateHostname('a0-x', 'example.com', 'a0-x.example.com')).to.be(null); - expect(apps._validateHostname('a0.x', 'example.com', 'a0-x.example.com')).to.be(null); - expect(apps._validateHostname('a0.x.y', 'example.com', 'a0.x.y.example.com')).to.be(null); - expect(apps._validateHostname('01', 'example.com', '01.example.com')).to.be(null); - }); - }); - describe('validatePortBindings', function () { it('does not allow invalid host port', function () { expect(apps._validatePortBindings({ port: -1 }, { tcpPorts: { port: 5000 } })).to.be.an(Error); diff --git a/src/test/domains-test.js b/src/test/domains-test.js new file mode 100644 index 000000000..9583f0799 --- /dev/null +++ b/src/test/domains-test.js @@ -0,0 +1,78 @@ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var async = require('async'), + config = require('../config.js'), + database = require('../database.js'), + domains = require('../domains.js'), + expect = require('expect.js'); + +describe('Domains', function () { + before(function (done) { + config._reset(); + + async.series([ + database.initialize, + database._clear + ], done); + }); + + after(function (done) { + async.series([ + database._clear, + database.uninitialize + ], done); + }); + + let domain = { + domain: 'example.com', + zoneName: 'example.com', + config: {} + }; + + describe('validateHostname', function () { + it('does not allow admin subdomain', function () { + config.setFqdn('example.com'); + config.setAdminFqdn('my.example.com'); + + expect(domains.validateHostname('my', domain)).to.be.an(Error); + }); + + it('cannot have >63 length subdomains', function () { + var s = Array(64).fill('s').join(''); + expect(domains.validateHostname(s, domain)).to.be.an(Error); + domain.zoneName = `dev.${s}.example.com`; + expect(domains.validateHostname(`dev.${s}`, domain)).to.be.an(Error); + }); + + it('allows only alphanumerics and hypen', function () { + expect(domains.validateHostname('#2r', domain)).to.be.an(Error); + expect(domains.validateHostname('a%b', domain)).to.be.an(Error); + expect(domains.validateHostname('ab_', domain)).to.be.an(Error); + expect(domains.validateHostname('ab.', domain)).to.be.an(Error); + expect(domains.validateHostname('ab..c', domain)).to.be.an(Error); + expect(domains.validateHostname('.ab', domain)).to.be.an(Error); + expect(domains.validateHostname('-ab', domain)).to.be.an(Error); + expect(domains.validateHostname('ab-', domain)).to.be.an(Error); + }); + + it('total length cannot exceed 255', function () { + var s = ''; + for (var i = 0; i < (255 - 'example.com'.length); i++) s += 's'; + + expect(domains.validateHostname(s, domain)).to.be.an(Error); + }); + + it('allow valid domains', function () { + expect(domains.validateHostname('a', domain)).to.be(null); + expect(domains.validateHostname('a0-x', domain)).to.be(null); + expect(domains.validateHostname('a0.x', domain)).to.be(null); + expect(domains.validateHostname('a0.x.y', domain)).to.be(null); + expect(domains.validateHostname('01', domain)).to.be(null); + }); + }); +});