Files
proxy/nodejs/utils/hostname_validate.js
T
wmantly a9dfebc481 Allow single-label hostnames as a Host target (#126)
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.
2026-07-14 01:12:02 -04:00

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,
};