* fix(security): block plaintext WebSocket connections to non-loopback addresses Addresses CWE-319 (Cleartext Transmission of Sensitive Information). Previously, ws:// connections to remote hosts were allowed, exposing both credentials and chat data to network interception. This change blocks ALL plaintext ws:// connections to non-loopback addresses, regardless of whether explicit credentials are configured (device tokens may be loaded dynamically). Security policy: - wss:// allowed to any host - ws:// allowed only to loopback (127.x.x.x, localhost, ::1) - ws:// to LAN/tailnet/remote hosts now requires TLS Changes: - Add isSecureWebSocketUrl() validation in net.ts - Block insecure connections in GatewayClient.start() - Block insecure URLs in buildGatewayConnectionDetails() - Handle malformed URLs gracefully without crashing - Update tests to use wss:// for non-loopback URLs Fixes #12519 * fix(test): update gateway-chat mock to preserve net.js exports Use importOriginal to spread actual module exports and mock only the functions needed for testing. This ensures isSecureWebSocketUrl and other exports remain available to the code under test.
429 lines
11 KiB
TypeScript
429 lines
11 KiB
TypeScript
import net from "node:net";
|
|
import os from "node:os";
|
|
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
|
|
|
/**
|
|
* Pick the primary non-internal IPv4 address (LAN IP).
|
|
* Prefers common interface names (en0, eth0) then falls back to any external IPv4.
|
|
*/
|
|
export function pickPrimaryLanIPv4(): string | undefined {
|
|
const nets = os.networkInterfaces();
|
|
const preferredNames = ["en0", "eth0"];
|
|
for (const name of preferredNames) {
|
|
const list = nets[name];
|
|
const entry = list?.find((n) => n.family === "IPv4" && !n.internal);
|
|
if (entry?.address) {
|
|
return entry.address;
|
|
}
|
|
}
|
|
for (const list of Object.values(nets)) {
|
|
const entry = list?.find((n) => n.family === "IPv4" && !n.internal);
|
|
if (entry?.address) {
|
|
return entry.address;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function normalizeHostHeader(hostHeader?: string): string {
|
|
return (hostHeader ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
export function resolveHostName(hostHeader?: string): string {
|
|
const host = normalizeHostHeader(hostHeader);
|
|
if (!host) {
|
|
return "";
|
|
}
|
|
if (host.startsWith("[")) {
|
|
const end = host.indexOf("]");
|
|
if (end !== -1) {
|
|
return host.slice(1, end);
|
|
}
|
|
}
|
|
// Unbracketed IPv6 host (e.g. "::1") has no port and should be returned as-is.
|
|
if (net.isIP(host) === 6) {
|
|
return host;
|
|
}
|
|
const [name] = host.split(":");
|
|
return name ?? "";
|
|
}
|
|
|
|
export function isLoopbackAddress(ip: string | undefined): boolean {
|
|
if (!ip) {
|
|
return false;
|
|
}
|
|
if (ip === "127.0.0.1") {
|
|
return true;
|
|
}
|
|
if (ip.startsWith("127.")) {
|
|
return true;
|
|
}
|
|
if (ip === "::1") {
|
|
return true;
|
|
}
|
|
if (ip.startsWith("::ffff:127.")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the IP belongs to a private or loopback network range.
|
|
* Private ranges: RFC1918, link-local, ULA IPv6, and CGNAT (100.64/10), plus loopback.
|
|
*/
|
|
export function isPrivateOrLoopbackAddress(ip: string | undefined): boolean {
|
|
if (!ip) {
|
|
return false;
|
|
}
|
|
if (isLoopbackAddress(ip)) {
|
|
return true;
|
|
}
|
|
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
|
|
const family = net.isIP(normalized);
|
|
if (!family) {
|
|
return false;
|
|
}
|
|
|
|
if (family === 4) {
|
|
const octets = normalized.split(".").map((value) => Number.parseInt(value, 10));
|
|
if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) {
|
|
return false;
|
|
}
|
|
const [o1, o2] = octets;
|
|
// RFC1918 IPv4 private ranges.
|
|
if (o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)) {
|
|
return true;
|
|
}
|
|
// IPv4 link-local and CGNAT (commonly used by Tailnet-like networks).
|
|
if ((o1 === 169 && o2 === 254) || (o1 === 100 && o2 >= 64 && o2 <= 127)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// IPv6 unique-local and link-local ranges.
|
|
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
|
|
return true;
|
|
}
|
|
if (/^fe[89ab]/.test(normalized)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeIPv4MappedAddress(ip: string): string {
|
|
if (ip.startsWith("::ffff:")) {
|
|
return ip.slice("::ffff:".length);
|
|
}
|
|
return ip;
|
|
}
|
|
|
|
function normalizeIp(ip: string | undefined): string | undefined {
|
|
const trimmed = ip?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
|
|
}
|
|
|
|
function stripOptionalPort(ip: string): string {
|
|
if (ip.startsWith("[")) {
|
|
const end = ip.indexOf("]");
|
|
if (end !== -1) {
|
|
return ip.slice(1, end);
|
|
}
|
|
}
|
|
if (net.isIP(ip)) {
|
|
return ip;
|
|
}
|
|
const lastColon = ip.lastIndexOf(":");
|
|
if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) {
|
|
const candidate = ip.slice(0, lastColon);
|
|
if (net.isIP(candidate) === 4) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return ip;
|
|
}
|
|
|
|
export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
|
|
const raw = forwardedFor?.split(",")[0]?.trim();
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
return normalizeIp(stripOptionalPort(raw));
|
|
}
|
|
|
|
function parseRealIp(realIp?: string): string | undefined {
|
|
const raw = realIp?.trim();
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
return normalizeIp(stripOptionalPort(raw));
|
|
}
|
|
|
|
/**
|
|
* Check if an IP address matches a CIDR block.
|
|
* Supports IPv4 CIDR notation (e.g., "10.42.0.0/24").
|
|
*
|
|
* @param ip - The IP address to check (e.g., "10.42.0.59")
|
|
* @param cidr - The CIDR block (e.g., "10.42.0.0/24")
|
|
* @returns True if the IP is within the CIDR block
|
|
*/
|
|
function ipMatchesCIDR(ip: string, cidr: string): boolean {
|
|
// Handle exact IP match (no CIDR notation)
|
|
if (!cidr.includes("/")) {
|
|
return ip === cidr;
|
|
}
|
|
|
|
const [subnet, prefixLenStr] = cidr.split("/");
|
|
const prefixLen = parseInt(prefixLenStr, 10);
|
|
|
|
// Validate prefix length
|
|
if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
|
|
return false;
|
|
}
|
|
|
|
// Convert IPs to 32-bit integers
|
|
const ipParts = ip.split(".").map((p) => parseInt(p, 10));
|
|
const subnetParts = subnet.split(".").map((p) => parseInt(p, 10));
|
|
|
|
// Validate IP format
|
|
if (
|
|
ipParts.length !== 4 ||
|
|
subnetParts.length !== 4 ||
|
|
ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) ||
|
|
subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
|
const subnetInt =
|
|
(subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
|
|
|
// Create mask and compare
|
|
const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen);
|
|
return (ipInt & mask) === (subnetInt & mask);
|
|
}
|
|
|
|
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
|
const normalized = normalizeIp(ip);
|
|
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return trustedProxies.some((proxy) => {
|
|
const candidate = proxy.trim();
|
|
if (!candidate) {
|
|
return false;
|
|
}
|
|
// Handle CIDR notation
|
|
if (candidate.includes("/")) {
|
|
return ipMatchesCIDR(normalized, candidate);
|
|
}
|
|
// Exact IP match
|
|
return normalizeIp(candidate) === normalized;
|
|
});
|
|
}
|
|
|
|
export function resolveGatewayClientIp(params: {
|
|
remoteAddr?: string;
|
|
forwardedFor?: string;
|
|
realIp?: string;
|
|
trustedProxies?: string[];
|
|
}): string | undefined {
|
|
const remote = normalizeIp(params.remoteAddr);
|
|
if (!remote) {
|
|
return undefined;
|
|
}
|
|
if (!isTrustedProxyAddress(remote, params.trustedProxies)) {
|
|
return remote;
|
|
}
|
|
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
|
|
}
|
|
|
|
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
|
if (isLoopbackAddress(ip)) {
|
|
return true;
|
|
}
|
|
if (!ip) {
|
|
return false;
|
|
}
|
|
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
|
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
|
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) {
|
|
return true;
|
|
}
|
|
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
|
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolves gateway bind host with fallback strategy.
|
|
*
|
|
* Modes:
|
|
* - loopback: 127.0.0.1 (rarely fails, but handled gracefully)
|
|
* - lan: always 0.0.0.0 (no fallback)
|
|
* - tailnet: Tailnet IPv4 if available, else loopback
|
|
* - auto: Loopback if available, else 0.0.0.0
|
|
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable
|
|
*
|
|
* @returns The bind address to use (never null)
|
|
*/
|
|
export async function resolveGatewayBindHost(
|
|
bind: import("../config/config.js").GatewayBindMode | undefined,
|
|
customHost?: string,
|
|
): Promise<string> {
|
|
const mode = bind ?? "loopback";
|
|
|
|
if (mode === "loopback") {
|
|
// 127.0.0.1 rarely fails, but handle gracefully
|
|
if (await canBindToHost("127.0.0.1")) {
|
|
return "127.0.0.1";
|
|
}
|
|
return "0.0.0.0"; // extreme fallback
|
|
}
|
|
|
|
if (mode === "tailnet") {
|
|
const tailnetIP = pickPrimaryTailnetIPv4();
|
|
if (tailnetIP && (await canBindToHost(tailnetIP))) {
|
|
return tailnetIP;
|
|
}
|
|
if (await canBindToHost("127.0.0.1")) {
|
|
return "127.0.0.1";
|
|
}
|
|
return "0.0.0.0";
|
|
}
|
|
|
|
if (mode === "lan") {
|
|
return "0.0.0.0";
|
|
}
|
|
|
|
if (mode === "custom") {
|
|
const host = customHost?.trim();
|
|
if (!host) {
|
|
return "0.0.0.0";
|
|
} // invalid config → fall back to all
|
|
|
|
if (isValidIPv4(host) && (await canBindToHost(host))) {
|
|
return host;
|
|
}
|
|
// Custom IP failed → fall back to LAN
|
|
return "0.0.0.0";
|
|
}
|
|
|
|
if (mode === "auto") {
|
|
if (await canBindToHost("127.0.0.1")) {
|
|
return "127.0.0.1";
|
|
}
|
|
return "0.0.0.0";
|
|
}
|
|
|
|
return "0.0.0.0";
|
|
}
|
|
|
|
/**
|
|
* Test if we can bind to a specific host address.
|
|
* Creates a temporary server, attempts to bind, then closes it.
|
|
*
|
|
* @param host - The host address to test
|
|
* @returns True if we can successfully bind to this address
|
|
*/
|
|
export async function canBindToHost(host: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const testServer = net.createServer();
|
|
testServer.once("error", () => {
|
|
resolve(false);
|
|
});
|
|
testServer.once("listening", () => {
|
|
testServer.close();
|
|
resolve(true);
|
|
});
|
|
// Use port 0 to let OS pick an available port for testing
|
|
testServer.listen(0, host);
|
|
});
|
|
}
|
|
|
|
export async function resolveGatewayListenHosts(
|
|
bindHost: string,
|
|
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
|
|
): Promise<string[]> {
|
|
if (bindHost !== "127.0.0.1") {
|
|
return [bindHost];
|
|
}
|
|
const canBind = opts?.canBindToHost ?? canBindToHost;
|
|
if (await canBind("::1")) {
|
|
return [bindHost, "::1"];
|
|
}
|
|
return [bindHost];
|
|
}
|
|
|
|
/**
|
|
* Validate if a string is a valid IPv4 address.
|
|
*
|
|
* @param host - The string to validate
|
|
* @returns True if valid IPv4 format
|
|
*/
|
|
export function isValidIPv4(host: string): boolean {
|
|
const parts = host.split(".");
|
|
if (parts.length !== 4) {
|
|
return false;
|
|
}
|
|
return parts.every((part) => {
|
|
const n = parseInt(part, 10);
|
|
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if a hostname or IP refers to the local machine.
|
|
* Handles: localhost, 127.x.x.x, ::1, [::1], ::ffff:127.x.x.x
|
|
* Note: 0.0.0.0 and :: are NOT loopback - they bind to all interfaces.
|
|
*/
|
|
export function isLoopbackHost(host: string): boolean {
|
|
if (!host) {
|
|
return false;
|
|
}
|
|
const h = host.trim().toLowerCase();
|
|
if (h === "localhost") {
|
|
return true;
|
|
}
|
|
// Handle bracketed IPv6 addresses like [::1]
|
|
const unbracket = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
return isLoopbackAddress(unbracket);
|
|
}
|
|
|
|
/**
|
|
* Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information).
|
|
*
|
|
* Returns true if the URL is secure for transmitting data:
|
|
* - wss:// (TLS) is always secure
|
|
* - ws:// is only secure for loopback addresses (localhost, 127.x.x.x, ::1)
|
|
*
|
|
* All other ws:// URLs are considered insecure because both credentials
|
|
* AND chat/conversation data would be exposed to network interception.
|
|
*/
|
|
export function isSecureWebSocketUrl(url: string): boolean {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
if (parsed.protocol === "wss:") {
|
|
return true;
|
|
}
|
|
|
|
if (parsed.protocol !== "ws:") {
|
|
return false;
|
|
}
|
|
|
|
// ws:// is only secure for loopback addresses
|
|
return isLoopbackHost(parsed.hostname);
|
|
}
|