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:
parent
492306f223
commit
2153ef9456
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "アドレスを使用"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "주소 사용"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "使用地址"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue