From a6ce8fa90ef17c63031bd7d3daf1120668b01ee9 Mon Sep 17 00:00:00 2001 From: zefie Date: Thu, 7 Aug 2025 17:16:30 -0400 Subject: [PATCH] start templating wtv-guide, improve WTVIRC --- .../prerendered/Alerts/HostMissing.js | 88 ----- .../prerendered/Alerts/InternalServerError.js | 82 ----- .../wtv-guide/prerendered/Alerts/NotFound.js | 83 ----- .../prerendered/Alerts/ServiceUnavailable.js | 81 ----- .../wtv-guide/templates/NunjucksTemplate.js | 30 ++ .../wtv-guide/templates/alerts/Forbidden.njk | 16 + .../templates/alerts/HostMissing.njk | 22 ++ .../templates/alerts/InternalServerError.njk | 16 + .../wtv-guide/templates/alerts/NotFound.njk | 17 + .../templates/alerts/ServiceUnavailable.njk | 15 + .../Forbidden.js => templates/error_base.njk} | 20 +- .../includes/classes/WTVGuide.js | 24 ++ zefie_wtvp_minisrv/includes/classes/WTVIRC.js | 332 ++++++++++++++++-- 13 files changed, 456 insertions(+), 370 deletions(-) delete mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/HostMissing.js delete mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/InternalServerError.js delete mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/NotFound.js delete mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/ServiceUnavailable.js create mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/NunjucksTemplate.js create mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/Forbidden.njk create mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/HostMissing.njk create mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/InternalServerError.njk create mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/NotFound.njk create mode 100644 zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/ServiceUnavailable.njk rename zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/{prerendered/Alerts/Forbidden.js => templates/error_base.njk} (75%) diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/HostMissing.js b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/HostMissing.js deleted file mode 100644 index 635d0535..00000000 --- a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/HostMissing.js +++ /dev/null @@ -1,88 +0,0 @@ -data = ` - - -Missing publisher's name - - - - - - - -
- - - -
-
- - - - -
-
- -Missing publisher's name  - -
-  -
-
- - - -
- -
- - - - - -
-
- -Web addresses generally include a publisher's -name after the colon. This name is often called -a host name. In this address, www.npr.org -is the publisher's name: - - - -
-
-http://www.npr.org/news/ -
-

-This message appears if you don't include the publisher's -name in an address that you type. -

- -Technical details
-This is result  400 Bad Request. -

- - -
-
-
-
- - - - -
-
- -`; \ No newline at end of file diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/InternalServerError.js b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/InternalServerError.js deleted file mode 100644 index 65f6f2a3..00000000 --- a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/InternalServerError.js +++ /dev/null @@ -1,82 +0,0 @@ -data = ` - - -Publisher problem - - - - - - - -
- - - -
-
- - - - -
-
- -Publisher problem  - -
-  -
-
- - - -
- -
- - - - - -
-
- -This message means that -the publisher's computer experienced a technical -problem while it was trying to send you information. -

-This can occur because of a mistake made by the -author of a page or by the publisher. -You might want to try getting the page again, -though this problem is likely to happen again. -

- -Technical details
-This is result  500 Internal Server Error. -

- - -
-
-
-
- - - - -
-
- -`; \ No newline at end of file diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/NotFound.js b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/NotFound.js deleted file mode 100644 index eef744c2..00000000 --- a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/NotFound.js +++ /dev/null @@ -1,83 +0,0 @@ -data = ` - - -Page was not found - - - - - - - -
- - - -
-
- - - - -
-
- -Page was not found  - -
-  -
-
- - - -
- -
- - - - - -
-
- -This message means that -the publisher could not find -a particular page that was requested. -

-If you were typing in a Web address, you -can check the address to make sure it is accurate. -

-This message also appears if a page's author -mistyped the address of another page. -

- -Technical details
-This is a server result  404 Not Found. -

- - -
-
-
-
- - - - -
-
- -`; \ No newline at end of file diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/ServiceUnavailable.js b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/ServiceUnavailable.js deleted file mode 100644 index b9f0198b..00000000 --- a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/ServiceUnavailable.js +++ /dev/null @@ -1,81 +0,0 @@ -data = ` - - -Publisher is too busy - - - - - - - -
- - - -
-
- - - - -
-
- -Publisher is too busy  - -
-  -
-
- - - -
- -
- - - - - -
-
- -This message means that -the publisher of the page you're trying to reach -is so busy sending pages to other people on the -Internet that it can't handle your request right now. -

-Try again in a minute or two, and the publisher -might be less busy. Many publishers are busiest in the mid-morning and early evening. -

- -Technical details
-This is result  503 Service Unavailable. -

- - -
-
-
-
- - - - -
-
- -`; \ No newline at end of file diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/NunjucksTemplate.js b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/NunjucksTemplate.js new file mode 100644 index 00000000..bc1ba667 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/NunjucksTemplate.js @@ -0,0 +1,30 @@ +const nunjucks = require('nunjucks'); +const path = require('path'); + +class WTVNunjucksTemplate { + page_args = {}; + + constructor(page_args) { + this.page_args = page_args; + } + + getTemplatePage() { + // Configure nunjucks with the templates directory + const templatesPath = path.join(__dirname, '../templates'); + const env = nunjucks.configure(templatesPath, { + autoescape: true, + throwOnUndefined: false + }); + + try { + // Render the template with the provided arguments + const rendered = env.render(this.page_args.template_name, this.page_args); + return rendered; + } catch (error) { + console.error('Error rendering Nunjucks template:', error); + return null; + } + } +} + +module.exports = WTVNunjucksTemplate; diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/Forbidden.njk b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/Forbidden.njk new file mode 100644 index 00000000..8af4b961 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/Forbidden.njk @@ -0,0 +1,16 @@ +{% set title = "Access is restricted" %} +{% set heading = "Access is restricted" %} +{% set error_code = "403 Forbidden" %} +{% set error_description = "result" %} +{% set content %} +This message means that +the publisher has restricted access to a page, +and will not allow you to see it. +

+Publishers sometimes restrict access to +limit the use of their information. +

+In general, it won't help to try again. +{% endset %} + +{% include "error_base.njk" %} diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/HostMissing.njk b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/HostMissing.njk new file mode 100644 index 00000000..5f2e9665 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/HostMissing.njk @@ -0,0 +1,22 @@ +{% set title = "Missing publisher's name" %} +{% set heading = "Missing publisher's name" %} +{% set error_code = "400 Bad Request" %} +{% set error_description = "result" %} +{% set content %} +Web addresses generally include a publisher's +name after the colon. This name is often called +a host name. In this address, www.npr.org +is the publisher's name: + + + +
+
+http://www.npr.org/news/ +
+

+This message appears if you don't include the publisher's +name in an address that you type. +{% endset %} + +{% include "error_base.njk" %} diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/InternalServerError.njk b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/InternalServerError.njk new file mode 100644 index 00000000..7cd62610 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/InternalServerError.njk @@ -0,0 +1,16 @@ +{% set title = "Publisher problem" %} +{% set heading = "Publisher problem" %} +{% set error_code = "500 Internal Server Error" %} +{% set error_description = "result" %} +{% set content %} +This message means that +the publisher's computer experienced a technical +problem while it was trying to send you information. +

+This can occur because of a mistake made by the +author of a page or by the publisher. +You might want to try getting the page again, +though this problem is likely to happen again. +{% endset %} + +{% include "error_base.njk" %} diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/NotFound.njk b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/NotFound.njk new file mode 100644 index 00000000..ff82afcc --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/NotFound.njk @@ -0,0 +1,17 @@ +{% set title = "Page was not found" %} +{% set heading = "Page was not found" %} +{% set error_code = "404 Not Found" %} +{% set error_description = "a server result" %} +{% set content %} +This message means that +the publisher could not find +a particular page that was requested. +

+If you were typing in a Web address, you +can check the address to make sure it is accurate. +

+This message also appears if a page's author +mistyped the address of another page. +{% endset %} + +{% include "error_base.njk" %} diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/ServiceUnavailable.njk b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/ServiceUnavailable.njk new file mode 100644 index 00000000..54d9ff21 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/alerts/ServiceUnavailable.njk @@ -0,0 +1,15 @@ +{% set title = "Publisher is too busy" %} +{% set heading = "Publisher is too busy" %} +{% set error_code = "503 Service Unavailable" %} +{% set error_description = "result" %} +{% set content %} +This message means that +the publisher of the page you're trying to reach +is so busy sending pages to other people on the +Internet that it can't handle your request right now. +

+Try again in a minute or two, and the publisher +might be less busy. Many publishers are busiest in the mid-morning and early evening. +{% endset %} + +{% include "error_base.njk" %} diff --git a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/Forbidden.js b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/error_base.njk similarity index 75% rename from zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/Forbidden.js rename to zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/error_base.njk index 2f43bc27..d8755a72 100644 --- a/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/prerendered/Alerts/Forbidden.js +++ b/zefie_wtvp_minisrv/includes/ServiceDeps/templates/wtv-guide/templates/error_base.njk @@ -1,7 +1,6 @@ -data = ` -Access is restricted +{{ title }}
- +
-Access is restricted  +{{ heading }}  @@ -47,18 +46,11 @@ Access is restricted  -This message means that -the publisher has restricted access to a page, -and will not allow you to see it. -

-Publishers sometimes restrict access to -limit the use of their information. -

-In general, it won't help to try again. +{{ content | safe }}

Technical details
-This is result  403 Forbidden. +This is {{ error_description }}  {{ error_code }}. @@ -79,4 +71,4 @@ selected> -`; \ No newline at end of file + diff --git a/zefie_wtvp_minisrv/includes/classes/WTVGuide.js b/zefie_wtvp_minisrv/includes/classes/WTVGuide.js index 82c3f9bd..cf0e685f 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVGuide.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVGuide.js @@ -23,6 +23,30 @@ class WTVGuide { var data = false; switch (topic.toLowerCase()) { + case "alerts": + // Handle error alert pages using Nunjucks templates + var template = this.wtvshared.getTemplate("wtv-guide", "templates/NunjucksTemplate.js", true); + if (this.fs.existsSync(template)) { + // Map error names to template files + var errorTemplateMap = { + "forbidden": topic + "/Forbidden.njk", + "hostmissing": topic + "/HostMissing.njk", + "internalservererror": topic + "/InternalServerError.njk", + "notfound": topic + "/NotFound.njk", + "serviceunavailable": topic + "/ServiceUnavailable.njk" + }; + + var templateName = errorTemplateMap[subtopic.toLowerCase()]; + if (templateName) { + template_args = { + template_name: templateName, + minisrv_config: this.minisrv_config, + session_data: this.session_data + }; + } + } + if (template) break; + case "glossary": var template =this.wtvshared.getTemplate("wtv-guide", "templates/glossary.js", true); var glossary_datafile =this.wtvshared.getTemplate("wtv-guide", "glossary.json", true); diff --git a/zefie_wtvp_minisrv/includes/classes/WTVIRC.js b/zefie_wtvp_minisrv/includes/classes/WTVIRC.js index 8dad3638..d68fbcc9 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVIRC.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVIRC.js @@ -69,8 +69,8 @@ class WTVIRC { this.servername = this.irc_config.server_hostname || 'irc.local'; this.network = this.irc_config.network || 'minisrv'; this.oper_username = this.irc_config.oper_username || 'minisrv'; - this.oper_password = this.irc_config.oper_password || 'changeme573'; - this.oper_enabled = this.irc_config.oper_enabled || this.debug || false; // Default to off to prevent accidental use with default credentials + this.oper_password = this.irc_config.oper_password || 'changeme573_PLEASE_CHANGE_THIS_PASSWORD'; + this.oper_enabled = this.irc_config.oper_enabled || false; // Default to off for security this.irc_motd = this.irc_config.motd || [ 'Welcome to the zefIRCd IRC server, powered by minisrv.', 'This server is powered by Node.js, and the minisrv project.', @@ -94,7 +94,7 @@ class WTVIRC { this.socket_timeout = 75; // Default socket timeout to 75 seconds, most clients will send PINGs every 60 seconds, so this should be enough to catch lost connections this.server_hello = this.irc_config.server_hello || `zefIRCd v${this.version} IRC server powered by minisrv`; this.serverId = this.irc_config.server_id || '00A'; // Default server ID, can be overridden in config - this.allow_public_vhosts = this.irc_config.allow_public_vhosts || true; // If true, users can set their host to a virtual host that is not a real hostname or IP address, if false, only opers can. + this.allow_public_vhosts = this.irc_config.allow_public_vhosts || false; // Default to false for security this.kick_insecure_users_on_secure = this.irc_config.kick_insecure_users_on_secure || true; // If true, users without SSL connections will be kicked from a channel when +Z is applied this.hide_version = this.irc_config.hide_version || false; // If true, the server will not send its version in the MOTD this.clientpeak = 0; @@ -111,6 +111,24 @@ class WTVIRC { this.supported_server_caps = ['TBURST', 'EOB', 'IE', 'EX']; this.enable_webtv_command_hacks = this.irc_config.enable_webtv_command_hacks || true; this.supported_webtv_command_hacks = ["MODE", "KICK"]; // You could technically add any command currently supported by the IRCd here, such as OPER, KILL, KLINE, etc. + + // Rate limiting configuration + this.rate_limit_enabled = this.irc_config.rate_limit_enabled || true; + this.max_messages_per_second = this.irc_config.max_messages_per_second || 5; + this.message_counts = new Map(); // socket -> {count, resetTime} + + // Brute force protection + this.failed_auth_attempts = new Map(); // IP -> {count, lockoutUntil} + this.max_auth_attempts = this.irc_config.max_auth_attempts || 3; + this.auth_lockout_duration = this.irc_config.auth_lockout_duration || 300; // 5 minutes + + // Connection limits + this.connections_per_ip = new Map(); // IP -> count + this.max_connections_per_ip = this.irc_config.max_connections_per_ip || 3; + + // Security logging + this.security_log_enabled = this.irc_config.security_log_enabled || true; + this.security_events = []; this.session_store_path = this.wtvshared.getAbsolutePath(this.minisrv_config.config.SessionStore + path.sep + 'minisrv_internal_irc'); this.klines_path = this.session_store_path + path.sep + 'klines.json'; this.caps = [ @@ -129,10 +147,16 @@ class WTVIRC { for (const channel of this.irc_config.channels) { this.createChannel(channel.name); if (channel.modes && Array.isArray(channel.modes)) { - this.channelData.get(channel.name).modes = [...channel.modes]; + const channelData = this.channelData.get(channel.name); + if (channelData) { + channelData.modes = [...channel.modes]; + } } if (channel.topic) { - this.channelData.get(channel.name).topic = channel.topic; + const channelData = this.channelData.get(channel.name); + if (channelData) { + channelData.topic = channel.topic; + } } } } @@ -142,6 +166,18 @@ class WTVIRC { // Detect SSL handshake and wrap socket if needed socket.once('data', async firstChunk => { this.totalConnections++; + + // Check connection limits per IP + const clientIP = socket.remoteAddress; + const currentConnections = this.connections_per_ip.get(clientIP) || 0; + if (currentConnections >= this.max_connections_per_ip) { + this.debugLog('warn', `Connection limit exceeded for IP ${clientIP}`); + socket.write(`:${this.servername} ERROR :Too many connections from your IP\r\n`); + socket.end(); + return; + } + this.connections_per_ip.set(clientIP, currentConnections + 1); + socket.removeAllListeners('data'); socket.pause(); socket.on('error', (err) => { @@ -219,17 +255,182 @@ class WTVIRC { data = data.substring(0, this.max_message_len - 2) + '\r\n'; this.debugLog('warn', `Data length exceeds max_message_len (${this.max_message_len}), truncating: ${data.length} > ${this.max_message_len}`); } - while (socket.writable === false) { + + // Add timeout to prevent infinite loop + let waitCount = 0; + const maxWaitIterations = 1000; // 10 seconds max wait + while (socket.writable === false && waitCount < maxWaitIterations) { await new Promise(resolve => setTimeout(resolve, 10)); + waitCount++; + } + + if (socket.writable === false) { + this.debugLog('error', 'Socket not writable after timeout, aborting write'); + return; } socket.write(data); } + + checkRateLimit(socket) { + if (!this.rate_limit_enabled || socket.isserver || this.isIRCOp(socket.nickname)) { + return true; // No rate limiting for servers or IRCOps + } + + const now = Date.now(); + const socketId = socket.remoteAddress + ':' + socket.remotePort; + + if (!this.message_counts.has(socketId)) { + this.message_counts.set(socketId, { count: 1, resetTime: now + 1000 }); + return true; + } + + const rateLimitData = this.message_counts.get(socketId); + + if (now > rateLimitData.resetTime) { + // Reset the counter + rateLimitData.count = 1; + rateLimitData.resetTime = now + 1000; + return true; + } + + if (rateLimitData.count >= this.max_messages_per_second) { + return false; // Rate limit exceeded + } + + rateLimitData.count++; + return true; + } + + checkAuthAttempts(socket) { + const ip = socket.realhost || socket.remoteAddress; + const now = Date.now(); + + if (!this.failed_auth_attempts.has(ip)) { + return true; + } + + const authData = this.failed_auth_attempts.get(ip); + if (authData.lockoutUntil && now < authData.lockoutUntil) { + return false; // Still locked out + } + + return true; + } + + recordAuthFailure(socket) { + const ip = socket.realhost || socket.remoteAddress; + const now = Date.now(); + + if (!this.failed_auth_attempts.has(ip)) { + this.failed_auth_attempts.set(ip, { count: 1, lockoutUntil: null }); + return; + } + + const authData = this.failed_auth_attempts.get(ip); + authData.count++; + + if (authData.count >= this.max_auth_attempts) { + authData.lockoutUntil = now + (this.auth_lockout_duration * 1000); + this.debugLog('warn', `IP ${ip} locked out for ${this.auth_lockout_duration} seconds due to failed auth attempts`); + } + } + + clearAuthFailures(socket) { + const ip = socket.realhost || socket.remoteAddress; + this.failed_auth_attempts.delete(ip); + } + + sanitizeInput(input, type = 'general') { + if (typeof input !== 'string') { + return ''; + } + + // Remove control characters except \r\n + input = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + + switch (type) { + case 'nickname': + // IRC nicknames: A-Z a-z 0-9 [ ] \ ` _ ^ { | } + return input.replace(/[^A-Za-z0-9\[\]\\`_^{|}]/g, '').substring(0, this.nicklen); + + case 'channel': + // Channel names: start with #, no spaces, commas, or control chars + if (!input.startsWith('#')) return ''; + return input.replace(/[^A-Za-z0-9#\-_.]/g, '').substring(0, this.channellen); + + case 'message': + // Messages: no control chars, reasonable length + return input.substring(0, 512); + + case 'username': + // Usernames: alphanumeric and some special chars + return input.replace(/[^A-Za-z0-9\-_.]/g, '').substring(0, 32); + + default: + return input.substring(0, 512); + } + } + + validateCommand(command, params) { + if (!command || typeof command !== 'string') { + return false; + } + + // Command must be uppercase letters only + if (!/^[A-Z]+$/.test(command)) { + return false; + } + + // Reasonable command length + if (command.length > 20) { + return false; + } + + // Parameters validation + if (params && Array.isArray(params)) { + for (const param of params) { + if (typeof param !== 'string' || param.length > 512) { + return false; + } + } + } + + return true; + } + + logSecurityEvent(event, socket, details = {}) { + if (!this.security_log_enabled) return; + + const securityEvent = { + timestamp: new Date().toISOString(), + event: event, + ip: socket ? (socket.realhost || socket.remoteAddress) : 'unknown', + nickname: socket ? socket.nickname : 'unknown', + details: details + }; + + this.security_events.push(securityEvent); + + // Keep only last 1000 security events in memory + if (this.security_events.length > 1000) { + this.security_events.shift(); + } + + // Log to console/file if needed + this.debugLog('security', `Security Event: ${event} from ${securityEvent.ip} (${securityEvent.nickname})`); + + // Write to security log file if configured + if (this.irc_config.security_log_file) { + const fs = require('fs'); + fs.appendFileSync(this.irc_config.security_log_file, JSON.stringify(securityEvent) + '\n'); + } + } async initializeSocket(socket, secure = false, oldSocket = null) { if (this.debug) { // debug output for socket data - const originalWrite = socket.write; + const originalWrite = socket.write.bind(socket); socket.write = function (...args) { var log_args = args.map(arg => { if (typeof arg === 'string') { @@ -238,7 +439,7 @@ class WTVIRC { return arg; }); console.log('<', ...log_args); - return originalWrite.apply(socket, args); + return originalWrite(...args); }; } if (oldSocket) { @@ -984,7 +1185,7 @@ class WTVIRC { whoisNick = whoisSocket.nickname; const whois_username = this.usernames.get(whoisNick); var userinfo = this.userinfo.get(whoisNick) || whoisSocket.userinfo || ''; - var output_lines = []; + output_lines = []; output_lines.push(`:${this.serverId} 311 ${targetUniqueId} ${whoisNick} ${whois_username} ${whoisSocket.host} * :${userinfo}\r\n`); if (this.awaymsgs.has(whoisNick)) { output_lines.push(`:${this.serverId} 301 ${targetUniqueId} ${whoisNick} :${this.awaymsgs.get(whoisNick)}\r\n`); @@ -1108,6 +1309,8 @@ class WTVIRC { } async processSocketData(socket, data) { + var output_lines = []; // Declare once at function scope + if (socket.signedoff) { return; } @@ -1184,6 +1387,16 @@ class WTVIRC { if (this.debug) { console.log(`> ${line}`); } + + // Rate limiting check for non-server connections + if (!socket.isserver && !this.checkRateLimit(socket)) { + this.debugLog('warn', `Rate limit exceeded for ${socket.remoteAddress}, disconnecting`); + this.logSecurityEvent('RATE_LIMIT_EXCEEDED', socket, { limit: this.max_messages_per_second }); + await this.safeWriteToSocket(socket, `:${this.servername} ERROR :Rate limit exceeded\r\n`); + this.terminateSession(socket, true); + return; + } + if (socket.isserver) { await this.processServerData(socket, line); continue; @@ -1221,11 +1434,27 @@ class WTVIRC { const [command, ...params] = line.trim().split(' '); + + // Validate command and parameters + if (!this.validateCommand(command.toUpperCase(), params)) { + this.logSecurityEvent('INVALID_COMMAND', socket, { command, params }); + await this.safeWriteToSocket(socket, `:${this.servername} 421 ${socket.nickname || '*'} ${command} :Unknown command\r\n`); + continue; + } + switch (command.toUpperCase()) { case 'OPER': + if (!socket.secure) { + await this.safeWriteToSocket(socket, `:${this.servername} 464 ${socket.nickname} :SSL required for OPER\r\n`); + } if (!this.checkRegistered(socket)) { break; } + if (!this.checkAuthAttempts(socket)) { + await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :Too many failed attempts. Try again later.\r\n`); + this.logSecurityEvent('OPER_LOCKOUT', socket, { attempts: this.failed_auth_attempts.get(socket.realhost || socket.remoteAddress) }); + break; + } if (!this.oper_enabled) { await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :This server does not support IRC operators\r\n`); break; @@ -1238,13 +1467,25 @@ class WTVIRC { if (operName !== this.oper_username) { await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :No permission\r\n`); this.debugLog('warn', `Invalid oper name attempt: ${operName} from ${socket.nickname} (${socket.username}@${socket.realhost})`); + this.logSecurityEvent('OPER_FAILED_USERNAME', socket, { provided_username: operName }); + this.recordAuthFailure(socket); break; } - if (operPassword !== this.oper_password) { + // Use timing-safe comparison to prevent timing attacks + const providedPassword = Buffer.from(operPassword, 'utf8'); + const actualPassword = Buffer.from(this.oper_password, 'utf8'); + const passwordMatch = providedPassword.length === actualPassword.length && + require('crypto').timingSafeEqual(providedPassword, actualPassword); + + if (!passwordMatch) { await this.safeWriteToSocket(socket, `:${this.servername} 464 ${socket.nickname} :Password incorrect\r\n`); this.debugLog('warn', `Invalid oper password attempt from ${socket.nickname} (${socket.username}@${socket.realhost}) (using oper name ${operName})`); + this.logSecurityEvent('OPER_FAILED_PASSWORD', socket, { username: operName }); + this.recordAuthFailure(socket); break; } + this.clearAuthFailures(socket); + this.logSecurityEvent('OPER_SUCCESS', socket, { username: operName }); this.setUserMode(socket.nickname, 'o', true); await this.safeWriteToSocket(socket, `:${this.servername} 381 ${socket.nickname} :You are now an IRC operator\r\n`); await this.safeWriteToSocket(socket, `:${socket.nickname}!${socket.username}@${socket.host} MODE ${socket.nickname} +o\r\n`); @@ -1262,6 +1503,26 @@ class WTVIRC { const seconds = uptime % 60; await this.safeWriteToSocket(socket, `:${this.servername} 242 ${socket.nickname} :Server uptime is ${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds\r\n`); break; + case 'SECURITY': + if (!this.checkRegistered(socket)) { + break; + } + if (!this.isIRCOp(socket.nickname)) { + await this.safeWriteToSocket(socket, `:${this.servername} 481 ${socket.nickname} :Permission denied - you are not an IRC operator\r\n`); + break; + } + + // Show security statistics + let output_lines = []; + output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :=== Security Report ===\r\n`); + output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Active connections per IP: ${this.connections_per_ip.size}\r\n`); + output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Failed auth attempts tracked: ${this.failed_auth_attempts.size}\r\n`); + output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Security events logged: ${this.security_events.length}\r\n`); + output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Rate limit violations: ${this.security_events.filter(e => e.event === 'RATE_LIMIT_EXCEEDED').length}\r\n`); + output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :=== End Report ===\r\n`); + + await this.sendThrottled(socket, output_lines); + break; case 'KICK': if (!this.checkRegistered(socket)) { break; @@ -1674,13 +1935,23 @@ class WTVIRC { case 'NICK': var old_nickname = socket.nickname; var new_nickname = params[0]; - if (new_nickname.startsWith(':')) { + if (new_nickname && new_nickname.startsWith(':')) { new_nickname = new_nickname.slice(1); } if (!new_nickname || new_nickname.length < 1) { await this.safeWriteToSocket(socket, `:${this.servername} 431 * :No nickname\r\n`); break; } + + // Sanitize nickname input + const sanitized_nickname = this.sanitizeInput(new_nickname, 'nickname'); + if (sanitized_nickname !== new_nickname || sanitized_nickname.length === 0) { + await this.safeWriteToSocket(socket, `:${this.servername} 432 * ${new_nickname} :Erroneus nickname (invalid characters)\r\n`); + this.logSecurityEvent('INVALID_NICKNAME', socket, { provided: new_nickname, sanitized: sanitized_nickname }); + break; + } + new_nickname = sanitized_nickname; + if (new_nickname.length > this.nicklen) { await this.safeWriteToSocket(socket, `:${this.servername} 432 * ${new_nickname} :Erroneus nickname (too long)\r\n`); break; @@ -1863,7 +2134,7 @@ class WTVIRC { const invited = this.channelData.get(ch).inviteexceptions; let isInvited = false; for (const inviteMask of invited) { - isInvited = checkMask(inviteMask, socket); + isInvited = this.checkMask(inviteMask, socket); if (isInvited) { break; // Stop checking if we found a match } @@ -1948,7 +2219,7 @@ class WTVIRC { } } var users = this.getUsersInChannel(ch); - var output_lines = []; + output_lines = []; var prefixRegex = new RegExp(`^[${this.supported_prefixes[1]}]`); if (users.length > 0) { users.sort((a, b) => { @@ -2006,7 +2277,7 @@ class WTVIRC { } } if (this.clientIsWebTV(socket) && this.enable_webtv_command_hacks) { - var output_lines = []; + output_lines = []; var channelObj = this.channelData.get(ch); output_lines.push("You have joined " + ch); output_lines.push("Current channel modes: +" + channelObj.modes.join('')); @@ -2038,7 +2309,7 @@ class WTVIRC { break; } var users = this.getUsersInChannel(channel); - var output_lines = []; + output_lines = []; if (users.length > 0) { if (socket.client_caps.includes('userhost-in-names')) { const userHosts = users.map(user => { @@ -2240,7 +2511,7 @@ class WTVIRC { await this.safeWriteToSocket(socket, `:${this.servername} 315 ${socket.nickname} ${target} :End of /WHO list\r\n`); } else { // WHO for nickname - var output_lines = []; + output_lines = []; if (target.includes('*') || target.includes('?')) { // Wildcard mask search for nicknames const maskRegex = new RegExp('^' + target.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); @@ -2574,7 +2845,7 @@ class WTVIRC { break; } var type = params[0] ? params[0].toUpperCase() : ''; - var output_lines = []; + output_lines = []; switch (type) { case "HELP": output_lines.push(`:${this.servername} 200 ${socket.nickname} :Available commands:\r\n`); @@ -2711,7 +2982,7 @@ class WTVIRC { } const userChannels = []; - var output_lines = []; + output_lines = []; for (const [ch, channelObj] of this.channelData.entries()) { if (channelObj.users.has(whoisNick)) { let prefix = ''; @@ -3010,11 +3281,28 @@ class WTVIRC { this.servers.delete(socket); this.serverusers.delete(socket); } else { - this.clients.filter(c => c !== socket); + this.clients = this.clients.filter(c => c !== socket); } if (socket._idleInterval) { clearInterval(socket._idleInterval); + socket._idleInterval = null; } + + // Clean up rate limiting data + const socketId = socket.remoteAddress + ':' + socket.remotePort; + this.message_counts.delete(socketId); + + // Clean up connection count per IP + const clientIP = socket.remoteAddress; + if (this.connections_per_ip.has(clientIP)) { + const currentCount = this.connections_per_ip.get(clientIP); + if (currentCount <= 1) { + this.connections_per_ip.delete(clientIP); + } else { + this.connections_per_ip.set(clientIP, currentCount - 1); + } + } + if (close) { socket.end(); } @@ -4057,7 +4345,7 @@ class WTVIRC { } } else if (modes === 'b') { // Get the list of channel bans - var output_lines = []; + output_lines = []; if (this.channelData.has(channel).bans) { const bans = this.channelData.has(channel).bans; for (const ban of bans) { @@ -4069,7 +4357,7 @@ class WTVIRC { return; } else if (modes === 'e') { // Get the list of channel exemptions - var output_lines = []; + output_lines = []; if (this.channelData.has(channel).exemptions) { const exemptions = this.channelData.has(channel).exemptions; for (const exemption of exemptions) { @@ -4081,7 +4369,7 @@ class WTVIRC { return; } else if (modes === 'I') { // Get the list of channel invites masks - var output_lines = []; + output_lines = []; if (this.channelData.has(channel).invites) { const invites = this.channelData.has(channel).invites; for (const invite of invites) {