Skip to content
Permalink
Browse files Browse the repository at this point in the history
🔒 Fixed filtering on private Author fields in Content API
refs 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.
  • Loading branch information
allouis committed May 3, 2023
1 parent 514c891 commit b3caf16
Show file tree
Hide file tree
Showing 7 changed files with 304 additions and 6 deletions.
24 changes: 22 additions & 2 deletions ghost/core/core/server/api/endpoints/authors-public.js
@@ -1,13 +1,25 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {mapQuery} = require('@tryghost/mongo-utils');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];

const messages = {
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 = {
docName: 'authors',

Expand All @@ -29,7 +41,11 @@ module.exports = {
},
permissions: true,
query(frame) {
return models.Author.findPage(frame.options);
const options = {
...frame.options,
mongoTransformer: rejectPrivateFieldsTransformer
};
return models.Author.findPage(options);
}
},

Expand All @@ -54,7 +70,11 @@ module.exports = {
},
permissions: true,
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) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
Expand Down
24 changes: 22 additions & 2 deletions ghost/core/core/server/api/endpoints/pages-public.js
@@ -1,5 +1,6 @@
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {mapQuery} = require('@tryghost/mongo-utils');
const models = require('../../models');

const ALLOWED_INCLUDES = ['tags', 'authors', 'tiers'];
Expand All @@ -8,6 +9,17 @@ const messages = {
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 = {
docName: 'pages',

Expand Down Expand Up @@ -35,7 +47,11 @@ module.exports = {
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
const options = {
...frame.options,
mongoTransformer: rejectPrivateFieldsTransformer
};
return models.Post.findPage(options);
}
},

Expand Down Expand Up @@ -64,7 +80,11 @@ module.exports = {
},
permissions: true,
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) => {
if (!model) {
throw new errors.NotFoundError({
Expand Down
24 changes: 22 additions & 2 deletions ghost/core/core/server/api/endpoints/posts-public.js
@@ -1,6 +1,7 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {mapQuery} = require('@tryghost/mongo-utils');
const postsPublicService = require('../../services/posts-public');

const allowedIncludes = ['tags', 'authors', 'tiers', 'sentiment'];
Expand All @@ -9,6 +10,17 @@ const messages = {
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 = {
docName: 'posts',

Expand Down Expand Up @@ -37,7 +49,11 @@ module.exports = {
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
const options = {
...frame.options,
mongoTransformer: rejectPrivateFieldsTransformer
};
return models.Post.findPage(options);
}
},

Expand Down Expand Up @@ -66,7 +82,11 @@ module.exports = {
},
permissions: true,
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) => {
if (!model) {
throw new errors.NotFoundError({
Expand Down
79 changes: 79 additions & 0 deletions ghost/core/test/regression/api/content/authors.test.js
Expand Up @@ -19,6 +19,85 @@ describe('Authors Content API', function () {
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 () {
return request.get(localUtils.API.getApiQuery(`authors/1/?key=${validKey}&fields=name`))
.set('Origin', testUtils.API.getURL())
Expand Down
79 changes: 79 additions & 0 deletions ghost/core/test/regression/api/content/pages.test.js
Expand Up @@ -20,6 +20,85 @@ describe('api/endpoints/content/pages', function () {
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 () {
return request.get(localUtils.API.getApiQuery(`pages/?key=${key}&filter=page:false`))
.set('Origin', testUtils.API.getURL())
Expand Down

0 comments on commit b3caf16

Please sign in to comment.