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
53 changes: 52 additions & 1 deletion lib/socket-relays.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,54 @@ debugError.color = 1;

let net;

/**
* Check if a hostname matches a pattern (supports wildcards)
* @param {string} hostName - The hostname to check
* @param {string} pattern - Pattern to match (e.g., 'example.com', '*.example.com', '*')
* @returns {boolean}
*/
function matchHost(hostName, pattern) {
if (!hostName || !pattern) return false;
if (pattern === '*') return true;
if (pattern.startsWith('*.')) {
// Wildcard subdomain match: *.example.com matches foo.example.com
const suffix = pattern.slice(1); // .example.com
return hostName.endsWith(suffix) || hostName === pattern.slice(2);
}
return hostName === pattern;
}

/**
* Check if a host is allowed based on whitelist/blacklist
* @param {string} hostName - The hostname to check
* @param {string} whitelist - Comma-separated list of allowed hosts/patterns
* @param {string} blacklist - Comma-separated list of blocked hosts/patterns
* @returns {boolean}
*/
export function isHostAllowed(hostName, whitelist, blacklist) {
// If blacklist contains the host, reject
if (blacklist) {
const blacklisted = blacklist.split(',').map(h => h.trim()).filter(Boolean);
if (blacklisted.some(pattern => matchHost(hostName, pattern))) {
debug('host %s blocked by blacklist', hostName);
return false;
}
}

// If whitelist is set, host must be in it
if (whitelist) {
const whitelisted = whitelist.split(',').map(h => h.trim()).filter(Boolean);
const allowed = whitelisted.some(pattern => matchHost(hostName, pattern));
if (!allowed) {
debug('host %s not in whitelist', hostName);
}
return allowed;
}

// No restrictions - allow all
return true;
}

export function setNet(netImpl) {
net = netImpl;
}
Expand Down Expand Up @@ -51,7 +99,10 @@ export function initRelays(hsyncClient) {
throw new Error('no relay found for port: ' + port);
}

// TODO: check white and black lists on peer
// Check whitelist/blacklist before allowing connection
if (!isHostAllowed(peer.hostName, relay.whitelist, relay.blacklist)) {
throw new Error(`host ${peer.hostName} not allowed for relay on port ${port}`);
}

// const relayDataTopic = `msg/${hostName}/${hsyncClient.myHostName}/relayData/${socketId}`;
return new Promise((resolve, reject) => {
Expand Down
140 changes: 139 additions & 1 deletion test/unit/socket-relays.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { initRelays, setNet } from '../../lib/socket-relays.js';
import { initRelays, setNet, isHostAllowed } from '../../lib/socket-relays.js';
import { sockets } from '../../lib/socket-map.js';

describe('isHostAllowed', () => {
it('should allow all hosts when no whitelist or blacklist', () => {
expect(isHostAllowed('any.host.com', '', '')).toBe(true);
expect(isHostAllowed('any.host.com', null, null)).toBe(true);
expect(isHostAllowed('any.host.com', undefined, undefined)).toBe(true);
});

it('should block hosts in blacklist', () => {
expect(isHostAllowed('blocked.com', '', 'blocked.com')).toBe(false);
expect(isHostAllowed('allowed.com', '', 'blocked.com')).toBe(true);
});

it('should support comma-separated blacklist', () => {
const blacklist = 'bad1.com, bad2.com, bad3.com';
expect(isHostAllowed('bad1.com', '', blacklist)).toBe(false);
expect(isHostAllowed('bad2.com', '', blacklist)).toBe(false);
expect(isHostAllowed('good.com', '', blacklist)).toBe(true);
});

it('should only allow hosts in whitelist when set', () => {
expect(isHostAllowed('allowed.com', 'allowed.com', '')).toBe(true);
expect(isHostAllowed('other.com', 'allowed.com', '')).toBe(false);
});

it('should support comma-separated whitelist', () => {
const whitelist = 'good1.com, good2.com';
expect(isHostAllowed('good1.com', whitelist, '')).toBe(true);
expect(isHostAllowed('good2.com', whitelist, '')).toBe(true);
expect(isHostAllowed('bad.com', whitelist, '')).toBe(false);
});

it('should support wildcard * to match all', () => {
expect(isHostAllowed('any.host.com', '*', '')).toBe(true);
});

it('should support wildcard subdomain matching', () => {
expect(isHostAllowed('sub.example.com', '*.example.com', '')).toBe(true);
expect(isHostAllowed('deep.sub.example.com', '*.example.com', '')).toBe(true);
expect(isHostAllowed('example.com', '*.example.com', '')).toBe(true);
expect(isHostAllowed('other.com', '*.example.com', '')).toBe(false);
});

it('should check blacklist before whitelist', () => {
// If host is blacklisted, reject even if it would match whitelist
expect(isHostAllowed('blocked.com', '*', 'blocked.com')).toBe(false);
});

it('should handle empty host gracefully', () => {
expect(isHostAllowed('', 'allowed.com', '')).toBe(false);
expect(isHostAllowed(null, 'allowed.com', '')).toBe(false);
});
});

describe('socket-relays', () => {
let mockNet;
let mockSocket;
Expand Down Expand Up @@ -311,5 +364,90 @@ describe('socket-relays', () => {

await expect(connectPromise).rejects.toThrow('Connection failed');
});

it('should reject connection when host is blacklisted', () => {
relays.addSocketRelay({
port: 3000,
blacklist: 'blocked.example.com',
});

mockPeer.hostName = 'blocked.example.com';

expect(() =>
relays.connectSocket(mockPeer, {
port: 3000,
socketId: 'test-socket',
hostName: 'blocked.example.com',
})
).toThrow('host blocked.example.com not allowed for relay on port 3000');
});

it('should reject connection when host not in whitelist', () => {
relays.addSocketRelay({
port: 3000,
whitelist: 'allowed.example.com',
});

mockPeer.hostName = 'other.example.com';

expect(() =>
relays.connectSocket(mockPeer, {
port: 3000,
socketId: 'test-socket',
hostName: 'other.example.com',
})
).toThrow('host other.example.com not allowed for relay on port 3000');
});

it('should allow connection when host is whitelisted', async () => {
relays.addSocketRelay({
port: 3000,
whitelist: 'allowed.example.com',
});

mockPeer.hostName = 'allowed.example.com';

const result = await relays.connectSocket(mockPeer, {
port: 3000,
socketId: 'test-socket',
hostName: 'allowed.example.com',
});

expect(result.socketId).toBe('test-socket');
});

it('should allow connection when host not blacklisted', async () => {
relays.addSocketRelay({
port: 3000,
blacklist: 'blocked.example.com',
});

mockPeer.hostName = 'allowed.example.com';

const result = await relays.connectSocket(mockPeer, {
port: 3000,
socketId: 'test-socket',
hostName: 'allowed.example.com',
});

expect(result.socketId).toBe('test-socket');
});

it('should support wildcard whitelist patterns', async () => {
relays.addSocketRelay({
port: 3000,
whitelist: '*.trusted.com',
});

mockPeer.hostName = 'agent.trusted.com';

const result = await relays.connectSocket(mockPeer, {
port: 3000,
socketId: 'test-socket',
hostName: 'agent.trusted.com',
});

expect(result.socketId).toBe('test-socket');
});
});
});