diff --git a/package.json b/package.json index 840d59a0..5fe3af0e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.36.4", + "version": "3.37.0", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/src/camo.js b/src/camo.js new file mode 100644 index 00000000..cf2ec070 --- /dev/null +++ b/src/camo.js @@ -0,0 +1,43 @@ +// @flow +import crypto from 'crypto'; +import { LoggerFactory } from '@calzoneman/jsli'; +import * as urlparse from 'url'; +import { CamoConfig } from './configuration/camoconfig'; + +const LOGGER = LoggerFactory.getLogger('camo'); + +function isWhitelisted(camoConfig: CamoConfig, url: string): boolean { + const whitelistedDomains = camoConfig.getWhitelistedDomains(); + const parsed = urlparse.parse(url); + return whitelistedDomains.includes(parsed.hostname); +} + +export function camoify(camoConfig: CamoConfig, url: string): string { + if (typeof url !== 'string') { + throw new TypeError(`camoify expected a string, not [${url}]`); + } + + if (isWhitelisted(camoConfig, url)) { + return url.replace(/^http:/, 'https:'); + } + + const hmac = crypto.createHmac('sha1', camoConfig.getKey()); + hmac.update(url); + const digest = hmac.digest('hex'); + const hexUrl = Buffer.from(url, 'utf8').toString('hex'); + return `${camoConfig.getServer()}/${digest}/${hexUrl}`; +} + +export function transformImgTags(camoConfig: CamoConfig, tagName: string, attribs: Object) { + if (typeof attribs.src === 'string') { + try { + const oldSrc = attribs.src; + attribs.src = camoify(camoConfig, attribs.src); + LOGGER.debug('Camoified "%s" to "%s"', oldSrc, attribs.src); + } catch (error) { + LOGGER.error(`Failed to generate camo URL for "${attribs.src}": ${error}`); + } + } + + return { tagName, attribs }; +} diff --git a/src/channel/chat.js b/src/channel/chat.js index fa33f973..b445f31d 100644 --- a/src/channel/chat.js +++ b/src/channel/chat.js @@ -6,6 +6,7 @@ var util = require("../utilities"); var Flags = require("../flags"); var url = require("url"); var counters = require("../counters"); +import { transformImgTags } from '../camo'; const SHADOW_TAG = "[shadow]"; const LINK = /(\w+:\/\/(?:[^:\/\[\]\s]+|\[[0-9a-f:]+\])(?::\d+)?(?:\/[^\/\s]*)*)/ig; @@ -381,7 +382,17 @@ ChatModule.prototype.filterMessage = function (msg) { } }); - return XSS.sanitizeHTML(result); + let settings = {}; + const camoConfig = Config.getCamoConfig(); + if (camoConfig.isEnabled()) { + settings = { + transformTags: { + img: transformImgTags.bind(null, camoConfig) + } + }; + } + + return XSS.sanitizeHTML(result, settings); }; ChatModule.prototype.sendModMessage = function (msg, minrank) { diff --git a/src/config.js b/src/config.js index 7e47c7e2..74c3d550 100644 --- a/src/config.js +++ b/src/config.js @@ -5,6 +5,8 @@ var net = require("net"); var YAML = require("yamljs"); import { LoggerFactory } from '@calzoneman/jsli'; +import { loadFromToml } from 'cytube-common/lib/configuration/configloader'; +import { CamoConfig } from './configuration/camoconfig'; const LOGGER = LoggerFactory.getLogger('config'); @@ -146,6 +148,7 @@ function merge(obj, def, path) { } var cfg = defaults; +let camoConfig = new CamoConfig(); /** * Initializes the configuration from the given YAML file @@ -186,8 +189,31 @@ exports.load = function (file) { preprocessConfig(cfg); LOGGER.info("Loaded configuration from " + file); + + loadCamoConfig(); }; +function loadCamoConfig() { + try { + camoConfig = loadFromToml(CamoConfig, + path.resolve(__dirname, '..', 'conf', 'camo.toml')); + const enabled = camoConfig.isEnabled() ? 'ENABLED' : 'DISABLED'; + LOGGER.info(`Loaded camo configuration from conf/camo.toml. Camo is ${enabled}`); + } catch (error) { + if (error.code === 'ENOENT') { + LOGGER.info('No camo configuration found, chat images will not be proxied.'); + camoConfig = new CamoConfig(); + return; + } + + if (typeof error.line !== 'undefined') { + LOGGER.error(`Error in conf/camo.toml: ${error} (line ${error.line})`); + } else { + LOGGER.error(`Error loading conf/camo.toml: ${error.stack}`); + } + } +} + // I'm sorry function preprocessConfig(cfg) { /* Detect 3.0.0-style config and warng the user about it */ @@ -447,3 +473,7 @@ exports.set = function (key, value) { obj[current] = value; }; + +exports.getCamoConfig = function getCamoConfig() { + return camoConfig; +}; diff --git a/src/configuration/camoconfig.js b/src/configuration/camoconfig.js new file mode 100644 index 00000000..4d4f0961 --- /dev/null +++ b/src/configuration/camoconfig.js @@ -0,0 +1,26 @@ +class CamoConfig { + constructor(config = { camo: { enabled: false } }) { + this.config = config.camo; + if (this.config.server) { + this.config.server = this.config.server.replace(/\/+$/, ''); + } + } + + isEnabled() { + return this.config.enabled; + } + + getKey() { + return this.config.key; + } + + getServer() { + return this.config.server; + } + + getWhitelistedDomains() { + return this.config['whitelisted-domains'] || []; + } +} + +export { CamoConfig }; diff --git a/src/xss.js b/src/xss.js index 6855db22..b88c6764 100644 --- a/src/xss.js +++ b/src/xss.js @@ -103,8 +103,9 @@ function decodeText(str) { return str; } -module.exports.sanitizeHTML = function (html) { - return sanitizeHTML(html, SETTINGS); +module.exports.sanitizeHTML = function (html, extraSettings = {}) { + const options = Object.assign({}, SETTINGS, extraSettings); + return sanitizeHTML(html, options); }; module.exports.looseSanitizeText = looseSanitizeText; diff --git a/test/camo.js b/test/camo.js new file mode 100644 index 00000000..5fc3ebfa --- /dev/null +++ b/test/camo.js @@ -0,0 +1,54 @@ +const assert = require('assert'); +const Camo = require('../lib/camo'); +const CamoConfig = require('../lib/configuration/camoconfig').CamoConfig; + +describe('Camo', () => { + const config = new CamoConfig({ + camo: { + server: 'http://localhost:8081', + key: '9LKC7708ZHOVRCTLOLE3G2YJ0U1T8F96', + 'whitelisted-domains': ['def.xyz'] + } + }); + + describe('#camoify', () => { + it('constructs a camo url', () => { + const result = Camo.camoify(config, 'http://abc.xyz/image.jpeg'); + assert.strictEqual(result, 'http://localhost:8081/a9c295dd7d8dcbc8247dec97ac5d9b4ee8baeb31/687474703a2f2f6162632e78797a2f696d6167652e6a706567'); + }); + + it('bypasses camo for whitelisted domains', () => { + const result = Camo.camoify(config, 'http://def.xyz/image.jpeg'); + assert.strictEqual(result, 'https://def.xyz/image.jpeg'); + }); + }); + + describe('#transformImgTags', () => { + it('transforms an img tag with a src', () => { + const attribs = { + src: 'http://abc.xyz/image.jpeg', + 'class': 'some-image' + }; + const expectedAttribs = { + src: 'http://localhost:8081/a9c295dd7d8dcbc8247dec97ac5d9b4ee8baeb31/687474703a2f2f6162632e78797a2f696d6167652e6a706567', + 'class': 'some-image' + }; + const result = Camo.transformImgTags(config, 'img', attribs); + assert.deepStrictEqual(result, { tagName: 'img', attribs: expectedAttribs }); + }); + + it('skips img tags with no src', () => { + const attribs = { 'class': 'some-image' }; + const result = Camo.transformImgTags(config, 'img', attribs); + assert.deepStrictEqual(result, { tagName: 'img', attribs: attribs }); + }); + + it('fails gracefully', () => { + const attribs = { src: 'http://abc.xyz/image.jpeg' }; + const config = new CamoConfig({ camo: { enabled: true }}); + config.getKey = () => { throw new Error('something happened'); }; + const result = Camo.transformImgTags(config, 'img', attribs); + assert.deepStrictEqual(result, { tagName: 'img', attribs: attribs }); + }); + }); +}); diff --git a/test/configuration/camoconfig.js b/test/configuration/camoconfig.js new file mode 100644 index 00000000..3f8c463b --- /dev/null +++ b/test/configuration/camoconfig.js @@ -0,0 +1,25 @@ +const assert = require('assert'); +const CamoConfig = require('../../lib/configuration/camoconfig').CamoConfig; + +describe('CamoConfig', () => { + describe('#constructor', () => { + it('strips trailing slashes from the server', () => { + const config = new CamoConfig({ + camo: { + server: 'http://abc.xyz/' + } + }); + assert.strictEqual(config.getServer(), 'http://abc.xyz'); + }); + + it('defaults to enabled=false', () => { + assert.strictEqual(new CamoConfig().isEnabled(), false); + }); + }); + + describe('#getWhitelistedDomains', () => { + it('defaults to an empty array', () => { + assert.deepStrictEqual(new CamoConfig().getWhitelistedDomains(), []); + }); + }); +});