Skip to content

Commit 5456642

Browse files
authored
Merge pull request #34 from monteslu/fix/cve-hsync-2026-006-host-validation
fix(security): CVE-HSYNC-2026-006 - Comprehensive host validation
2 parents 83a63c0 + 1406ac6 commit 5456642

File tree

2 files changed

+164
-2
lines changed

2 files changed

+164
-2
lines changed

lib/socket-listeners.js

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,67 @@ debugError.color = 1;
99

1010
let net;
1111

12+
// Localhost aliases that all refer to the same machine
13+
const LOCALHOST_ALIASES = new Set([
14+
'localhost',
15+
'127.0.0.1',
16+
'::1',
17+
'0.0.0.0',
18+
'0:0:0:0:0:0:0:1',
19+
'[::1]',
20+
'[0:0:0:0:0:0:0:1]',
21+
]);
22+
23+
/**
24+
* Normalize a hostname for comparison
25+
* - Lowercase
26+
* - Remove trailing dots (DNS root)
27+
* - Strip IPv6 brackets
28+
* - Expand common IPv6 localhost representations
29+
* @param {string} hostname
30+
* @returns {string}
31+
*/
32+
function normalizeHostname(hostname) {
33+
if (!hostname) return '';
34+
let normalized = hostname.toLowerCase().trim();
35+
36+
// Remove trailing dot (DNS root indicator)
37+
if (normalized.endsWith('.')) {
38+
normalized = normalized.slice(0, -1);
39+
}
40+
41+
// Strip IPv6 brackets for comparison
42+
if (normalized.startsWith('[') && normalized.endsWith(']')) {
43+
normalized = normalized.slice(1, -1);
44+
}
45+
46+
return normalized;
47+
}
48+
49+
/**
50+
* Check if two hostnames refer to the same host
51+
* Handles localhost aliases, IPv6, and normalization
52+
* @param {string} host1
53+
* @param {string} host2
54+
* @returns {boolean}
55+
*/
56+
export function isSameHost(host1, host2) {
57+
const norm1 = normalizeHostname(host1);
58+
const norm2 = normalizeHostname(host2);
59+
60+
// Direct match after normalization
61+
if (norm1 === norm2) {
62+
return true;
63+
}
64+
65+
// Check if both are localhost aliases
66+
if (LOCALHOST_ALIASES.has(norm1) && LOCALHOST_ALIASES.has(norm2)) {
67+
return true;
68+
}
69+
70+
return false;
71+
}
72+
1273
export function setNet(netImpl) {
1374
net = netImpl;
1475
}
@@ -40,7 +101,10 @@ export function initListeners(hsyncClient) {
40101
cleanHost = cleanHost.substring(0, cleanHost.length - 1);
41102
}
42103
const url = new URL(cleanHost);
43-
if (url.hostname.toLowerCase() === hsyncClient.myHostName.toLowerCase()) {
104+
// Security: Use comprehensive hostname comparison to prevent bypass attacks
105+
// CVE-HSYNC-2026-006: Simple string comparison could be bypassed via
106+
// localhost aliases, IPv6, trailing dots, etc.
107+
if (isSameHost(url.hostname, hsyncClient.myHostName)) {
44108
throw new Error('targetHost must be a different host');
45109
}
46110
debug('creating handler', port, cleanHost);

test/unit/socket-listeners.test.js

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,49 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { initListeners, setNet } from '../../lib/socket-listeners.js';
2+
import { initListeners, setNet, isSameHost } from '../../lib/socket-listeners.js';
3+
4+
describe('isSameHost', () => {
5+
it('should match identical hostnames', () => {
6+
expect(isSameHost('example.com', 'example.com')).toBe(true);
7+
});
8+
9+
it('should be case insensitive', () => {
10+
expect(isSameHost('Example.COM', 'example.com')).toBe(true);
11+
expect(isSameHost('LOCALHOST', 'localhost')).toBe(true);
12+
});
13+
14+
it('should handle trailing dots (DNS root)', () => {
15+
expect(isSameHost('example.com.', 'example.com')).toBe(true);
16+
expect(isSameHost('example.com', 'example.com.')).toBe(true);
17+
});
18+
19+
it('should match localhost aliases', () => {
20+
expect(isSameHost('localhost', '127.0.0.1')).toBe(true);
21+
expect(isSameHost('localhost', '::1')).toBe(true);
22+
expect(isSameHost('127.0.0.1', '::1')).toBe(true);
23+
expect(isSameHost('localhost', '0.0.0.0')).toBe(true);
24+
});
25+
26+
it('should handle IPv6 brackets', () => {
27+
expect(isSameHost('[::1]', '::1')).toBe(true);
28+
expect(isSameHost('[::1]', 'localhost')).toBe(true);
29+
});
30+
31+
it('should handle expanded IPv6 localhost', () => {
32+
expect(isSameHost('0:0:0:0:0:0:0:1', '::1')).toBe(true);
33+
expect(isSameHost('0:0:0:0:0:0:0:1', 'localhost')).toBe(true);
34+
});
35+
36+
it('should not match different hosts', () => {
37+
expect(isSameHost('example.com', 'other.com')).toBe(false);
38+
expect(isSameHost('localhost', 'example.com')).toBe(false);
39+
});
40+
41+
it('should handle empty/null inputs', () => {
42+
expect(isSameHost('', '')).toBe(true);
43+
expect(isSameHost('example.com', '')).toBe(false);
44+
expect(isSameHost('', 'example.com')).toBe(false);
45+
});
46+
});
347

448
describe('socket-listeners', () => {
549
let mockNet;
@@ -100,6 +144,60 @@ describe('socket-listeners', () => {
100144
).toThrow('targetHost must be a different host');
101145
});
102146

147+
it('should throw if targetHost matches client with different case', () => {
148+
expect(() =>
149+
listeners.addSocketListener({
150+
port: 3000,
151+
targetHost: 'https://LOCAL.EXAMPLE.COM',
152+
})
153+
).toThrow('targetHost must be a different host');
154+
});
155+
156+
it('should throw if targetHost matches client with trailing dot', () => {
157+
expect(() =>
158+
listeners.addSocketListener({
159+
port: 3000,
160+
targetHost: 'https://local.example.com.',
161+
})
162+
).toThrow('targetHost must be a different host');
163+
});
164+
165+
it('should block localhost bypass via 127.0.0.1', () => {
166+
mockHsyncClient.myHostName = 'localhost';
167+
listeners = initListeners(mockHsyncClient);
168+
169+
expect(() =>
170+
listeners.addSocketListener({
171+
port: 3000,
172+
targetHost: 'https://127.0.0.1',
173+
})
174+
).toThrow('targetHost must be a different host');
175+
});
176+
177+
it('should block localhost bypass via IPv6 ::1', () => {
178+
mockHsyncClient.myHostName = 'localhost';
179+
listeners = initListeners(mockHsyncClient);
180+
181+
expect(() =>
182+
listeners.addSocketListener({
183+
port: 3000,
184+
targetHost: 'https://[::1]',
185+
})
186+
).toThrow('targetHost must be a different host');
187+
});
188+
189+
it('should block localhost bypass via 0.0.0.0', () => {
190+
mockHsyncClient.myHostName = '127.0.0.1';
191+
listeners = initListeners(mockHsyncClient);
192+
193+
expect(() =>
194+
listeners.addSocketListener({
195+
port: 3000,
196+
targetHost: 'https://0.0.0.0',
197+
})
198+
).toThrow('targetHost must be a different host');
199+
});
200+
103201
it('should clean trailing slash from targetHost', () => {
104202
const listener = listeners.addSocketListener({
105203
port: 3000,

0 commit comments

Comments
 (0)