From 50ca141f1d2e970ede144887d6c8f0e00e66f391 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 26 Oct 2015 22:56:53 -0700 Subject: [PATCH 1/7] Web refactoring --- index.js | 1 + src/errors.js | 5 ++ src/server.js | 11 ++++- src/web/csrf.js | 7 ++- src/web/httpstatus.js | 3 ++ src/web/localchannelindex.js | 14 ++++++ src/web/routes/channel.js | 25 ++++++++++ src/web/routes/index.js | 19 ++++++++ src/web/routes/socketconfig.js | 10 ++-- src/web/webserver.js | 86 +++++++++------------------------- 10 files changed, 106 insertions(+), 75 deletions(-) create mode 100644 src/web/httpstatus.js create mode 100644 src/web/localchannelindex.js create mode 100644 src/web/routes/channel.js create mode 100644 src/web/routes/index.js diff --git a/index.js b/index.js index 0cebe046..2cda75be 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ try { } catch (err) { console.error('FATAL: Failed to require() lib/server.js'); console.error('Have you run `npm run build-server` yet to generate it?'); + console.error(err.stack); process.exit(1); } var Config = require("./lib/config"); diff --git a/src/errors.js b/src/errors.js index d8ea077b..f5b45089 100644 --- a/src/errors.js +++ b/src/errors.js @@ -1,4 +1,9 @@ import createError from 'create-error'; +import * as HTTPStatus from './web/httpstatus'; export const ChannelStateSizeError = createError('ChannelStateSizeError'); export const ChannelNotFoundError = createError('ChannelNotFoundError'); +export const CSRFError = createError('CSRFError'); +export const HTTPError = createError('HTTPError', { + status: HTTPStatus.INTERNAL_SERVER_ERROR +}); diff --git a/src/server.js b/src/server.js index 31e5bfbe..234bd57d 100644 --- a/src/server.js +++ b/src/server.js @@ -42,6 +42,9 @@ var $util = require("./utilities"); var db = require("./database"); var Flags = require("./flags"); var sio = require("socket.io"); +import LocalChannelIndex from './web/localchannelindex'; +import IOConfiguration from './configuration/ioconfig'; +import NullClusterClient from './io/cluster/nullclusterclient'; var Server = function () { var self = this; @@ -60,8 +63,14 @@ var Server = function () { ChannelStore.init(); // webserver init ----------------------------------------------------- + const ioConfig = IOConfiguration.fromOldConfig(Config); + const clusterClient = new NullClusterClient(ioConfig); + const channelIndex = new LocalChannelIndex(); self.express = express(); - require("./web/webserver").init(self.express); + require("./web/webserver").init(self.express, + ioConfig, + clusterClient, + channelIndex); // http/https/sio server init ----------------------------------------- var key = "", cert = "", ca = undefined; diff --git a/src/web/csrf.js b/src/web/csrf.js index 688370ca..2fd6fa66 100644 --- a/src/web/csrf.js +++ b/src/web/csrf.js @@ -2,8 +2,9 @@ * Adapted from https://github.com/expressjs/csurf */ +import { CSRFError } from '../errors'; + var csrf = require("csrf"); -var createError = require("http-errors"); var tokens = csrf(); @@ -39,8 +40,6 @@ exports.verify = function csrfVerify(req) { var token = req.body._csrf || req.query._csrf; if (!tokens.verify(secret, token)) { - throw createError(403, 'invalid csrf token', { - code: 'EBADCSRFTOKEN' - }); + throw new CSRFError('Invalid CSRF token'); } }; diff --git a/src/web/httpstatus.js b/src/web/httpstatus.js new file mode 100644 index 00000000..eaba24d3 --- /dev/null +++ b/src/web/httpstatus.js @@ -0,0 +1,3 @@ +export const BAD_REQUEST = 400; +export const FORBIDDEN = 403; +export const INTERNAL_SERVER_ERROR = 500; diff --git a/src/web/localchannelindex.js b/src/web/localchannelindex.js new file mode 100644 index 00000000..6dc73c32 --- /dev/null +++ b/src/web/localchannelindex.js @@ -0,0 +1,14 @@ +import Promise from 'bluebird'; +import Server from '../server'; + +var SERVER = null; + +export default class LocalChannelIndex { + listPublicChannels() { + if (SERVER === null) { + SERVER = require('../server').getServer(); + } + + return Promise.resolve(SERVER.packChannelList(true)); + } +} diff --git a/src/web/routes/channel.js b/src/web/routes/channel.js new file mode 100644 index 00000000..4a185f3e --- /dev/null +++ b/src/web/routes/channel.js @@ -0,0 +1,25 @@ +import CyTubeUtil from '../../utilities'; +import { sanitizeText } from '../../xss'; +import { sendJade } from '../jade'; +import * as HTTPStatus from '../httpstatus'; +import { HTTPError } from '../../errors'; + +export default function initialize(app, ioConfig) { + app.get('/r/:channel', (req, res) => { + if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) { + throw new HTTPError(`"${sanitizeText(req.params.channel)} is not a valid ` + + 'channel name.', { status: HTTPStatus.BAD_REQUEST }); + } + + const endpoints = ioConfig.getSocketEndpoints(); + if (endpoints.length === 0) { + throw new HTTPError('No socket.io endpoints configured'); + } + const socketBaseURL = endpoints[0].url; + + sendJade(res, 'channel', { + channelName: req.params.channel, + sioSource: `${socketBaseURL}/socket.io/socket.io.js` + }); + }); +} diff --git a/src/web/routes/index.js b/src/web/routes/index.js new file mode 100644 index 00000000..86aaf9b9 --- /dev/null +++ b/src/web/routes/index.js @@ -0,0 +1,19 @@ +import { sendJade } from '../jade'; + +export default function initialize(app, channelIndex) { + app.get('/', (req, res) => { + channelIndex.listPublicChannels().then((channels) => { + channels.sort((a, b) => { + if (a.usercount === b.usercount) { + return a.uniqueName > b.uniqueName ? -1 : 1; + } + + return b.usercount - a.usercount; + }); + + sendJade(res, 'index', { + channels: channels + }); + }); + }); +} diff --git a/src/web/routes/socketconfig.js b/src/web/routes/socketconfig.js index 836a6916..b6f04d87 100644 --- a/src/web/routes/socketconfig.js +++ b/src/web/routes/socketconfig.js @@ -1,16 +1,12 @@ -import IOConfiguration from '../../configuration/ioconfig'; -import NullClusterClient from '../../io/cluster/nullclusterclient'; import Config from '../../config'; import CyTubeUtil from '../../utilities'; import Logger from '../../logger'; +import * as HTTPStatus from '../httpstatus'; -export default function initialize(app) { - const ioConfig = IOConfiguration.fromOldConfig(Config); - const clusterClient = new NullClusterClient(ioConfig); - +export default function initialize(app, clusterClient) { app.get('/socketconfig/:channel.json', (req, res) => { if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) { - return res.status(404).json({ + return res.status(HTTPStatus.NOT_FOUND).json({ error: `Channel "${req.params.channel}" does not exist.` }); } diff --git a/src/web/webserver.js b/src/web/webserver.js index a15d0d47..0b4bdc0a 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -16,6 +16,8 @@ var morgan = require("morgan"); var session = require("../session"); var csrf = require("./csrf"); var XSS = require("../xss"); +import * as HTTPStatus from './httpstatus'; +import { CSRFError } from '../errors'; const LOG_FORMAT = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'; morgan.token('real-address', function (req) { return req._ip; }); @@ -71,51 +73,6 @@ function redirectHttp(req, res) { return false; } -/** - * Handles a GET request for /r/:channel - serves channel.html - */ -function handleChannel(req, res) { - if (!$util.isValidChannelName(req.params.channel)) { - res.status(404); - res.send("Invalid channel name '" + XSS.sanitizeText(req.params.channel) + "'"); - return; - } - - var sio; - if (net.isIPv6(ipForRequest(req))) { - sio = Config.get("io.ipv6-default"); - } - - if (!sio) { - sio = Config.get("io.ipv4-default"); - } - - sio += "/socket.io/socket.io.js"; - - sendJade(res, "channel", { - channelName: req.params.channel, - sioSource: sio - }); -} - -/** - * Handles a request for the index page - */ -function handleIndex(req, res) { - var channels = Server.getServer().packChannelList(true); - channels.sort(function (a, b) { - if (a.usercount === b.usercount) { - return a.uniqueName > b.uniqueName ? -1 : 1; - } - - return b.usercount - a.usercount; - }); - - sendJade(res, "index", { - channels: channels - }); -} - /** * Legacy socket.io configuration endpoint. This is being migrated to * /socketconfig/.json (see ./routes/socketconfig.js) @@ -185,7 +142,7 @@ module.exports = { /** * Initializes webserver callbacks */ - init: function (app) { + init: function (app, ioConfig, clusterClient, channelIndex) { app.use(function (req, res, next) { req._ip = ipForRequest(req); next(); @@ -241,10 +198,10 @@ module.exports = { Logger.syslog.log("Enabled express-minify for CSS and JS"); } - app.get("/r/:channel", handleChannel); - app.get("/", handleIndex); + require("./routes/channel")(app, ioConfig); + require("./routes/index")(app, channelIndex); app.get("/sioconfig(.json)?", handleSocketConfig); - require("./routes/socketconfig")(app); + require("./routes/socketconfig")(app, clusterClient); app.get("/useragreement", handleUserAgreement); app.get("/contact", handleContactPage); require("./auth").init(app); @@ -256,21 +213,24 @@ module.exports = { })); app.use(function (err, req, res, next) { if (err) { - if (err.message && err.message.match(/failed to decode param/i)) { - return res.status(400).send("Malformed path: " + req.path); - } else if (err.message && err.message.match(/range not satisfiable/i)) { - return res.status(416).end(); - } else if (err.message && err.message.match(/request entity too large/i)) { - return res.status(413).end(); - } else if (err.message && err.message.match(/bad request/i)) { - return res.status(400).end("Bad Request"); - } else if (err.message && err.message.match(/invalid csrf token/i)) { - res.status(403); - sendJade(res, 'csrferror', { path: req.path }); - return; + if (err instanceof CSRFError) { + res.status(HTTPStatus.FORBIDDEN); + return sendJade(res, 'csrferror', { path: req.path }); } - Logger.errlog.log(err.stack); - res.status(500).end(); + + let { message, status } = err; + if (!status) { + status = HTTPStatus.INTERNAL_SERVER_ERROR; + } + if (!message) { + message = 'An unknown error occurred.'; + } + + if (Math.floor(status / 100) === 5) { + Logger.errlog.log(err.stack); + } + + return res.status(status).send(message); } else { next(); } From 26e8660af45149404e99584f9c3c7d2365afa4bc Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 26 Oct 2015 23:21:09 -0700 Subject: [PATCH 2/7] Change /logout from GET to POST (#515) --- src/web/auth.js | 4 ++-- templates/nav.jade | 6 ++++-- www/css/cytube.css | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/web/auth.js b/src/web/auth.js index 09c7384e..e7c8578b 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -127,7 +127,7 @@ function handleLogout(req, res) { res.clearCookie("auth"); req.user = res.user = null; // Try to find an appropriate redirect - var dest = req.query.dest || req.header("referer"); + var dest = req.params.dest || req.header("referer"); dest = dest && dest.match(/login|logout|account/) ? null : dest; var host = req.hostname; @@ -234,7 +234,7 @@ module.exports = { init: function (app) { app.get("/login", handleLoginPage); app.post("/login", handleLogin); - app.get("/logout", handleLogout); + app.post("/logout", handleLogout); app.get("/register", handleRegisterPage); app.post("/register", handleRegister); } diff --git a/templates/nav.jade b/templates/nav.jade index 8068c9b3..3645e99d 100644 --- a/templates/nav.jade +++ b/templates/nav.jade @@ -67,8 +67,10 @@ mixin navloginform(redirect) mixin navlogoutform(redirect) - p#logoutform.navbar-text.pull-right + form#logoutform.navbar-text.pull-right(action="/logout", method="post") + input(type="hidden", name="dest", value=baseUrl + redirect) + input(type="hidden", name="_csrf", value=csrfToken) span#welcome Welcome, #{loginName} span  ·  - a#logout.navbar-link(href="/logout?dest=#{encodeURIComponent(baseUrl + redirect)}&_csrf=#{csrfToken}") Logout + input#logout.navbar-link(type="submit", value="Logout") diff --git a/www/css/cytube.css b/www/css/cytube.css index 840f6f4a..0f9b4f24 100644 --- a/www/css/cytube.css +++ b/www/css/cytube.css @@ -639,3 +639,13 @@ li.vjs-menu-item.vjs-selected { .video-js video::-webkit-media-text-track-container { bottom: 50px; } + +input#logout[type="submit"] { + background: none; + border: none; + padding: 0; +} + +input#logout[type="submit"]:hover { + text-decoration: underline; +} From 88236e036c1ec4a33d1e329641efdd13ef5342d5 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 27 Oct 2015 20:44:40 -0700 Subject: [PATCH 3/7] Add better error pages --- src/web/httpstatus.js | 1 + src/web/routes/channel.js | 2 +- src/web/webserver.js | 20 +++++++++++++++++--- templates/csrferror.jade | 3 ++- templates/httperror.jade | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 templates/httperror.jade diff --git a/src/web/httpstatus.js b/src/web/httpstatus.js index eaba24d3..b2e2430d 100644 --- a/src/web/httpstatus.js +++ b/src/web/httpstatus.js @@ -1,3 +1,4 @@ export const BAD_REQUEST = 400; export const FORBIDDEN = 403; +export const NOT_FOUND = 404; export const INTERNAL_SERVER_ERROR = 500; diff --git a/src/web/routes/channel.js b/src/web/routes/channel.js index 4a185f3e..8fcf1dea 100644 --- a/src/web/routes/channel.js +++ b/src/web/routes/channel.js @@ -7,7 +7,7 @@ import { HTTPError } from '../../errors'; export default function initialize(app, ioConfig) { app.get('/r/:channel', (req, res) => { if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) { - throw new HTTPError(`"${sanitizeText(req.params.channel)} is not a valid ` + + throw new HTTPError(`"${sanitizeText(req.params.channel)}" is not a valid ` + 'channel name.', { status: HTTPStatus.BAD_REQUEST }); } diff --git a/src/web/webserver.js b/src/web/webserver.js index 0b4bdc0a..c0c4b586 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -17,7 +17,7 @@ var session = require("../session"); var csrf = require("./csrf"); var XSS = require("../xss"); import * as HTTPStatus from './httpstatus'; -import { CSRFError } from '../errors'; +import { CSRFError, HTTPError } from '../errors'; const LOG_FORMAT = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'; morgan.token('real-address', function (req) { return req._ip; }); @@ -211,11 +211,19 @@ module.exports = { app.use(serveStatic(path.join(__dirname, "..", "..", "www"), { maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl") })); + app.use((req, res, next) => { + return next(new HTTPError(`No route for ${req.path}`, { + status: HTTPStatus.NOT_FOUND + })); + }); app.use(function (err, req, res, next) { if (err) { if (err instanceof CSRFError) { res.status(HTTPStatus.FORBIDDEN); - return sendJade(res, 'csrferror', { path: req.path }); + return sendJade(res, 'csrferror', { + path: req.path, + referer: req.header('referer') + }); } let { message, status } = err; @@ -226,11 +234,17 @@ module.exports = { message = 'An unknown error occurred.'; } + // Log 5xx (server) errors if (Math.floor(status / 100) === 5) { Logger.errlog.log(err.stack); } - return res.status(status).send(message); + res.status(status); + return sendJade(res, 'httperror', { + path: req.path, + status: status, + message: message + }); } else { next(); } diff --git a/templates/csrferror.jade b/templates/csrferror.jade index b67bd428..a3a8add3 100644 --- a/templates/csrferror.jade +++ b/templates/csrferror.jade @@ -24,7 +24,8 @@ html(lang="en") li A malicious user has attempted to tamper with your session li Your browser does not support cookies, or they are not enabled | If the problem persists, please contact an administrator. - a(href=path) Return to previous page + if referer + a(href=referer) Return to previous page include footer mixin footer() diff --git a/templates/httperror.jade b/templates/httperror.jade new file mode 100644 index 00000000..5ac23c71 --- /dev/null +++ b/templates/httperror.jade @@ -0,0 +1,36 @@ +mixin notfound() + h1 Not Found + p The page you were looking for doesn't seem to exist. Please check that you typed the URL correctly. +mixin forbidden() + h1 Forbidden + p You don't have permission to access #{path} +mixin genericerror() + h1 Oops + p Your request could not be processed. Status code: #{status}, message: #{message} +doctype html +html(lang="en") + head + include head + mixin head() + body + #wrap + nav.navbar.navbar-inverse.navbar-fixed-top(role="navigation") + include nav + mixin navheader() + #nav-collapsible.collapse.navbar-collapse + ul.nav.navbar-nav + mixin navdefaultlinks(path) + mixin navloginlogout(path) + + section#mainpage.container + .col-md-12 + .alert.alert-danger + if status == 404 + mixin notfound() + else if status == 403 + mixin forbidden() + else + mixin genericerror() + + include footer + mixin footer() From 13d4a499766883204fa2c8a1c9742683f37d5e79 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 27 Oct 2015 22:04:21 -0700 Subject: [PATCH 4/7] Move contact page to its own route handler --- package.json | 1 + src/configuration/webconfig.js | 27 ++++++++++++++++++++++++++ src/server.js | 3 +++ src/web/routes/contact.js | 26 +++++++++++++++++++++++++ src/web/webserver.js | 35 +++++----------------------------- 5 files changed, 62 insertions(+), 30 deletions(-) create mode 100644 src/configuration/webconfig.js create mode 100644 src/web/routes/contact.js diff --git a/package.json b/package.json index 16de6baa..7cb574c4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "bluebird": "^2.10.1", "body-parser": "^1.14.0", "cheerio": "^0.19.0", + "clone": "^1.0.2", "compression": "^1.5.2", "cookie-parser": "^1.4.0", "create-error": "^0.3.1", diff --git a/src/configuration/webconfig.js b/src/configuration/webconfig.js new file mode 100644 index 00000000..607094f7 --- /dev/null +++ b/src/configuration/webconfig.js @@ -0,0 +1,27 @@ +import clone from 'clone'; + +export default class WebConfiguration { + constructor(config) { + this.config = config; + } + + getEmailContacts() { + return clone(this.config.contacts); + } +} + +WebConfiguration.fromOldConfig = function (oldConfig) { + const config = { + contacts: [] + }; + + oldConfig.get('contacts').forEach(contact => { + config.contacts.push({ + name: contact.name, + email: contact.email, + title: contact.title + }); + }); + + return new WebConfiguration(config); +}; diff --git a/src/server.js b/src/server.js index 234bd57d..54ae6557 100644 --- a/src/server.js +++ b/src/server.js @@ -44,6 +44,7 @@ var Flags = require("./flags"); var sio = require("socket.io"); import LocalChannelIndex from './web/localchannelindex'; import IOConfiguration from './configuration/ioconfig'; +import WebConfiguration from './configuration/webconfig'; import NullClusterClient from './io/cluster/nullclusterclient'; var Server = function () { @@ -64,10 +65,12 @@ var Server = function () { // webserver init ----------------------------------------------------- const ioConfig = IOConfiguration.fromOldConfig(Config); + const webConfig = WebConfiguration.fromOldConfig(Config); const clusterClient = new NullClusterClient(ioConfig); const channelIndex = new LocalChannelIndex(); self.express = express(); require("./web/webserver").init(self.express, + webConfig, ioConfig, clusterClient, channelIndex); diff --git a/src/web/routes/contact.js b/src/web/routes/contact.js new file mode 100644 index 00000000..32907469 --- /dev/null +++ b/src/web/routes/contact.js @@ -0,0 +1,26 @@ +import CyTubeUtil from '../../utilities'; +import { sendJade } from '../jade'; + +export default function initialize(app, webConfig) { + app.get('/contact', (req, res) => { + // Basic obfuscation of email addresses to prevent spambots + // from picking them up. Not real encryption. + // Deobfuscated by clientside JS. + const contacts = webConfig.getEmailContacts().map(contact => { + const emkey = CyTubeUtil.randomSalt(16); + let email = new Array(contact.email.length); + for (let i = 0; i < contact.email.length; i++) { + email[i] = String.fromCharCode( + contact.email.charCodeAt(i) ^ emkey.charCodeAt(i % emkey.length) + ); + } + contact.email = escape(email.join("")); + contact.emkey = escape(emkey); + return contact; + }); + + return sendJade(res, 'contact', { + contacts: contacts + }); + }); +} diff --git a/src/web/webserver.js b/src/web/webserver.js index c0c4b586..3b2ba345 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -110,39 +110,11 @@ function handleUserAgreement(req, res) { }); } -function handleContactPage(req, res) { - // Make a copy to prevent messing with the original - var contacts = Config.get("contacts").map(function (c) { - return { - name: c.name, - email: c.email, - title: c.title - }; - }); - - // Rudimentary hiding of email addresses to prevent spambots - contacts.forEach(function (c) { - c.emkey = $util.randomSalt(16) - var email = new Array(c.email.length); - for (var i = 0; i < c.email.length; i++) { - email[i] = String.fromCharCode( - c.email.charCodeAt(i) ^ c.emkey.charCodeAt(i % c.emkey.length) - ); - } - c.email = escape(email.join("")); - c.emkey = escape(c.emkey); - }); - - sendJade(res, "contact", { - contacts: contacts - }); -} - module.exports = { /** * Initializes webserver callbacks */ - init: function (app, ioConfig, clusterClient, channelIndex) { + init: function (app, webConfig, ioConfig, clusterClient, channelIndex) { app.use(function (req, res, next) { req._ip = ipForRequest(req); next(); @@ -203,7 +175,7 @@ module.exports = { app.get("/sioconfig(.json)?", handleSocketConfig); require("./routes/socketconfig")(app, clusterClient); app.get("/useragreement", handleUserAgreement); - app.get("/contact", handleContactPage); + require("./routes/contact")(app, webConfig); require("./auth").init(app); require("./account").init(app); require("./acp").init(app); @@ -232,6 +204,9 @@ module.exports = { } if (!message) { message = 'An unknown error occurred.'; + } else if (/\.(jade|js)/.test(message)) { + // Prevent leakage of stack traces + message = 'An internal error occurred.'; } // Log 5xx (server) errors From c2726898e5d262a3626f76382203e48e1fd2e836 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Tue, 27 Oct 2015 23:54:32 -0700 Subject: [PATCH 5/7] Move x-forwarded-for middleware --- src/configuration/webconfig.js | 9 +++++++ src/web/account.js | 12 +++++----- src/web/acp.js | 2 +- src/web/auth.js | 4 ++-- src/web/middleware/x-forwarded-for.js | 32 +++++++++++++++++++++++++ src/web/webserver.js | 34 +++------------------------ 6 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 src/web/middleware/x-forwarded-for.js diff --git a/src/configuration/webconfig.js b/src/configuration/webconfig.js index 607094f7..da977acd 100644 --- a/src/configuration/webconfig.js +++ b/src/configuration/webconfig.js @@ -1,5 +1,10 @@ import clone from 'clone'; +const DEFAULT_TRUSTED_PROXIES = [ + '127.0.0.1', + '::1' +]; + export default class WebConfiguration { constructor(config) { this.config = config; @@ -8,6 +13,10 @@ export default class WebConfiguration { getEmailContacts() { return clone(this.config.contacts); } + + getTrustedProxies() { + return DEFAULT_TRUSTED_PROXIES.slice(); + } } WebConfiguration.fromOldConfig = function (oldConfig) { diff --git a/src/web/account.js b/src/web/account.js index 19d61fff..627d341e 100644 --- a/src/web/account.js +++ b/src/web/account.js @@ -92,7 +92,7 @@ function handleChangePassword(req, res) { return; } - Logger.eventlog.log("[account] " + webserver.ipForRequest(req) + + Logger.eventlog.log("[account] " + req.realIP + " changed password for " + name); db.users.getUser(name, function (err, user) { @@ -172,7 +172,7 @@ function handleChangeEmail(req, res) { }); return; } - Logger.eventlog.log("[account] " + webserver.ipForRequest(req) + + Logger.eventlog.log("[account] " + req.realIP + " changed email for " + name + " to " + email); sendJade(res, "account-edit", { @@ -269,7 +269,7 @@ function handleNewChannel(req, res) { db.channels.register(name, req.user.name, function (err, channel) { if (!err) { Logger.eventlog.log("[channel] " + req.user.name + "@" + - webserver.ipForRequest(req) + + req.realIP + " registered channel " + name); var sv = Server.getServer(); if (sv.isChannelLoaded(name)) { @@ -336,7 +336,7 @@ function handleDeleteChannel(req, res) { db.channels.drop(name, function (err) { if (!err) { Logger.eventlog.log("[channel] " + req.user.name + "@" + - webserver.ipForRequest(req) + " deleted channel " + + req.realIP + " deleted channel " + name); } var sv = Server.getServer(); @@ -498,7 +498,7 @@ function handlePasswordReset(req, res) { var hash = $util.sha1($util.randomSalt(64)); // 24-hour expiration var expire = Date.now() + 86400000; - var ip = webserver.ipForRequest(req); + var ip = req.realIP; db.addPasswordReset({ ip: ip, @@ -575,7 +575,7 @@ function handlePasswordRecover(req, res) { return; } - var ip = webserver.ipForRequest(req); + var ip = req.realIP; db.lookupPasswordReset(hash, function (err, row) { if (err) { diff --git a/src/web/acp.js b/src/web/acp.js index 707c6a8b..d418417b 100644 --- a/src/web/acp.js +++ b/src/web/acp.js @@ -15,7 +15,7 @@ function checkAdmin(cb) { if (req.user.global_rank < 255) { res.send(403); Logger.eventlog.log("[acp] Attempted GET "+req.path+" from non-admin " + - user.name + "@" + webserver.ipForRequest(req)); + user.name + "@" + req.realIP); return; } diff --git a/src/web/auth.js b/src/web/auth.js index e7c8578b..8fca7352 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -54,7 +54,7 @@ function handleLogin(req, res) { if (err) { if (err === "Invalid username/password combination") { Logger.eventlog.log("[loginfail] Login failed (bad password): " + name - + "@" + webserver.ipForRequest(req)); + + "@" + req.realIP); } sendJade(res, "login", { loggedIn: false, @@ -173,7 +173,7 @@ function handleRegister(req, res) { if (typeof email !== "string") { email = ""; } - var ip = webserver.ipForRequest(req); + var ip = req.realIP; if (typeof name !== "string" || typeof password !== "string") { res.sendStatus(400); diff --git a/src/web/middleware/x-forwarded-for.js b/src/web/middleware/x-forwarded-for.js new file mode 100644 index 00000000..28b93098 --- /dev/null +++ b/src/web/middleware/x-forwarded-for.js @@ -0,0 +1,32 @@ +import net from 'net'; + +export default function initialize(app, webConfig) { + function isTrustedProxy(ip) { + return webConfig.getTrustedProxies().indexOf(ip) >= 0; + } + + function getForwardedIP(req) { + const xForwardedFor = req.header('x-forwarded-for'); + if (!xForwardedFor) { + return req.ip; + } + + const ipList = xForwardedFor.split(','); + for (let i = 0; i < ipList.length; i++) { + const ip = ipList[i].trim(); + if (net.isIP(ip)) { + return ip; + } + } + + return req.ip; + } + + app.use((req, res, next) => { + if (isTrustedProxy(req.ip)) { + req.realIP = getForwardedIP(req); + } + + next(); + }); +} diff --git a/src/web/webserver.js b/src/web/webserver.js index 3b2ba345..726c7b68 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -20,30 +20,7 @@ import * as HTTPStatus from './httpstatus'; import { CSRFError, HTTPError } from '../errors'; const LOG_FORMAT = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'; -morgan.token('real-address', function (req) { return req._ip; }); - -/** - * Extracts an IP address from a request. Uses X-Forwarded-For if the IP is localhost - */ -function ipForRequest(req) { - var ip = req.ip; - if (ip === "127.0.0.1" || ip === "::1") { - var xforward = req.header("x-forwarded-for"); - if (typeof xforward !== "string") { - xforward = []; - } else { - xforward = xforward.split(","); - } - - for (var i = 0; i < xforward.length; i++) { - if (net.isIP(xforward[i])) { - return xforward[i]; - } - } - return ip; - } - return ip; -} +morgan.token('real-address', function (req) { return req.realIP; }); /** * Redirects a request to HTTPS if the server supports it @@ -87,7 +64,7 @@ function handleSocketConfig(req, res) { var sioconfig = Config.get("sioconfig"); var iourl; - var ip = ipForRequest(req); + var ip = req.realIP; var ipv6 = false; if (net.isIPv6(ip)) { @@ -115,10 +92,7 @@ module.exports = { * Initializes webserver callbacks */ init: function (app, webConfig, ioConfig, clusterClient, channelIndex) { - app.use(function (req, res, next) { - req._ip = ipForRequest(req); - next(); - }); + require("./middleware/x-forwarded-for")(app, webConfig); app.use(bodyParser.urlencoded({ extended: false, limit: '1kb' // No POST data should ever exceed this size under normal usage @@ -226,8 +200,6 @@ module.exports = { }); }, - ipForRequest: ipForRequest, - redirectHttps: redirectHttps, redirectHttp: redirectHttp From 6505aa2f5ece393b360afb287538d1d88c27c238 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 1 Nov 2015 17:42:20 -0800 Subject: [PATCH 6/7] More refactoring --- src/configuration/webconfig.js | 44 +++++- src/server.js | 5 +- src/web/middleware/authorize.js | 19 +++ src/web/webserver.js | 238 +++++++++++++++----------------- 4 files changed, 177 insertions(+), 129 deletions(-) create mode 100644 src/web/middleware/authorize.js diff --git a/src/configuration/webconfig.js b/src/configuration/webconfig.js index da977acd..aee7322a 100644 --- a/src/configuration/webconfig.js +++ b/src/configuration/webconfig.js @@ -1,9 +1,9 @@ import clone from 'clone'; -const DEFAULT_TRUSTED_PROXIES = [ +const DEFAULT_TRUSTED_PROXIES = Object.freeze([ '127.0.0.1', '::1' -]; +]); export default class WebConfiguration { constructor(config) { @@ -15,7 +15,31 @@ export default class WebConfiguration { } getTrustedProxies() { - return DEFAULT_TRUSTED_PROXIES.slice(); + return DEFAULT_TRUSTED_PROXIES; + } + + getCookieSecret() { + return this.config.authCookie.cookieSecret; + } + + getCookieDomain() { + return this.config.authCookie.cookieDomain; + } + + getEnableGzip() { + return this.config.gzip.enabled; + } + + getGzipThreshold() { + return this.config.gzip.threshold; + } + + getEnableMinification() { + return this.config.enableMinification; + } + + getCacheTTL() { + return this.config.cacheTTL; } } @@ -32,5 +56,19 @@ WebConfiguration.fromOldConfig = function (oldConfig) { }); }); + config.gzip = { + enabled: oldConfig.get('http.gzip'), + threshold: oldConfig.get('http.gzip-threshold') + }; + + config.authCookie = { + cookieSecret: oldConfig.get('http.cookie-secret'), + cookieDomain: oldConfig.get('http.root-domain-dotted') + }; + + config.enableMinification = oldConfig.get('http.minify'); + + config.cacheTTL = oldConfig.get('http.max-age'); + return new WebConfiguration(config); }; diff --git a/src/server.js b/src/server.js index 54ae6557..c387ccd3 100644 --- a/src/server.js +++ b/src/server.js @@ -46,6 +46,7 @@ import LocalChannelIndex from './web/localchannelindex'; import IOConfiguration from './configuration/ioconfig'; import WebConfiguration from './configuration/webconfig'; import NullClusterClient from './io/cluster/nullclusterclient'; +import session from './session'; var Server = function () { var self = this; @@ -73,7 +74,8 @@ var Server = function () { webConfig, ioConfig, clusterClient, - channelIndex); + channelIndex, + session); // http/https/sio server init ----------------------------------------- var key = "", cert = "", ca = undefined; @@ -255,4 +257,3 @@ Server.prototype.shutdown = function () { process.exit(1); }); }; - diff --git a/src/web/middleware/authorize.js b/src/web/middleware/authorize.js new file mode 100644 index 00000000..e4fbba4c --- /dev/null +++ b/src/web/middleware/authorize.js @@ -0,0 +1,19 @@ +const STATIC_RESOURCE = /\..+$/; + +export default function initialize(app, session) { + app.use((req, res, next) => { + if (STATIC_RESOURCE.test(req.path)) { + return next(); + } else if (!req.signedCookies || !req.signedCookies.auth) { + return nuext(); + } else { + session.verifySession(req.signedCookies.auth, (err, account) => { + if (!err) { + req.user = res.user = account; + } + + next(); + }); + } + }); +} diff --git a/src/web/webserver.js b/src/web/webserver.js index 726c7b68..f06d109a 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -1,33 +1,37 @@ -var path = require("path"); -var fs = require("fs"); -var net = require("net"); -var express = require("express"); -var webroot = path.join(__dirname, "..", "www"); -var sendJade = require("./jade").sendJade; -var Server = require("../server"); -var $util = require("../utilities"); -var Logger = require("../logger"); -var Config = require("../config"); -var db = require("../database"); -var bodyParser = require("body-parser"); -var cookieParser = require("cookie-parser"); -var serveStatic = require("serve-static"); -var morgan = require("morgan"); -var session = require("../session"); -var csrf = require("./csrf"); -var XSS = require("../xss"); +import fs from 'fs'; +import path from 'path'; +import net from 'net'; +import express from 'express'; +import { sendJade } from './jade'; +import Logger from '../logger'; +import Config from '../config'; +import bodyParser from 'body-parser'; +import cookieParser from 'cookie-parser'; +import serveStatic from 'serve-static'; +import morgan from 'morgan'; +import csrf from './csrf'; import * as HTTPStatus from './httpstatus'; import { CSRFError, HTTPError } from '../errors'; -const LOG_FORMAT = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'; -morgan.token('real-address', function (req) { return req.realIP; }); +function initializeLog(app) { + const logFormat = ':real-address - :remote-user [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'; + const logPath = path.join(__dirname, '..', '..', 'http.log'); + const outputStream = fs.createWriteStream(logPath, { + flags: 'a', // append to existing file + encoding: 'utf8' + }); + morgan.token('real-address', req => req.realIP); + app.use(morgan(logFormat, { + stream: outputStream + })); +} /** * Redirects a request to HTTPS if the server supports it */ function redirectHttps(req, res) { - if (!req.secure && Config.get("https.enabled") && Config.get("https.redirect")) { - var ssldomain = Config.get("https.full-address"); + if (!req.secure && Config.get('https.enabled') && Config.get('https.redirect')) { + var ssldomain = Config.get('https.full-address'); if (ssldomain.indexOf(req.hostname) < 0) { return false; } @@ -43,7 +47,7 @@ function redirectHttps(req, res) { */ function redirectHttp(req, res) { if (req.secure) { - var domain = Config.get("http.full-address"); + var domain = Config.get('http.full-address'); res.redirect(domain + req.path); return true; } @@ -54,36 +58,81 @@ function redirectHttp(req, res) { * Legacy socket.io configuration endpoint. This is being migrated to * /socketconfig/.json (see ./routes/socketconfig.js) */ -function handleSocketConfig(req, res) { +function handleLegacySocketConfig(req, res) { if (/\.json$/.test(req.path)) { - res.json(Config.get("sioconfigjson")); + res.json(Config.get('sioconfigjson')); return; } - res.type("application/javascript"); + res.type('application/javascript'); - var sioconfig = Config.get("sioconfig"); + var sioconfig = Config.get('sioconfig'); var iourl; var ip = req.realIP; var ipv6 = false; if (net.isIPv6(ip)) { - iourl = Config.get("io.ipv6-default"); + iourl = Config.get('io.ipv6-default'); ipv6 = true; } if (!iourl) { - iourl = Config.get("io.ipv4-default"); + iourl = Config.get('io.ipv4-default'); } - sioconfig += "var IO_URL='" + iourl + "';"; - sioconfig += "var IO_V6=" + ipv6 + ";"; + sioconfig += 'var IO_URL=\'' + iourl + '\';'; + sioconfig += 'var IO_V6=' + ipv6 + ';'; res.send(sioconfig); } function handleUserAgreement(req, res) { - sendJade(res, "tos", { - domain: Config.get("http.domain") + sendJade(res, 'tos', { + domain: Config.get('http.domain') + }); +} + +function initializeErrorHandlers(app) { + app.use((req, res, next) => { + return next(new HTTPError(`No route for ${req.path}`, { + status: HTTPStatus.NOT_FOUND + })); + }); + + app.use((err, req, res, next) => { + if (err) { + if (err instanceof CSRFError) { + res.status(HTTPStatus.FORBIDDEN); + return sendJade(res, 'csrferror', { + path: req.path, + referer: req.header('referer') + }); + } + + let { message, status } = err; + if (!status) { + status = HTTPStatus.INTERNAL_SERVER_ERROR; + } + if (!message) { + message = 'An unknown error occurred.'; + } else if (/\.(jade|js)/.test(message)) { + // Prevent leakage of stack traces + message = 'An internal error occurred.'; + } + + // Log 5xx (server) errors + if (Math.floor(status / 100) === 5) { + Logger.errlog.log(err.stack); + } + + res.status(status); + return sendJade(res, 'httperror', { + path: req.path, + status: status, + message: message + }); + } else { + next(); + } }); } @@ -91,113 +140,54 @@ module.exports = { /** * Initializes webserver callbacks */ - init: function (app, webConfig, ioConfig, clusterClient, channelIndex) { - require("./middleware/x-forwarded-for")(app, webConfig); + init: function (app, webConfig, ioConfig, clusterClient, channelIndex, session) { + require('./middleware/x-forwarded-for')(app, webConfig); app.use(bodyParser.urlencoded({ extended: false, limit: '1kb' // No POST data should ever exceed this size under normal usage })); - if (Config.get("http.cookie-secret") === "change-me") { - Logger.errlog.log("YOU SHOULD CHANGE THE VALUE OF cookie-secret IN config.yaml"); + if (webConfig.getCookieSecret() === 'change-me') { + Logger.errlog.log('WARNING: The configured cookie secret was left as the ' + + 'default of "change-me".'); } - app.use(cookieParser(Config.get("http.cookie-secret"))); - app.use(csrf.init(Config.get("http.root-domain-dotted"))); - app.use(morgan(LOG_FORMAT, { - stream: require("fs").createWriteStream(path.join(__dirname, "..", "..", - "http.log"), { - flags: "a", - encoding: "utf-8" - }) - })); + app.use(cookieParser(webConfig.getCookieSecret())); + app.use(csrf.init(webConfig.getCookieDomain())); + initializeLog(app); + require('./middleware/authorize')(app, session); - app.use(function (req, res, next) { - if (req.path.match(/^\/(css|js|img|boop).*$/)) { - return next(); - } - - if (!req.signedCookies || !req.signedCookies.auth) { - return next(); - } - - session.verifySession(req.signedCookies.auth, function (err, account) { - if (!err) { - req.user = res.user = account; - } - - next(); - }); - }); - - if (Config.get("http.gzip")) { - app.use(require("compression")({ threshold: Config.get("http.gzip-threshold") })); - Logger.syslog.log("Enabled gzip compression"); + if (webConfig.getEnableGzip()) { + app.use(require('compression')({ + threshold: webConfig.getGzipThreshold() + })); + Logger.syslog.log('Enabled gzip compression'); } - if (Config.get("http.minify")) { - var cache = path.join(__dirname, "..", "..", "www", "cache") + if (webConfig.getEnableMinification()) { + const cacheDir = path.join(__dirname, '..', '..', 'www', 'cache'); if (!fs.existsSync(cache)) { fs.mkdirSync(cache); } - app.use(require("express-minify")({ - cache: cache + app.use(require('express-minify')({ + cache: cacheDir })); - Logger.syslog.log("Enabled express-minify for CSS and JS"); + Logger.syslog.log('Enabled express-minify for CSS and JS'); } - require("./routes/channel")(app, ioConfig); - require("./routes/index")(app, channelIndex); - app.get("/sioconfig(.json)?", handleSocketConfig); - require("./routes/socketconfig")(app, clusterClient); - app.get("/useragreement", handleUserAgreement); - require("./routes/contact")(app, webConfig); - require("./auth").init(app); - require("./account").init(app); - require("./acp").init(app); - require("../google2vtt").attach(app); - app.use(serveStatic(path.join(__dirname, "..", "..", "www"), { - maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl") + require('./routes/channel')(app, ioConfig); + require('./routes/index')(app, channelIndex); + app.get('/sioconfig(.json)?', handleLegacySocketConfig); + require('./routes/socketconfig')(app, clusterClient); + app.get('/useragreement', handleUserAgreement); + require('./routes/contact')(app, webConfig); + require('./auth').init(app); + require('./account').init(app); + require('./acp').init(app); + require('../google2vtt').attach(app); + app.use(serveStatic(path.join(__dirname, '..', '..', 'www'), { + maxAge: webConfig.getCacheTTL() })); - app.use((req, res, next) => { - return next(new HTTPError(`No route for ${req.path}`, { - status: HTTPStatus.NOT_FOUND - })); - }); - app.use(function (err, req, res, next) { - if (err) { - if (err instanceof CSRFError) { - res.status(HTTPStatus.FORBIDDEN); - return sendJade(res, 'csrferror', { - path: req.path, - referer: req.header('referer') - }); - } - let { message, status } = err; - if (!status) { - status = HTTPStatus.INTERNAL_SERVER_ERROR; - } - if (!message) { - message = 'An unknown error occurred.'; - } else if (/\.(jade|js)/.test(message)) { - // Prevent leakage of stack traces - message = 'An internal error occurred.'; - } - - // Log 5xx (server) errors - if (Math.floor(status / 100) === 5) { - Logger.errlog.log(err.stack); - } - - res.status(status); - return sendJade(res, 'httperror', { - path: req.path, - status: status, - message: message - }); - } else { - next(); - } - }); + initializeErrorHandlers(app); }, redirectHttps: redirectHttps, From 5c339656b7a08f8159391c8be5ae1fa59d11a1fa Mon Sep 17 00:00:00 2001 From: calzoneman Date: Mon, 2 Nov 2015 20:52:57 -0800 Subject: [PATCH 7/7] Minor fixes --- src/web/auth.js | 2 +- src/web/routes/channel.js | 2 +- templates/httperror.jade | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/web/auth.js b/src/web/auth.js index 8fca7352..5e639db8 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -127,7 +127,7 @@ function handleLogout(req, res) { res.clearCookie("auth"); req.user = res.user = null; // Try to find an appropriate redirect - var dest = req.params.dest || req.header("referer"); + var dest = req.body.dest || req.header("referer"); dest = dest && dest.match(/login|logout|account/) ? null : dest; var host = req.hostname; diff --git a/src/web/routes/channel.js b/src/web/routes/channel.js index 8fcf1dea..457565d9 100644 --- a/src/web/routes/channel.js +++ b/src/web/routes/channel.js @@ -8,7 +8,7 @@ export default function initialize(app, ioConfig) { app.get('/r/:channel', (req, res) => { if (!req.params.channel || !CyTubeUtil.isValidChannelName(req.params.channel)) { throw new HTTPError(`"${sanitizeText(req.params.channel)}" is not a valid ` + - 'channel name.', { status: HTTPStatus.BAD_REQUEST }); + 'channel name.', { status: HTTPStatus.NOT_FOUND }); } const endpoints = ioConfig.getSocketEndpoints(); diff --git a/templates/httperror.jade b/templates/httperror.jade index 5ac23c71..56909bc9 100644 --- a/templates/httperror.jade +++ b/templates/httperror.jade @@ -1,6 +1,8 @@ mixin notfound() h1 Not Found p The page you were looking for doesn't seem to exist. Please check that you typed the URL correctly. + if message + p Reason: #{message} mixin forbidden() h1 Forbidden p You don't have permission to access #{path}