0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

🔒 Fully separated front-end and admin app urls

no issue

- uses `vhost` in parent-app to properly split front-end and admin/api apps when a separate admin url is configured
This commit is contained in:
Kevin Ansfield 2019-09-08 23:59:28 +01:00
parent 58b9aea00d
commit b46f9b1dc2
6 changed files with 552 additions and 17 deletions

View file

@ -1,9 +1,16 @@
const debug = require('ghost-ignition').debug('web:parent');
const express = require('express');
const vhost = require('vhost');
const config = require('../config');
const compress = require('compression');
const netjet = require('netjet');
const shared = require('./shared');
const escapeRegExp = require('lodash.escaperegexp');
const {URL} = require('url');
const urlUtils = require('../lib/url-utils');
const storage = require('../adapters/storage');
const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`;
module.exports = function setupParentApp(options = {}) {
debug('ParentApp setup start');
@ -37,17 +44,31 @@ module.exports = function setupParentApp(options = {}) {
// This sets global res.locals which are needed everywhere
parentApp.use(shared.middlewares.ghostLocals);
// Wrap the admin and API apps into a single express app for use with vhost
const adminApp = express();
adminApp.enable('trust proxy'); // required to respect x-forwarded-proto in admin requests
adminApp.use('/ghost/api', require('./api')());
adminApp.use('/ghost', require('./admin')());
// TODO: remove /content/* once we're sure the API is not returning relative asset URLs anywhere
adminApp.use(STATIC_IMAGE_URL_PREFIX, shared.middlewares.image.handleImageSizes, storage.getStorage().serve());
// Mount the apps on the parentApp
// API
// @TODO: finish refactoring the API app
parentApp.use('/ghost/api', require('./api')());
const adminHost = config.get('admin:url') ? (new URL(config.get('admin:url')).hostname) : '';
const frontendHost = new URL(config.get('url')).hostname;
const hasSeparateAdmin = adminHost && adminHost !== frontendHost;
// ADMIN
parentApp.use('/ghost', require('./admin')());
// ADMIN + API
// with a separate admin url only serve on that host, otherwise serve on all hosts
const adminVhostArg = hasSeparateAdmin && adminHost ? adminHost : /.*/;
parentApp.use(vhost(adminVhostArg, adminApp));
// BLOG
parentApp.use(require('./site')(options));
// with a separate admin url we adjust the frontend vhost to exclude requests to that host, otherwise serve on all hosts
const frontendVhostArg = (hasSeparateAdmin && adminHost) ?
new RegExp(`^(?!${escapeRegExp(adminHost)}).*`) : /.*/;
parentApp.use(vhost(frontendVhostArg, require('./site')(options)));
debug('ParentApp setup end');

View file

@ -14,7 +14,7 @@ module.exports = function adminRedirects() {
router.get(/^\/(logout|signout)\/$/, adminRedirect('#/signout/'));
router.get(/^\/signup\/$/, adminRedirect('#/signup/'));
// redirect to /ghost and let that do the authentication to prevent redirects to /ghost//admin etc.
router.get(/^\/((ghost-admin|admin|dashboard|signin|login)\/?)$/, adminRedirect('/'));
router.get(/^\/((ghost|ghost-admin|admin|dashboard|signin|login)\/?)$/, adminRedirect('/'));
return router;
};

View file

@ -5202,4 +5202,458 @@ describe('Integration - Web - Site', function () {
});
});
});
describe('parent app vhosts', function () {
describe('no separate admin', function () {
before(function () {
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true});
testUtils.integrationTesting.overrideGhostConfig(configUtils);
configUtils.set('url', 'http://example.com');
configUtils.set('admin:url', null);
return testUtils.integrationTesting.initGhost()
.then(function () {
sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2');
sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2);
app = siteApp({start: true});
return testUtils.integrationTesting.urlService.waitTillFinished();
})
.then(() => {
return appsService.init();
});
});
before(function () {
configUtils.set('url', 'http://example.com');
configUtils.set('admin:url', null);
urlUtils.stubUrlUtilsFromConfig();
});
after(function () {
configUtils.restore();
urlUtils.restore();
sinon.restore();
});
it('loads the front-end on configured url', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the front-end on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the admin', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the admin on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the api', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the api on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
});
describe('separate admin host', function () {
before(function () {
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true});
testUtils.integrationTesting.overrideGhostConfig(configUtils);
configUtils.set('url', 'http://example.com');
configUtils.set('admin:url', 'https://admin.example.com');
return testUtils.integrationTesting.initGhost()
.then(function () {
sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2');
sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2);
app = siteApp({start: true});
return testUtils.integrationTesting.urlService.waitTillFinished();
})
.then(() => {
return appsService.init();
});
});
before(function () {
urlUtils.stubUrlUtilsFromConfig();
});
after(function () {
configUtils.restore();
urlUtils.restore();
sinon.restore();
});
it('loads the front-end on configured url', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the front-end on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('redirects /ghost/ on configured url', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://admin.example.com/ghost/');
});
});
it('404s the api on configured url', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
});
});
it('404s the api on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
});
});
it('loads the admin on configured admin url', function () {
const req = {
secure: true,
method: 'GET',
url: '/ghost/',
host: 'admin.example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the api on configured admin url', function () {
const req = {
secure: true,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'admin.example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('redirects to the correct protocol on configured admin url', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'admin.example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://admin.example.com/ghost/');
});
});
it('404s the front-end on configured admin url', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'admin.example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
});
});
});
describe('same host separate protocol', function () {
before(function () {
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true});
testUtils.integrationTesting.overrideGhostConfig(configUtils);
configUtils.set('url', 'http://example.com');
configUtils.set('admin:url', 'https://example.com');
return testUtils.integrationTesting.initGhost()
.then(function () {
sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2');
sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2);
app = siteApp({start: true});
return testUtils.integrationTesting.urlService.waitTillFinished();
})
.then(() => {
return appsService.init();
});
});
before(function () {
urlUtils.stubUrlUtilsFromConfig();
});
it('loads the front-end on configured url (http)', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('404s the front-end on configured url (https)', function () {
const req = {
secure: true,
method: 'GET',
url: '/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('loads the front-end on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('redirects /ghost/ on configured url', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://example.com/ghost/');
});
});
it('redirects /ghost/ on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://example.com/ghost/');
});
});
it('redirects api to correct protocol on configured admin url', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://example.com/ghost/api/v2/admin/site/');
});
});
it('loads the admin on configured admin url', function () {
const req = {
secure: true,
method: 'GET',
url: '/ghost/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('redirects the admin on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://example.com/ghost/');
});
});
it('loads the api on configured admin url', function () {
const req = {
secure: true,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
it('redirects the api on localhost', function () {
const req = {
secure: false,
method: 'GET',
url: '/ghost/api/v2/admin/site/',
host: 'localhost'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('https://example.com/ghost/api/v2/admin/site/');
});
});
});
});
});

View file

@ -1,9 +1,11 @@
var should = require('should'),
sinon = require('sinon'),
proxyquire = require('proxyquire');
const should = require('should');
const sinon = require('sinon');
const proxyquire = require('proxyquire');
const configUtils = require('../../utils/configUtils');
describe('parent app', function () {
let expressStub;
let vhostSpy;
let use;
let apiSpy;
let parentApp;
@ -19,6 +21,7 @@ describe('parent app', function () {
enable: () => {}
});
vhostSpy = sinon.spy();
apiSpy = sinon.spy();
adminSpy = sinon.spy();
siteSpy = sinon.spy();
@ -27,6 +30,7 @@ describe('parent app', function () {
parentApp = proxyquire('../../../server/web/parent-app', {
express: expressStub,
vhost: vhostSpy,
'./api': apiSpy,
'./admin': adminSpy,
'./site': siteSpy,
@ -35,20 +39,70 @@ describe('parent app', function () {
authPages: authPagesSpy
}
});
configUtils.set('url', 'http://ghost.blog');
});
afterEach(function () {
sinon.restore();
configUtils.restore();
});
it('should mount 3 apps and assign correct routes to them', function () {
parentApp();
// url = 'https://ghost.blog'
describe('without separate admin url', function () {
it('should mount and assign correct routes', function () {
parentApp();
use.calledWith('/ghost/api').should.be.true();
use.calledWith('/ghost').should.be.true();
use.calledWith('/ghost/api').should.be.true();
use.calledWith('/ghost').should.be.true();
use.calledWith('/content/images').should.be.true();
apiSpy.called.should.be.true();
adminSpy.called.should.be.true();
siteSpy.called.should.be.true();
apiSpy.called.should.be.true();
adminSpy.called.should.be.true();
siteSpy.called.should.be.true();
vhostSpy.calledTwice.should.be.true();
vhostSpy.firstCall.calledWith(/.*/).should.be.true();
vhostSpy.secondCall.calledWith(/.*/).should.be.true();
});
});
// url = 'https://ghost.blog'
// admin.url = 'https://admin.ghost.blog'
describe('with separate admin url', function () {
beforeEach(function () {
configUtils.set('admin:url', 'https://admin.ghost.blog');
});
it('should mount and assign correct routes', function () {
parentApp();
vhostSpy.calledTwice.should.be.true();
vhostSpy.firstCall.calledWith('admin.ghost.blog').should.be.true();
vhostSpy.secondCall.calledWith(/^(?!admin\.ghost\.blog).*/).should.be.true();
});
it('should have regex that excludes admin traffic on front-end', function () {
parentApp();
const frontendRegex = vhostSpy.secondCall.args[0];
frontendRegex.test('localhost').should.be.true();
frontendRegex.test('ghost.blog').should.be.true();
frontendRegex.test('admin.ghost.blog').should.be.false();
});
});
// url = 'http://ghost.blog'
// admin.url = 'https://ghost.blog'
describe('with separate admin protocol', function () {
it('should mount and assign correct routes', function () {
configUtils.set('admin:url', 'https://ghost.blog');
parentApp();
vhostSpy.calledTwice.should.be.true();
vhostSpy.firstCall.calledWith(/.*/).should.be.true();
vhostSpy.secondCall.calledWith(/.*/).should.be.true();
});
});
});

View file

@ -127,6 +127,7 @@
"simple-html-tokenizer": "0.5.8",
"uuid": "3.3.3",
"validator": "6.3.0",
"vhost": "3.0.2",
"xml": "1.0.1"
},
"optionalDependencies": {

View file

@ -8623,6 +8623,11 @@ verror@1.10.0:
core-util-is "1.0.2"
extsprintf "^1.2.0"
vhost@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/vhost/-/vhost-3.0.2.tgz#2fb1decd4c466aa88b0f9341af33dc1aff2478d5"
integrity sha1-L7HezUxGaqiLD5NBrzPcGv8keNU=
video-extensions@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/video-extensions/-/video-extensions-1.1.0.tgz#eaa86b45f29a853c2b873e9d8e23b513712997d6"