From 6d8dc45209e93d6f223b3f4d7a58b11c54d71e43 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 11:18:47 -0400 Subject: [PATCH] Validate host/target fields (hostname or IP; host allows */** wildcards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (utils/hostname_validate.js, enforced in routes/host.js on create/update): - host: IPv4 or a wildcard pattern whose labels may be normal, "*" (one fragment) or "**" (any depth, incl. a bare "**" catch-all) — matching Host.lookUp. Lowered Host.host min length to 1 so "**"/"*" pass the model. - target (ip): IPv4 or a strict hostname, no wildcards. - Both reject scheme, "/", ":" and whitespace; 422 with per-field keys. Frontend (val.js) mirrors the rules: host/target validators + hosts.ejs fields point at them. Unit tests in test/unit/hostname_validate.test.js. Co-Authored-By: Claude Opus 4.8 --- nodejs/models/host.js | 4 +- nodejs/package.json | 6 +- nodejs/public/css/styles.css | 5 - nodejs/public/lib/js/val.js | 120 +++++++++++++++------ nodejs/routes/host.js | 10 ++ nodejs/test/unit/hostname_validate.test.js | 120 +++++++++++++++++++++ nodejs/utils/hostname_validate.js | 96 +++++++++++++++++ nodejs/views/hosts.ejs | 4 +- 8 files changed, 321 insertions(+), 44 deletions(-) create mode 100644 nodejs/test/unit/hostname_validate.test.js create mode 100644 nodejs/utils/hostname_validate.js diff --git a/nodejs/models/host.js b/nodejs/models/host.js index bf1cef7..4c33072 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -23,7 +23,9 @@ class Host extends Table{ 'updated_by': {default:"__NONE__", isRequired: false, type: 'string',}, 'updated_on': {default: function(){return (new Date).getTime()}, always: true}, - 'host': {isRequired: true, type: 'string', min: 3, max: 500}, + // min 1 so wildcard patterns like "**" / "*" are allowed (see + // utils/hostname_validate.js; format is enforced at the route layer). + 'host': {isRequired: true, type: 'string', min: 1, max: 500}, 'ip': {isRequired: true, type: 'string', min: 3, max: 500}, 'targetPort': {isRequired: true, type: 'number', min:0, max:65535}, 'forcessl': {isRequired: false, default: true, type: 'boolean'}, diff --git a/nodejs/package.json b/nodejs/package.json index 8ce6bfc..45ef98e 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,10 +11,10 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", - "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js", + "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", + "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js", "test:integration": "node --test test/integration/dns_provider.test.js", - "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" + "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 14a6177..7fa9bbc 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -11,8 +11,3 @@ nav.navbar{ .card-title{ font-weight: bold; } - -.actionMessage{ - position: fixed; -} - diff --git a/nodejs/public/lib/js/val.js b/nodejs/public/lib/js/val.js index b372555..90c8742 100755 --- a/nodejs/public/lib/js/val.js +++ b/nodejs/public/lib/js/val.js @@ -61,7 +61,7 @@ //checks if empty to stop processing if(!isNaN(options) && value.length === 0) { }else if(rule in settings.rule){ - let message = settings.rule[rule].apply(this, [value, options]); + message = settings.rule[rule].apply(this, [value, options]); } this.validateMessage(message) @@ -93,41 +93,95 @@ }( jQuery )); -$.validateSettings({ - rule:{ - ip: function( value ) { - value = value.split( '.' ); - - if ( value.length != 4 ) { - return "Malformed IP"; - } - - $.each( value, function( key, value ) { - if( value > 255 || value < 0 ) { +// Host / target validation, mirrored from the backend (utils/hostname_validate.js): +// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The +// incoming host may be a wildcard ("*.example.com"); the target may not. +(function(){ + var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; + var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; + var FORBIDDEN = /[\s/:]/; + + function isIPv4( value ) { + var parts = value.split( '.' ); + if ( parts.length !== 4 ) return false; + return parts.every( function( p ) { + return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255; + }); + } + + // Incoming-host pattern: labels may be normal, "*" (one fragment), or "**" + // (any number of fragments, incl. a bare "**" global catch-all). + function isHostPattern( value ) { + if ( value.length > 253 ) return false; + return value.split( '.' ).every( function( l ) { + return l === '*' || l === '**' || LABEL.test( l ); + }); + } + + function forbidden( value ) { + return FORBIDDEN.test( value ) || value.includes( '://' ); + } + + // Incoming host: IPv4 or a wildcard host pattern. + function checkHost( value ) { + if ( typeof value !== 'string' || value.length === 0 ) return "Required"; + if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; + if ( isIPv4( value ) || isHostPattern( value ) ) return; + return "Enter a valid host or wildcard (*, **)"; + } + + // Downstream target: IPv4 or a strict hostname, no wildcard. + function checkTarget( value ) { + if ( typeof value !== 'string' || value.length === 0 ) return "Required"; + if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; + if ( isIPv4( value ) || HOSTNAME.test( value ) ) return; + return "Enter a valid hostname or IP"; + } + + $.validateSettings({ + rule:{ + ip: function( value ) { + value = value.split( '.' ); + + if ( value.length != 4 ) { return "Malformed IP"; } - }); - }, - host: function( value ) { - var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/; - if ( reg.test( value ) === false ) { - return "Invalid"; - } - }, + $.each( value, function( key, value ) { + if( value > 255 || value < 0 ) { + return "Malformed IP"; + } + }); + }, - user: function( value ) { - var reg = /^[a-z0-9\_\-\@\.]{1,32}$/; - if ( reg.test( value ) === false ) { - return "Invalid"; - } - }, - - password: function( value ) { - var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/; - if ( reg.test( value ) === false ) { - return "Weak password, Try again"; + // Incoming host name — hostname, IPv4, or wildcard pattern (*, **). + host: function( value ) { + return checkHost( value ); + }, + + // Downstream target — hostname or IPv4, no wildcard. + target: function( value ) { + return checkTarget( value ); + }, + + // Back-compat alias (no wildcard). + hostname: function( value ) { + return checkTarget( value ); + }, + + user: function( value ) { + var reg = /^[a-z0-9\_\-\@\.]{1,32}$/; + if ( reg.test( value ) === false ) { + return "Invalid"; + } + }, + + password: function( value ) { + var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/; + if ( reg.test( value ) === false ) { + return "Weak password, Try again"; + } } } - } -}); \ No newline at end of file + }); +})(); \ No newline at end of file diff --git a/nodejs/routes/host.js b/nodejs/routes/host.js index de971d6..75fcb6c 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -4,9 +4,17 @@ const router = require('express').Router(); const {Host, Domain} = require('../models').models; const authz = require('../middleware/authz'); const {normalizeHostFeatures} = require('../utils/host_features'); +const {collectHostFieldErrors} = require('../utils/hostname_validate'); const Model = Host; +// Reject a malformed host/target before it reaches the model. Throws a 422 +// ObjectValidateError (per-field keys) that the frontend surfaces inline. +function validateHostFields(body){ + let errors = collectHostFieldErrors(body); + if(errors.length) throw Model.errors.ObjectValidateError(errors); +} + router.get('/', async function(req, res, next){ try{ let results = await Model[req.query.detail ? "listDetail" : "list"](); @@ -25,6 +33,7 @@ router.get('/', async function(req, res, next){ router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){ try{ req.body.created_by = authz.reqUsername(req); + validateHostFields(req.body); normalizeHostFeatures(req.body); let item = await Model.create(req.body); @@ -89,6 +98,7 @@ router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){ try{ req.body.updated_by = authz.reqUsername(req); + validateHostFields(req.body); normalizeHostFeatures(req.body); let item = await Model.get(req.params.item); item = await item.update(req.body); diff --git a/nodejs/test/unit/hostname_validate.test.js b/nodejs/test/unit/hostname_validate.test.js new file mode 100644 index 0000000..42ae4c3 --- /dev/null +++ b/nodejs/test/unit/hostname_validate.test.js @@ -0,0 +1,120 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const { + isValidIPv4, + isValidHostname, + isValidHostPattern, + isValidHostField, + isValidTargetField, + collectHostFieldErrors, +} = require('../../utils/hostname_validate'); + +describe('isValidIPv4', () => { + test('accepts dotted quads in range', () => { + assert.ok(isValidIPv4('10.10.10.10')); + assert.ok(isValidIPv4('0.0.0.0')); + assert.ok(isValidIPv4('255.255.255.255')); + }); + test('rejects out-of-range, wrong length, leading zeros, junk', () => { + assert.ok(!isValidIPv4('256.1.1.1')); + assert.ok(!isValidIPv4('1.2.3')); + assert.ok(!isValidIPv4('1.2.3.4.5')); + assert.ok(!isValidIPv4('01.2.3.4')); + assert.ok(!isValidIPv4('a.b.c.d')); + }); +}); + +describe('isValidHostname (strict, for target)', () => { + test('accepts dotted hostnames with an alphabetic TLD', () => { + assert.ok(isValidHostname('example.com')); + assert.ok(isValidHostname('app.internal.net')); + }); + test('rejects bare labels, numeric TLDs, wildcards', () => { + assert.ok(!isValidHostname('localhost')); + assert.ok(!isValidHostname('10.10.10.10')); + assert.ok(!isValidHostname('*.example.com')); + assert.ok(!isValidHostname('')); + }); +}); + +describe('isValidHostPattern (incoming host)', () => { + test('accepts plain hostnames and single/double wildcards', () => { + assert.ok(isValidHostPattern('proxy.cloud-ops.net')); + assert.ok(isValidHostPattern('*.example.com')); + assert.ok(isValidHostPattern('**.mysite.com')); + assert.ok(isValidHostPattern('payments.**')); + assert.ok(isValidHostPattern('**')); // global catch-all + assert.ok(isValidHostPattern('*')); + assert.ok(isValidHostPattern('a.*.b.**.c')); + }); + test('rejects empty labels, edge hyphens, "***"', () => { + assert.ok(!isValidHostPattern('a..b')); + assert.ok(!isValidHostPattern('.example.com')); + assert.ok(!isValidHostPattern('example.com.')); + assert.ok(!isValidHostPattern('-bad.example.com')); + assert.ok(!isValidHostPattern('***.example.com')); + assert.ok(!isValidHostPattern('')); + }); +}); + +describe('isValidHostField (host: pattern or IP, no forbidden chars)', () => { + test('accepts wildcard patterns and IPv4', () => { + assert.ok(isValidHostField('**')); + assert.ok(isValidHostField('**.mysite.com')); + assert.ok(isValidHostField('payments.**')); + assert.ok(isValidHostField('10.10.10.10')); + }); + test('rejects protocol, path, port, whitespace', () => { + assert.ok(!isValidHostField('http://x.com')); + assert.ok(!isValidHostField('x.com:8080')); + assert.ok(!isValidHostField('x.com/y')); + assert.ok(!isValidHostField('a b.com')); + assert.ok(!isValidHostField('')); + }); +}); + +describe('isValidTargetField (target: hostname or IP, no wildcard)', () => { + test('accepts hostnames and IPv4', () => { + assert.ok(isValidTargetField('app.internal.net')); + assert.ok(isValidTargetField('10.0.0.5')); + }); + test('rejects wildcards, protocol, port, path', () => { + assert.ok(!isValidTargetField('*.example.com')); + assert.ok(!isValidTargetField('**')); + assert.ok(!isValidTargetField('http://10.0.0.5')); + assert.ok(!isValidTargetField('10.0.0.5:443')); + }); +}); + +describe('collectHostFieldErrors', () => { + test('no errors when both fields are valid', () => { + assert.deepStrictEqual( + collectHostFieldErrors({host: 'api.example.com', ip: '10.0.0.5'}), + [] + ); + }); + test('wildcard host with concrete target is allowed', () => { + assert.deepStrictEqual( + collectHostFieldErrors({host: '**.example.com', ip: 'app.internal.net'}), + [] + ); + assert.deepStrictEqual(collectHostFieldErrors({host: '**'}), []); + assert.deepStrictEqual(collectHostFieldErrors({host: 'payments.**'}), []); + }); + test('flags an invalid host with a port', () => { + let errs = collectHostFieldErrors({host: 'api.example.com:8080', ip: '10.0.0.5'}); + assert.strictEqual(errs.length, 1); + assert.strictEqual(errs[0].key, 'host'); + }); + test('flags a wildcard target (not allowed) and a protocol target', () => { + assert.strictEqual(collectHostFieldErrors({ip: '*.example.com'})[0].key, 'ip'); + assert.strictEqual(collectHostFieldErrors({ip: 'http://10.0.0.5'})[0].key, 'ip'); + }); + test('skips absent / empty fields (model handles presence)', () => { + assert.deepStrictEqual(collectHostFieldErrors({}), []); + assert.deepStrictEqual(collectHostFieldErrors({host: '', ip: undefined}), []); + }); +}); diff --git a/nodejs/utils/hostname_validate.js b/nodejs/utils/hostname_validate.js new file mode 100644 index 0000000..73f52ea --- /dev/null +++ b/nodejs/utils/hostname_validate.js @@ -0,0 +1,96 @@ +'use strict'; + +/** + * Validation for the user-supplied host / target fields on a Host entry. + * + * Neither field may carry a scheme (http://), a path ("/"), a port or ":" of any + * kind, or whitespace. + * + * host (incoming) — an IPv4 address or a hostname pattern whose dot-separated + * labels may be normal DNS labels or wildcard fragments: + * "*" matches exactly one subdomain fragment + * "**" matches any number of fragments + * e.g. "*.example.com", "**.mysite.com", "payments.**", and + * a bare "**" as a global catch-all. (Matched by + * Host.lookUp in models/host.js.) + * ip (target) — a concrete destination: an IPv4 address or a strict + * hostname (dotted, alphabetic TLD). No wildcards. + * + * Pure (no I/O) so it can be unit tested and reused. Enforced at the route layer + * (routes/host.js) so internally-created entries (wildcard children, on-demand + * cache) are unaffected. + */ + +// A single DNS label. +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; +// A strict hostname: dotted labels + alphabetic TLD (for the target). +const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; +// Scheme, path, port, or whitespace — anything that means it isn't a bare host. +const FORBIDDEN = /[\s/:]/; + +function isValidIPv4(value){ + if(typeof value !== 'string') return false; + let parts = value.split('.'); + if(parts.length !== 4) return false; + // Each octet 0-255, no leading zeros (0 itself is fine). + return parts.every(p => /^(0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255); +} + +// A strict, concrete hostname (used for the downstream target). No wildcards. +function isValidHostname(value){ + return typeof value === 'string' && HOSTNAME.test(value); +} + +// An incoming-host pattern: dot-separated labels, each a normal label or a +// wildcard fragment ("*" / "**"). A bare "**" is the global catch-all. +function isValidHostPattern(value){ + if(typeof value !== 'string' || value.length === 0 || value.length > 253) return false; + return value.split('.').every(l => l === '*' || l === '**' || LABEL.test(l)); +} + +// The incoming `host` field: IPv4 or a wildcard host pattern, no forbidden chars. +function isValidHostField(value){ + if(typeof value !== 'string' || value.length === 0) return false; + if(FORBIDDEN.test(value)) return false; + return isValidIPv4(value) || isValidHostPattern(value); +} + +// The `ip` (target) field: IPv4 or a strict hostname, no forbidden chars. +function isValidTargetField(value){ + if(typeof value !== 'string' || value.length === 0) return false; + if(FORBIDDEN.test(value)) return false; + return isValidIPv4(value) || isValidHostname(value); +} + +const NO_CHARS = 'no protocol, "/", or ":".'; + +/** + * Collect {key, message} errors for whichever of host / ip are present on the + * body. Absent fields are skipped (presence/length is handled by the model), so + * this works for both create (both present) and partial update. + */ +function collectHostFieldErrors(body){ + let errors = []; + body = body || {}; + + if(body.host !== undefined && body.host !== null && body.host !== ''){ + if(!isValidHostField(body.host)){ + errors.push({key: 'host', message: `Host must be a hostname, IP, or wildcard pattern (*, **) — ${NO_CHARS}`}); + } + } + if(body.ip !== undefined && body.ip !== null && body.ip !== ''){ + if(!isValidTargetField(body.ip)){ + errors.push({key: 'ip', message: `Target must be a valid hostname or IP address — ${NO_CHARS}`}); + } + } + return errors; +} + +module.exports = { + isValidIPv4, + isValidHostname, + isValidHostPattern, + isValidHostField, + isValidTargetField, + collectHostFieldErrors, +}; diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index 89a5342..8ef3c2e 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -368,7 +368,7 @@ Incoming Host Name
- +
@@ -419,7 +419,7 @@ - +