a9dfebc481
The target ("ip") field validator required at least two dot-separated
labels, rejecting legitimate single-label hostnames like Docker Compose
service names ("sso-manager"), /etc/hosts entries, or anything resolved
via a search domain. This was enforced identically client-side
(public/lib/js/val.js) and server-side (utils/hostname_validate.js,
routes/host.js), so there was no way to set one through the UI or API
— only by writing to the Host model directly, bypassing validation
entirely (which is how theta-env's setup.sh registers sso-manager as a
target today, working only because it calls Host.create() directly).
Relax HOSTNAME in both places to accept either a bare single label or
the existing dotted-FQDN pattern. Flips the one existing test that
codified the old behavior (isValidHostname('localhost') was asserted
false) and adds coverage for the reported case.
101 lines
4.0 KiB
JavaScript
101 lines
4.0 KiB
JavaScript
'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, a single
|
|
* unqualified label (e.g. "sso-manager" — a Docker
|
|
* Compose service name, or any other host resolvable
|
|
* via /etc/hosts or a search domain), or a dotted
|
|
* hostname with an 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: either one bare label (Docker service names, /etc/hosts
|
|
// entries, search-domain-relative names) or dotted labels + alphabetic TLD.
|
|
const HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/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,
|
|
};
|