Validate host/target fields (hostname or IP; host allows */** wildcards)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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'},
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
@@ -11,8 +11,3 @@ nav.navbar{
|
||||
.card-title{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actionMessage{
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
|
||||
+87
-33
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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}), []);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -368,7 +368,7 @@
|
||||
Incoming Host Name
|
||||
</label>
|
||||
<div>
|
||||
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net" validate=":3" >
|
||||
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net, *.cloud-ops.net, **.cloud-ops.net, or **" validate="host" >
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,7 +419,7 @@
|
||||
<label for="ip" class="form-label">
|
||||
Target IP or Host Name
|
||||
</label>
|
||||
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10" validate=":3" />
|
||||
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10 or app.internal.net" validate="target:3" />
|
||||
<b class="invalid-feedback"></b>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user