Merge pull request #670 from calzoneman/camo-proxy-chat-images

Support proxying chat images via camo
This commit is contained in:
Calvin Montgomery 2017-05-29 10:32:01 -07:00 committed by GitHub
commit 290f802b7c
10 changed files with 208 additions and 4 deletions

1
.gitignore vendored
View file

@ -13,3 +13,4 @@ www/cache
google-drive-subtitles
lib/
integration-test-config.json
conf/*.toml

13
conf/example/camo.toml Normal file
View file

@ -0,0 +1,13 @@
# Configuration for proxying images to a camo (or camo-compatible) proxy server.
# To use, copy to conf/camo.toml.
# More info on camo: https://github.com/atmos/camo
[camo]
enabled = true
server = 'https://my-camo-server'
# The key must match the `CAMO_KEY` environment variable passed to the camo server.
key = 'ABCDEFGH'
# Bypass the proxy for domains you trust that already support HTTPS and won't be harmful to users.
whitelisted-domains = [
'i.imgur.com',
'i.4cdn.org'
]

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