Accept any hostname (and optional :port) in manual network address entry (#7223)

* Allow arbitrary hostnames in manual network address entry

parseManualNetworkAddress only accepted an IPv4 address or a Tailscale
MagicDNS (*.ts.net) hostname, so users behind a dynamic residential IP
who rely on a DDNS domain or self-hosted relay had no way to enter it
in the desktop UI short of bypassing validation via DevTools/IPC.

The main process already resolves any host: resolvePairingEndpoint
and parsePairingAddressOverride in src/main/runtime/runtime-rpc.ts
accept an arbitrary hostname and an optional host:port. This change
brings the renderer-side validation in line with what pairing already
supports: any RFC 1123 hostname (a superset that still covers
*.ts.net), optionally suffixed with :port (1-65535). IPv4 validation
is unchanged, including still rejecting malformed dotted-numeric input
instead of silently treating it as an all-digit hostname.

Updates the custom-address dialog copy in NetworkInterfacePicker.tsx
to describe the wider grammar.

* Polish manual-address takeover: fix bare-numeric guard, sync 5 locales, lint

- Require a dot in the IPv4-typo guard so a bare numeric label (`123`)
  validates as a legal RFC 1123 hostname, matching the code's own comment
  and the main-process resolver; add coverage.
- Update en.json + es/ja/ko/zh placeholder/hint to the broadened grammar
  (translate() reads en.json before the TSX fallback, so the copy change
  was previously inert; the other locales described the old ts.net-only rule).
- Replace indexOf(...)!==-1 with includes() to satisfy oxlint.

Co-authored-by: Orca <help@stably.ai>

* Keep validator a strict subset of the backend resolver

Review surfaced two ways the renderer could accept an address the main
process handles differently:
- All-numeric hosts (bare `123` and dotted `256.0.0.1`) are now rejected.
  The WHATWG URL host parser downstream reinterprets a numeric host as IPv4
  (`123` -> `0.0.0.123`), so accepting one would validate an address the
  pairing resolver silently dials as a different host.
- Ports with leading zeros are rejected. `^[0-9]+$` let an arbitrarily long
  zero-padded string past the range check and inflate the returned address
  beyond the hostname length cap that the old whole-string check enforced.

Co-authored-by: Orca <help@stably.ai>

* Reject any numeric final label, not just fully-numeric hosts

WHATWG URL host parsing treats a host whose last label is numeric
(`foo.123`, `foo.0x1`) as an IPv4 signal, so the pairing resolver would
fail to parse it and silently dial a fallback host. Widen the ambiguous-IP
guard to a single last-label check that subsumes the earlier all-numeric
case, keeping the renderer a strict subset of what the backend resolves
correctly. Normal hostnames whose last label merely contains digits
(`host2.example.com`) are unaffected.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
e0_7 2026-07-04 03:52:55 +08:00 committed by GitHub
parent 492306f223
commit 2153ef9456
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 187 additions and 32 deletions

View File

@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac
// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection
// (Settings → Mobile) both need the same network selector. This wraps the
// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net)
// and copy. Discovered interfaces come from the OS; "Add custom address…"
// opens a dialog for an address the OS didn't surface — the only way to pair
// across networks.
// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123
// hostname — including Tailscale *.ts.net and DDNS domains — optionally
// with :port) and copy. Discovered interfaces come from the OS; "Add custom
// address…" opens a dialog for an address the OS didn't surface — the only
// way to pair across networks.
export type NetworkInterfacePickerProps = {
networkInterfaces: readonly MobileNetworkInterface[]
@ -81,11 +82,11 @@ export function NetworkInterfacePicker({
inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'),
placeholder: translate(
'auto.components.mobile.CustomNetworkAddressDialog.placeholder',
'my-mac.ts.net or 192.168.1.50'
'my-mac.ts.net, home.example.com, or 192.168.1.50'
),
hint: translate(
'auto.components.mobile.CustomNetworkAddressDialog.hint',
'Enter an IP address or a Tailscale hostname (ends in .ts.net).'
'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.'
),
cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'),
confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address')

View File

@ -10216,8 +10216,8 @@
"title": "Custom network address",
"description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.",
"label": "Address",
"placeholder": "my-mac.ts.net or 192.168.1.50",
"hint": "Enter an IP address or a Tailscale hostname (ends in .ts.net).",
"placeholder": "my-mac.ts.net, home.example.com, or 192.168.1.50",
"hint": "Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.",
"cancel": "Cancel",
"use": "Use address"
},

View File

@ -10216,8 +10216,8 @@
"title": "Dirección de red personalizada",
"description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.",
"label": "Dirección",
"placeholder": "my-mac.ts.net o 192.168.1.50",
"hint": "Introduce una dirección IP o un nombre de host de Tailscale (termina en .ts.net).",
"placeholder": "my-mac.ts.net, home.example.com o 192.168.1.50",
"hint": "Introduce una dirección IP o un nombre de host (un nombre MagicDNS de Tailscale, un dominio DDNS o un nombre de host de LAN), opcionalmente con :puerto.",
"cancel": "Cancelar",
"use": "Usar dirección"
},

View File

@ -10216,8 +10216,8 @@
"title": "カスタムネットワークアドレス",
"description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。",
"label": "アドレス",
"placeholder": "my-mac.ts.net または 192.168.1.50",
"hint": "IP アドレスまたは Tailscale のホスト名(.ts.net で終わる)を入力してください。",
"placeholder": "my-mac.ts.net、home.example.com、または 192.168.1.50",
"hint": "IP アドレスまたはホスト名Tailscale の MagicDNS 名、DDNS ドメイン、LAN のホスト名など)を入力してください。必要に応じて :ポート を付けられます。",
"cancel": "キャンセル",
"use": "アドレスを使用"
},

View File

@ -10216,8 +10216,8 @@
"title": "사용자 지정 네트워크 주소",
"description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.",
"label": "주소",
"placeholder": "my-mac.ts.net 또는 192.168.1.50",
"hint": "IP 주소 또는 Tailscale 호스트 이름(.ts.net으로 끝남)을 입력하세요.",
"placeholder": "my-mac.ts.net, home.example.com 또는 192.168.1.50",
"hint": "IP 주소 또는 호스트 이름(Tailscale MagicDNS 이름, DDNS 도메인 또는 LAN 호스트 이름)을 입력하세요. 필요하면 :포트를 붙일 수 있습니다.",
"cancel": "취소",
"use": "주소 사용"
},

View File

@ -10216,8 +10216,8 @@
"title": "自定义网络地址",
"description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。",
"label": "地址",
"placeholder": "my-mac.ts.net 或 192.168.1.50",
"hint": "请输入 IP 地址或 Tailscale 主机名(以 .ts.net 结尾)。",
"placeholder": "my-mac.ts.net、home.example.com 或 192.168.1.50",
"hint": "请输入 IP 地址或主机名Tailscale MagicDNS 名称、DDNS 域名或 LAN 主机名),可选择附加 :端口。",
"cancel": "取消",
"use": "使用地址"
},

View File

@ -19,11 +19,18 @@ describe('parseManualNetworkAddress', () => {
expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true)
})
it('accepts an IPv4 address with a port suffix', () => {
expect(parseManualNetworkAddress('192.168.1.24:8080')).toEqual({
ok: true,
address: '192.168.1.24:8080'
})
})
it('rejects malformed IPv4', () => {
for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) {
expect(parseManualNetworkAddress(bad)).toEqual({
ok: false,
error: 'Enter an IPv4 address or Tailscale MagicDNS hostname'
error: 'Enter an IPv4 address or hostname, optionally with a :port suffix'
})
}
})
@ -31,7 +38,7 @@ describe('parseManualNetworkAddress', () => {
it('rejects leading zeros in octets', () => {
expect(parseManualNetworkAddress('01.02.03.04')).toEqual({
ok: false,
error: 'Enter an IPv4 address or Tailscale MagicDNS hostname'
error: 'Enter an IPv4 address or hostname, optionally with a :port suffix'
})
expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true)
})
@ -57,11 +64,107 @@ describe('parseManualNetworkAddress', () => {
expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true)
})
it('rejects non-Tailscale hostnames', () => {
for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) {
it('rejects a MagicDNS-shaped hostname with a malformed label', () => {
// Leading/trailing hyphens are invalid in any RFC 1123 label,
// regardless of the `.ts.net` suffix.
expect(parseManualNetworkAddress('-foo.ts.net').ok).toBe(false)
})
})
describe('arbitrary hostnames (DDNS / self-hosted domains)', () => {
it('accepts a bare single-label hostname', () => {
expect(parseManualNetworkAddress('my-nas')).toEqual({
ok: true,
address: 'my-nas'
})
})
it('accepts a multi-label domain, e.g. a DDNS hostname', () => {
expect(parseManualNetworkAddress('home.example.com')).toEqual({
ok: true,
address: 'home.example.com'
})
expect(parseManualNetworkAddress('my-house.ddns.net').ok).toBe(true)
})
it('accepts a hostname with a port suffix', () => {
expect(parseManualNetworkAddress('home.example.com:8443')).toEqual({
ok: true,
address: 'home.example.com:8443'
})
})
it('is case-insensitive', () => {
expect(parseManualNetworkAddress('HOME.EXAMPLE.COM').ok).toBe(true)
})
it('rejects labels with leading or trailing hyphens', () => {
for (const bad of ['-foo.example.com', 'foo-.example.com', 'foo..com']) {
expect(parseManualNetworkAddress(bad).ok).toBe(false)
}
})
it('rejects hostnames with characters outside the RFC 1123 grammar', () => {
for (const bad of ['my_host.example.com', 'my host.example.com', 'example.com/path']) {
expect(parseManualNetworkAddress(bad).ok).toBe(false)
}
})
it('rejects a host whose last label is numeric as an ambiguous IPv4', () => {
// The WHATWG URL parser downstream treats "ends in a number" as an IPv4
// signal and would reinterpret or fail to resolve these (`123` ->
// `0.0.0.123`; `foo.123` -> no-op fallback), so accepting one here would
// validate an address the main process dials differently.
const bad = [
'123',
'123:8080',
'1.2.3',
'1.2.3.4.5',
'256.0.0.1',
'999.999.999.999',
'foo.123',
'foo.123:8080',
'foo.0x1'
]
for (const value of bad) {
expect(parseManualNetworkAddress(value).ok).toBe(false)
}
})
it('still accepts a hostname whose last label merely contains digits', () => {
// Only a fully-numeric (or hex) final label is ambiguous; a label like
// `com` or `nas1` is a normal hostname.
expect(parseManualNetworkAddress('host2.example.com').ok).toBe(true)
expect(parseManualNetworkAddress('my-nas1').ok).toBe(true)
})
})
describe('port suffix', () => {
it('accepts boundary port values', () => {
expect(parseManualNetworkAddress('example.com:1').ok).toBe(true)
expect(parseManualNetworkAddress('example.com:65535').ok).toBe(true)
})
it('rejects out-of-range ports', () => {
expect(parseManualNetworkAddress('example.com:0').ok).toBe(false)
expect(parseManualNetworkAddress('example.com:65536').ok).toBe(false)
expect(parseManualNetworkAddress('example.com:99999').ok).toBe(false)
})
it('rejects a non-numeric or empty port', () => {
expect(parseManualNetworkAddress('example.com:abc').ok).toBe(false)
expect(parseManualNetworkAddress('example.com:').ok).toBe(false)
})
it('rejects a leading-zero (non-canonical, unbounded-length) port', () => {
expect(parseManualNetworkAddress('example.com:0080').ok).toBe(false)
expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false)
})
it('rejects addresses with more than one colon (e.g. IPv6-shaped input)', () => {
expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false)
expect(parseManualNetworkAddress('::1').ok).toBe(false)
})
})
describe('length and whitespace', () => {
@ -77,5 +180,9 @@ describe('parseManualNetworkAddress', () => {
address: '192.168.1.24'
})
})
it('rejects whitespace inside the address', () => {
expect(parseManualNetworkAddress('my host.example.com').ok).toBe(false)
})
})
})

View File

@ -1,36 +1,83 @@
// Why: pure shared helper so the same validation runs in renderer
// today and in any future CLI/main-process caller without duplicating
// the IPv4 + Tailscale MagicDNS grammar.
// the IPv4 + hostname + optional-port grammar.
const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}`
// MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net.
// Labels may not start or end with a hyphen; max 63 chars per label (DNS limit).
const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net`
const IPV4_REGEX = new RegExp(`^${IPV4}$`)
// RFC 1123 hostname label: letters/digits/hyphens, 1-63 chars, may not
// start or end with a hyphen. This covers plain LAN hostnames, DDNS domains
// (e.g. `home.example.com`), and Tailscale MagicDNS names (`*.ts.net`) as a
// special case of the same grammar — no separate MagicDNS-only pattern needed.
const HOSTNAME_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
const HOSTNAME = `(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}`
const HOSTNAME_REGEX = new RegExp(`^${HOSTNAME}$`, 'i')
const HOSTNAME_MAX_LENGTH = 253
const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname'
const MIN_PORT = 1
const MAX_PORT = 65535
const ERROR_MESSAGE = 'Enter an IPv4 address or hostname, optionally with a :port suffix'
export type ParseManualAddressResult = { ok: true; address: string } | { ok: false; error: string }
export function parseManualNetworkAddress(input: string): ParseManualAddressResult {
const trimmed = input.trim()
if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) {
if (trimmed === '') {
return { ok: false, error: ERROR_MESSAGE }
}
if (/\s/.test(trimmed)) {
return { ok: false, error: ERROR_MESSAGE }
}
const ipv4Regex = new RegExp(`^${IPV4}$`)
if (ipv4Regex.test(trimmed)) {
return { ok: true, address: trimmed }
const { host, port } = splitHostPort(trimmed)
if (host === '' || host.length > HOSTNAME_MAX_LENGTH) {
return { ok: false, error: ERROR_MESSAGE }
}
if (port !== null && !isValidPort(port)) {
return { ok: false, error: ERROR_MESSAGE }
}
const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i')
if (magicRegex.test(trimmed)) {
if (IPV4_REGEX.test(host)) {
return { ok: true, address: trimmed }
}
// Why: reject a host whose last label is numeric — a bare `123`, a dotted
// `256.0.0.1`, or a hostname with a numeric final label (`foo.123`, `foo.0x1`).
// The WHATWG URL host parser that `resolvePairingEndpoint` feeds this into
// treats "ends in a number" as an IPv4 signal and tries to parse the whole
// host as IPv4. A real IPv4 was already accepted above, so anything reaching
// here would fail that parse and the main process would silently dial a
// different host — so treat these as mistyped IPs, not hostnames.
const lastLabel = host.split('.').at(-1) ?? ''
if (/^[0-9]+$/.test(lastLabel) || /^0x[0-9a-f]*$/i.test(lastLabel)) {
return { ok: false, error: ERROR_MESSAGE }
}
if (HOSTNAME_REGEX.test(host)) {
return { ok: true, address: trimmed }
}
return { ok: false, error: ERROR_MESSAGE }
}
// Why: mirrors `parsePairingAddressOverride` in src/main/runtime/runtime-rpc.ts
// so the UI only accepts what the main process's pairing endpoint resolution
// can already handle. IPv6 stays out of scope (same as that function), so a
// second colon is left in `host` and fails the grammar checks below instead
// of being misparsed as a port.
function splitHostPort(value: string): { host: string; port: string | null } {
const firstColon = value.indexOf(':')
if (firstColon === -1 || value.includes(':', firstColon + 1)) {
return { host: value, port: null }
}
return { host: value.slice(0, firstColon), port: value.slice(firstColon + 1) }
}
function isValidPort(port: string): boolean {
// Reject leading zeros: a canonical port has none, and allowing them lets an
// arbitrarily long zero-padded string (`00…08080`) slip past the range check
// and inflate the returned address past the hostname length cap.
if (!/^[1-9][0-9]*$/.test(port)) {
return false
}
const value = Number(port)
return value >= MIN_PORT && value <= MAX_PORT
}