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

Upgraded got package from v9.6.0 to v11.8.6 (#16261)

Refs TryGhost/Team#2459
-upgraded got from v9.6.0 to v11.8.6 to support following redirects (and
other fixes)
-got v12+ requires ESM, so we do not want to upgrade further at this
time
-required changes to a few libraries that use externalRequests
-mention discovery service tests updated to test for follow redirects
This commit is contained in:
Steve Larson 2023-02-20 09:33:11 -06:00 committed by GitHub
parent 52a26a7f80
commit 2d84b7d990
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 117 additions and 139 deletions

View file

@ -20,22 +20,32 @@ function isPrivateIp(addr) {
async function errorIfHostnameResolvesToPrivateIp(options) { async function errorIfHostnameResolvesToPrivateIp(options) {
// allow requests through to local Ghost instance // allow requests through to local Ghost instance
const siteUrl = new URL(config.get('url')); const siteUrl = new URL(config.get('url'));
const requestUrl = new URL(options.href); const requestUrl = new URL(options.url.href);
if (requestUrl.host === siteUrl.host) { if (requestUrl.host === siteUrl.host) {
return Promise.resolve(); return Promise.resolve();
} }
const result = await dnsPromises.lookup(options.hostname); const result = await dnsPromises.lookup(options.url.hostname);
if (isPrivateIp(result.address)) { if (isPrivateIp(result.address)) {
return Promise.reject(new errors.InternalServerError({ return Promise.reject(new errors.InternalServerError({
message: 'URL resolves to a non-permitted private IP block', message: 'URL resolves to a non-permitted private IP block',
code: 'URL_PRIVATE_INVALID', code: 'URL_PRIVATE_INVALID',
context: options.href context: options.url.href
})); }));
} }
} }
async function errorIfInvalidUrl(options) {
if (!options.url.hostname || !validator.isURL(options.url.hostname)) {
throw new errors.InternalServerError({
message: 'URL invalid.',
code: 'URL_MISSING_INVALID',
context: options.url.href
});
}
}
// same as our normal request lib but if any request in a redirect chain resolves // same as our normal request lib but if any request in a redirect chain resolves
// to a private IP address it will be blocked before the request is made. // to a private IP address it will be blocked before the request is made.
const externalRequest = got.extend({ const externalRequest = got.extend({
@ -44,16 +54,7 @@ const externalRequest = got.extend({
}, },
timeout: 10000, // default is no timeout timeout: 10000, // default is no timeout
hooks: { hooks: {
init: [(options) => { beforeRequest: [errorIfInvalidUrl,errorIfHostnameResolvesToPrivateIp],
if (!options.hostname || !validator.isURL(options.hostname)) {
throw new errors.InternalServerError({
message: 'URL empty or invalid.',
code: 'URL_MISSING_INVALID',
context: options.href
});
}
}],
beforeRequest: [errorIfHostnameResolvesToPrivateIp],
beforeRedirect: [errorIfHostnameResolvesToPrivateIp] beforeRedirect: [errorIfHostnameResolvesToPrivateIp]
} }
}); });

View file

@ -42,9 +42,8 @@ class NFTOEmbedProvider {
headers['X-API-KEY'] = this.dependencies.config.apiKey; headers['X-API-KEY'] = this.dependencies.config.apiKey;
} }
const result = await externalRequest(`https://api.opensea.io/api/v1/asset/${transaction}/${asset}/?format=json`, { const result = await externalRequest(`https://api.opensea.io/api/v1/asset/${transaction}/${asset}/?format=json`, {
json: true,
headers headers
}); }).json();
return { return {
version: '1.0', version: '1.0',
type: 'nft', type: 'nft',

View file

@ -170,7 +170,7 @@
"fs-extra": "11.1.0", "fs-extra": "11.1.0",
"ghost-storage-base": "1.0.0", "ghost-storage-base": "1.0.0",
"glob": "8.1.0", "glob": "8.1.0",
"got": "9.6.0", "got": "11.8.6",
"gscan": "4.36.0", "gscan": "4.36.0",
"human-number": "2.0.1", "human-number": "2.0.1",
"image-size": "1.0.2", "image-size": "1.0.2",

View file

@ -128,6 +128,12 @@ describe('Oembed API', function () {
}); });
it('errors when fetched url is an IP address', async function () { it('errors when fetched url is an IP address', async function () {
// in order to follow the 302, we need to stub differently; externalRequest will block the internal IP
dnsPromises.lookup.restore();
let dnsStub = sinon.stub(dnsPromises, 'lookup');
dnsStub.onCall(0).returns(Promise.resolve({address: '123.123.123.123'}));
dnsStub.onCall(1).returns(Promise.resolve({address: '0.0.0.0'}));
const redirectMock = nock('http://test.com/') const redirectMock = nock('http://test.com/')
.get('/') .get('/')
.reply(302, undefined, {Location: 'http://0.0.0.0:8080'}); .reply(302, undefined, {Location: 'http://0.0.0.0:8080'});
@ -147,7 +153,7 @@ describe('Oembed API', function () {
.expect('Cache-Control', testUtils.cacheRules.private) .expect('Cache-Control', testUtils.cacheRules.private)
.expect(422); .expect(422);
pageMock.isDone().should.be.true(); pageMock.isDone().should.be.false(); // we shouldn't hit this; blocked by externalRequest
should.exist(res.body.errors); should.exist(res.body.errors);
}); });
@ -376,6 +382,15 @@ describe('Oembed API', function () {
}); });
it('skips fetching IPv4 addresses', async function () { it('skips fetching IPv4 addresses', async function () {
dnsPromises.lookup.restore();
sinon.stub(dnsPromises, 'lookup').callsFake(function (hostname) {
if (hostname === '192.168.0.1') {
return Promise.resolve({address: '192.168.0.1'});
} else {
return Promise.resolve({address: '123.123.123.123'});
}
});
const pageMock = nock('http://test.com') const pageMock = nock('http://test.com')
.get('/') .get('/')
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://192.168.0.1/oembed"></head></html>'); .reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://192.168.0.1/oembed"></head></html>');
@ -399,6 +414,15 @@ describe('Oembed API', function () {
}); });
it('skips fetching IPv6 addresses', async function () { it('skips fetching IPv6 addresses', async function () {
dnsPromises.lookup.restore();
sinon.stub(dnsPromises, 'lookup').callsFake(function (hostname) {
if (hostname === '[2607:f0d0:1002:51::4]') {
return Promise.resolve({address: '192.168.0.1'});
} else {
return Promise.resolve({address: '123.123.123.123'});
}
});
const pageMock = nock('http://test.com') const pageMock = nock('http://test.com')
.get('/') .get('/')
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://[2607:f0d0:1002:51::4]:9999/oembed"></head></html>'); .reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://[2607:f0d0:1002:51::4]:9999/oembed"></head></html>');
@ -422,6 +446,14 @@ describe('Oembed API', function () {
}); });
it('skips fetching localhost', async function () { it('skips fetching localhost', async function () {
dnsPromises.lookup.restore();
sinon.stub(dnsPromises, 'lookup').callsFake(function (hostname) {
if (hostname === 'localhost') {
return Promise.resolve({address: '127.0.0.1'});
} else {
return Promise.resolve({address: '123.123.123.123'});
}
});
const pageMock = nock('http://test.com') const pageMock = nock('http://test.com')
.get('/') .get('/')
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://localhost:9999/oembed"></head></html>'); .reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://localhost:9999/oembed"></head></html>');

View file

@ -263,7 +263,7 @@ describe('External Request', function () {
throw new Error('Request should have rejected with invalid url message'); throw new Error('Request should have rejected with invalid url message');
}, (err) => { }, (err) => {
should.exist(err); should.exist(err);
err.message.should.be.equal('URL empty or invalid.'); err.code.should.be.equal('ERR_INVALID_URL');
}); });
}); });
@ -279,7 +279,8 @@ describe('External Request', function () {
throw new Error('Request should have rejected with invalid url message'); throw new Error('Request should have rejected with invalid url message');
}, (err) => { }, (err) => {
should.exist(err); should.exist(err);
err.message.should.be.equal('URL empty or invalid.'); // got v11+ throws an error instead of the external requests lib
err.message.should.be.equal('No URL protocol specified');
}); });
}); });
@ -300,7 +301,7 @@ describe('External Request', function () {
}, (err) => { }, (err) => {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
should.exist(err); should.exist(err);
err.statusMessage.should.be.equal('Not Found'); err.response.statusMessage.should.be.equal('Not Found');
}); });
}); });
@ -322,9 +323,7 @@ describe('External Request', function () {
}, (err) => { }, (err) => {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
should.exist(err); should.exist(err);
err.statusMessage.should.be.equal('Internal Server Error'); err.response.statusMessage.should.be.equal(`Internal Server Error`);
err.body.should.match(/something awful happened/);
err.body.should.match(/AWFUL_ERROR/);
}); });
}); });
}); });

View file

@ -7,9 +7,8 @@ module.exports = class GeolocationService {
if (!ipAddress || (!IPV4_REGEX.test(ipAddress) && !IPV6_REGEX.test(ipAddress))) { if (!ipAddress || (!IPV4_REGEX.test(ipAddress) && !IPV6_REGEX.test(ipAddress))) {
return; return;
} }
const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ipAddress)}.json`; const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ipAddress)}.json`;
const response = await got(geojsUrl, {json: true, timeout: 500}); const response = await got(geojsUrl, {timeout: 500}).json();
return response.body; return response;
} }
}; };

View file

@ -41,7 +41,7 @@
"body-parser": "1.20.1", "body-parser": "1.20.1",
"bson-objectid": "2.0.4", "bson-objectid": "2.0.4",
"express": "4.18.2", "express": "4.18.2",
"got": "9.6.0", "got": "11.8.6",
"jsonwebtoken": "8.5.1", "jsonwebtoken": "8.5.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"moment": "2.29.4", "moment": "2.29.4",

View file

@ -4,7 +4,6 @@ const logging = require('@tryghost/logging');
const {extract, hasProvider} = require('oembed-parser'); const {extract, hasProvider} = require('oembed-parser');
const cheerio = require('cheerio'); const cheerio = require('cheerio');
const _ = require('lodash'); const _ = require('lodash');
const {CookieJar} = require('tough-cookie');
const charset = require('charset'); const charset = require('charset');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
@ -68,16 +67,7 @@ class OEmbed {
this.config = config; this.config = config;
/** @type {IExternalRequest} */ /** @type {IExternalRequest} */
this.externalRequest = async (url, requestConfig) => { this.externalRequest = externalRequest;
if (this.isIpOrLocalhost(url)) {
return this.unknownProvider(url);
}
const response = await externalRequest(url, requestConfig);
if (this.isIpOrLocalhost(response.url)) {
return this.unknownProvider(url);
}
return response;
};
/** @type {ICustomProvider[]} */ /** @type {ICustomProvider[]} */
this.customProviders = []; this.customProviders = [];
@ -117,16 +107,13 @@ class OEmbed {
* @param {string} url * @param {string} url
* @param {Object} options * @param {Object} options
* *
* @returns {Promise<{url: string, body: any, headers: any}>} * @returns {GotPromise<any>}
*/ */
async fetchPage(url, options) { fetchPage(url, options) {
const cookieJar = new CookieJar();
return this.externalRequest( return this.externalRequest(
url, url,
{ {
cookieJar, timeout: 2000,
method: 'GET',
timeout: 2 * 1000,
followRedirect: true, followRedirect: true,
...options ...options
}); });
@ -140,7 +127,7 @@ class OEmbed {
async fetchPageHtml(url) { async fetchPageHtml(url) {
// Fetch url and get response as binary buffer to // Fetch url and get response as binary buffer to
// avoid implicit cast // avoid implicit cast
const {headers, body, url: responseUrl} = await this.fetchPage( let {headers, body, url: responseUrl} = await this.fetchPage(
url, url,
{ {
encoding: 'binary', encoding: 'binary',
@ -162,7 +149,7 @@ class OEmbed {
} }
const decodedBody = iconv.decode( const decodedBody = iconv.decode(
Buffer.from(body, 'binary'), encoding); body, encoding);
return { return {
body: decodedBody, body: decodedBody,
@ -184,12 +171,9 @@ class OEmbed {
* @returns {Promise<{url: string, body: Object}>} * @returns {Promise<{url: string, body: Object}>}
*/ */
async fetchPageJson(url) { async fetchPageJson(url) {
const {body, url: pageUrl} = await this.fetchPage( const res = await this.fetchPage(url, {responseType: 'json'});
url, const body = res.body;
{ const pageUrl = res.url;
json: true
});
return { return {
body, body,
url: pageUrl url: pageUrl
@ -247,34 +231,6 @@ class OEmbed {
}; };
} }
/**
* @param {string} url
* @returns {boolean}
*/
isIpOrLocalhost(url) {
try {
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /:/; // fqdns will not have colons
const HTTP_REGEX = /^https?:/i;
const siteUrl = new URL(this.config.get('url'));
const {protocol, hostname, host} = new URL(url);
// allow requests to Ghost's own url through
if (siteUrl.host === host) {
return false;
}
if (!HTTP_REGEX.test(protocol) || hostname === 'localhost' || IPV4_REGEX.test(hostname) || IPV6_REGEX.test(hostname)) {
return true;
}
return false;
} catch (e) {
return true;
}
}
/** /**
* @param {string} url * @param {string} url
* @param {string} html * @param {string} html
@ -300,7 +256,6 @@ class OEmbed {
// fetch oembed response from embedded rel="alternate" url // fetch oembed response from embedded rel="alternate" url
const oembedResponse = await this.fetchPageJson(oembedUrl); const oembedResponse = await this.fetchPageJson(oembedUrl);
// validate the fetched json against the oembed spec to avoid // validate the fetched json against the oembed spec to avoid
// leaking non-oembed responses // leaking non-oembed responses
const body = oembedResponse.body; const body = oembedResponse.body;

View file

@ -17,7 +17,7 @@ module.exports = class MentionDiscoveryService {
try { try {
const response = await this.#externalRequest(url.href, { const response = await this.#externalRequest(url.href, {
throwHttpErrors: false, throwHttpErrors: false,
followRedirects: true, followRedirect: true,
maxRedirects: 10 maxRedirects: 10
}); });
if (response.statusCode === 404) { if (response.statusCode === 404) {

View file

@ -79,22 +79,24 @@ module.exports = class MentionSendingService {
async send({source, target, endpoint}) { async send({source, target, endpoint}) {
logging.info('[Webmention] Sending webmention from ' + source.href + ' to ' + target.href + ' via ' + endpoint.href); logging.info('[Webmention] Sending webmention from ' + source.href + ' to ' + target.href + ' via ' + endpoint.href);
// default content type is application/x-www-form-encoded which is what we need for the webmentions spec
const response = await this.#externalRequest.post(endpoint.href, { const response = await this.#externalRequest.post(endpoint.href, {
body: { form: {
source: source.href, source: source.href,
target: target.href, target: target.href,
source_is_ghost: true source_is_ghost: true
}, },
form: true,
throwHttpErrors: false, throwHttpErrors: false,
maxRedirects: 10, maxRedirects: 10,
followRedirect: true, followRedirect: true,
methodRewriting: false, // WARNING! this setting has a different meaning in got v12!
timeout: 10000 timeout: 10000
}); });
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return; return;
} }
throw new errors.BadRequestError({ throw new errors.BadRequestError({
message: 'Webmention sending failed with status code ' + response.statusCode, message: 'Webmention sending failed with status code ' + response.statusCode,
statusCode: response.statusCode statusCode: response.statusCode

View file

@ -37,22 +37,20 @@ describe('MentionDiscoveryService', function () {
assert.equal(endpoint, null); assert.equal(endpoint, null);
}); });
// TODO: need to support redirects it('Follows redirects', async function () {
// it('Follows redirects', async function () { let url = new URL('http://redirector.io/');
let nextUrl = new URL('http://testpage.com/');
// let url = new URL('http://redirector.io/'); nock(url.href)
// let nextUrl = new URL('http://testpage.com/'); .intercept('/', 'HEAD')
.reply(301, undefined, {location: nextUrl.href})
.get('/')
.reply(200, '<link rel="webmention" href="http://valid.site.org" />Very cool site', {'content-type': 'text/html'});
// let mockRedirect = nock(url.href) let endpoint = await service.getEndpoint(url);
// .intercept("/", "HEAD")
// .reply(301, undefined, { location: nextUrl.href })
// .get('/')
// .reply(200, '<link rel="webmention" href="http://valid.site.org" />Very cool site', { 'content-type': 'text/html' });
// let endpoint = await service.getEndpoint(url); assert(endpoint instanceof URL);
});
// assert(endpoint instanceof URL);
// });
describe('Can parse headers', function () { describe('Can parse headers', function () {
it('Returns null for a valid non-html site', async function () { it('Returns null for a valid non-html site', async function () {

View file

@ -380,6 +380,9 @@ describe('MentionSendingService', function () {
describe('send', function () { describe('send', function () {
it('Can handle 202 accepted responses', async function () { it('Can handle 202 accepted responses', async function () {
const source = new URL('https://example.com/source');
const target = new URL('https://target.com/target');
const endpoint = new URL('https://example.org/webmentions-test');
const scope = nock('https://example.org') const scope = nock('https://example.org')
.persist() .persist()
.post('/webmentions-test', `source=${encodeURIComponent('https://example.com/source')}&target=${encodeURIComponent('https://target.com/target')}&source_is_ghost=true`) .post('/webmentions-test', `source=${encodeURIComponent('https://example.com/source')}&target=${encodeURIComponent('https://target.com/target')}&source_is_ghost=true`)
@ -387,14 +390,17 @@ describe('MentionSendingService', function () {
const service = new MentionSendingService({externalRequest}); const service = new MentionSendingService({externalRequest});
await service.send({ await service.send({
source: new URL('https://example.com/source'), source: source,
target: new URL('https://target.com/target'), target: target,
endpoint: new URL('https://example.org/webmentions-test') endpoint: endpoint
}); });
assert(scope.isDone()); assert(scope.isDone());
}); });
it('Can handle 201 created responses', async function () { it('Can handle 201 created responses', async function () {
const source = new URL('https://example.com/source');
const target = new URL('https://target.com/target');
const endpoint = new URL('https://example.org/webmentions-test');
const scope = nock('https://example.org') const scope = nock('https://example.org')
.persist() .persist()
.post('/webmentions-test', `source=${encodeURIComponent('https://example.com/source')}&target=${encodeURIComponent('https://target.com/target')}&source_is_ghost=true`) .post('/webmentions-test', `source=${encodeURIComponent('https://example.com/source')}&target=${encodeURIComponent('https://target.com/target')}&source_is_ghost=true`)
@ -402,9 +408,9 @@ describe('MentionSendingService', function () {
const service = new MentionSendingService({externalRequest}); const service = new MentionSendingService({externalRequest});
await service.send({ await service.send({
source: new URL('https://example.com/source'), source: source,
target: new URL('https://target.com/target'), target: target,
endpoint: new URL('https://example.org/webmentions-test') endpoint: endpoint
}); });
assert(scope.isDone()); assert(scope.isDone());
}); });
@ -439,6 +445,28 @@ describe('MentionSendingService', function () {
assert(scope.isDone()); assert(scope.isDone());
}); });
it('Can handle redirect responses', async function () {
const scope = nock('https://example.org')
.persist()
.post('/webmentions-test')
.reply(302, '', {
Location: 'https://example.org/webmentions-test-2'
});
const scope2 = nock('https://example.org')
.persist()
.post('/webmentions-test-2')
.reply(201);
const service = new MentionSendingService({externalRequest});
await service.send({
source: new URL('https://example.com'),
target: new URL('https://example.com'),
endpoint: new URL('https://example.org/webmentions-test')
});
assert(scope.isDone());
assert(scope2.isDone());
});
it('Can handle network errors', async function () { it('Can handle network errors', async function () {
const scope = nock('https://example.org') const scope = nock('https://example.org')
.persist() .persist()
@ -454,41 +482,6 @@ describe('MentionSendingService', function () {
assert(scope.isDone()); assert(scope.isDone());
}); });
// Redirects are currently not supported by got for POST requests!
//it('Can handle redirect responses', async function () {
// const scope = nock('https://example.org')
// .persist()
// .post('/webmentions-test')
// .reply(302, '', {
// headers: {
// Location: 'https://example.org/webmentions-test-2'
// }
// });
// const scope2 = nock('https://example.org')
// .persist()
// .post('/webmentions-test-2')
// .reply(201);
//
// const service = new MentionSendingService({externalRequest});
// await service.send({
// source: new URL('https://example.com'),
// target: new URL('https://example.com'),
// endpoint: new URL('https://example.org/webmentions-test')
// });
// assert(scope.isDone());
// assert(scope2.isDone());
//});
// TODO: also check if we don't follow private IPs after redirects
it('Does not send to private IP', async function () {
const service = new MentionSendingService({externalRequest});
await assert.rejects(service.send({
source: new URL('https://example.com/source'),
target: new URL('https://target.com/target'),
endpoint: new URL('http://localhost/webmentions')
}), /non-permitted private IP/);
});
it('Does not send to private IP behind DNS', async function () { it('Does not send to private IP behind DNS', async function () {
// Test that we don't make a request when a domain resolves to a private IP // Test that we don't make a request when a domain resolves to a private IP
// domaincontrol.com -> 127.0.0.1 // domaincontrol.com -> 127.0.0.1