Merge pull request #614 from calzoneman/ip-session-age

Restrict chat messages from newer accounts/IPs
This commit is contained in:
Calvin Montgomery 2016-08-24 19:49:46 -07:00 committed by GitHub
commit b34a8fce3c
12 changed files with 278 additions and 36 deletions

View File

@ -2,7 +2,7 @@
"author": "Calvin Montgomery", "author": "Calvin Montgomery",
"name": "CyTube", "name": "CyTube",
"description": "Online media synchronizer and chat", "description": "Online media synchronizer and chat",
"version": "3.20.0", "version": "3.21.0",
"repository": { "repository": {
"url": "http://github.com/calzoneman/sync" "url": "http://github.com/calzoneman/sync"
}, },

View File

@ -121,6 +121,27 @@ ChatModule.prototype.shadowMutedUsers = function () {
}); });
}; };
ChatModule.prototype.restrictNewAccount = function restrictNewAccount(user, data) {
if (user.account.effectiveRank < 2 && this.channel.modules.options) {
const firstSeen = user.getFirstSeenTime();
const opts = this.channel.modules.options;
if (firstSeen > Date.now() - opts.get("new_user_chat_delay")*1000) {
user.socket.emit("spamFiltered", {
reason: "NEW_USER_CHAT"
});
return true;
} else if ((firstSeen > Date.now() - opts.get("new_user_chat_link_delay")*1000)
&& data.msg.match(LINK)) {
user.socket.emit("spamFiltered", {
reason: "NEW_USER_CHAT_LINK"
});
return true;
}
}
return false;
};
ChatModule.prototype.handleChatMsg = function (user, data) { ChatModule.prototype.handleChatMsg = function (user, data) {
var self = this; var self = this;
counters.add("chat:incoming"); counters.add("chat:incoming");
@ -131,6 +152,12 @@ ChatModule.prototype.handleChatMsg = function (user, data) {
// Limit to 240 characters // Limit to 240 characters
data.msg = data.msg.substring(0, 240); data.msg = data.msg.substring(0, 240);
// Restrict new accounts/IPs from chatting and posting links
if (this.restrictNewAccount(user, data)) {
return;
}
// If channel doesn't permit them, strip ASCII control characters // If channel doesn't permit them, strip ASCII control characters
if (!this.channel.modules.options || if (!this.channel.modules.options ||
!this.channel.modules.options.get("allow_ascii_control")) { !this.channel.modules.options.get("allow_ascii_control")) {
@ -174,6 +201,11 @@ ChatModule.prototype.handlePm = function (user, data) {
}); });
} }
// Restrict new accounts/IPs from chatting and posting links
if (this.restrictNewAccount(user, data)) {
return;
}
if (data.msg.match(Config.get("link-domain-blacklist-regex"))) { if (data.msg.match(Config.get("link-domain-blacklist-regex"))) {
this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " + this.channel.logger.log(user.displayip + " (" + user.getName() + ") was kicked for " +
"blacklisted domain"); "blacklisted domain");

View File

@ -25,7 +25,9 @@ function OptionsModule(channel) {
allow_dupes: false, // Allow duplicate videos on the playlist allow_dupes: false, // Allow duplicate videos on the playlist
torbanned: false, // Block connections from Tor exit nodes torbanned: false, // Block connections from Tor exit nodes
allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f) allow_ascii_control: false,// Allow ASCII control characters (\x00-\x1f)
playlist_max_per_user: 0 // Maximum number of playlist items per user playlist_max_per_user: 0, // Maximum number of playlist items per user
new_user_chat_delay: 10 * 60, // Minimum account/IP age to chat
new_user_chat_link_delay: 60 * 60 // Minimum account/IP age to post links
}; };
} }
@ -271,6 +273,20 @@ OptionsModule.prototype.handleSetOptions = function (user, data) {
} }
} }
if ("new_user_chat_delay" in data) {
var delay = data.new_user_chat_delay;
if (!isNaN(delay) && delay >= 0) {
this.opts.new_user_chat_delay = delay;
}
}
if ("new_user_chat_link_delay" in data) {
var delay = data.new_user_chat_link_delay;
if (!isNaN(delay) && delay >= 0) {
this.opts.new_user_chat_link_delay = delay;
}
}
this.channel.logger.log("[mod] " + user.getName() + " updated channel options"); this.channel.logger.log("[mod] " + user.getName() + " updated channel options");
this.sendOpts(this.channel.users); this.sendOpts(this.channel.users);
}; };

View File

@ -134,7 +134,7 @@ module.exports = {
callback(err, null); callback(err, null);
return; return;
} }
if (accts.length >= Config.get("max-accounts-per-ip")) { if (accts.length >= Config.get("max-accounts-per-ip")) {
delete registrationLock[lname]; delete registrationLock[lname];
callback("You have registered too many accounts from this "+ callback("You have registered too many accounts from this "+
@ -207,7 +207,7 @@ module.exports = {
the hashes match. the hashes match.
*/ */
db.query("SELECT name,password,global_rank FROM `users` WHERE name=?", db.query("SELECT name,password,global_rank,time FROM `users` WHERE name=?",
[name], [name],
function (err, rows) { function (err, rows) {
if (err) { if (err) {

View File

@ -15,6 +15,7 @@ var crypto = require("crypto");
var isTorExit = require("../tor").isTorExit; var isTorExit = require("../tor").isTorExit;
var session = require("../session"); var session = require("../session");
import counters from '../counters'; import counters from '../counters';
import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
var CONNECT_RATE = { var CONNECT_RATE = {
burst: 5, burst: 5,
@ -25,33 +26,54 @@ var ipThrottle = {};
// Keep track of number of connections per IP // Keep track of number of connections per IP
var ipCount = {}; var ipCount = {};
function parseCookies(socket, accept) {
var req = socket.request;
if (req.headers.cookie) {
cookieParser(req, null, () => {
accept(null, true);
});
} else {
req.cookies = {};
req.signedCookies = {};
accept(null, true);
}
}
/** /**
* Called before an incoming socket.io connection is accepted. * Called before an incoming socket.io connection is accepted.
*/ */
function handleAuth(socket, accept) { function handleAuth(socket, accept) {
var data = socket.request;
socket.user = false; socket.user = false;
if (data.headers.cookie) { var auth = socket.request.signedCookies.auth;
cookieParser(data, null, function () { if (!auth) {
var auth = data.signedCookies.auth; return accept(null, true);
if (!auth) {
return accept(null, true);
}
session.verifySession(auth, function (err, user) {
if (!err) {
socket.user = {
name: user.name,
global_rank: user.global_rank
};
}
accept(null, true);
});
});
} else {
accept(null, true);
} }
session.verifySession(auth, function (err, user) {
if (!err) {
socket.user = {
name: user.name,
global_rank: user.global_rank,
registrationTime: new Date(user.time)
};
}
accept(null, true);
});
}
function handleIPSessionCookie(socket, accept) {
var cookie = socket.request.signedCookies['ip-session'];
if (!cookie) {
socket.ipSessionFirstSeen = new Date();
return accept(null, true);
}
var sessionMatch = verifyIPSessionCookie(socket._realip, cookie);
if (sessionMatch) {
socket.ipSessionFirstSeen = sessionMatch.date;
} else {
socket.ipSessionFirstSeen = new Date();
}
accept(null, true);
} }
function throttleIP(sock) { function throttleIP(sock) {
@ -214,6 +236,7 @@ function handleConnection(sock) {
user.setFlag(Flags.U_REGISTERED); user.setFlag(Flags.U_REGISTERED);
user.clearFlag(Flags.U_READY); user.clearFlag(Flags.U_READY);
user.account.name = sock.user.name; user.account.name = sock.user.name;
user.registrationTime = sock.user.registrationTime;
user.refreshAccount(function (err, account) { user.refreshAccount(function (err, account) {
if (err) { if (err) {
user.clearFlag(Flags.U_REGISTERED); user.clearFlag(Flags.U_REGISTERED);
@ -247,8 +270,10 @@ module.exports = {
}; };
var io = sio.instance = sio(); var io = sio.instance = sio();
io.use(handleAuth);
io.use(ipForwardingMiddleware(webConfig)); io.use(ipForwardingMiddleware(webConfig));
io.use(parseCookies);
io.use(handleIPSessionCookie);
io.use(handleAuth);
io.on("connection", handleConnection); io.on("connection", handleConnection);
Config.get("listen").forEach(function (bind) { Config.get("listen").forEach(function (bind) {

View File

@ -288,6 +288,7 @@ User.prototype.login = function (name, pw) {
} }
self.account.name = user.name; self.account.name = user.name;
self.registrationTime = new Date(user.time);
self.setFlag(Flags.U_REGISTERED); self.setFlag(Flags.U_REGISTERED);
self.refreshAccount(function (err, account) { self.refreshAccount(function (err, account) {
if (err) { if (err) {
@ -449,4 +450,18 @@ User.prototype.refreshAccount = function (cb) {
}); });
}; };
User.prototype.getFirstSeenTime = function getFirstSeenTime() {
if (this.registrationTime && this.socket.ipSessionFirstSeen) {
return Math.min(this.registrationTime.getTime(), this.socket.ipSessionFirstSeen.getTime());
} else if (this.registrationTime) {
return this.registrationTime.getTime();
} else if (this.socket.ipSessionFirstSeen) {
return this.socket.ipSessionFirstSeen.getTime();
} else {
Logger.errlog.log(`User "${this.getName()}" (IP: ${this.realip}) has neither ` +
"an IP sesion first seen time nor a registered account.");
return Date.now();
}
};
module.exports = User; module.exports = User;

View File

@ -0,0 +1,86 @@
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
const STATE_FOLDER_PATH = path.resolve(__dirname, '..', '..', '..', 'state');
const SALT_PATH = path.resolve(__dirname, '..', '..', '..', 'state', 'ipsessionsalt.json');
const NO_EXPIRATION = new Date('Fri, 31 Dec 9999 23:59:59 GMT');
var SALT;
try {
SALT = require(SALT_PATH);
} catch (error) {
SALT = crypto.randomBytes(32).toString('base64');
try {
fs.mkdirSync(STATE_FOLDER_PATH);
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
fs.writeFileSync(SALT_PATH, JSON.stringify(SALT));
}
function sha256(input) {
var hash = crypto.createHash("sha256");
hash.update(input);
return hash.digest("base64");
}
export function createIPSessionCookie(ip, date) {
const hashInput = [
ip,
date.getTime(),
SALT
].join(':');
return [
date.getTime(),
sha256(hashInput)
].join(':');
}
export function verifyIPSessionCookie(ip, cookie) {
const parts = cookie.split(':');
if (parts.length !== 2) {
return false;
}
const timestamp = parseInt(parts[0], 10);
if (isNaN(timestamp)) {
return false;
}
const date = new Date(timestamp);
const expected = createIPSessionCookie(ip, date);
if (expected !== cookie) {
return false;
}
return {
date: date,
};
}
export function ipSessionCookieMiddleware(req, res, next) {
var firstSeen = new Date();
var hasSession = false;
if (req.signedCookies && req.signedCookies['ip-session']) {
var sessionMatch = verifyIPSessionCookie(req.realIP, req.signedCookies['ip-session']);
if (sessionMatch) {
hasSession = true;
firstSeen = sessionMatch.date;
}
}
if (!hasSession) {
res.cookie('ip-session', createIPSessionCookie(req.realIP, firstSeen), {
signed: true,
httpOnly: true,
expires: NO_EXPIRATION
});
}
req.ipSessionFirstSeen = firstSeen;
next();
}

View File

@ -146,6 +146,7 @@ module.exports = {
} }
app.use(cookieParser(webConfig.getCookieSecret())); app.use(cookieParser(webConfig.getCookieSecret()));
app.use(csrf.init(webConfig.getCookieDomain())); app.use(csrf.init(webConfig.getCookieDomain()));
app.use('/r/:channel', require('./middleware/ipsessioncookie').ipSessionCookieMiddleware);
initializeLog(app); initializeLog(app);
require('./middleware/authorize')(app, session); require('./middleware/authorize')(app, session);

View File

@ -46,6 +46,15 @@ mixin textbox-auto(id, label, placeholder)
else else
input.form-control.cs-textbox(id=id, type="text") input.form-control.cs-textbox(id=id, type="text")
mixin textbox-timeinput-auto(id, label, placeholder)
.form-group
label.control-label.col-sm-4(for=id)= label
.col-sm-8
if placeholder
input.form-control.cs-textbox-timeinput(id=id, type="text", placeholder=placeholder)
else
input.form-control.cs-textbox-timeinput(id=id, type="text")
mixin miscoptions mixin miscoptions
#cs-miscoptions.tab-pane.active #cs-miscoptions.tab-pane.active
h4 General Settings h4 General Settings
@ -63,6 +72,11 @@ mixin miscoptions
+rcheckbox-auto("cs-chat_antiflood", "Throttle chat") +rcheckbox-auto("cs-chat_antiflood", "Throttle chat")
+textbox-auto("cs-chat_antiflood_burst", "# of messages allowed before throttling") +textbox-auto("cs-chat_antiflood_burst", "# of messages allowed before throttling")
+textbox-auto("cs-chat_antiflood_sustained", "# of messages (after burst) allowed per second") +textbox-auto("cs-chat_antiflood_sustained", "# of messages (after burst) allowed per second")
+textbox-timeinput-auto("cs-new_user_chat_delay", "Delay before new accounts can chat", "0")
.form-group
.col-sm-8.col-sm-offset-4
span.text-info Restrictions to new accounts can be disabled by setting the delay to 0.
+textbox-timeinput-auto("cs-new_user_chat_link_delay", "Delay before new accounts can post links in chat", "0")
.form-group .form-group
.col-sm-8.col-sm-offset-4 .col-sm-8.col-sm-offset-4
span.text-info Changes are automatically saved. span.text-info Changes are automatically saved.

View File

@ -88,6 +88,22 @@ Callbacks = {
scrollChat(); scrollChat();
}, },
spamFiltered: function(data) {
var message = "Spam Filtered.";
switch (data.reason) {
case "NEW_USER_CHAT":
message = "Your account is too new to chat in this channel. " +
"Please wait a while and try again.";
break;
case "NEW_USER_CHAT_LINK":
message = "Your account is too new to post links in this channel. " +
"Please wait a while and try again.";
break;
}
errDialog(message);
},
needPassword: function (wrongpw) { needPassword: function (wrongpw) {
var div = $("<div/>"); var div = $("<div/>");
$("<strong/>").text("Channel Password") $("<strong/>").text("Channel Password")

View File

@ -636,6 +636,36 @@ $(".cs-textbox").keyup(function () {
}, 1000); }, 1000);
}); });
$(".cs-textbox-timeinput").keyup(function (event) {
var box = $(this);
var key = box.attr("id").replace("cs-", "");
var value = box.val();
var lastkey = Date.now();
box.data("lastkey", lastkey);
setTimeout(function () {
if (box.data("lastkey") !== lastkey || box.val() !== value) {
return;
}
$("#cs-textbox-timeinput-validation-error-" + key).remove();
$(event.target).parent().removeClass("has-error");
var data = {};
try {
data[key] = parseTimeout(value);
} catch (error) {
var msg = "Invalid timespan value '" + value + "'. Please use the format " +
"HH:MM:SS or enter a single number for the number of seconds.";
var validationError = $("<p/>").addClass("text-danger").text(msg)
.attr("id", "cs-textbox-timeinput-validation-error-" + key);
validationError.insertAfter(event.target);
$(event.target).parent().addClass("has-error");
return;
}
socket.emit("setOptions", data);
}, 1000);
});
$("#cs-chanlog-refresh").click(function () { $("#cs-chanlog-refresh").click(function () {
socket.emit("readChanLog"); socket.emit("readChanLog");
}); });

View File

@ -761,18 +761,23 @@ function applyOpts() {
} }
} }
function showPollMenu() { function parseTimeout(t) {
function parseTimeout(t) { var m;
var m; if (m = t.match(/^(\d+):(\d+):(\d+)$/)) {
if (m = t.match(/^(\d+):(\d+)$/)) { // HH:MM:SS
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10);
} else if (m = t.match(/^(\d+)$/)) { } else if (m = t.match(/^(\d+):(\d+)$/)) {
return parseInt(m[1], 10); // MM:SS
} else { return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
throw new Error("Invalid timeout value '" + t + "'"); } else if (m = t.match(/^(\d+)$/)) {
} // Seconds
return parseInt(m[1], 10);
} else {
throw new Error("Invalid timeout value '" + t + "'");
} }
}
function showPollMenu() {
$("#pollwrap .poll-menu").remove(); $("#pollwrap .poll-menu").remove();
var menu = $("<div/>").addClass("well poll-menu") var menu = $("<div/>").addClass("well poll-menu")
.prependTo($("#pollwrap")); .prependTo($("#pollwrap"));
@ -932,6 +937,8 @@ function handleModPermissions() {
$("#cs-torbanned").prop("checked", CHANNEL.opts.torbanned); $("#cs-torbanned").prop("checked", CHANNEL.opts.torbanned);
$("#cs-allow_ascii_control").prop("checked", CHANNEL.opts.allow_ascii_control); $("#cs-allow_ascii_control").prop("checked", CHANNEL.opts.allow_ascii_control);
$("#cs-playlist_max_per_user").val(CHANNEL.opts.playlist_max_per_user || 0); $("#cs-playlist_max_per_user").val(CHANNEL.opts.playlist_max_per_user || 0);
$("#cs-new_user_chat_delay").val(formatTime(CHANNEL.opts.new_user_chat_delay || 0));
$("#cs-new_user_chat_link_delay").val(formatTime(CHANNEL.opts.new_user_chat_link_delay || 0));
(function() { (function() {
if(typeof CHANNEL.opts.maxlength != "number") { if(typeof CHANNEL.opts.maxlength != "number") {
$("#cs-maxlength").val(""); $("#cs-maxlength").val("");