Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion lib/socket-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,67 @@ debugError.color = 1;

let net;

// Localhost aliases that all refer to the same machine
const LOCALHOST_ALIASES = new Set([
'localhost',
'127.0.0.1',
'::1',
'0.0.0.0',
'0:0:0:0:0:0:0:1',
'[::1]',
'[0:0:0:0:0:0:0:1]',
]);

/**
* Normalize a hostname for comparison
* - Lowercase
* - Remove trailing dots (DNS root)
* - Strip IPv6 brackets
* - Expand common IPv6 localhost representations
* @param {string} hostname
* @returns {string}
*/
function normalizeHostname(hostname) {
if (!hostname) return '';
let normalized = hostname.toLowerCase().trim();

// Remove trailing dot (DNS root indicator)
if (normalized.endsWith('.')) {
normalized = normalized.slice(0, -1);
}

// Strip IPv6 brackets for comparison
if (normalized.startsWith('[') && normalized.endsWith(']')) {
normalized = normalized.slice(1, -1);
}

return normalized;
}

/**
* Check if two hostnames refer to the same host
* Handles localhost aliases, IPv6, and normalization
* @param {string} host1
* @param {string} host2
* @returns {boolean}
*/
export function isSameHost(host1, host2) {
const norm1 = normalizeHostname(host1);
const norm2 = normalizeHostname(host2);

// Direct match after normalization
if (norm1 === norm2) {
return true;
}

// Check if both are localhost aliases
if (LOCALHOST_ALIASES.has(norm1) && LOCALHOST_ALIASES.has(norm2)) {
return true;
}

return false;
}

export function setNet(netImpl) {
net = netImpl;
}
Expand Down Expand Up @@ -40,7 +101,10 @@ export function initListeners(hsyncClient) {
cleanHost = cleanHost.substring(0, cleanHost.length - 1);
}
const url = new URL(cleanHost);
if (url.hostname.toLowerCase() === hsyncClient.myHostName.toLowerCase()) {
// Security: Use comprehensive hostname comparison to prevent bypass attacks
// CVE-HSYNC-2026-006: Simple string comparison could be bypassed via
// localhost aliases, IPv6, trailing dots, etc.
if (isSameHost(url.hostname, hsyncClient.myHostName)) {
throw new Error('targetHost must be a different host');
}
debug('creating handler', port, cleanHost);
Expand Down
100 changes: 99 additions & 1 deletion test/unit/socket-listeners.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { initListeners, setNet } from '../../lib/socket-listeners.js';
import { initListeners, setNet, isSameHost } from '../../lib/socket-listeners.js';

describe('isSameHost', () => {
it('should match identical hostnames', () => {
expect(isSameHost('example.com', 'example.com')).toBe(true);
});

it('should be case insensitive', () => {
expect(isSameHost('Example.COM', 'example.com')).toBe(true);
expect(isSameHost('LOCALHOST', 'localhost')).toBe(true);
});

it('should handle trailing dots (DNS root)', () => {
expect(isSameHost('example.com.', 'example.com')).toBe(true);
expect(isSameHost('example.com', 'example.com.')).toBe(true);
});

it('should match localhost aliases', () => {
expect(isSameHost('localhost', '127.0.0.1')).toBe(true);
expect(isSameHost('localhost', '::1')).toBe(true);
expect(isSameHost('127.0.0.1', '::1')).toBe(true);
expect(isSameHost('localhost', '0.0.0.0')).toBe(true);
});

it('should handle IPv6 brackets', () => {
expect(isSameHost('[::1]', '::1')).toBe(true);
expect(isSameHost('[::1]', 'localhost')).toBe(true);
});

it('should handle expanded IPv6 localhost', () => {
expect(isSameHost('0:0:0:0:0:0:0:1', '::1')).toBe(true);
expect(isSameHost('0:0:0:0:0:0:0:1', 'localhost')).toBe(true);
});

it('should not match different hosts', () => {
expect(isSameHost('example.com', 'other.com')).toBe(false);
expect(isSameHost('localhost', 'example.com')).toBe(false);
});

it('should handle empty/null inputs', () => {
expect(isSameHost('', '')).toBe(true);
expect(isSameHost('example.com', '')).toBe(false);
expect(isSameHost('', 'example.com')).toBe(false);
});
});

describe('socket-listeners', () => {
let mockNet;
Expand Down Expand Up @@ -100,6 +144,60 @@ describe('socket-listeners', () => {
).toThrow('targetHost must be a different host');
});

it('should throw if targetHost matches client with different case', () => {
expect(() =>
listeners.addSocketListener({
port: 3000,
targetHost: 'https://LOCAL.EXAMPLE.COM',
})
).toThrow('targetHost must be a different host');
});

it('should throw if targetHost matches client with trailing dot', () => {
expect(() =>
listeners.addSocketListener({
port: 3000,
targetHost: 'https://local.example.com.',
})
).toThrow('targetHost must be a different host');
});

it('should block localhost bypass via 127.0.0.1', () => {
mockHsyncClient.myHostName = 'localhost';
listeners = initListeners(mockHsyncClient);

expect(() =>
listeners.addSocketListener({
port: 3000,
targetHost: 'https://127.0.0.1',
})
).toThrow('targetHost must be a different host');
});

it('should block localhost bypass via IPv6 ::1', () => {
mockHsyncClient.myHostName = 'localhost';
listeners = initListeners(mockHsyncClient);

expect(() =>
listeners.addSocketListener({
port: 3000,
targetHost: 'https://[::1]',
})
).toThrow('targetHost must be a different host');
});

it('should block localhost bypass via 0.0.0.0', () => {
mockHsyncClient.myHostName = '127.0.0.1';
listeners = initListeners(mockHsyncClient);

expect(() =>
listeners.addSocketListener({
port: 3000,
targetHost: 'https://0.0.0.0',
})
).toThrow('targetHost must be a different host');
});

it('should clean trailing slash from targetHost', () => {
const listener = listeners.addSocketListener({
port: 3000,
Expand Down