tlsd 2.16.1 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rpc_static/rpc/index.html +30 -10
- package/rpc_static/rpc/rpc.js +28 -3
- package/tlsd.js +460 -17
package/package.json
CHANGED
|
@@ -36,11 +36,16 @@
|
|
|
36
36
|
.textarea-group:first-child {
|
|
37
37
|
position: relative;
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
.textarea-group-head {
|
|
41
|
+
display: flex;
|
|
42
|
+
justify-content: space-between;
|
|
43
|
+
}
|
|
39
44
|
|
|
40
45
|
.send-button {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
zposition: absolute;
|
|
47
|
+
ztop: 0;
|
|
48
|
+
zright: 0;
|
|
44
49
|
background-color: #007bff;
|
|
45
50
|
color: white;
|
|
46
51
|
border: none;
|
|
@@ -94,8 +99,13 @@
|
|
|
94
99
|
<body>
|
|
95
100
|
<div class="grid-container">
|
|
96
101
|
<div class="textarea-group">
|
|
97
|
-
<
|
|
98
|
-
|
|
102
|
+
<div class="textarea-group-head">
|
|
103
|
+
<label class="textarea-label">Message Data</label>
|
|
104
|
+
<div>
|
|
105
|
+
<button class="send-button" id="send_auto">Send</button>
|
|
106
|
+
<button class="send-button" id="send_post">POST</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
99
109
|
<textarea class="textarea-input" id="e_input" ></textarea>
|
|
100
110
|
</div>
|
|
101
111
|
<div class="textarea-group">
|
|
@@ -115,14 +125,15 @@
|
|
|
115
125
|
<script>
|
|
116
126
|
|
|
117
127
|
// Get the send button and first textarea
|
|
128
|
+
const sendButtonAuto = document.querySelector('#send_auto');
|
|
129
|
+
const sendButtonPOST = document.querySelector('#send_post');
|
|
118
130
|
const sendButton = document.querySelector('.send-button');
|
|
119
131
|
const e_input = document.querySelector('#e_input');
|
|
120
132
|
const e_preview = document.querySelector('#e_preview');
|
|
121
133
|
const e_sent = document.querySelector('#e_sent');
|
|
122
134
|
const e_received = document.querySelector('#e_received');
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
sendButton.addEventListener('click', function() {
|
|
135
|
+
|
|
136
|
+
function send( post = false ) {
|
|
126
137
|
// Get the content from the first textarea
|
|
127
138
|
const textareaContent = e_input.value;
|
|
128
139
|
|
|
@@ -139,13 +150,22 @@
|
|
|
139
150
|
|
|
140
151
|
e_received.value = "...";
|
|
141
152
|
|
|
142
|
-
|
|
153
|
+
let fn = post ? RPC.POST : RPC;
|
|
154
|
+
|
|
155
|
+
fn( data, function( rsp ) {
|
|
143
156
|
e_received.value = o2j( rsp, null, 2 );
|
|
144
157
|
}, function( err ) {
|
|
145
158
|
e_received.value = "ERROR:\n" + o2j( err, null, 2 );
|
|
146
159
|
});
|
|
147
|
-
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
sendButtonAuto.addEventListener('click', function() {
|
|
163
|
+
send( false );
|
|
148
164
|
});
|
|
165
|
+
sendButtonPOST.addEventListener('click', function() {
|
|
166
|
+
send( true );
|
|
167
|
+
});
|
|
168
|
+
|
|
149
169
|
|
|
150
170
|
// Load content from local storage when page loads
|
|
151
171
|
window.addEventListener('pageshow', function() {
|
package/rpc_static/rpc/rpc.js
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
const o2j = function( v ) { try { return JSON.stringify( v, null, 2 ) } catch( e ) { return null } }
|
|
24
24
|
const time = function( dt ) { return Math.round( ( new Date() ).getTime() / 1000 ); }
|
|
25
25
|
|
|
26
|
+
const getCookie = function( name ) {
|
|
27
|
+
const match = document.cookie.match( new RegExp( "(^| )" + name + "=([^;]+)" ) );
|
|
28
|
+
return match ? match[ 2 ] : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
26
31
|
|
|
27
32
|
let DBG = function( ...args ) {
|
|
28
33
|
if( RPC.debug ) {
|
|
@@ -95,7 +100,7 @@
|
|
|
95
100
|
|
|
96
101
|
const waiting = new WaitList()
|
|
97
102
|
|
|
98
|
-
const send = function(m, cb, fail) {
|
|
103
|
+
const send = async function(m, cb, fail) {
|
|
99
104
|
if(m.msg_id === undefined) {
|
|
100
105
|
m.msg_id = "CMID-" + next_seq(); // every message must have an id
|
|
101
106
|
}
|
|
@@ -105,8 +110,24 @@
|
|
|
105
110
|
waiting.ins( { msg: m, cb, fail }, m.msg_id );
|
|
106
111
|
}
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
const MAX_WAIT_TIME = 10000; // 10 seconds
|
|
114
|
+
const startTime = Date.now();
|
|
115
|
+
while(socket.readyState !== WebSocket.OPEN) {
|
|
116
|
+
if(Date.now() - startTime >= MAX_WAIT_TIME) {
|
|
117
|
+
if(fail) fail("WebSocket connection timeout after " + MAX_WAIT_TIME + "ms");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
DBG("Waiting for socket to be open", socket.readyState, MAX_WAIT_TIME - (Date.now() - startTime));
|
|
121
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
DBG(">>--->", m, socket.readyState );
|
|
126
|
+
socket.send( o2j( m ) );
|
|
127
|
+
} catch(error) {
|
|
128
|
+
DBG("Send error:", error);
|
|
129
|
+
if(fail) fail(error);
|
|
130
|
+
}
|
|
110
131
|
}
|
|
111
132
|
|
|
112
133
|
var loc = document.location
|
|
@@ -222,6 +243,10 @@
|
|
|
222
243
|
xhr.open( "POST", RPC_URL );
|
|
223
244
|
xhr.setRequestHeader( "Content-Type", "application/json" );
|
|
224
245
|
xhr.setRequestHeader( "Accept", "application/json" );
|
|
246
|
+
const csrfToken = getCookie( "csrf_token" );
|
|
247
|
+
if( csrfToken ) {
|
|
248
|
+
xhr.setRequestHeader( "X-CSRF-Token", csrfToken );
|
|
249
|
+
}
|
|
225
250
|
xhr.send( o2j( msg ) );
|
|
226
251
|
};
|
|
227
252
|
|
package/tlsd.js
CHANGED
|
@@ -10,7 +10,6 @@ const serveStatic = require( "serve-static" );
|
|
|
10
10
|
|
|
11
11
|
const body_parser = require( "body-parser" ).json( { limit: "10mb" } );
|
|
12
12
|
const compression = require( "compression" )();
|
|
13
|
-
const cors = require( "cors" )();
|
|
14
13
|
const queryString = require( "querystring" );
|
|
15
14
|
|
|
16
15
|
const { log5, o2j, j2o, toInt, is_dir, sha1 } = require( "sleepless" );
|
|
@@ -21,8 +20,40 @@ const { D, V, I, W, E } = L;
|
|
|
21
20
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/tmp/tlsd-put-files/";
|
|
22
21
|
const UPLOAD_MAX_BYTES = toInt( process.env.UPLOAD_MAX_BYTES ) || ( 200 * 1024 * 1024 );
|
|
23
22
|
|
|
23
|
+
// DOS protection configuration
|
|
24
|
+
const MAX_RPC_MESSAGE_SIZE = toInt( process.env.MAX_RPC_MESSAGE_SIZE ) || ( 1024 * 1024 ); // 1MB default
|
|
25
|
+
const MAX_WS_CONNECTIONS = toInt( process.env.MAX_WS_CONNECTIONS ) || 1000;
|
|
26
|
+
const MAX_CONNECTIONS_PER_IP = toInt( process.env.MAX_CONNECTIONS_PER_IP ) || 50;
|
|
27
|
+
const MAX_CONNECTIONS_TOTAL = toInt( process.env.MAX_CONNECTIONS_TOTAL ) || 1000;
|
|
28
|
+
const REQUEST_TIMEOUT_MS = toInt( process.env.REQUEST_TIMEOUT_MS ) || 30000; // 30 seconds
|
|
29
|
+
const SERVER_TIMEOUT_MS = toInt( process.env.SERVER_TIMEOUT_MS ) || 30000; // 30 seconds
|
|
30
|
+
const KEEP_ALIVE_TIMEOUT_MS = toInt( process.env.KEEP_ALIVE_TIMEOUT_MS ) || 65000; // 65 seconds
|
|
31
|
+
const HEADERS_TIMEOUT_MS = toInt( process.env.HEADERS_TIMEOUT_MS ) || 66000; // 66 seconds
|
|
32
|
+
const RATE_LIMIT_WINDOW_MS = toInt( process.env.RATE_LIMIT_WINDOW_MS ) || ( 15 * 60 * 1000 ); // 15 minutes
|
|
33
|
+
const RATE_LIMIT_MAX_REQUESTS = toInt( process.env.RATE_LIMIT_MAX_REQUESTS ) || 1000; // per window
|
|
34
|
+
|
|
24
35
|
let dev_mode = false;
|
|
25
36
|
|
|
37
|
+
// Connection tracking for DOS protection
|
|
38
|
+
const ipConnections = new Map();
|
|
39
|
+
let totalConnections = 0;
|
|
40
|
+
let activeWSConnections = 0;
|
|
41
|
+
|
|
42
|
+
// Rate limiting storage: Map<ip, {count: number, resetTime: number}>
|
|
43
|
+
const rateLimitStore = new Map();
|
|
44
|
+
|
|
45
|
+
function cleanupRateLimitStore() {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
for( const [ ip, data ] of rateLimitStore.entries() ) {
|
|
48
|
+
if( now > data.resetTime ) {
|
|
49
|
+
rateLimitStore.delete( ip );
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Clean up rate limit store every minute
|
|
55
|
+
setInterval( cleanupRateLimitStore, 60 * 1000 );
|
|
56
|
+
|
|
26
57
|
|
|
27
58
|
function usage() {
|
|
28
59
|
const base = path.basename( module.filename );
|
|
@@ -45,10 +76,112 @@ function next_seq() {
|
|
|
45
76
|
}
|
|
46
77
|
|
|
47
78
|
|
|
79
|
+
function generate_csrf_token() {
|
|
80
|
+
return sha1( "" + ( Date.now() + Math.random() ) );
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
function parse_cookies( cookie_header ) {
|
|
85
|
+
const cookies = {};
|
|
86
|
+
if( ! cookie_header ) {
|
|
87
|
+
return cookies;
|
|
88
|
+
}
|
|
89
|
+
cookie_header.split( ";" ).forEach( part => {
|
|
90
|
+
const pieces = part.split( "=" );
|
|
91
|
+
const name = pieces.shift().trim();
|
|
92
|
+
if( ! name ) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const value = pieces.join( "=" ).trim();
|
|
96
|
+
const unquoted = value && value[ 0 ] == "\"" && value[ value.length - 1 ] == "\""
|
|
97
|
+
? value.slice( 1, -1 )
|
|
98
|
+
: value;
|
|
99
|
+
cookies[ name ] = decodeURIComponent( unquoted || "" );
|
|
100
|
+
} );
|
|
101
|
+
return cookies;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
function add_set_cookie_header( res, cookie_str ) {
|
|
106
|
+
const existing = res.getHeader( "Set-Cookie" );
|
|
107
|
+
if( ! existing ) {
|
|
108
|
+
res.setHeader( "Set-Cookie", [ cookie_str ] );
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if( Array.isArray( existing ) ) {
|
|
112
|
+
res.setHeader( "Set-Cookie", existing.concat( cookie_str ) );
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.setHeader( "Set-Cookie", [ existing, cookie_str ] );
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
function build_cookie_string( name, value, options ) {
|
|
120
|
+
const opts = options || {};
|
|
121
|
+
const bits = [];
|
|
122
|
+
bits.push( name + "=" + encodeURIComponent( value || "" ) );
|
|
123
|
+
if( opts.domain ) {
|
|
124
|
+
bits.push( "Domain=" + opts.domain );
|
|
125
|
+
}
|
|
126
|
+
if( opts.path || opts.path === "" ) {
|
|
127
|
+
bits.push( "Path=" + opts.path );
|
|
128
|
+
}
|
|
129
|
+
if( opts.maxAge !== undefined && opts.maxAge !== null ) {
|
|
130
|
+
bits.push( "Max-Age=" + toInt( opts.maxAge ) );
|
|
131
|
+
}
|
|
132
|
+
if( opts.sameSite ) {
|
|
133
|
+
bits.push( "SameSite=" + opts.sameSite );
|
|
134
|
+
}
|
|
135
|
+
if( opts.expires ) {
|
|
136
|
+
bits.push( "Expires=" + opts.expires );
|
|
137
|
+
}
|
|
138
|
+
if( opts.httpOnly ) {
|
|
139
|
+
bits.push( "HttpOnly" );
|
|
140
|
+
}
|
|
141
|
+
if( opts.secure ) {
|
|
142
|
+
bits.push( "Secure" );
|
|
143
|
+
}
|
|
144
|
+
return bits.join( "; " );
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
function make_cookie_setters( res ) {
|
|
149
|
+
if( ! res ) {
|
|
150
|
+
return {
|
|
151
|
+
setCookie: function( ) {
|
|
152
|
+
W( "setCookie ignored: no response available" );
|
|
153
|
+
},
|
|
154
|
+
clearCookie: function( ) {
|
|
155
|
+
W( "clearCookie ignored: no response available" );
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const setCookie = function( name, value, options ) {
|
|
161
|
+
const cookie_str = build_cookie_string( name, value, options );
|
|
162
|
+
add_set_cookie_header( res, cookie_str );
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const clearCookie = function( name, options ) {
|
|
166
|
+
const opts = options ? Object.assign( {}, options ) : {};
|
|
167
|
+
if( ! opts.expires ) {
|
|
168
|
+
opts.expires = "Thu, 01 Jan 1970 00:00:00 GMT";
|
|
169
|
+
}
|
|
170
|
+
opts.maxAge = 0;
|
|
171
|
+
const cookie_str = build_cookie_string( name, "", opts );
|
|
172
|
+
add_set_cookie_header( res, cookie_str );
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return { setCookie, clearCookie };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
48
179
|
// Handles incoming RPC messages.
|
|
49
180
|
// Load order for RPC handlers under the provided root:
|
|
50
|
-
// 1) Tries explicit CommonJS module at "root/
|
|
51
|
-
// 2) Falls back to legacy directory loader at "root/
|
|
181
|
+
// 1) Tries explicit CommonJS module at "root/server/index.cjs"
|
|
182
|
+
// 2) Falls back to legacy directory loader at "root/server"
|
|
183
|
+
// 3) Falls back to explicit CommonJS module at "root/rpc/index.cjs"
|
|
184
|
+
// 4) Falls back to legacy directory loader at "root/rpc"
|
|
52
185
|
// If loading succeeds, passes the message to the handler.
|
|
53
186
|
// context may be one of:
|
|
54
187
|
// { transport_type: "WS", connection: { ... } }
|
|
@@ -68,14 +201,22 @@ function rpc_handler( root, msg, context, _okay, _fail ) {
|
|
|
68
201
|
why = new Error( why );
|
|
69
202
|
}
|
|
70
203
|
E( root + ": " + why.stack );
|
|
71
|
-
_fail();
|
|
204
|
+
_fail( why );
|
|
72
205
|
};
|
|
73
206
|
|
|
207
|
+
// Check RPC message size
|
|
208
|
+
const msgSize = JSON.stringify( msg ).length;
|
|
209
|
+
if( msgSize > MAX_RPC_MESSAGE_SIZE ) {
|
|
210
|
+
W( "RPC message too large: " + msgSize + " bytes (max: " + MAX_RPC_MESSAGE_SIZE + ")" );
|
|
211
|
+
fail( new Error( "RPC message too large" ) );
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
74
215
|
context.upload_dir = UPLOAD_DIR;
|
|
75
216
|
|
|
76
217
|
try {
|
|
77
|
-
// try loading explicit common js module
|
|
78
|
-
const mod_path = root + "/
|
|
218
|
+
// try loading explicit common js module from server folder
|
|
219
|
+
const mod_path = root + "/server/index.cjs";
|
|
79
220
|
const mod = require( mod_path );
|
|
80
221
|
try {
|
|
81
222
|
mod( msg, okay, fail, context );
|
|
@@ -85,9 +226,9 @@ function rpc_handler( root, msg, context, _okay, _fail ) {
|
|
|
85
226
|
|
|
86
227
|
} catch( err ) {
|
|
87
228
|
|
|
88
|
-
// try loading it the old way using the dir
|
|
229
|
+
// try loading it the old way using the server dir
|
|
89
230
|
try {
|
|
90
|
-
const mod_path = root + "/
|
|
231
|
+
const mod_path = root + "/server";
|
|
91
232
|
const mod = require( mod_path );
|
|
92
233
|
try {
|
|
93
234
|
mod( msg, okay, fail, context );
|
|
@@ -96,7 +237,35 @@ function rpc_handler( root, msg, context, _okay, _fail ) {
|
|
|
96
237
|
}
|
|
97
238
|
|
|
98
239
|
} catch( err ) {
|
|
99
|
-
|
|
240
|
+
|
|
241
|
+
// fall back to rpc folder - try explicit common js module
|
|
242
|
+
try {
|
|
243
|
+
const mod_path = root + "/rpc/index.cjs";
|
|
244
|
+
const mod = require( mod_path );
|
|
245
|
+
try {
|
|
246
|
+
mod( msg, okay, fail, context );
|
|
247
|
+
} catch( err ) {
|
|
248
|
+
fail( err );
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
} catch( err ) {
|
|
252
|
+
|
|
253
|
+
// fall back to rpc folder - try loading it the old way using the dir
|
|
254
|
+
try {
|
|
255
|
+
const mod_path = root + "/rpc";
|
|
256
|
+
const mod = require( mod_path );
|
|
257
|
+
try {
|
|
258
|
+
mod( msg, okay, fail, context );
|
|
259
|
+
} catch( err ) {
|
|
260
|
+
fail( err );
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
} catch( err ) {
|
|
264
|
+
fail( err );
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
}
|
|
268
|
+
|
|
100
269
|
}
|
|
101
270
|
|
|
102
271
|
}
|
|
@@ -106,7 +275,10 @@ function rpc_handler( root, msg, context, _okay, _fail ) {
|
|
|
106
275
|
// Glue function to call rpc handler module and then return response
|
|
107
276
|
// via the websockets msg object
|
|
108
277
|
function ws_msg_handler( root, msg, host, connection ) {
|
|
109
|
-
|
|
278
|
+
const { setCookie, clearCookie } = make_cookie_setters();
|
|
279
|
+
const cookies = connection.cookies || {};
|
|
280
|
+
|
|
281
|
+
rpc_handler( root, msg.msg, { transport_type: "WS", host, connection, cookies, setCookie, clearCookie }, data => {
|
|
110
282
|
msg.reply( data );
|
|
111
283
|
}, err => {
|
|
112
284
|
msg.error( err || "Unspecified Error" );
|
|
@@ -121,6 +293,75 @@ function populate_query( req, res, next ) {
|
|
|
121
293
|
}
|
|
122
294
|
|
|
123
295
|
|
|
296
|
+
// Rate limiting middleware
|
|
297
|
+
function rate_limit( req, res, next ) {
|
|
298
|
+
const ip = req.socket.remoteAddress || "unknown";
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
|
|
301
|
+
let limitData = rateLimitStore.get( ip );
|
|
302
|
+
if( ! limitData || now > limitData.resetTime ) {
|
|
303
|
+
limitData = { count: 0, resetTime: now + RATE_LIMIT_WINDOW_MS };
|
|
304
|
+
rateLimitStore.set( ip, limitData );
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
limitData.count++;
|
|
308
|
+
|
|
309
|
+
if( limitData.count > RATE_LIMIT_MAX_REQUESTS ) {
|
|
310
|
+
W( "Rate limit exceeded for IP: " + ip + " (" + limitData.count + " requests)" );
|
|
311
|
+
res.writeHead( 429, {
|
|
312
|
+
"Content-Type": "application/json",
|
|
313
|
+
"Retry-After": Math.ceil( ( limitData.resetTime - now ) / 1000 ),
|
|
314
|
+
} );
|
|
315
|
+
res.write( o2j( { error: "Too many requests" } ) );
|
|
316
|
+
res.end();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
next();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
// Request timeout middleware
|
|
325
|
+
function timeout_middleware( req, res, next ) {
|
|
326
|
+
const timeout = setTimeout( function( ) {
|
|
327
|
+
if( ! res.headersSent ) {
|
|
328
|
+
W( "Request timeout for " + req.method + " " + req.url );
|
|
329
|
+
res.writeHead( 408, { "Content-Type": "application/json" } );
|
|
330
|
+
res.write( o2j( { error: "Request timeout" } ) );
|
|
331
|
+
res.end();
|
|
332
|
+
}
|
|
333
|
+
}, REQUEST_TIMEOUT_MS );
|
|
334
|
+
|
|
335
|
+
res.on( "finish", function( ) {
|
|
336
|
+
clearTimeout( timeout );
|
|
337
|
+
} );
|
|
338
|
+
|
|
339
|
+
next();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
// Per-IP connection limit check
|
|
344
|
+
function check_ip_connection_limit( ip ) {
|
|
345
|
+
const count = ipConnections.get( ip ) || 0;
|
|
346
|
+
if( count >= MAX_CONNECTIONS_PER_IP ) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
ipConnections.set( ip, count + 1 );
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
function release_ip_connection( ip ) {
|
|
355
|
+
const count = ipConnections.get( ip ) || 0;
|
|
356
|
+
if( count > 0 ) {
|
|
357
|
+
ipConnections.set( ip, count - 1 );
|
|
358
|
+
if( count === 1 ) {
|
|
359
|
+
ipConnections.delete( ip );
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
124
365
|
// Simple logging handler
|
|
125
366
|
function logger( req, res, next ) {
|
|
126
367
|
const host = req.headers[ "host" ];
|
|
@@ -131,6 +372,92 @@ function logger( req, res, next ) {
|
|
|
131
372
|
}
|
|
132
373
|
|
|
133
374
|
|
|
375
|
+
// CSRF protection middleware using double-submit cookie pattern
|
|
376
|
+
function csrf_protection( root ) {
|
|
377
|
+
return function( req, res, next ) {
|
|
378
|
+
const cookies = parse_cookies( req.headers[ "cookie" ] );
|
|
379
|
+
const { setCookie } = make_cookie_setters( res );
|
|
380
|
+
const host = req.headers[ "host" ];
|
|
381
|
+
const method = req.method;
|
|
382
|
+
const url = req.url.split( "?" ).shift();
|
|
383
|
+
|
|
384
|
+
// Generate CSRF token if it doesn't exist
|
|
385
|
+
if( ! cookies.csrf_token ) {
|
|
386
|
+
const token = generate_csrf_token();
|
|
387
|
+
const cookieOptions = {
|
|
388
|
+
sameSite: "Strict",
|
|
389
|
+
path: "/",
|
|
390
|
+
maxAge: 86400 * 365, // 1 year
|
|
391
|
+
};
|
|
392
|
+
// Set Secure flag in TLS mode (when not in dev mode)
|
|
393
|
+
if( ! dev_mode ) {
|
|
394
|
+
cookieOptions.secure = true;
|
|
395
|
+
}
|
|
396
|
+
setCookie( "csrf_token", token, cookieOptions );
|
|
397
|
+
cookies.csrf_token = token;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Skip validation for GET requests, WebSocket upgrades, and static files
|
|
401
|
+
if( method === "GET" ) {
|
|
402
|
+
next();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Only validate POST and PUT requests to /rpc or any PUT request
|
|
407
|
+
const is_rpc = ( url === "/rpc" || url === "/rpc/" );
|
|
408
|
+
const is_put = ( method === "PUT" );
|
|
409
|
+
|
|
410
|
+
if( ! is_rpc && ! is_put ) {
|
|
411
|
+
next();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Validate CSRF token (double-submit cookie pattern)
|
|
416
|
+
const cookieToken = cookies.csrf_token;
|
|
417
|
+
const headerToken = req.headers[ "x-csrf-token" ];
|
|
418
|
+
|
|
419
|
+
if( ! cookieToken || ! headerToken || cookieToken !== headerToken ) {
|
|
420
|
+
W( "CSRF token validation failed for " + method + " " + url );
|
|
421
|
+
res.writeHead( 403, {
|
|
422
|
+
"Content-Type": "application/json",
|
|
423
|
+
"Cache-Control": "no-store",
|
|
424
|
+
} );
|
|
425
|
+
res.write( o2j( { error: "CSRF token validation failed" } ) );
|
|
426
|
+
res.end();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Additional Origin/Referer validation
|
|
431
|
+
const origin = req.headers[ "origin" ];
|
|
432
|
+
const referer = req.headers[ "referer" ];
|
|
433
|
+
let originValid = false;
|
|
434
|
+
|
|
435
|
+
if( origin ) {
|
|
436
|
+
// Check if origin matches host (with protocol)
|
|
437
|
+
const expectedOrigin = ( dev_mode ? "http://" : "https://" ) + host;
|
|
438
|
+
originValid = origin === expectedOrigin;
|
|
439
|
+
} else if( referer ) {
|
|
440
|
+
// Fall back to Referer if Origin is missing
|
|
441
|
+
const expectedReferer = ( dev_mode ? "http://" : "https://" ) + host;
|
|
442
|
+
originValid = referer.startsWith( expectedReferer );
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if( ! originValid ) {
|
|
446
|
+
W( "CSRF Origin/Referer validation failed for " + method + " " + url + " origin: " + origin + " referer: " + referer );
|
|
447
|
+
res.writeHead( 403, {
|
|
448
|
+
"Content-Type": "application/json",
|
|
449
|
+
"Cache-Control": "no-store",
|
|
450
|
+
} );
|
|
451
|
+
res.write( o2j( { error: "CSRF token validation failed" } ) );
|
|
452
|
+
res.end();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
next();
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
134
461
|
// Creates and returns a handler that intercepts and services requests to
|
|
135
462
|
// the "/rpc" endpoint. In TLS mode only POST is allowed; in dev mode
|
|
136
463
|
// GET is also allowed with query-string payloads.
|
|
@@ -160,7 +487,16 @@ function rpc_post( root ) {
|
|
|
160
487
|
D( method + " >------> " + o2j( input, null, 2 ) );
|
|
161
488
|
|
|
162
489
|
// Summon the rpc handler for the domain root.
|
|
163
|
-
|
|
490
|
+
const cookies = parse_cookies( req.headers[ "cookie" ] );
|
|
491
|
+
const { setCookie, clearCookie } = make_cookie_setters( res );
|
|
492
|
+
|
|
493
|
+
rpc_handler( root, input, {
|
|
494
|
+
transport_type: method,
|
|
495
|
+
connection: { host: req.headers[ "host" ], req, res, },
|
|
496
|
+
cookies,
|
|
497
|
+
setCookie,
|
|
498
|
+
clearCookie,
|
|
499
|
+
} , output => {
|
|
164
500
|
res.writeHead( 200, {
|
|
165
501
|
"Content-Type": "application/json",
|
|
166
502
|
"Cache-Control": "no-store",
|
|
@@ -168,11 +504,13 @@ function rpc_post( root ) {
|
|
|
168
504
|
D( method + " <------< " + o2j( output, null, 2 ) );
|
|
169
505
|
res.write( o2j( output ) );
|
|
170
506
|
res.end();
|
|
171
|
-
}, () => {
|
|
507
|
+
}, ( err ) => {
|
|
508
|
+
const error_message = err && err.message ? err.message : ( err || "Internal server error" );
|
|
172
509
|
res.writeHead( 500, {
|
|
173
510
|
"Content-Type": "application/json",
|
|
174
511
|
"Cache-Control": "no-store",
|
|
175
512
|
} );
|
|
513
|
+
res.write( o2j( { error: error_message } ) );
|
|
176
514
|
res.end();
|
|
177
515
|
} );
|
|
178
516
|
};
|
|
@@ -290,11 +628,13 @@ function put_handler( req, res, next ) {
|
|
|
290
628
|
// default (basic) functionality.
|
|
291
629
|
function basic_handler( root ) {
|
|
292
630
|
const app = connect();
|
|
631
|
+
app.use( timeout_middleware );
|
|
632
|
+
app.use( rate_limit );
|
|
293
633
|
app.use( body_parser );
|
|
294
634
|
app.use( compression );
|
|
295
|
-
app.use( cors ); // allow requests from other domains
|
|
296
635
|
app.use( populate_query );
|
|
297
636
|
app.use( logger );
|
|
637
|
+
app.use( csrf_protection( root ) );
|
|
298
638
|
app.use( put_handler )
|
|
299
639
|
app.use( rpc_post( root ) );
|
|
300
640
|
app.use( rpc_static );
|
|
@@ -310,7 +650,39 @@ const cached_basic_handlers = {};
|
|
|
310
650
|
// Handle REST calls (as opposed to websocket messages)
|
|
311
651
|
function rest_handler( root, req, rsp ) {
|
|
312
652
|
|
|
313
|
-
const remote_ip = req.socket.remoteAddress;
|
|
653
|
+
const remote_ip = req.socket.remoteAddress || "unknown";
|
|
654
|
+
|
|
655
|
+
// Check total connection limit
|
|
656
|
+
if( totalConnections >= MAX_CONNECTIONS_TOTAL ) {
|
|
657
|
+
W( "Max connections exceeded: " + totalConnections );
|
|
658
|
+
rsp.writeHead( 503, { "Content-Type": "application/json" } );
|
|
659
|
+
rsp.write( o2j( { error: "Service temporarily unavailable" } ) );
|
|
660
|
+
rsp.end();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Check per-IP connection limit
|
|
665
|
+
if( ! check_ip_connection_limit( remote_ip ) ) {
|
|
666
|
+
W( "Max connections per IP exceeded for: " + remote_ip );
|
|
667
|
+
rsp.writeHead( 503, { "Content-Type": "application/json" } );
|
|
668
|
+
rsp.write( o2j( { error: "Too many connections from this IP" } ) );
|
|
669
|
+
rsp.end();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
totalConnections++;
|
|
674
|
+
|
|
675
|
+
// Release connection tracking on response finish
|
|
676
|
+
rsp.on( "finish", function( ) {
|
|
677
|
+
totalConnections--;
|
|
678
|
+
release_ip_connection( remote_ip );
|
|
679
|
+
} );
|
|
680
|
+
|
|
681
|
+
rsp.on( "close", function( ) {
|
|
682
|
+
totalConnections--;
|
|
683
|
+
release_ip_connection( remote_ip );
|
|
684
|
+
} );
|
|
685
|
+
|
|
314
686
|
I( remote_ip + " " + req.headers[ "host" ] + ": " + req.method + " " + req.url );
|
|
315
687
|
D( "rest_handler root: " + root );
|
|
316
688
|
|
|
@@ -357,9 +729,50 @@ function ws_attach( server, msg_handler ) {
|
|
|
357
729
|
|
|
358
730
|
V( "WS: connection request from "+wsreq.remoteAddress+" "+wsreq.resource )
|
|
359
731
|
|
|
732
|
+
const remote_ip = wsreq.remoteAddress || "unknown";
|
|
733
|
+
|
|
734
|
+
// Check WebSocket connection limit
|
|
735
|
+
if( activeWSConnections >= MAX_WS_CONNECTIONS ) {
|
|
736
|
+
W( "Max WebSocket connections exceeded: " + activeWSConnections );
|
|
737
|
+
wsreq.reject( 503, "Too many WebSocket connections" );
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Check per-IP connection limit
|
|
742
|
+
if( ! check_ip_connection_limit( remote_ip ) ) {
|
|
743
|
+
W( "Max connections per IP exceeded for WebSocket: " + remote_ip );
|
|
744
|
+
wsreq.reject( 503, "Too many connections from this IP" );
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Check total connection limit
|
|
749
|
+
if( totalConnections >= MAX_CONNECTIONS_TOTAL ) {
|
|
750
|
+
W( "Max total connections exceeded: " + totalConnections );
|
|
751
|
+
wsreq.reject( 503, "Service temporarily unavailable" );
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
activeWSConnections++;
|
|
756
|
+
totalConnections++;
|
|
757
|
+
|
|
360
758
|
const host = wsreq.httpRequest.headers[ "host" ];
|
|
759
|
+
const cookies = parse_cookies( wsreq.httpRequest.headers[ "cookie" ] );
|
|
760
|
+
const origin = wsreq.origin;
|
|
761
|
+
|
|
762
|
+
// Validate WebSocket origin matches host
|
|
763
|
+
if( origin ) {
|
|
764
|
+
const expectedOrigin = ( dev_mode ? "http://" : "https://" ) + host;
|
|
765
|
+
if( origin !== expectedOrigin ) {
|
|
766
|
+
W( "WS: Origin validation failed: " + origin + " expected: " + expectedOrigin );
|
|
767
|
+
activeWSConnections--;
|
|
768
|
+
totalConnections--;
|
|
769
|
+
release_ip_connection( remote_ip );
|
|
770
|
+
wsreq.reject( 403, "Origin validation failed" );
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
361
774
|
|
|
362
|
-
const socket = wsreq.accept( null,
|
|
775
|
+
const socket = wsreq.accept( null, origin || host );
|
|
363
776
|
|
|
364
777
|
const name = "ws-conn-" + next_seq(); // XXX just use the websocket id
|
|
365
778
|
|
|
@@ -373,20 +786,33 @@ function ws_attach( server, msg_handler ) {
|
|
|
373
786
|
socket.send( o2j( msg ) );
|
|
374
787
|
};
|
|
375
788
|
|
|
376
|
-
const conn = { name, socket, send };
|
|
789
|
+
const conn = { name, socket, send, cookies };
|
|
377
790
|
|
|
378
791
|
socket.on( "error", function( err ) {
|
|
379
792
|
E( "WS: error", err.stack || err );
|
|
793
|
+
activeWSConnections--;
|
|
794
|
+
totalConnections--;
|
|
795
|
+
release_ip_connection( remote_ip );
|
|
380
796
|
});
|
|
381
797
|
|
|
382
798
|
socket.on("close", function() {
|
|
383
799
|
D( "WS: disconnect" );
|
|
800
|
+
activeWSConnections--;
|
|
801
|
+
totalConnections--;
|
|
802
|
+
release_ip_connection( remote_ip );
|
|
384
803
|
});
|
|
385
804
|
|
|
386
805
|
// incoming msgs from client come through here
|
|
387
806
|
socket.on( "message", function( x ) {
|
|
388
807
|
D( "WS >------> "+x.utf8Data );
|
|
389
808
|
|
|
809
|
+
// Check WebSocket message size
|
|
810
|
+
if( x.utf8Data && x.utf8Data.length > MAX_RPC_MESSAGE_SIZE ) {
|
|
811
|
+
W( "WS message too large: " + x.utf8Data.length + " bytes (max: " + MAX_RPC_MESSAGE_SIZE + ")" );
|
|
812
|
+
socket.close( 1009, "Message too large" );
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
390
816
|
const json = x.utf8Data; // raw message is a utf8 string
|
|
391
817
|
const msg_in = j2o( json );
|
|
392
818
|
if( msg_in == null ) {
|
|
@@ -433,7 +859,9 @@ if( argv.length == 2 ) {
|
|
|
433
859
|
|
|
434
860
|
L( toInt( VERBOSITY ) );
|
|
435
861
|
|
|
862
|
+
const pkg = require( __dirname + "/package.json" );
|
|
436
863
|
I( "=== TLS MODE ===" );
|
|
864
|
+
I( "Version: " + pkg.version );
|
|
437
865
|
V( "DOMAINS_ROOT: " + DOMAINS_ROOT );
|
|
438
866
|
V( "MAINTAINER_EMAIL: " + MAINTAINER_EMAIL );
|
|
439
867
|
V( "VERBOSITY: " + VERBOSITY );
|
|
@@ -446,6 +874,12 @@ if( argv.length == 2 ) {
|
|
|
446
874
|
} ).ready( glx => {
|
|
447
875
|
|
|
448
876
|
var server = glx.httpsServer();
|
|
877
|
+
|
|
878
|
+
// Configure server timeouts and connection limits
|
|
879
|
+
server.maxConnections = MAX_CONNECTIONS_TOTAL;
|
|
880
|
+
server.timeout = SERVER_TIMEOUT_MS;
|
|
881
|
+
server.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
|
|
882
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
449
883
|
|
|
450
884
|
ws_attach( server, function( msg, connection, host ) {
|
|
451
885
|
const root = path.resolve( DOMAINS_ROOT + "/" + host );
|
|
@@ -480,7 +914,9 @@ if( argv.length == 5 ) {
|
|
|
480
914
|
|
|
481
915
|
SITE_ROOT = path.resolve( SITE_ROOT );
|
|
482
916
|
|
|
917
|
+
const pkg = require( __dirname + "/package.json" );
|
|
483
918
|
I( "=== DEV MODE ===" );
|
|
919
|
+
I( "Version: " + pkg.version );
|
|
484
920
|
V( "VERBOSITY: " + VERBOSITY );
|
|
485
921
|
V( "SITE_ROOT: " + SITE_ROOT );
|
|
486
922
|
V( "PORT: " + PORT );
|
|
@@ -488,6 +924,12 @@ if( argv.length == 5 ) {
|
|
|
488
924
|
const server = http.createServer( ( req, res ) => {
|
|
489
925
|
rest_handler( SITE_ROOT, req, res );
|
|
490
926
|
} );
|
|
927
|
+
|
|
928
|
+
// Configure server timeouts and connection limits
|
|
929
|
+
server.maxConnections = MAX_CONNECTIONS_TOTAL;
|
|
930
|
+
server.timeout = SERVER_TIMEOUT_MS;
|
|
931
|
+
server.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
|
|
932
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
491
933
|
|
|
492
934
|
ws_attach( server, ( msg, connection, host ) => {
|
|
493
935
|
ws_msg_handler( SITE_ROOT, msg, host, connection );
|
|
@@ -508,7 +950,8 @@ if( argv.length == 3 && argv[ 2 ] == "-v" ) {
|
|
|
508
950
|
|
|
509
951
|
// pull package version from package.json and print it
|
|
510
952
|
const p = require( __dirname + "/package.json" );
|
|
511
|
-
|
|
953
|
+
console.log( p.version );
|
|
954
|
+
process.exit();
|
|
512
955
|
|
|
513
956
|
}
|
|
514
957
|
else {
|