From a9dfebc481b93ea259f7cfdce1cc8ef5159370c9 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 14 Jul 2026 01:12:02 -0400 Subject: [PATCH] Allow single-label hostnames as a Host target (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- nodejs/public/lib/js/val.js | 4 +++- nodejs/test/unit/hostname_validate.test.js | 11 +++++++++-- nodejs/utils/hostname_validate.js | 12 ++++++++---- nodejs/views/hosts.ejs | 2 +- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/nodejs/public/lib/js/val.js b/nodejs/public/lib/js/val.js index 0029957..fd3da8f 100755 --- a/nodejs/public/lib/js/val.js +++ b/nodejs/public/lib/js/val.js @@ -98,7 +98,9 @@ // 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; + // Either one bare label (Docker service names, /etc/hosts entries) or a + // dotted hostname with an alphabetic TLD. + var 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; var FORBIDDEN = /[\s/:]/; function isIPv4( value ) { diff --git a/nodejs/test/unit/hostname_validate.test.js b/nodejs/test/unit/hostname_validate.test.js index 42ae4c3..db9ac16 100644 --- a/nodejs/test/unit/hostname_validate.test.js +++ b/nodejs/test/unit/hostname_validate.test.js @@ -32,8 +32,12 @@ describe('isValidHostname (strict, for target)', () => { assert.ok(isValidHostname('example.com')); assert.ok(isValidHostname('app.internal.net')); }); - test('rejects bare labels, numeric TLDs, wildcards', () => { - assert.ok(!isValidHostname('localhost')); + test('accepts a bare single-label hostname (Docker service names, /etc/hosts)', () => { + assert.ok(isValidHostname('localhost')); + assert.ok(isValidHostname('sso-manager')); + assert.ok(isValidHostname('redis')); + }); + test('rejects numeric-looking targets, wildcards', () => { assert.ok(!isValidHostname('10.10.10.10')); assert.ok(!isValidHostname('*.example.com')); assert.ok(!isValidHostname('')); @@ -81,6 +85,9 @@ describe('isValidTargetField (target: hostname or IP, no wildcard)', () => { assert.ok(isValidTargetField('app.internal.net')); assert.ok(isValidTargetField('10.0.0.5')); }); + test('accepts a bare single-label hostname (e.g. a Docker Compose service name)', () => { + assert.ok(isValidTargetField('sso-manager')); + }); test('rejects wildcards, protocol, port, path', () => { assert.ok(!isValidTargetField('*.example.com')); assert.ok(!isValidTargetField('**')); diff --git a/nodejs/utils/hostname_validate.js b/nodejs/utils/hostname_validate.js index 73f52ea..b2017e2 100644 --- a/nodejs/utils/hostname_validate.js +++ b/nodejs/utils/hostname_validate.js @@ -13,8 +13,11 @@ * 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. + * 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 @@ -23,8 +26,9 @@ // 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; +// 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/:]/; diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index 918bbfa..8add303 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -489,7 +489,7 @@
- + Where matching requests are proxied. Hostname or IP only — no protocol, port, or path.