Skip to content

Commit

Permalink
Support for urlSSL config option and forceAdminSSL 403 response
Browse files Browse the repository at this point in the history
closes #1838
- adding `forceAdminSSL: {redirect: true/false}` option to allow 403 over non-SSL rather than redirect
- adding `urlSSL` option to specify SSL variant of `url`
- using `urlSSL` when redirecting to SSL (forceAdminSSL), if specified
- dynamically patching `.url` property for view engine templates to use SSL variant over HTTPS connections (pass `.secure` property as view engine data)
- using `urlSSL` in a "reset password" email, if specified
- adding unit tests to test `forceAdminSSL` and `urlSSL` options
- created a unit test utility function to dynamically fork a new instance of Ghost during the test, with different configuration options
  • Loading branch information
gimelfarb committed Apr 27, 2014
1 parent 33884e7 commit a013840
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 12 deletions.
16 changes: 12 additions & 4 deletions core/server/config/url.js
Expand Up @@ -26,17 +26,19 @@ function setConfig(config) {
// Parameters:
// - urlPath - string which must start and end with a slash
// - absolute (optional, default:false) - boolean whether or not the url should be absolute
// - secure (optional, default:false) - boolean whether or not to use urlSSL or url config
// Returns:
// - a URL which always ends with a slash
function createUrl(urlPath, absolute) {
function createUrl(urlPath, absolute, secure) {
urlPath = urlPath || '/';
absolute = absolute || false;

var output = '';
var output = '', baseUrl;

// create base of url, always ends without a slash
if (absolute) {
output += ghostConfig.url.replace(/\/$/, '');
baseUrl = (secure && ghostConfig.urlSSL) ? ghostConfig.urlSSL : ghostConfig.url;
output += baseUrl.replace(/\/$/, '');
} else {
output += ghostConfig.paths.subdir;
}
Expand Down Expand Up @@ -99,6 +101,7 @@ function urlPathForPost(post, permalinks) {
// This is probably not the right place for this, but it's the best place for now
function urlFor(context, data, absolute) {
var urlPath = '/',
secure,
knownObjects = ['post', 'tag', 'user'],
knownPaths = {'home': '/', 'rss': '/rss/'}; // this will become really big

Expand All @@ -108,22 +111,27 @@ function urlFor(context, data, absolute) {
data = null;
}

// Can pass 'secure' flag in either context or data arg
secure = (context && context.secure) || (data && data.secure);

if (_.isObject(context) && context.relativeUrl) {
urlPath = context.relativeUrl;
} else if (_.isString(context) && _.indexOf(knownObjects, context) !== -1) {
// trying to create a url for an object
if (context === 'post' && data.post && data.permalinks) {
urlPath = urlPathForPost(data.post, data.permalinks);
secure = data.post.secure;
} else if (context === 'tag' && data.tag) {
urlPath = '/tag/' + data.tag.slug + '/';
secure = data.tag.secure;
}
// other objects are recognised but not yet supported
} else if (_.isString(context) && _.indexOf(_.keys(knownPaths), context) !== -1) {
// trying to create a url for a named path
urlPath = knownPaths[context] || '/';
}

return createUrl(urlPath, absolute);
return createUrl(urlPath, absolute, secure);
}

// ## urlForPost
Expand Down
5 changes: 3 additions & 2 deletions core/server/controllers/admin.js
Expand Up @@ -301,8 +301,9 @@ adminControllers = {
var email = req.body.email;

api.users.generateResetToken(email).then(function (token) {
var siteLink = '<a href="' + config().url + '">' + config().url + '</a>',
resetUrl = config().url.replace(/\/$/, '') + '/ghost/reset/' + token + '/',
var baseUrl = config().forceAdminSSL ? (config().urlSSL || config().url) : config().url,
siteLink = '<a href="' + baseUrl + '">' + baseUrl + '</a>',
resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/reset/' + token + '/',
resetLink = '<a href="' + resetUrl + '">' + resetUrl + '</a>',
message = {
to: email,
Expand Down
22 changes: 20 additions & 2 deletions core/server/controllers/frontend.js
Expand Up @@ -56,6 +56,14 @@ function handleError(next) {
};
}

// Add Request context parameter to the data object
// to be passed down to the templates
function setReqCtx(req, data) {
(Array.isArray(data) ? data : [data]).forEach(function (d) {
d.secure = req.secure;
});
}

frontendControllers = {
'homepage': function (req, res, next) {
// Parse the page number
Expand All @@ -76,6 +84,8 @@ frontendControllers = {
return res.redirect(page.meta.pagination.pages === 1 ? config().paths.subdir + '/' : (config().paths.subdir + '/page/' + page.meta.pagination.pages + '/'));
}

setReqCtx(req, page.posts);

// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
res.render('index', formatPageResponse(posts, page));
Expand Down Expand Up @@ -113,6 +123,9 @@ frontendControllers = {
return res.redirect(tagUrl(options.tag, page.meta.pagination.pages));
}

setReqCtx(req, page.posts);
setReqCtx(req, page.aspect.tag);

// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
api.settings.read('activeTheme').then(function (activeTheme) {
Expand Down Expand Up @@ -184,6 +197,9 @@ frontendControllers = {
// Use throw 'no match' to show 404.
throw new Error('no match');
}

setReqCtx(req, post);

filters.doFilter('prePostsRender', post).then(function (post) {
api.settings.read('activeTheme').then(function (activeTheme) {
var paths = config().paths.availableThemes[activeTheme.value],
Expand Down Expand Up @@ -279,8 +295,8 @@ frontendControllers = {
var title = result[0].value.value,
description = result[1].value.value,
permalinks = result[2].value,
siteUrl = config.urlFor('home', null, true),
feedUrl = config.urlFor('rss', null, true),
siteUrl = config.urlFor('home', {secure: req.secure}, true),
feedUrl = config.urlFor('rss', {secure: req.secure}, true),
maxPage = page.meta.pagination.pages,
feedItems = [],
feed;
Expand Down Expand Up @@ -315,6 +331,8 @@ frontendControllers = {
}
}

setReqCtx(req, page.posts);

filters.doFilter('prePostsRender', page.posts).then(function (posts) {
posts.forEach(function (post) {
var deferred = when.defer(),
Expand Down
30 changes: 28 additions & 2 deletions core/server/middleware/index.js
Expand Up @@ -70,20 +70,35 @@ function ghostLocals(req, res, next) {
}
}

function initThemeData(secure) {
var themeConfig = config.theme();
if (secure && config().urlSSL) {
// For secure requests override .url property with the SSL version
themeConfig = _.clone(themeConfig);
themeConfig.url = config().urlSSL.replace(/\/$/, '');
}
return themeConfig;
}

// ### InitViews Middleware
// Initialise Theme or Admin Views
function initViews(req, res, next) {
/*jslint unparam:true*/

if (!res.isAdmin) {
hbs.updateTemplateOptions({ data: {blog: config.theme()} });
var themeData = initThemeData(req.secure);
hbs.updateTemplateOptions({ data: {blog: themeData} });
expressServer.engine('hbs', expressServer.get('theme view engine'));
expressServer.set('views', path.join(config().paths.themePath, expressServer.get('activeTheme')));
} else {
expressServer.engine('hbs', expressServer.get('admin view engine'));
expressServer.set('views', config().paths.adminViews);
}

// Pass 'secure' flag to the view engine
// so that templates can choose 'url' vs 'urlSSL'
res.locals.secure = req.secure;

next();
}

Expand Down Expand Up @@ -184,9 +199,20 @@ function isSSLrequired(isAdmin) {
function checkSSL(req, res, next) {
if (isSSLrequired(res.isAdmin)) {
if (!req.secure) {
var forceAdminSSL = config().forceAdminSSL,
redirectUrl;

// Check if forceAdminSSL: { redirect: false } is set, which means
// we should just deny non-SSL access rather than redirect
if (forceAdminSSL && forceAdminSSL.redirect !== undefined && !forceAdminSSL.redirect) {
return res.send(403);
}

redirectUrl = url.parse(config().urlSSL || config().url);
return res.redirect(301, url.format({
protocol: 'https:',
hostname: url.parse(config().url).hostname,
hostname: redirectUrl.hostname,
port: redirectUrl.port,
pathname: req.path,
query: req.query
}));
Expand Down
74 changes: 74 additions & 0 deletions core/test/functional/routes/admin_test.js
Expand Up @@ -111,6 +111,80 @@ describe('Admin Routing', function () {
.end(doEndNoAuth(done));
});
});

// we'll use X-Forwarded-Proto: https to simulate an 'https://' request behind a proxy
describe('Require HTTPS - no redirect', function() {
var forkedGhost, request;
before(function (done) {
var configTestHttps = testUtils.fork.config();
configTestHttps.forceAdminSSL = {redirect: false};
configTestHttps.urlSSL = 'https://localhost/';

testUtils.fork.ghost(configTestHttps, 'testhttps')
.then(function(child) {
forkedGhost = child;
request = require('supertest');
request = request(configTestHttps.url.replace(/\/$/, ''));
}, done)
.then(done);
});

after(function (done) {
if (forkedGhost) {
forkedGhost.kill(done);
}
});

it('should block admin access over non-HTTPS', function(done) {
request.get('/ghost/')
.expect(403)
.end(done);
});

it('should allow admin access over HTTPS', function(done) {
request.get('/ghost/signup/')
.set('X-Forwarded-Proto', 'https')
.expect(200)
.end(doEnd(done));
});
});

describe('Require HTTPS - redirect', function() {
var forkedGhost, request;
before(function (done) {
var configTestHttps = testUtils.fork.config();
configTestHttps.forceAdminSSL = {redirect: true};
configTestHttps.urlSSL = 'https://localhost/';

testUtils.fork.ghost(configTestHttps, 'testhttps')
.then(function(child) {
forkedGhost = child;
request = require('supertest');
request = request(configTestHttps.url.replace(/\/$/, ''));
}, done)
.then(done);
});

after(function (done) {
if (forkedGhost) {
forkedGhost.kill(done);
}
});

it('should redirect admin access over non-HTTPS', function(done) {
request.get('/ghost/')
.expect('Location', /^https:\/\/localhost\/ghost\//)
.expect(301)
.end(done);
});

it('should allow admin access over HTTPS', function(done) {
request.get('/ghost/signup/')
.set('X-Forwarded-Proto', 'https')
.expect(200)
.end(done);
});
});

describe('Ghost Admin Signup', function () {
it('should have a session cookie which expires in 12 hours', function (done) {
Expand Down
42 changes: 42 additions & 0 deletions core/test/functional/routes/frontend_test.js
Expand Up @@ -9,6 +9,7 @@ var request = require('supertest'),
express = require('express'),
should = require('should'),
moment = require('moment'),
path = require('path'),

testUtils = require('../../utils'),
ghost = require('../../../../core'),
Expand Down Expand Up @@ -142,6 +143,47 @@ describe('Frontend Routing', function () {
.end(doEnd(done));
});
});

// we'll use X-Forwarded-Proto: https to simulate an 'https://' request behind a proxy
describe('HTTPS', function() {
var forkedGhost, request;
before(function (done) {
var configTestHttps = testUtils.fork.config();
configTestHttps.forceAdminSSL = {redirect: false};
configTestHttps.urlSSL = 'https://localhost/';

testUtils.fork.ghost(configTestHttps, 'testhttps')
.then(function(child) {
forkedGhost = child;
request = require('supertest');
request = request(configTestHttps.url.replace(/\/$/, ''));
}, done)
.then(done);
});

after(function (done) {
if (forkedGhost) {
forkedGhost.kill(done);
}
});

it('should set links to url over non-HTTPS', function(done) {
request.get('/')
.expect(200)
.expect(/\<link rel="canonical" href="http:\/\/127.0.0.1:2370\/" \/\>/)
.expect(/copyright \<a href="http:\/\/127.0.0.1:2370\/">Ghost\<\/a\>/)
.end(doEnd(done));
});

it('should set links to urlSSL over HTTPS', function(done) {
request.get('/')
.set('X-Forwarded-Proto', 'https')
.expect(200)
.expect(/\<link rel="canonical" href="https:\/\/localhost\/" \/\>/)
.expect(/copyright \<a href="https:\/\/localhost\/">Ghost\<\/a\>/)
.end(doEnd(done));
});
});

describe('RSS', function () {
it('should redirect without slash', function (done) {
Expand Down

0 comments on commit a013840

Please sign in to comment.