start templating wtv-guide, improve WTVIRC

This commit is contained in:
zefie
2025-08-07 17:16:30 -04:00
parent fee0c0fa39
commit a6ce8fa90e
13 changed files with 456 additions and 370 deletions

View File

@@ -1,88 +0,0 @@
data = `
<html>
<head>
<title>Missing publisher's name</title>
<display
noscroll
showwhencomplete
>
</head>
<body hspace=0 vspace=0
text='E6E6E6' link='E6E6E6' vlink='E6E6E6'
fontsize='medium'
bgcolor=00292f
>
<table cellspacing=0 cellpadding=0>
<tr>
<td width=560 height=96 valign=top>
<table background="wtv-guide:/ROMCache/help/common/helpMastheadBlank.swf" width=560 height=96 cellspacing=0 cellpadding=0>
<tr>
<td width=107 height=96 valign=top rowspan=2>
<spacer type=vertical height=7><br>
<spacer type=horizontal width=7>
<a href='wtv-home:/home'>
<img src="${minisrv_config.config.service_logo}" width=87 height=67>
</a>
<td width=453 valign=top>
<spacer type=vertical height=54><br>
<font size=+3><blackface>
Missing publisher's name&nbsp;
</blackface></font>
<tr>
<td align=right>
&nbsp;
</table>
<tr>
<td width=560 valign=top height=225>
<table cellpadding=0 cellspacing=0 width=560>
<tr>
<td width=25 height=17>
<td width=535>
<tr>
<td>
<td height=225 rowspan=2 valign=top>
<table cellpadding=0 cellspacing=0 height=225 width=535>
<tr>
<td height=15>
<tr>
<td>
<td valign=top>
Web addresses generally include a publisher's
name after the colon. This name is often called
a <i>host</i> name. In this address, <B>www.npr.org</B>
is the publisher's name:
<table>
<tr>
<td height=8>
<tr>
<td width=10>
<td><tt>http://<b>www.npr.org</b>/news/</tt>
</table>
<p>
This message appears if you don't include the publisher's
name in an address that you type.
<p>
<font size=-1>
<i>Technical details</i><br>
This is result&nbsp;&nbsp;<tt>400 Bad Request</tt>.
<tr>
<td width=35>
<td width=450>
<td width=50>
</table>
</table>
<tr>
<td valign=bottom align=right>
<form>
<font color=ffcf69><shadow>
<input type=button usestyle borderimage="file://ROM/Borders/ButtonBorder2.bif"
action="client:goback"
value="Done"
width='110'
selected>
<spacer type=horizontal width=20>
</shadow></font>
</form>
</table>
</body>
`;

View File

@@ -1,82 +0,0 @@
data = `
<html>
<head>
<title>Publisher problem</title>
<display
noscroll
showwhencomplete
>
</head>
<body hspace=0 vspace=0
text='E6E6E6' link='E6E6E6' vlink='E6E6E6'
fontsize='medium'
bgcolor=00292f
>
<table cellspacing=0 cellpadding=0>
<tr>
<td width=560 height=96 valign=top>
<table background="wtv-guide:/ROMCache/help/common/helpMastheadBlank.swf" width=560 height=96 cellspacing=0 cellpadding=0>
<tr>
<td width=107 height=96 valign=top rowspan=2>
<spacer type=vertical height=7><br>
<spacer type=horizontal width=7>
<a href='wtv-home:/home'>
<img src="${minisrv_config.config.service_logo}" width=87 height=67>
</a>
<td width=453 valign=top>
<spacer type=vertical height=54><br>
<font size=+3><blackface>
Publisher problem&nbsp;
</blackface></font>
<tr>
<td align=right>
&nbsp;
</table>
<tr>
<td width=560 valign=top height=225>
<table cellpadding=0 cellspacing=0 width=560>
<tr>
<td width=25 height=17>
<td width=535>
<tr>
<td>
<td height=225 rowspan=2 valign=top>
<table cellpadding=0 cellspacing=0 height=225 width=535>
<tr>
<td height=15>
<tr>
<td>
<td valign=top>
This message means that
the publisher's computer experienced a technical
problem while it was trying to send you information.
<p>
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.
<p>
<font size=-1>
<i>Technical details</i><br>
This is result&nbsp;&nbsp;<tt>500 Internal Server Error</tt>.
<tr>
<td width=35>
<td width=450>
<td width=50>
</table>
</table>
<tr>
<td valign=bottom align=right>
<form>
<font color=ffcf69><shadow>
<input type=button usestyle borderimage="file://ROM/Borders/ButtonBorder2.bif"
action="client:goback"
value="Done"
width='110'
selected>
<spacer type=horizontal width=20>
</shadow></font>
</form>
</table>
</body>
`;

View File

@@ -1,83 +0,0 @@
data = `
<html>
<head>
<title>Page was not found</title>
<display
noscroll
showwhencomplete
>
</head>
<body hspace=0 vspace=0
text='E6E6E6' link='E6E6E6' vlink='E6E6E6'
fontsize='medium'
bgcolor=00292f
>
<table cellspacing=0 cellpadding=0>
<tr>
<td width=560 height=96 valign=top>
<table background="wtv-guide:/ROMCache/help/common/helpMastheadBlank.swf" width=560 height=96 cellspacing=0 cellpadding=0>
<tr>
<td width=107 height=96 valign=top rowspan=2>
<spacer type=vertical height=7><br>
<spacer type=horizontal width=7>
<a href='wtv-home:/home'>
<img src="${minisrv_config.config.service_logo}" width=87 height=67>
</a>
<td width=453 valign=top>
<spacer type=vertical height=54><br>
<font size=+3><blackface>
Page was not found&nbsp;
</blackface></font>
<tr>
<td align=right>
&nbsp;
</table>
<tr>
<td width=560 valign=top height=225>
<table cellpadding=0 cellspacing=0 width=560>
<tr>
<td width=25 height=17>
<td width=535>
<tr>
<td>
<td height=225 rowspan=2 valign=top>
<table cellpadding=0 cellspacing=0 height=225 width=535>
<tr>
<td height=15>
<tr>
<td>
<td valign=top>
This message means that
the publisher could not find
a particular page that was requested.
<p>
If you were typing in a Web address, you
can check the address to make sure it is accurate.
<p>
This message also appears if a page's author
mistyped the address of another page.
<p>
<font size=-1>
<i>Technical details</i><br>
This is a server result&nbsp;&nbsp;<tt>404 Not Found</tt>.
<tr>
<td width=35>
<td width=450>
<td width=50>
</table>
</table>
<tr>
<td valign=bottom align=right>
<form>
<font color=ffcf69><shadow>
<input type=button usestyle borderimage="file://ROM/Borders/ButtonBorder2.bif"
action="client:goback"
value="Done"
width='110'
selected>
<spacer type=horizontal width=20>
</shadow></font>
</form>
</table>
</body>
`;

View File

@@ -1,81 +0,0 @@
data = `
<html>
<head>
<title>Publisher is too busy</title>
<display
noscroll
showwhencomplete
>
</head>
<body hspace=0 vspace=0
text='E6E6E6' link='E6E6E6' vlink='E6E6E6'
fontsize='medium'
bgcolor=00292f
>
<table cellspacing=0 cellpadding=0>
<tr>
<td width=560 height=96 valign=top>
<table background="wtv-guide:/ROMCache/help/common/helpMastheadBlank.swf" width=560 height=96 cellspacing=0 cellpadding=0>
<tr>
<td width=107 height=96 valign=top rowspan=2>
<spacer type=vertical height=7><br>
<spacer type=horizontal width=7>
<a href='wtv-home:/home'>
<img src="${minisrv_config.config.service_logo}" width=87 height=67>
</a>
<td width=453 valign=top>
<spacer type=vertical height=54><br>
<font size=+3><blackface>
Publisher is too busy&nbsp;
</blackface></font>
<tr>
<td align=right>
&nbsp;
</table>
<tr>
<td width=560 valign=top height=225>
<table cellpadding=0 cellspacing=0 width=560>
<tr>
<td width=25 height=17>
<td width=535>
<tr>
<td>
<td height=225 rowspan=2 valign=top>
<table cellpadding=0 cellspacing=0 height=225 width=535>
<tr>
<td height=15>
<tr>
<td>
<td valign=top>
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.
<p>
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.
<p>
<font size=-1>
<i>Technical details</i><br>
This is result&nbsp;&nbsp;<tt>503 Service Unavailable</tt>.
<tr>
<td width=35>
<td width=450>
<td width=50>
</table>
</table>
<tr>
<td valign=bottom align=right>
<form>
<font color=ffcf69><shadow>
<input type=button usestyle borderimage="file://ROM/Borders/ButtonBorder2.bif"
action="client:goback"
value="Done"
width='110'
selected>
<spacer type=horizontal width=20>
</shadow></font>
</form>
</table>
</body>
`;

View File

@@ -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;

View File

@@ -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.
<p>
Publishers sometimes restrict access to
limit the use of their information.
<p>
In general, it won't help to try again.
{% endset %}
{% include "error_base.njk" %}

View File

@@ -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 <i>host</i> name. In this address, <B>www.npr.org</B>
is the publisher's name:
<table>
<tr>
<td height=8>
<tr>
<td width=10>
<td><tt>http://<b>www.npr.org</b>/news/</tt>
</table>
<p>
This message appears if you don't include the publisher's
name in an address that you type.
{% endset %}
{% include "error_base.njk" %}

View File

@@ -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.
<p>
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" %}

View File

@@ -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.
<p>
If you were typing in a Web address, you
can check the address to make sure it is accurate.
<p>
This message also appears if a page's author
mistyped the address of another page.
{% endset %}
{% include "error_base.njk" %}

View File

@@ -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.
<p>
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" %}

View File

@@ -1,7 +1,6 @@
data = `
<html> <html>
<head> <head>
<title>Access is restricted</title> <title>{{ title }}</title>
<display <display
noscroll noscroll
showwhencomplete showwhencomplete
@@ -21,12 +20,12 @@ bgcolor=00292f
<spacer type=vertical height=7><br> <spacer type=vertical height=7><br>
<spacer type=horizontal width=7> <spacer type=horizontal width=7>
<a href='wtv-home:/home'> <a href='wtv-home:/home'>
<img src="${minisrv_config.config.service_logo}" width=87 height=67> <img src="{{ minisrv_config.config.service_logo }}" width=87 height=67>
</a> </a>
<td width=453 valign=top> <td width=453 valign=top>
<spacer type=vertical height=54><br> <spacer type=vertical height=54><br>
<font size=+3><blackface> <font size=+3><blackface>
Access is restricted&nbsp; {{ heading }}&nbsp;
</blackface></font> </blackface></font>
<tr> <tr>
<td align=right> <td align=right>
@@ -47,18 +46,11 @@ Access is restricted&nbsp;
<tr> <tr>
<td> <td>
<td valign=top> <td valign=top>
This message means that {{ content | safe }}
the publisher has restricted access to a page,
and will not allow you to see it.
<p>
Publishers sometimes restrict access to
limit the use of their information.
<p>
In general, it won't help to try again.
<p> <p>
<font size=-1> <font size=-1>
<i>Technical details</i><br> <i>Technical details</i><br>
This is result&nbsp;&nbsp;<tt>403 Forbidden</tt>. This is {{ error_description }}&nbsp;&nbsp;<tt>{{ error_code }}</tt>.
<tr> <tr>
<td width=35> <td width=35>
<td width=450> <td width=450>
@@ -79,4 +71,4 @@ selected>
</form> </form>
</table> </table>
</body> </body>
`; </html>

View File

@@ -23,6 +23,30 @@ class WTVGuide {
var data = false; var data = false;
switch (topic.toLowerCase()) { 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": case "glossary":
var template =this.wtvshared.getTemplate("wtv-guide", "templates/glossary.js", true); var template =this.wtvshared.getTemplate("wtv-guide", "templates/glossary.js", true);
var glossary_datafile =this.wtvshared.getTemplate("wtv-guide", "glossary.json", true); var glossary_datafile =this.wtvshared.getTemplate("wtv-guide", "glossary.json", true);

View File

@@ -69,8 +69,8 @@ class WTVIRC {
this.servername = this.irc_config.server_hostname || 'irc.local'; this.servername = this.irc_config.server_hostname || 'irc.local';
this.network = this.irc_config.network || 'minisrv'; this.network = this.irc_config.network || 'minisrv';
this.oper_username = this.irc_config.oper_username || 'minisrv'; this.oper_username = this.irc_config.oper_username || 'minisrv';
this.oper_password = this.irc_config.oper_password || 'changeme573'; this.oper_password = this.irc_config.oper_password || 'changeme573_PLEASE_CHANGE_THIS_PASSWORD';
this.oper_enabled = this.irc_config.oper_enabled || this.debug || false; // Default to off to prevent accidental use with default credentials this.oper_enabled = this.irc_config.oper_enabled || false; // Default to off for security
this.irc_motd = this.irc_config.motd || [ this.irc_motd = this.irc_config.motd || [
'Welcome to the zefIRCd IRC server, powered by minisrv.', 'Welcome to the zefIRCd IRC server, powered by minisrv.',
'This server is powered by Node.js, and the minisrv project.', '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.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.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.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.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.hide_version = this.irc_config.hide_version || false; // If true, the server will not send its version in the MOTD
this.clientpeak = 0; this.clientpeak = 0;
@@ -111,6 +111,24 @@ class WTVIRC {
this.supported_server_caps = ['TBURST', 'EOB', 'IE', 'EX']; this.supported_server_caps = ['TBURST', 'EOB', 'IE', 'EX'];
this.enable_webtv_command_hacks = this.irc_config.enable_webtv_command_hacks || true; 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. 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.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.klines_path = this.session_store_path + path.sep + 'klines.json';
this.caps = [ this.caps = [
@@ -129,10 +147,16 @@ class WTVIRC {
for (const channel of this.irc_config.channels) { for (const channel of this.irc_config.channels) {
this.createChannel(channel.name); this.createChannel(channel.name);
if (channel.modes && Array.isArray(channel.modes)) { 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) { 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 // Detect SSL handshake and wrap socket if needed
socket.once('data', async firstChunk => { socket.once('data', async firstChunk => {
this.totalConnections++; 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.removeAllListeners('data');
socket.pause(); socket.pause();
socket.on('error', (err) => { socket.on('error', (err) => {
@@ -219,17 +255,182 @@ class WTVIRC {
data = data.substring(0, this.max_message_len - 2) + '\r\n'; 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}`); 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)); 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); 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) { async initializeSocket(socket, secure = false, oldSocket = null) {
if (this.debug) { if (this.debug) {
// debug output for socket data // debug output for socket data
const originalWrite = socket.write; const originalWrite = socket.write.bind(socket);
socket.write = function (...args) { socket.write = function (...args) {
var log_args = args.map(arg => { var log_args = args.map(arg => {
if (typeof arg === 'string') { if (typeof arg === 'string') {
@@ -238,7 +439,7 @@ class WTVIRC {
return arg; return arg;
}); });
console.log('<', ...log_args); console.log('<', ...log_args);
return originalWrite.apply(socket, args); return originalWrite(...args);
}; };
} }
if (oldSocket) { if (oldSocket) {
@@ -984,7 +1185,7 @@ class WTVIRC {
whoisNick = whoisSocket.nickname; whoisNick = whoisSocket.nickname;
const whois_username = this.usernames.get(whoisNick); const whois_username = this.usernames.get(whoisNick);
var userinfo = this.userinfo.get(whoisNick) || whoisSocket.userinfo || ''; 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`); output_lines.push(`:${this.serverId} 311 ${targetUniqueId} ${whoisNick} ${whois_username} ${whoisSocket.host} * :${userinfo}\r\n`);
if (this.awaymsgs.has(whoisNick)) { if (this.awaymsgs.has(whoisNick)) {
output_lines.push(`:${this.serverId} 301 ${targetUniqueId} ${whoisNick} :${this.awaymsgs.get(whoisNick)}\r\n`); output_lines.push(`:${this.serverId} 301 ${targetUniqueId} ${whoisNick} :${this.awaymsgs.get(whoisNick)}\r\n`);
@@ -1108,6 +1309,8 @@ class WTVIRC {
} }
async processSocketData(socket, data) { async processSocketData(socket, data) {
var output_lines = []; // Declare once at function scope
if (socket.signedoff) { if (socket.signedoff) {
return; return;
} }
@@ -1184,6 +1387,16 @@ class WTVIRC {
if (this.debug) { if (this.debug) {
console.log(`> ${line}`); 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) { if (socket.isserver) {
await this.processServerData(socket, line); await this.processServerData(socket, line);
continue; continue;
@@ -1221,11 +1434,27 @@ class WTVIRC {
const [command, ...params] = line.trim().split(' '); 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()) { switch (command.toUpperCase()) {
case 'OPER': case 'OPER':
if (!socket.secure) {
await this.safeWriteToSocket(socket, `:${this.servername} 464 ${socket.nickname} :SSL required for OPER\r\n`);
}
if (!this.checkRegistered(socket)) { if (!this.checkRegistered(socket)) {
break; 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) { if (!this.oper_enabled) {
await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :This server does not support IRC operators\r\n`); await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :This server does not support IRC operators\r\n`);
break; break;
@@ -1238,13 +1467,25 @@ class WTVIRC {
if (operName !== this.oper_username) { if (operName !== this.oper_username) {
await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :No permission\r\n`); 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.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; 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`); 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.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; break;
} }
this.clearAuthFailures(socket);
this.logSecurityEvent('OPER_SUCCESS', socket, { username: operName });
this.setUserMode(socket.nickname, 'o', true); 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, `:${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`); 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; 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`); await this.safeWriteToSocket(socket, `:${this.servername} 242 ${socket.nickname} :Server uptime is ${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds\r\n`);
break; 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': case 'KICK':
if (!this.checkRegistered(socket)) { if (!this.checkRegistered(socket)) {
break; break;
@@ -1674,13 +1935,23 @@ class WTVIRC {
case 'NICK': case 'NICK':
var old_nickname = socket.nickname; var old_nickname = socket.nickname;
var new_nickname = params[0]; var new_nickname = params[0];
if (new_nickname.startsWith(':')) { if (new_nickname && new_nickname.startsWith(':')) {
new_nickname = new_nickname.slice(1); new_nickname = new_nickname.slice(1);
} }
if (!new_nickname || new_nickname.length < 1) { if (!new_nickname || new_nickname.length < 1) {
await this.safeWriteToSocket(socket, `:${this.servername} 431 * :No nickname\r\n`); await this.safeWriteToSocket(socket, `:${this.servername} 431 * :No nickname\r\n`);
break; 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) { if (new_nickname.length > this.nicklen) {
await this.safeWriteToSocket(socket, `:${this.servername} 432 * ${new_nickname} :Erroneus nickname (too long)\r\n`); await this.safeWriteToSocket(socket, `:${this.servername} 432 * ${new_nickname} :Erroneus nickname (too long)\r\n`);
break; break;
@@ -1863,7 +2134,7 @@ class WTVIRC {
const invited = this.channelData.get(ch).inviteexceptions; const invited = this.channelData.get(ch).inviteexceptions;
let isInvited = false; let isInvited = false;
for (const inviteMask of invited) { for (const inviteMask of invited) {
isInvited = checkMask(inviteMask, socket); isInvited = this.checkMask(inviteMask, socket);
if (isInvited) { if (isInvited) {
break; // Stop checking if we found a match break; // Stop checking if we found a match
} }
@@ -1948,7 +2219,7 @@ class WTVIRC {
} }
} }
var users = this.getUsersInChannel(ch); var users = this.getUsersInChannel(ch);
var output_lines = []; output_lines = [];
var prefixRegex = new RegExp(`^[${this.supported_prefixes[1]}]`); var prefixRegex = new RegExp(`^[${this.supported_prefixes[1]}]`);
if (users.length > 0) { if (users.length > 0) {
users.sort((a, b) => { users.sort((a, b) => {
@@ -2006,7 +2277,7 @@ class WTVIRC {
} }
} }
if (this.clientIsWebTV(socket) && this.enable_webtv_command_hacks) { if (this.clientIsWebTV(socket) && this.enable_webtv_command_hacks) {
var output_lines = []; output_lines = [];
var channelObj = this.channelData.get(ch); var channelObj = this.channelData.get(ch);
output_lines.push("You have joined " + ch); output_lines.push("You have joined " + ch);
output_lines.push("Current channel modes: +" + channelObj.modes.join('')); output_lines.push("Current channel modes: +" + channelObj.modes.join(''));
@@ -2038,7 +2309,7 @@ class WTVIRC {
break; break;
} }
var users = this.getUsersInChannel(channel); var users = this.getUsersInChannel(channel);
var output_lines = []; output_lines = [];
if (users.length > 0) { if (users.length > 0) {
if (socket.client_caps.includes('userhost-in-names')) { if (socket.client_caps.includes('userhost-in-names')) {
const userHosts = users.map(user => { 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`); await this.safeWriteToSocket(socket, `:${this.servername} 315 ${socket.nickname} ${target} :End of /WHO list\r\n`);
} else { } else {
// WHO for nickname // WHO for nickname
var output_lines = []; output_lines = [];
if (target.includes('*') || target.includes('?')) { if (target.includes('*') || target.includes('?')) {
// Wildcard mask search for nicknames // Wildcard mask search for nicknames
const maskRegex = new RegExp('^' + target.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); const maskRegex = new RegExp('^' + target.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
@@ -2574,7 +2845,7 @@ class WTVIRC {
break; break;
} }
var type = params[0] ? params[0].toUpperCase() : ''; var type = params[0] ? params[0].toUpperCase() : '';
var output_lines = []; output_lines = [];
switch (type) { switch (type) {
case "HELP": case "HELP":
output_lines.push(`:${this.servername} 200 ${socket.nickname} :Available commands:\r\n`); output_lines.push(`:${this.servername} 200 ${socket.nickname} :Available commands:\r\n`);
@@ -2711,7 +2982,7 @@ class WTVIRC {
} }
const userChannels = []; const userChannels = [];
var output_lines = []; output_lines = [];
for (const [ch, channelObj] of this.channelData.entries()) { for (const [ch, channelObj] of this.channelData.entries()) {
if (channelObj.users.has(whoisNick)) { if (channelObj.users.has(whoisNick)) {
let prefix = ''; let prefix = '';
@@ -3010,11 +3281,28 @@ class WTVIRC {
this.servers.delete(socket); this.servers.delete(socket);
this.serverusers.delete(socket); this.serverusers.delete(socket);
} else { } else {
this.clients.filter(c => c !== socket); this.clients = this.clients.filter(c => c !== socket);
} }
if (socket._idleInterval) { if (socket._idleInterval) {
clearInterval(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) { if (close) {
socket.end(); socket.end();
} }
@@ -4057,7 +4345,7 @@ class WTVIRC {
} }
} else if (modes === 'b') { } else if (modes === 'b') {
// Get the list of channel bans // Get the list of channel bans
var output_lines = []; output_lines = [];
if (this.channelData.has(channel).bans) { if (this.channelData.has(channel).bans) {
const bans = this.channelData.has(channel).bans; const bans = this.channelData.has(channel).bans;
for (const ban of bans) { for (const ban of bans) {
@@ -4069,7 +4357,7 @@ class WTVIRC {
return; return;
} else if (modes === 'e') { } else if (modes === 'e') {
// Get the list of channel exemptions // Get the list of channel exemptions
var output_lines = []; output_lines = [];
if (this.channelData.has(channel).exemptions) { if (this.channelData.has(channel).exemptions) {
const exemptions = this.channelData.has(channel).exemptions; const exemptions = this.channelData.has(channel).exemptions;
for (const exemption of exemptions) { for (const exemption of exemptions) {
@@ -4081,7 +4369,7 @@ class WTVIRC {
return; return;
} else if (modes === 'I') { } else if (modes === 'I') {
// Get the list of channel invites masks // Get the list of channel invites masks
var output_lines = []; output_lines = [];
if (this.channelData.has(channel).invites) { if (this.channelData.has(channel).invites) {
const invites = this.channelData.has(channel).invites; const invites = this.channelData.has(channel).invites;
for (const invite of invites) { for (const invite of invites) {