Enforce stricter validation on polls

This commit is contained in:
Calvin Montgomery 2017-03-20 21:37:32 -07:00
parent 41a538c655
commit 9dc82ad444
9 changed files with 231 additions and 11 deletions

View File

@ -1,3 +1,9 @@
2017-03-20
==========
Polls are now more strictly validated, including the number of options. The
default limit is 50 options, which you can configure via `poll.max-options`.
2017-03-11 2017-03-11
========== ==========

View File

@ -250,3 +250,6 @@ service-socket:
# Twitch Client ID for the data API (used for VOD lookups) # Twitch Client ID for the data API (used for VOD lookups)
# https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup # https://github.com/justintv/Twitch-API/blob/master/authentication.md#developer-setup
twitch-client-id: null twitch-client-id: null
poll:
max-options: 50

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.34.3", "version": "3.34.4",
"repository": { "repository": {
"url": "http://github.com/calzoneman/sync" "url": "http://github.com/calzoneman/sync"
}, },
@ -54,7 +54,7 @@
"postinstall": "./postinstall.sh", "postinstall": "./postinstall.sh",
"server-dev": "babel -D --watch --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/", "server-dev": "babel -D --watch --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/",
"generate-userscript": "$npm_node_execpath gdrive-userscript/generate-userscript $@ > www/js/cytube-google-drive.user.js", "generate-userscript": "$npm_node_execpath gdrive-userscript/generate-userscript $@ > www/js/cytube-google-drive.user.js",
"test": "mocha", "test": "mocha --recursive test",
"integration-test": "mocha --recursive integration_test" "integration-test": "mocha --recursive integration_test"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,7 @@
var ChannelModule = require("./module"); var ChannelModule = require("./module");
var Poll = require("../poll").Poll; var Poll = require("../poll").Poll;
import { ValidationError } from '../errors';
import Config from '../config';
const TYPE_NEW_POLL = { const TYPE_NEW_POLL = {
title: "string", title: "string",
@ -130,16 +132,55 @@ PollModule.prototype.broadcastPoll = function (isNewPoll) {
this.channel.broadcastToRoom(event, obscured, this.roomNoViewHidden); this.channel.broadcastToRoom(event, obscured, this.roomNoViewHidden);
}; };
PollModule.prototype.handleNewPoll = function (user, data) { PollModule.prototype.validatePollInput = function validatePollInput(title, options) {
if (typeof title !== 'string') {
throw new ValidationError('Poll title must be a string.');
}
if (title.length > 255) {
throw new ValidationError('Poll title must be no more than 255 characters long.');
}
if (!Array.isArray(options)) {
throw new ValidationError('Poll options must be an array.');
}
if (options.length > Config.get('poll.max-options')) {
throw new ValidationError(`Polls are limited to a maximum of ${Config.get('poll.max-options')} options.`);
}
for (let i = 0; i < options.length; i++) {
if (typeof options[i] !== 'string') {
throw new ValidationError('Poll options must be strings.');
}
if (options[i].length === 0 || options[i].length > 255) {
throw new ValidationError('Poll options must be 1-255 characters long.');
}
}
};
PollModule.prototype.handleNewPoll = function (user, data, ack) {
if (!this.channel.modules.permissions.canControlPoll(user)) { if (!this.channel.modules.permissions.canControlPoll(user)) {
return; return;
} }
var title = data.title.substring(0, 255); if (typeof data !== 'object' || data === null) {
var opts = data.opts.map(function (x) { return (""+x).substring(0, 255); }); ack({
var obscured = data.obscured; error: {
message: 'Invalid data received for poll creation.'
}
});
return;
}
var poll = new Poll(user.getName(), title, opts, obscured); try {
this.validatePollInput(data.title, data.opts);
} catch (error) {
ack({
error: {
message: error.message
}
});
return;
}
var poll = new Poll(user.getName(), data.title, data.opts, data.obscured);
var self = this; var self = this;
if (data.hasOwnProperty("timeout") && !isNaN(data.timeout) && data.timeout > 0) { if (data.hasOwnProperty("timeout") && !isNaN(data.timeout) && data.timeout > 0) {
poll.timer = setTimeout(function () { poll.timer = setTimeout(function () {
@ -155,6 +196,7 @@ PollModule.prototype.handleNewPoll = function (user, data) {
this.poll = poll; this.poll = poll;
this.broadcastPoll(true); this.broadcastPoll(true);
this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'"); this.channel.logger.log("[poll] " + user.getName() + " opened poll: '" + poll.title + "'");
ack({});
}; };
PollModule.prototype.handleVote = function (user, data) { PollModule.prototype.handleVote = function (user, data) {
@ -198,6 +240,16 @@ PollModule.prototype.handlePollCmd = function (obscured, user, msg, meta) {
var args = msg.split(","); var args = msg.split(",");
var title = args.shift(); var title = args.shift();
try {
this.validatePollInput(title, args);
} catch (error) {
user.socket.emit('errorMsg', {
msg: 'Error creating poll: ' + error.message
});
return;
}
var poll = new Poll(user.getName(), title, args, obscured); var poll = new Poll(user.getName(), title, args, obscured);
this.poll = poll; this.poll = poll;
this.broadcastPoll(true); this.broadcastPoll(true);

View File

@ -119,7 +119,10 @@ var defaults = {
"google-drive": { "google-drive": {
"html5-hack-enabled": false "html5-hack-enabled": false
}, },
"twitch-client-id": null "twitch-client-id": null,
poll: {
"max-options": 50
}
}; };
/** /**

View File

@ -7,3 +7,4 @@ export const CSRFError = createError('CSRFError');
export const HTTPError = createError('HTTPError', { export const HTTPError = createError('HTTPError', {
status: HTTPStatus.INTERNAL_SERVER_ERROR status: HTTPStatus.INTERNAL_SERVER_ERROR
}); });
export const ValidationError = createError('ValidationError');

View File

@ -133,14 +133,14 @@ function ipLimitReached(sock) {
function addTypecheckedFunctions(sock) { function addTypecheckedFunctions(sock) {
sock.typecheckedOn = function (msg, template, cb) { sock.typecheckedOn = function (msg, template, cb) {
sock.on(msg, function (data) { sock.on(msg, function (data, ack) {
typecheck(data, template, function (err, data) { typecheck(data, template, function (err, data) {
if (err) { if (err) {
sock.emit("errorMsg", { sock.emit("errorMsg", {
msg: "Unexpected error for message " + msg + ": " + err.message msg: "Unexpected error for message " + msg + ": " + err.message
}); });
} else { } else {
cb(data); cb(data, ack);
} }
}); });
}); });

145
test/channel/poll.js Normal file
View File

@ -0,0 +1,145 @@
const PollModule = require('../../lib/channel/poll');
const assert = require('assert');
const Config = require('../../lib/config');
describe('PollModule', () => {
describe('#validatePollInput', () => {
let pollModule = new PollModule({ uniqueName: 'testChannel', modules: {} });
it('accepts valid input', () => {
let title = '';
for (let i = 0; i < 20; i++) {
title += 'x';
}
pollModule.validatePollInput(title, ['ab', 'cd']);
});
it('rejects non-string titles', () => {
assert.throws(() => {
pollModule.validatePollInput(null, []);
}, /title/);
});
it('rejects invalidly long titles', () => {
let title = '';
for (let i = 0; i < 256; i++) {
title += 'x';
}
assert.throws(() => {
pollModule.validatePollInput(title, []);
}, /title/);
});
it('rejects non-array option parameter', () => {
assert.throws(() => {
pollModule.validatePollInput('poll', 1234);
}, /options/);
});
it('rejects too many options', () => {
const limit = Config.get('poll.max-options');
Config.set('poll.max-options', 2);
try {
assert.throws(() => {
pollModule.validatePollInput('poll', ['1', '2', '3', '4']);
}, /maximum of 2 options/);
} finally {
Config.set('poll.max-options', limit);
}
});
it('rejects non-string options', () => {
assert.throws(() => {
pollModule.validatePollInput('poll', [null]);
}, /options must be strings/);
});
it('rejects invalidly long options', () => {
let option = '';
for (let i = 0; i < 256; i++) {
option += 'x';
}
assert.throws(() => {
pollModule.validatePollInput('poll', [option]);
}, /options must be 1-255 characters/);
});
});
describe('#handleNewPoll', () => {
let fakeChannel = {
uniqueName: 'testChannel',
logger: {
log() {
}
},
broadcastToRoom() {
},
broadcastAll() {
},
modules: {
permissions: {
canControlPoll() {
return true;
}
}
}
};
let fakeUser = {
getName() {
return 'testUser';
}
};
let pollModule = new PollModule(fakeChannel);
it('creates a valid poll', () => {
let sentNewPoll = false;
let sentClosePoll = false;
fakeChannel.broadcastToRoom = (event, data, room) => {
if (room === 'testChannel:viewHidden' && event === 'newPoll') {
sentNewPoll = true;
}
};
fakeChannel.broadcastAll = (event) => {
if (event === 'closePoll') {
sentClosePoll = true;
}
};
pollModule.handleNewPoll(fakeUser, {
title: 'test poll',
opts: [
'option 1',
'option 2'
],
obscured: false
}, (ackResult) => {
assert(!ackResult.error, `Unexpected error: ${ackResult.error}`);
});
assert(sentClosePoll, 'Expected broadcast of closePoll event');
assert(sentNewPoll, 'Expected broadcast of newPoll event');
});
it('rejects an invalid poll', () => {
fakeChannel.broadcastToRoom = (event, data, room) => {
assert(false, 'Expected no events to be sent');
};
fakeChannel.broadcastAll = (event) => {
assert(false, 'Expected no events to be sent');
};
const options = [];
for (let i = 0; i < 200; i++) {
options.push('option ' + i);
}
pollModule.handleNewPoll(fakeUser, {
title: 'test poll',
opts: options,
obscured: false
}, (ackResult) => {
assert.equal(ackResult.error.message, 'Polls are limited to a maximum of 50 options.');
});
});
})
});

View File

@ -789,6 +789,7 @@ function showPollMenu() {
$("<strong/>").text("Title").appendTo(menu); $("<strong/>").text("Title").appendTo(menu);
var title = $("<input/>").addClass("form-control") var title = $("<input/>").addClass("form-control")
.attr("maxlength", "255")
.attr("type", "text") .attr("type", "text")
.appendTo(menu); .appendTo(menu);
@ -820,6 +821,7 @@ function showPollMenu() {
function addOption() { function addOption() {
$("<input/>").addClass("form-control") $("<input/>").addClass("form-control")
.attr("type", "text") .attr("type", "text")
.attr("maxlength", "255")
.addClass("poll-menu-option") .addClass("poll-menu-option")
.insertBefore(addbtn); .insertBefore(addbtn);
} }
@ -859,8 +861,16 @@ function showPollMenu() {
opts: opts, opts: opts,
obscured: hidden.prop("checked"), obscured: hidden.prop("checked"),
timeout: t timeout: t
}, function ack(result) {
if (result.error) {
modalAlert({
title: 'Error creating poll',
textContent: result.error.message
});
} else {
menu.remove();
}
}); });
menu.remove();
}); });
} }