Support proxying chat images via camo

Camo: https://github.com/atmos/camo.  This has a couple advantages over
just allowing images to be dumped as-is:

  - Prevents mixed-content warnings by allowing the server to proxy HTTP
    images to an HTTPS camo instance
  - Protects users' privacy by not exposing their browser directly to
    the image host
  - Allows the camo proxy to intercept and reject bad image sources
    (URLs that are not actually images, gigapixel-sized images likely to
    DoS users' browsers, etc.)

Whitelisting specific domains is supported for cases where the source is
known to be trustworthy.
This commit is contained in:
Calvin Montgomery 2017-05-28 19:38:43 -07:00
parent f968521936
commit 22a9acfc90
8 changed files with 194 additions and 4 deletions

View File

@ -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"
},

43
src/camo.js Normal file
View File

@ -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 };
}

View File

@ -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) {

View File

@ -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;
};

View File

@ -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 };

View File

@ -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;

54
test/camo.js Normal file
View File

@ -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 });
});
});
});

View File

@ -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(), []);
});
});
});