mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-17 23:44:39 -05:00
🔒 Fixed filtering on private Author fields in Content API
refs https://github.com/TryGhost/Ghost/security/advisories/GHSA-r97q-ghch-82j9 Because our filtering layer is so coupled to the DB and we don't generally apply restrictions, it was possible to fetch authors and filter by their password or email field. Coupled with the "starts with" operator this can be used to brute force the first character of these fields by trying random combinations until an author is included in the filter. After which the next character can be brute forced, and so on until the data has been leaked completely.
This commit is contained in:
parent
45e84a60fe
commit
a22717a8e7
7 changed files with 304 additions and 6 deletions
|
@ -1,6 +1,7 @@
|
||||||
const Promise = require('bluebird');
|
const Promise = require('bluebird');
|
||||||
const tpl = require('@tryghost/tpl');
|
const tpl = require('@tryghost/tpl');
|
||||||
const errors = require('@tryghost/errors');
|
const errors = require('@tryghost/errors');
|
||||||
|
const {mapQuery} = require('@tryghost/mongo-utils');
|
||||||
const models = require('../../models');
|
const models = require('../../models');
|
||||||
const ALLOWED_INCLUDES = ['count.posts'];
|
const ALLOWED_INCLUDES = ['count.posts'];
|
||||||
|
|
||||||
|
@ -8,6 +9,17 @@ const messages = {
|
||||||
notFound: 'Author not found.'
|
notFound: 'Author not found.'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rejectPrivateFieldsTransformer = input => mapQuery(input, function (value, key) {
|
||||||
|
const lowerCaseKey = key.toLowerCase();
|
||||||
|
if (lowerCaseKey.startsWith('password') || lowerCaseKey.startsWith('email')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[key]: value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
docName: 'authors',
|
docName: 'authors',
|
||||||
|
|
||||||
|
@ -29,7 +41,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Author.findPage(frame.options);
|
const options = {
|
||||||
|
...frame.options,
|
||||||
|
mongoTransformer: rejectPrivateFieldsTransformer
|
||||||
|
};
|
||||||
|
return models.Author.findPage(options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -54,7 +70,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Author.findOne(frame.data, frame.options)
|
const options = {
|
||||||
|
...frame.options,
|
||||||
|
mongoTransformer: rejectPrivateFieldsTransformer
|
||||||
|
};
|
||||||
|
return models.Author.findOne(frame.data, options)
|
||||||
.then((model) => {
|
.then((model) => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return Promise.reject(new errors.NotFoundError({
|
return Promise.reject(new errors.NotFoundError({
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const tpl = require('@tryghost/tpl');
|
const tpl = require('@tryghost/tpl');
|
||||||
const errors = require('@tryghost/errors');
|
const errors = require('@tryghost/errors');
|
||||||
|
const {mapQuery} = require('@tryghost/mongo-utils');
|
||||||
const models = require('../../models');
|
const models = require('../../models');
|
||||||
|
|
||||||
const ALLOWED_INCLUDES = ['tags', 'authors', 'tiers'];
|
const ALLOWED_INCLUDES = ['tags', 'authors', 'tiers'];
|
||||||
|
@ -8,6 +9,17 @@ const messages = {
|
||||||
pageNotFound: 'Page not found.'
|
pageNotFound: 'Page not found.'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rejectPrivateFieldsTransformer = input => mapQuery(input, function (value, key) {
|
||||||
|
let lowerCaseKey = key.toLowerCase();
|
||||||
|
if (lowerCaseKey.startsWith('authors.password') || lowerCaseKey.startsWith('authors.email')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[key]: value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
docName: 'pages',
|
docName: 'pages',
|
||||||
|
|
||||||
|
@ -35,7 +47,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Post.findPage(frame.options);
|
const options = {
|
||||||
|
...frame.options,
|
||||||
|
mongoTransformer: rejectPrivateFieldsTransformer
|
||||||
|
};
|
||||||
|
return models.Post.findPage(options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -64,7 +80,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Post.findOne(frame.data, frame.options)
|
const options = {
|
||||||
|
...frame.options,
|
||||||
|
mongoTransformer: rejectPrivateFieldsTransformer
|
||||||
|
};
|
||||||
|
return models.Post.findOne(frame.data, options)
|
||||||
.then((model) => {
|
.then((model) => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
throw new errors.NotFoundError({
|
throw new errors.NotFoundError({
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const models = require('../../models');
|
const models = require('../../models');
|
||||||
const tpl = require('@tryghost/tpl');
|
const tpl = require('@tryghost/tpl');
|
||||||
const errors = require('@tryghost/errors');
|
const errors = require('@tryghost/errors');
|
||||||
|
const {mapQuery} = require('@tryghost/mongo-utils');
|
||||||
const postsPublicService = require('../../services/posts-public');
|
const postsPublicService = require('../../services/posts-public');
|
||||||
|
|
||||||
const allowedIncludes = ['tags', 'authors', 'tiers', 'sentiment'];
|
const allowedIncludes = ['tags', 'authors', 'tiers', 'sentiment'];
|
||||||
|
@ -9,6 +10,17 @@ const messages = {
|
||||||
postNotFound: 'Post not found.'
|
postNotFound: 'Post not found.'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rejectPrivateFieldsTransformer = input => mapQuery(input, function (value, key) {
|
||||||
|
const lowerCaseKey = key.toLowerCase();
|
||||||
|
if (lowerCaseKey.startsWith('authors.password') || lowerCaseKey.startsWith('authors.email')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[key]: value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
docName: 'posts',
|
docName: 'posts',
|
||||||
|
|
||||||
|
@ -37,7 +49,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Post.findPage(frame.options);
|
const options = {
|
||||||
|
...frame.options,
|
||||||
|
mongoTransformer: rejectPrivateFieldsTransformer
|
||||||
|
};
|
||||||
|
return models.Post.findPage(options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -66,7 +82,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.Post.findOne(frame.data, frame.options)
|
const options = {
|
||||||
|
...frame.options,
|
||||||
|
mongoTransformer: rejectPrivateFieldsTransformer
|
||||||
|
};
|
||||||
|
return models.Post.findOne(frame.data, options)
|
||||||
.then((model) => {
|
.then((model) => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
throw new errors.NotFoundError({
|
throw new errors.NotFoundError({
|
||||||
|
|
|
@ -19,6 +19,85 @@ describe('Authors Content API', function () {
|
||||||
await configUtils.restore();
|
await configUtils.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can not filter authors by password', async function () {
|
||||||
|
const hashedPassword = '$2a$10$FxFlCsNBgXw42cBj0l1GFu39jffibqTqyAGBz7uCLwetYAdBYJEe6';
|
||||||
|
const userId = '644fd18ca1f0b764b0279b2d';
|
||||||
|
|
||||||
|
await testUtils.knex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
slug: 'brute-force-password-test-user',
|
||||||
|
name: 'Brute Force Password Test User',
|
||||||
|
email: 'bruteforcepasswordtestuser@example.com',
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2019-01-01 00:00:00',
|
||||||
|
created_by: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').insert({
|
||||||
|
id: '644fd18ca1f0b764b0279b2f',
|
||||||
|
post_id: postId,
|
||||||
|
author_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery(`authors/?key=${validKey}&filter=password:'${hashedPassword}'`))
|
||||||
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.public)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data = JSON.parse(res.text);
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
|
||||||
|
await testUtils.knex('users').where('id', userId).del();
|
||||||
|
|
||||||
|
if (data.authors.length === 1) {
|
||||||
|
throw new Error('fuck');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can not filter authors by email', async function () {
|
||||||
|
const hashedPassword = '$2a$10$FxFlCsNBgXw42cBj0l1GFu39jffibqTqyAGBz7uCLwetYAdBYJEe6';
|
||||||
|
const userEmail = 'bruteforcepasswordtestuser@example.com';
|
||||||
|
const userId = '644fd18ca1f0b764b0279b2d';
|
||||||
|
|
||||||
|
await testUtils.knex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
slug: 'brute-force-password-test-user',
|
||||||
|
name: 'Brute Force Password Test User',
|
||||||
|
email: userEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2019-01-01 00:00:00',
|
||||||
|
created_by: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').insert({
|
||||||
|
id: '644fd18ca1f0b764b0279b2f',
|
||||||
|
post_id: postId,
|
||||||
|
author_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery(`authors/?key=${validKey}&filter=email:'${userEmail}'`))
|
||||||
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.public)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data = JSON.parse(res.text);
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
|
||||||
|
await testUtils.knex('users').where('id', userId).del();
|
||||||
|
|
||||||
|
if (data.authors.length === 1) {
|
||||||
|
throw new Error('fuck');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('can read authors with fields', function () {
|
it('can read authors with fields', function () {
|
||||||
return request.get(localUtils.API.getApiQuery(`authors/1/?key=${validKey}&fields=name`))
|
return request.get(localUtils.API.getApiQuery(`authors/1/?key=${validKey}&fields=name`))
|
||||||
.set('Origin', testUtils.API.getURL())
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
|
|
@ -20,6 +20,85 @@ describe('api/endpoints/content/pages', function () {
|
||||||
await configUtils.restore();
|
await configUtils.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can not filter pages by author.password or authors.password', async function () {
|
||||||
|
const hashedPassword = '$2a$10$FxFlCsNBgXw42cBj0l1GFu39jffibqTqyAGBz7uCLwetYAdBYJEe6';
|
||||||
|
const userId = '644fd18ca1f0b764b0279b2d';
|
||||||
|
|
||||||
|
await testUtils.knex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
slug: 'brute-force-password-test-user',
|
||||||
|
name: 'Brute Force Password Test User',
|
||||||
|
email: 'bruteforcepasswordtestuseremail@example.com',
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2019-01-01 00:00:00',
|
||||||
|
created_by: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {id: postId} = await testUtils.knex('posts').first('id').where('type', 'page');
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').insert({
|
||||||
|
id: '644fd18ca1f0b764b0279b2f',
|
||||||
|
post_id: postId,
|
||||||
|
author_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=authors.password:'${hashedPassword}'`))
|
||||||
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.public)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data = JSON.parse(res.text);
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
|
||||||
|
await testUtils.knex('users').where('id', userId).del();
|
||||||
|
|
||||||
|
if (data.pages.length === 1) {
|
||||||
|
throw new Error('fuck');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can not filter pages by author.email or authors.email', async function () {
|
||||||
|
const hashedPassword = '$2a$10$FxFlCsNBgXw42cBj0l1GFu39jffibqTqyAGBz7uCLwetYAdBYJEe6';
|
||||||
|
const userEmail = 'bruteforcepasswordtestuseremail@example.com';
|
||||||
|
const userId = '644fd18ca1f0b764b0279b2d';
|
||||||
|
|
||||||
|
await testUtils.knex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
slug: 'brute-force-password-test-user',
|
||||||
|
name: 'Brute Force Password Test User',
|
||||||
|
email: userEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2019-01-01 00:00:00',
|
||||||
|
created_by: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {id: postId} = await testUtils.knex('posts').first('id').where('type', 'page');
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').insert({
|
||||||
|
id: '644fd18ca1f0b764b0279b2f',
|
||||||
|
post_id: postId,
|
||||||
|
author_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=authors.email:'${userEmail}'`))
|
||||||
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.public)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data = JSON.parse(res.text);
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
|
||||||
|
await testUtils.knex('users').where('id', userId).del();
|
||||||
|
|
||||||
|
if (data.pages.length === 1) {
|
||||||
|
throw new Error('fuck');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('Returns a validation error when unsupported "page" filter is used', function () {
|
it('Returns a validation error when unsupported "page" filter is used', function () {
|
||||||
return request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=page:false`))
|
return request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=page:false`))
|
||||||
.set('Origin', testUtils.API.getURL())
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
|
|
@ -23,6 +23,85 @@ describe('api/endpoints/content/posts', function () {
|
||||||
|
|
||||||
const validKey = localUtils.getValidKey();
|
const validKey = localUtils.getValidKey();
|
||||||
|
|
||||||
|
it('can not filter posts by author.password or authors.password', async function () {
|
||||||
|
const hashedPassword = '$2a$10$FxFlCsNBgXw42cBj0l1GFu39jffibqTqyAGBz7uCLwetYAdBYJEe6';
|
||||||
|
const userId = '644fd18ca1f0b764b0279b2d';
|
||||||
|
|
||||||
|
await testUtils.knex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
slug: 'brute-force-password-test-user',
|
||||||
|
name: 'Brute Force Password Test User',
|
||||||
|
email: 'bruteforcepasswordtestuseremail@example.com',
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2019-01-01 00:00:00',
|
||||||
|
created_by: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').insert({
|
||||||
|
id: '644fd18ca1f0b764b0279b2f',
|
||||||
|
post_id: postId,
|
||||||
|
author_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=authors.password:'${hashedPassword}'`))
|
||||||
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.public)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data = JSON.parse(res.text);
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
|
||||||
|
await testUtils.knex('users').where('id', userId).del();
|
||||||
|
|
||||||
|
if (data.posts.length === 1) {
|
||||||
|
throw new Error('fuck');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can not filter posts by author.email or authors.email', async function () {
|
||||||
|
const hashedPassword = '$2a$10$FxFlCsNBgXw42cBj0l1GFu39jffibqTqyAGBz7uCLwetYAdBYJEe6';
|
||||||
|
const userEmail = 'bruteforcepasswordtestuseremail@example.com';
|
||||||
|
const userId = '644fd18ca1f0b764b0279b2d';
|
||||||
|
|
||||||
|
await testUtils.knex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
slug: 'brute-force-password-test-user',
|
||||||
|
name: 'Brute Force Password Test User',
|
||||||
|
email: userEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2019-01-01 00:00:00',
|
||||||
|
created_by: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').insert({
|
||||||
|
id: '644fd18ca1f0b764b0279b2f',
|
||||||
|
post_id: postId,
|
||||||
|
author_id: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=authors.email:'${userEmail}'`))
|
||||||
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Cache-Control', testUtils.cacheRules.public)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data = JSON.parse(res.text);
|
||||||
|
|
||||||
|
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
|
||||||
|
await testUtils.knex('users').where('id', userId).del();
|
||||||
|
|
||||||
|
if (data.posts.length === 1) {
|
||||||
|
throw new Error('fuck');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('browse posts', function (done) {
|
it('browse posts', function (done) {
|
||||||
request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`))
|
request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}`))
|
||||||
.set('Origin', testUtils.API.getURL())
|
.set('Origin', testUtils.API.getURL())
|
||||||
|
|
|
@ -99,6 +99,7 @@ module.exports = {
|
||||||
|
|
||||||
teardownDb: dbUtils.teardown,
|
teardownDb: dbUtils.teardown,
|
||||||
truncate: dbUtils.truncate,
|
truncate: dbUtils.truncate,
|
||||||
|
knex: dbUtils.knex,
|
||||||
setup: setup,
|
setup: setup,
|
||||||
createUser: createUser,
|
createUser: createUser,
|
||||||
createPost: createPost,
|
createPost: createPost,
|
||||||
|
|
Loading…
Add table
Reference in a new issue