Merge pull request #3770 from thelounge/sts
Implement strict transport security (STS) for IRC networks
This commit is contained in:
commit
bec6665044
@ -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
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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() {
|
||||||
|
@ -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();
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
78
src/plugins/irc-events/cap.js
Normal file
78
src/plugins/irc-events/cap.js
Normal 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
95
src/plugins/sts.js
Normal 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();
|
Loading…
Reference in New Issue
Block a user