Skip to content

Commit f61bbc7

Browse files
committed
fix(mysql): re-order create user statements
refs TryGhost#511 - always run create user with hashed password to fix requirements with secure installations
1 parent 2a14688 commit f61bbc7

File tree

2 files changed

+75
-35
lines changed

2 files changed

+75
-35
lines changed

extensions/mysql/index.js

+31-19
Original file line numberDiff line numberDiff line change
@@ -79,35 +79,47 @@ class MySQLExtension extends cli.Extension {
7979
createUser(ctx, dbconfig) {
8080
const randomPassword = crypto.randomBytes(10).toString('hex');
8181

82-
// IMPORTANT: we generate random MySQL usernames
83-
// e.g. you delete all your Ghost instances from your droplet and start from scratch, the MySQL users would remain and the CLI has to generate a random user name to work
84-
// e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances
85-
// the most important fact is, that if a MySQL user exists, we have no access to the password, which we need to autofill the Ghost config
86-
// disadvantage: the CLI could potentially create lot's of MySQL users (but this should only happen if the user installs Ghost over and over again with root credentials)
87-
const username = 'ghost-' + Math.floor(Math.random() * 1000);
88-
89-
return this._query(`CREATE USER '${username}'@'${dbconfig.host}' IDENTIFIED WITH mysql_native_password;`).then(() => {
90-
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
82+
let username;
9183

92-
return this._query('SET old_passwords = 0;');
93-
}).then(() => {
84+
// Ensure old passwords is set to 0
85+
return this._query('SET old_passwords = 0;').then(() => {
9486
this.ui.logVerbose('MySQL: successfully disabled old_password', 'green');
9587

96-
return this._query(`SET PASSWORD FOR '${username}'@'${dbconfig.host}' = PASSWORD('${randomPassword}');`);
88+
return this._query(`SELECT PASSWORD('${randomPassword}') AS password;`);
89+
}).then((result) => {
90+
this.ui.logVerbose('MySQL: successfully created password hash.', 'green');
91+
92+
const tryCreateUser = () => {
93+
// IMPORTANT: we generate random MySQL usernames
94+
// e.g. you delete all your Ghost instances from your droplet and start from scratch, the MySQL users would remain and the CLI has to generate a random user name to work
95+
// e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances
96+
// the most important fact is, that if a MySQL user exists, we have no access to the password, which we need to autofill the Ghost config
97+
// disadvantage: the CLI could potentially create lot's of MySQL users (but this should only happen if the user installs Ghost over and over again with root credentials)
98+
username = `ghost-${Math.floor(Math.random() * 1000)}`;
99+
100+
return this._query(
101+
`CREATE USER '${username}'@'${dbconfig.host}' ` +
102+
`IDENTIFIED WITH mysql_native_password AS '${result[0].password}';`
103+
).catch((error) => {
104+
// User already exists, run this method again
105+
if (error.errno === 1396) {
106+
this.ui.logVerbose('MySQL: user exists, re-trying user creation with new username', 'yellow');
107+
return tryCreateUser();
108+
}
109+
110+
return Promise.reject(error);
111+
});
112+
};
113+
114+
return tryCreateUser();
97115
}).then(() => {
98-
this.ui.logVerbose(`MySQL: successfully created password for user ${username}`, 'green');
116+
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
99117

100118
ctx.mysql = {
101119
username: username,
102120
password: randomPassword
103121
};
104122
}).catch((error) => {
105-
// User already exists, run this method again
106-
if (error.errno === 1396) {
107-
this.ui.logVerbose('MySQL: user exists, re-trying user creation with new username', 'yellow');
108-
return this.createUser(ctx, dbconfig);
109-
}
110-
111123
this.ui.logVerbose('MySQL: Unable to create custom Ghost user', 'red');
112124
this.connection.end(); // Ensure we end the connection
113125
return Promise.reject(new cli.errors.SystemError(`Creating new mysql user errored with message: ${error.message}`));

extensions/mysql/test/extension-spec.js

+44-16
Original file line numberDiff line numberDiff line change
@@ -186,17 +186,18 @@ describe('Unit: Mysql extension', function () {
186186
const logStub = sinon.stub();
187187
const instance = new MysqlExtension({logVerbose: logStub}, {}, {}, '/some/dir');
188188
const queryStub = sinon.stub(instance, '_query').resolves();
189+
queryStub.onSecondCall().resolves([{password: '*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19'}]);
189190
const ctx = {};
190191

191192
return instance.createUser(ctx, {host: 'localhost'}).then(() => {
192193
expect(queryStub.calledThrice).to.be.true;
193-
expect(queryStub.args[0][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password;$/);
194-
expect(queryStub.args[1][0]).to.equal('SET old_passwords = 0;');
195-
expect(queryStub.args[2][0]).to.match(/^SET PASSWORD FOR 'ghost-[0-9]{1,4}'@'localhost' = PASSWORD\('[0-9A-Fa-f]*'\);$/);
194+
expect(queryStub.args[0][0]).to.equal('SET old_passwords = 0;');
195+
expect(queryStub.args[1][0]).to.match(/^SELECT PASSWORD\('[0-9A-Fa-f]*'\) AS password;$/);
196+
expect(queryStub.args[2][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password AS '\*[0-9A-F]*';$/);
196197
expect(logStub.calledThrice).to.be.true;
197-
expect(logStub.args[0][0]).to.match(/created new user/);
198-
expect(logStub.args[1][0]).to.match(/disabled old_password/);
199-
expect(logStub.args[2][0]).to.match(/successfully created password for user/);
198+
expect(logStub.args[0][0]).to.match(/disabled old_password/);
199+
expect(logStub.args[1][0]).to.match(/created password hash/);
200+
expect(logStub.args[2][0]).to.match(/successfully created new user/);
200201
expect(ctx.mysql).to.exist;
201202
expect(ctx.mysql.username).to.match(/^ghost-[0-9]{1,4}$/);
202203
expect(ctx.mysql.password).to.match(/^[0-9A-Fa-f]*$/);
@@ -207,7 +208,8 @@ describe('Unit: Mysql extension', function () {
207208
const logStub = sinon.stub();
208209
const instance = new MysqlExtension({logVerbose: logStub}, {}, {}, '/some/dir');
209210
const queryStub = sinon.stub(instance, '_query').resolves();
210-
queryStub.onFirstCall().callsFake(() => {
211+
queryStub.onSecondCall().resolves([{password: '*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19'}]);
212+
queryStub.onThirdCall().callsFake(() => {
211213
const error = new Error();
212214
error.errno = 1396;
213215
return Promise.reject(error);
@@ -216,21 +218,47 @@ describe('Unit: Mysql extension', function () {
216218

217219
return instance.createUser(ctx, {host: 'localhost'}).then(() => {
218220
expect(queryStub.callCount).to.equal(4);
219-
expect(queryStub.args[0][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password;$/);
220-
expect(queryStub.args[1][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password;$/);
221-
expect(queryStub.args[2][0]).to.equal('SET old_passwords = 0;');
222-
expect(queryStub.args[3][0]).to.match(/^SET PASSWORD FOR 'ghost-[0-9]{1,4}'@'localhost' = PASSWORD\('[0-9A-Fa-f]*'\);$/);
221+
expect(queryStub.args[0][0]).to.equal('SET old_passwords = 0;');
222+
expect(queryStub.args[1][0]).to.match(/^SELECT PASSWORD\('[0-9A-Fa-f]*'\) AS password;$/);
223+
expect(queryStub.args[2][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password AS '\*[0-9A-F]*';$/);
224+
expect(queryStub.args[3][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password AS '\*[0-9A-F]*';$/);
223225
expect(logStub.callCount).to.equal(4);
224-
expect(logStub.args[0][0]).to.match(/user exists, re-trying user creation/);
225-
expect(logStub.args[1][0]).to.match(/created new user/);
226-
expect(logStub.args[2][0]).to.match(/disabled old_password/);
227-
expect(logStub.args[3][0]).to.match(/successfully created password for user/);
226+
expect(logStub.args[0][0]).to.match(/disabled old_password/);
227+
expect(logStub.args[1][0]).to.match(/created password hash/);
228+
expect(logStub.args[2][0]).to.match(/user exists, re-trying user creation/);
229+
expect(logStub.args[3][0]).to.match(/successfully created new user/);
228230
expect(ctx.mysql).to.exist;
229231
expect(ctx.mysql.username).to.match(/^ghost-[0-9]{1,4}$/);
230232
expect(ctx.mysql.password).to.match(/^[0-9A-Fa-f]*$/);
231233
});
232234
});
233235

236+
it('rejects if error occurs during user creation', function () {
237+
const logStub = sinon.stub();
238+
const instance = new MysqlExtension({logVerbose: logStub}, {}, {}, '/some/dir');
239+
const queryStub = sinon.stub(instance, '_query').resolves();
240+
queryStub.onSecondCall().resolves([{password: '*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19'}]);
241+
queryStub.onThirdCall().rejects();
242+
const endStub = sinon.stub();
243+
instance.connection = {end: endStub};
244+
245+
return instance.createUser({}, {host: 'localhost'}).then(() => {
246+
expect(false, 'error should have been thrown').to.be.true;
247+
}).catch((error) => {
248+
expect(error).to.be.an.instanceof(errors.SystemError);
249+
expect(error.message).to.match(/Creating new mysql user errored/);
250+
expect(queryStub.callCount).to.equal(3);
251+
expect(queryStub.args[0][0]).to.equal('SET old_passwords = 0;');
252+
expect(queryStub.args[1][0]).to.match(/^SELECT PASSWORD\('[0-9A-Fa-f]*'\) AS password;$/);
253+
expect(queryStub.args[2][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password AS '\*[0-9A-F]*';$/);
254+
expect(logStub.callCount).to.equal(3);
255+
expect(logStub.args[0][0]).to.match(/disabled old_password/);
256+
expect(logStub.args[1][0]).to.match(/created password hash/);
257+
expect(logStub.args[2][0]).to.match(/Unable to create custom Ghost user/);
258+
expect(endStub.calledOnce).to.be.true;
259+
});
260+
});
261+
234262
it('rejects with SystemError and ends connection if any query fails', function () {
235263
const logStub = sinon.stub();
236264
const instance = new MysqlExtension({logVerbose: logStub}, {}, {}, '/some/dir');
@@ -244,7 +272,7 @@ describe('Unit: Mysql extension', function () {
244272
expect(error).to.be.an.instanceof(errors.SystemError);
245273
expect(error.message).to.match(/Creating new mysql user errored/);
246274
expect(queryStub.calledOnce).to.be.true;
247-
expect(queryStub.args[0][0]).to.match(/^CREATE USER 'ghost-[0-9]{1,4}'@'localhost' IDENTIFIED WITH mysql_native_password;$/);
275+
expect(queryStub.args[0][0]).to.equal('SET old_passwords = 0;');
248276
expect(logStub.calledOnce).to.be.true;
249277
expect(logStub.args[0][0]).to.match(/Unable to create custom Ghost user/);
250278
expect(endStub.calledOnce).to.be.true;

0 commit comments

Comments
 (0)