Skip to content

Commit 0383412

Browse files
authored
Merge pull request #31 from monteslu/fix/security-web-handler-validation
fix(security): CVE-HSYNC-2026-001 - Add request validation and rate limiting
2 parents dfb147e + 55c747c commit 0383412

File tree

2 files changed

+542
-10
lines changed

2 files changed

+542
-10
lines changed

lib/web-handler.js

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,157 @@ export function setNet(netImpl) {
1111
net = netImpl;
1212
}
1313

14-
export function createWebHandler({ myHostName, localHost, port, mqConn }) {
14+
// Security defaults
15+
const DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
16+
const DEFAULT_MAX_CONCURRENT_SOCKETS = 100;
17+
const DEFAULT_RATE_LIMIT_WINDOW_MS = 1000; // 1 second
18+
const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 100; // per window
19+
20+
/**
21+
* Validates that a message looks like a valid HTTP request.
22+
* This is a basic sanity check for the HTTP proxy use case.
23+
* @param {Buffer} message - The message to validate
24+
* @returns {{ valid: boolean, reason?: string }}
25+
*/
26+
export function validateHttpRequest(message) {
27+
if (!Buffer.isBuffer(message)) {
28+
return { valid: false, reason: 'Message must be a Buffer' };
29+
}
30+
31+
if (message.length === 0) {
32+
return { valid: false, reason: 'Empty message' };
33+
}
34+
35+
// For HTTP requests, check for valid method at start
36+
const firstLine = message
37+
.slice(0, Math.min(message.length, 200))
38+
.toString('utf8')
39+
.split('\r\n')[0];
40+
41+
// Valid HTTP methods
42+
const httpMethods = [
43+
'GET',
44+
'POST',
45+
'PUT',
46+
'DELETE',
47+
'PATCH',
48+
'HEAD',
49+
'OPTIONS',
50+
'CONNECT',
51+
'TRACE',
52+
];
53+
const startsWithMethod = httpMethods.some((method) => firstLine.startsWith(method + ' '));
54+
55+
if (!startsWithMethod) {
56+
// Could be a continuation of a previous request (body data)
57+
// or websocket frame, which is valid for existing sockets
58+
return { valid: true, isInitialRequest: false };
59+
}
60+
61+
// Check for HTTP version
62+
if (!firstLine.includes('HTTP/')) {
63+
return { valid: false, reason: 'Invalid HTTP request line' };
64+
}
65+
66+
return { valid: true, isInitialRequest: true };
67+
}
68+
69+
/**
70+
* Simple rate limiter using sliding window
71+
*/
72+
class RateLimiter {
73+
constructor(windowMs, maxRequests) {
74+
this.windowMs = windowMs;
75+
this.maxRequests = maxRequests;
76+
this.requests = new Map(); // socketId -> [timestamps]
77+
}
78+
79+
isAllowed(socketId) {
80+
const now = Date.now();
81+
const windowStart = now - this.windowMs;
82+
83+
let timestamps = this.requests.get(socketId) || [];
84+
85+
// Remove old timestamps outside window
86+
timestamps = timestamps.filter((ts) => ts > windowStart);
87+
88+
if (timestamps.length >= this.maxRequests) {
89+
this.requests.set(socketId, timestamps);
90+
return false;
91+
}
92+
93+
timestamps.push(now);
94+
this.requests.set(socketId, timestamps);
95+
return true;
96+
}
97+
98+
cleanup() {
99+
const now = Date.now();
100+
const windowStart = now - this.windowMs;
101+
102+
for (const [socketId, timestamps] of this.requests.entries()) {
103+
const valid = timestamps.filter((ts) => ts > windowStart);
104+
if (valid.length === 0) {
105+
this.requests.delete(socketId);
106+
} else {
107+
this.requests.set(socketId, valid);
108+
}
109+
}
110+
}
111+
}
112+
113+
export function createWebHandler({
114+
myHostName,
115+
localHost,
116+
port,
117+
mqConn,
118+
// Security options
119+
maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE,
120+
maxConcurrentSockets = DEFAULT_MAX_CONCURRENT_SOCKETS,
121+
validateRequests = true,
122+
enableRateLimiting = true,
123+
rateLimitWindowMs = DEFAULT_RATE_LIMIT_WINDOW_MS,
124+
rateLimitMaxRequests = DEFAULT_RATE_LIMIT_MAX_REQUESTS,
125+
}) {
15126
const sockets = {};
127+
const rateLimiter = enableRateLimiting
128+
? new RateLimiter(rateLimitWindowMs, rateLimitMaxRequests)
129+
: null;
130+
131+
// Periodic cleanup of rate limiter state
132+
let cleanupInterval;
133+
if (rateLimiter) {
134+
cleanupInterval = setInterval(() => rateLimiter.cleanup(), 60000);
135+
// Don't block process exit
136+
if (cleanupInterval.unref) {
137+
cleanupInterval.unref();
138+
}
139+
}
16140

17141
function handleWebRequest(hostName, socketId, action, message) {
142+
// Security: Validate hostname matches
18143
if (hostName !== myHostName) {
19144
return; // why did this get sent to me?
20145
}
21146

147+
// Security: Validate socketId format (should be alphanumeric/dash)
148+
if (!socketId || !/^[\w-]+$/.test(socketId)) {
149+
debugError('Invalid socketId format:', socketId);
150+
return;
151+
}
152+
153+
// Security: Check rate limiting
154+
if (rateLimiter && !rateLimiter.isAllowed(socketId)) {
155+
debugError('Rate limit exceeded for socket:', socketId);
156+
return;
157+
}
158+
159+
// Security: Validate message size
160+
if (message && message.length > maxMessageSize) {
161+
debugError('Message exceeds max size:', message.length, '>', maxMessageSize);
162+
return;
163+
}
164+
22165
if (socketId) {
23166
let socket = sockets[socketId];
24167
if (action === 'close') {
@@ -29,6 +172,27 @@ export function createWebHandler({ myHostName, localHost, port, mqConn }) {
29172
}
30173
return;
31174
} else if (!socket) {
175+
// Security: Check concurrent socket limit
176+
const currentSocketCount = Object.keys(sockets).length;
177+
if (currentSocketCount >= maxConcurrentSockets) {
178+
debugError('Max concurrent sockets reached:', currentSocketCount);
179+
return;
180+
}
181+
182+
// Security: Validate initial HTTP request format
183+
if (validateRequests && message) {
184+
const validation = validateHttpRequest(message);
185+
if (!validation.valid) {
186+
debugError('Invalid request rejected:', validation.reason);
187+
return;
188+
}
189+
// For NEW sockets, we require a valid HTTP request (not continuation data)
190+
if (!validation.isInitialRequest) {
191+
debugError('Non-HTTP initial request rejected');
192+
return;
193+
}
194+
}
195+
32196
socket = new net.Socket();
33197
socket.socketId = socketId;
34198
sockets[socketId] = socket;
@@ -60,12 +224,24 @@ export function createWebHandler({ myHostName, localHost, port, mqConn }) {
60224
return;
61225
}
62226

227+
// Security: For existing sockets, still validate message size (already done above)
228+
// and check it's a Buffer
229+
if (!Buffer.isBuffer(message)) {
230+
debugError('Invalid message type for existing socket');
231+
return;
232+
}
233+
63234
debug('←', socketId, message.length);
64235
socket.write(message);
65236
}
66237
}
67238

68239
function end() {
240+
// Clear rate limiter cleanup interval
241+
if (cleanupInterval) {
242+
clearInterval(cleanupInterval);
243+
}
244+
69245
const sockKeys = Object.keys(sockets);
70246
sockKeys.forEach((sk) => {
71247
try {
@@ -77,9 +253,24 @@ export function createWebHandler({ myHostName, localHost, port, mqConn }) {
77253
});
78254
}
79255

256+
/**
257+
* Get current security stats for monitoring
258+
*/
259+
function getStats() {
260+
return {
261+
activeSockets: Object.keys(sockets).length,
262+
maxConcurrentSockets,
263+
maxMessageSize,
264+
rateLimitingEnabled: enableRateLimiting,
265+
};
266+
}
267+
80268
return {
81269
handleWebRequest,
82270
sockets,
83271
end,
272+
getStats,
273+
// Exported for testing
274+
validateHttpRequest,
84275
};
85276
}

0 commit comments

Comments
 (0)