Merge pull request #3770 from thelounge/sts

Implement strict transport security (STS) for IRC networks
This commit is contained in:
Pavel Djundik 2020-02-27 13:53:51 +02:00 committed by GitHub
commit bec6665044
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 224 additions and 21 deletions

View file

@ -65,10 +65,18 @@
type="checkbox" type="checkbox"
name="tls" name="tls"
:checked="defaults.tls ? true : false" :checked="defaults.tls ? true : false"
:disabled="config.lockNetwork ? true : false" :disabled="
config.lockNetwork || defaults.hasSTSPolicy ? true : false
"
@change="onSecureChanged" @change="onSecureChanged"
/> />
Use secure connection (TLS) Use secure connection (TLS)
<span
v-if="defaults.hasSTSPolicy"
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="This network has a strict transport security policy, you will be unable to disable TLS"
>🔒 STS</span
>
</label> </label>
<label class="tls"> <label class="tls">
<input <input

View file

@ -22,6 +22,7 @@ module.exports = Client;
const events = [ const events = [
"away", "away",
"cap",
"connection", "connection",
"unhandled", "unhandled",
"ctcp", "ctcp",
@ -616,10 +617,7 @@ Client.prototype.quit = function(signOut) {
} }
this.networks.forEach((network) => { this.networks.forEach((network) => {
if (network.irc) { network.quit(Helper.config.leaveMessage);
network.irc.quit(Helper.config.leaveMessage);
}
network.destroy(); network.destroy();
}); });

View file

@ -6,6 +6,7 @@ const IrcFramework = require("irc-framework");
const Chan = require("./chan"); const Chan = require("./chan");
const Msg = require("./msg"); const Msg = require("./msg");
const Helper = require("../helper"); const Helper = require("../helper");
const STSPolicies = require("../plugins/sts");
module.exports = Network; module.exports = Network;
@ -78,7 +79,7 @@ Network.prototype.validate = function(client) {
this.username = cleanString(this.username) || "thelounge"; this.username = cleanString(this.username) || "thelounge";
this.realname = cleanString(this.realname) || "The Lounge User"; this.realname = cleanString(this.realname) || "The Lounge User";
this.password = cleanString(this.password); this.password = cleanString(this.password);
this.host = cleanString(this.host); this.host = cleanString(this.host).toLowerCase();
this.name = cleanString(this.name); this.name = cleanString(this.name);
if (!this.port) { if (!this.port) {
@ -124,6 +125,23 @@ Network.prototype.validate = function(client) {
return false; return false;
} }
const stsPolicy = STSPolicies.get(this.host);
if (stsPolicy && !this.tls) {
this.channels[0].pushMessage(
client,
new Msg({
type: Msg.Type.ERROR,
text: `${this.host} has an active strict transport security policy, will connect to port ${stsPolicy.port} over a secure connection.`,
}),
true
);
this.port = stsPolicy.port;
this.tls = true;
this.rejectUnauthorized = true;
}
return true; return true;
}; };
@ -355,6 +373,17 @@ Network.prototype.addChannel = function(newChan) {
return index; return index;
}; };
Network.prototype.quit = function(quitMessage) {
if (!this.irc) {
return;
}
// https://ircv3.net/specs/extensions/sts#rescheduling-expiry-on-disconnect
STSPolicies.refreshExpiration(this.host);
this.irc.quit(quitMessage || Helper.config.leaveMessage);
};
Network.prototype.exportForEdit = function() { Network.prototype.exportForEdit = function() {
let fieldsToReturn; let fieldsToReturn;
@ -378,7 +407,11 @@ Network.prototype.exportForEdit = function() {
fieldsToReturn = ["name", "nick", "username", "password", "realname"]; fieldsToReturn = ["name", "nick", "username", "password", "realname"];
} }
return _.pick(this, fieldsToReturn); const data = _.pick(this, fieldsToReturn);
data.hasSTSPolicy = !!STSPolicies.get(this.host);
return data;
}; };
Network.prototype.export = function() { Network.prototype.export = function() {

View file

@ -1,19 +1,13 @@
"use strict"; "use strict";
const Helper = require("../../helper");
exports.commands = ["disconnect"]; exports.commands = ["disconnect"];
exports.allowDisconnected = true; exports.allowDisconnected = true;
exports.input = function(network, chan, cmd, args) { exports.input = function(network, chan, cmd, args) {
const quitMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage; const quitMessage = args[0] ? args.join(" ") : null;
// Even if we are disconnected, but there is an internal connection object
// pass the quit/end to it, so the reconnection timer stops
if (network.irc && network.irc.connection) {
network.irc.quit(quitMessage);
}
network.quit(quitMessage);
network.userDisconnected = true; network.userDisconnected = true;
this.save(); this.save();
}; };

View file

@ -1,7 +1,6 @@
"use strict"; "use strict";
const _ = require("lodash"); const _ = require("lodash");
const Helper = require("../../helper");
exports.commands = ["quit"]; exports.commands = ["quit"];
exports.allowDisconnected = true; exports.allowDisconnected = true;
@ -16,10 +15,8 @@ exports.input = function(network, chan, cmd, args) {
network: network.uuid, network: network.uuid,
}); });
if (network.irc) { const quitMessage = args[0] ? args.join(" ") : null;
const quitMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage; network.quit(quitMessage);
network.irc.quit(quitMessage);
}
return true; return true;
}; };

View file

@ -0,0 +1,78 @@
"use strict";
const Msg = require("../../models/msg");
const STSPolicies = require("../sts");
module.exports = function(irc, network) {
const client = this;
irc.on("cap ls", (data) => {
handleSTS(data, true);
});
irc.on("cap new", (data) => {
handleSTS(data, false);
});
function handleSTS(data, shouldReconnect) {
if (!Object.prototype.hasOwnProperty.call(data.capabilities, "sts")) {
return;
}
const isSecure = irc.connection.transport.socket.encrypted;
const values = {};
data.capabilities.sts.split(",").map((value) => {
value = value.split("=", 2);
values[value[0]] = value[1];
});
if (isSecure) {
const duration = parseInt(values.duration, 10);
if (isNaN(duration)) {
return;
}
STSPolicies.update(network.host, network.port, duration);
} else {
const port = parseInt(values.port, 10);
if (isNaN(port)) {
return;
}
network.channels[0].pushMessage(
client,
new Msg({
text: `Server sent a strict transport security policy, reconnecting to ${network.host}:${port}`,
}),
true
);
// Forcefully end the connection if STS is seen in CAP LS
// We will update the port and tls setting if we see CAP NEW,
// but will not force a reconnection
if (shouldReconnect) {
irc.connection.end();
}
// Update the port
network.port = port;
irc.options.port = port;
// Enable TLS
network.tls = true;
network.rejectUnauthorized = true;
irc.options.tls = true;
irc.options.rejectUnauthorized = true;
if (shouldReconnect) {
// Start a new connection
irc.connect();
}
client.save();
}
}
};

95
src/plugins/sts.js Normal file
View file

@ -0,0 +1,95 @@
"use strict";
const _ = require("lodash");
const fs = require("fs");
const path = require("path");
const log = require("../log");
const Helper = require("../helper");
class STSPolicies {
constructor() {
this.stsFile = path.join(Helper.getHomePath(), "sts-policies.json");
this.policies = new Map();
this.refresh = _.debounce(this.saveFile, 10000, {maxWait: 60000});
if (!fs.existsSync(this.stsFile)) {
return;
}
const storedPolicies = JSON.parse(fs.readFileSync(this.stsFile, "utf-8"));
const now = Date.now();
storedPolicies.forEach((value) => {
if (value.expires > now) {
this.policies.set(value.host, {
port: value.port,
duration: value.duration,
expires: value.expires,
});
}
});
}
get(host) {
const policy = this.policies.get(host);
if (typeof policy === "undefined") {
return null;
}
if (policy.expires <= Date.now()) {
this.policies.delete(host);
this.refresh();
return null;
}
return policy;
}
update(host, port, duration) {
if (duration > 0) {
this.policies.set(host, {
port: port,
duration: duration,
expires: Date.now() + duration * 1000,
});
} else {
this.policies.delete(host);
}
this.refresh();
}
refreshExpiration(host) {
const policy = this.policies.get(host);
if (typeof policy === "undefined") {
return null;
}
policy.expires = Date.now() + policy.duration * 1000;
}
saveFile() {
const policiesToStore = [];
this.policies.forEach((value, key) => {
policiesToStore.push({
host: key,
port: value.port,
duration: value.duration,
expires: value.expires,
});
});
const file = JSON.stringify(policiesToStore, null, "\t");
fs.writeFile(this.stsFile, file, {flag: "w+"}, (err) => {
if (err) {
log.error("Failed to update STS policies file!", err);
}
});
}
}
module.exports = new STSPolicies();