diff --git a/ghost/core/core/server/lib/request-external.js b/ghost/core/core/server/lib/request-external.js
index 18fbbf6981..a311d2c3e1 100644
--- a/ghost/core/core/server/lib/request-external.js
+++ b/ghost/core/core/server/lib/request-external.js
@@ -20,22 +20,32 @@ function isPrivateIp(addr) {
async function errorIfHostnameResolvesToPrivateIp(options) {
// allow requests through to local Ghost instance
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) {
return Promise.resolve();
}
- const result = await dnsPromises.lookup(options.hostname);
+ const result = await dnsPromises.lookup(options.url.hostname);
if (isPrivateIp(result.address)) {
return Promise.reject(new errors.InternalServerError({
message: 'URL resolves to a non-permitted private IP block',
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
// to a private IP address it will be blocked before the request is made.
const externalRequest = got.extend({
@@ -44,16 +54,7 @@ const externalRequest = got.extend({
},
timeout: 10000, // default is no timeout
hooks: {
- init: [(options) => {
- 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],
+ beforeRequest: [errorIfInvalidUrl,errorIfHostnameResolvesToPrivateIp],
beforeRedirect: [errorIfHostnameResolvesToPrivateIp]
}
});
diff --git a/ghost/core/core/server/services/oembed/nft-oembed.js b/ghost/core/core/server/services/oembed/nft-oembed.js
index bc2a293e72..8f1ec570f5 100644
--- a/ghost/core/core/server/services/oembed/nft-oembed.js
+++ b/ghost/core/core/server/services/oembed/nft-oembed.js
@@ -42,9 +42,8 @@ class NFTOEmbedProvider {
headers['X-API-KEY'] = this.dependencies.config.apiKey;
}
const result = await externalRequest(`https://api.opensea.io/api/v1/asset/${transaction}/${asset}/?format=json`, {
- json: true,
headers
- });
+ }).json();
return {
version: '1.0',
type: 'nft',
diff --git a/ghost/core/package.json b/ghost/core/package.json
index 331de0e697..afd590dd3a 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -170,7 +170,7 @@
"fs-extra": "11.1.0",
"ghost-storage-base": "1.0.0",
"glob": "8.1.0",
- "got": "9.6.0",
+ "got": "11.8.6",
"gscan": "4.36.0",
"human-number": "2.0.1",
"image-size": "1.0.2",
diff --git a/ghost/core/test/e2e-api/admin/oembed.test.js b/ghost/core/test/e2e-api/admin/oembed.test.js
index 54db7dacaf..6f0f2fca48 100644
--- a/ghost/core/test/e2e-api/admin/oembed.test.js
+++ b/ghost/core/test/e2e-api/admin/oembed.test.js
@@ -128,6 +128,12 @@ describe('Oembed API', 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/')
.get('/')
.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(422);
- pageMock.isDone().should.be.true();
+ pageMock.isDone().should.be.false(); // we shouldn't hit this; blocked by externalRequest
should.exist(res.body.errors);
});
@@ -376,6 +382,15 @@ describe('Oembed API', 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')
.get('/')
.reply(200, '
');
@@ -399,6 +414,15 @@ describe('Oembed API', 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')
.get('/')
.reply(200, '');
@@ -422,6 +446,14 @@ describe('Oembed API', 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')
.get('/')
.reply(200, '');
diff --git a/ghost/core/test/unit/server/lib/request-external.test.js b/ghost/core/test/unit/server/lib/request-external.test.js
index 8ad9818108..62a67e9861 100644
--- a/ghost/core/test/unit/server/lib/request-external.test.js
+++ b/ghost/core/test/unit/server/lib/request-external.test.js
@@ -263,7 +263,7 @@ describe('External Request', function () {
throw new Error('Request should have rejected with invalid url message');
}, (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');
}, (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) => {
requestMock.isDone().should.be.true();
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) => {
requestMock.isDone().should.be.true();
should.exist(err);
- err.statusMessage.should.be.equal('Internal Server Error');
- err.body.should.match(/something awful happened/);
- err.body.should.match(/AWFUL_ERROR/);
+ err.response.statusMessage.should.be.equal(`Internal Server Error`);
});
});
});
diff --git a/ghost/members-api/lib/services/geolocation.js b/ghost/members-api/lib/services/geolocation.js
index 0f745c7ed3..72f7e907c7 100644
--- a/ghost/members-api/lib/services/geolocation.js
+++ b/ghost/members-api/lib/services/geolocation.js
@@ -7,9 +7,8 @@ module.exports = class GeolocationService {
if (!ipAddress || (!IPV4_REGEX.test(ipAddress) && !IPV6_REGEX.test(ipAddress))) {
return;
}
-
const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ipAddress)}.json`;
- const response = await got(geojsUrl, {json: true, timeout: 500});
- return response.body;
+ const response = await got(geojsUrl, {timeout: 500}).json();
+ return response;
}
};
diff --git a/ghost/members-api/package.json b/ghost/members-api/package.json
index 6ea79044c6..cd3e500cc6 100644
--- a/ghost/members-api/package.json
+++ b/ghost/members-api/package.json
@@ -41,7 +41,7 @@
"body-parser": "1.20.1",
"bson-objectid": "2.0.4",
"express": "4.18.2",
- "got": "9.6.0",
+ "got": "11.8.6",
"jsonwebtoken": "8.5.1",
"lodash": "4.17.21",
"moment": "2.29.4",
diff --git a/ghost/oembed-service/lib/oembed-service.js b/ghost/oembed-service/lib/oembed-service.js
index 1bd7dbd7fe..373592cc0b 100644
--- a/ghost/oembed-service/lib/oembed-service.js
+++ b/ghost/oembed-service/lib/oembed-service.js
@@ -4,7 +4,6 @@ const logging = require('@tryghost/logging');
const {extract, hasProvider} = require('oembed-parser');
const cheerio = require('cheerio');
const _ = require('lodash');
-const {CookieJar} = require('tough-cookie');
const charset = require('charset');
const iconv = require('iconv-lite');
@@ -68,16 +67,7 @@ class OEmbed {
this.config = config;
/** @type {IExternalRequest} */
- this.externalRequest = async (url, requestConfig) => {
- 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;
- };
+ this.externalRequest = externalRequest;
/** @type {ICustomProvider[]} */
this.customProviders = [];
@@ -117,16 +107,13 @@ class OEmbed {
* @param {string} url
* @param {Object} options
*
- * @returns {Promise<{url: string, body: any, headers: any}>}
+ * @returns {GotPromise}
*/
- async fetchPage(url, options) {
- const cookieJar = new CookieJar();
+ fetchPage(url, options) {
return this.externalRequest(
url,
{
- cookieJar,
- method: 'GET',
- timeout: 2 * 1000,
+ timeout: 2000,
followRedirect: true,
...options
});
@@ -140,7 +127,7 @@ class OEmbed {
async fetchPageHtml(url) {
// Fetch url and get response as binary buffer to
// avoid implicit cast
- const {headers, body, url: responseUrl} = await this.fetchPage(
+ let {headers, body, url: responseUrl} = await this.fetchPage(
url,
{
encoding: 'binary',
@@ -162,7 +149,7 @@ class OEmbed {
}
const decodedBody = iconv.decode(
- Buffer.from(body, 'binary'), encoding);
+ body, encoding);
return {
body: decodedBody,
@@ -184,12 +171,9 @@ class OEmbed {
* @returns {Promise<{url: string, body: Object}>}
*/
async fetchPageJson(url) {
- const {body, url: pageUrl} = await this.fetchPage(
- url,
- {
- json: true
- });
-
+ const res = await this.fetchPage(url, {responseType: 'json'});
+ const body = res.body;
+ const pageUrl = res.url;
return {
body,
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} html
@@ -300,7 +256,6 @@ class OEmbed {
// fetch oembed response from embedded rel="alternate" url
const oembedResponse = await this.fetchPageJson(oembedUrl);
-
// validate the fetched json against the oembed spec to avoid
// leaking non-oembed responses
const body = oembedResponse.body;
diff --git a/ghost/webmentions/lib/MentionDiscoveryService.js b/ghost/webmentions/lib/MentionDiscoveryService.js
index e4dd4b63ee..70185c0ac8 100644
--- a/ghost/webmentions/lib/MentionDiscoveryService.js
+++ b/ghost/webmentions/lib/MentionDiscoveryService.js
@@ -17,7 +17,7 @@ module.exports = class MentionDiscoveryService {
try {
const response = await this.#externalRequest(url.href, {
throwHttpErrors: false,
- followRedirects: true,
+ followRedirect: true,
maxRedirects: 10
});
if (response.statusCode === 404) {
diff --git a/ghost/webmentions/lib/MentionSendingService.js b/ghost/webmentions/lib/MentionSendingService.js
index c5f3f8ff76..b5a5fc105e 100644
--- a/ghost/webmentions/lib/MentionSendingService.js
+++ b/ghost/webmentions/lib/MentionSendingService.js
@@ -79,22 +79,24 @@ module.exports = class MentionSendingService {
async send({source, target, endpoint}) {
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, {
- body: {
+ form: {
source: source.href,
target: target.href,
source_is_ghost: true
},
- form: true,
throwHttpErrors: false,
maxRedirects: 10,
followRedirect: true,
- methodRewriting: false, // WARNING! this setting has a different meaning in got v12!
timeout: 10000
});
+
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
+
throw new errors.BadRequestError({
message: 'Webmention sending failed with status code ' + response.statusCode,
statusCode: response.statusCode
diff --git a/ghost/webmentions/test/MentionDiscoveryService.test.js b/ghost/webmentions/test/MentionDiscoveryService.test.js
index e819a865ce..17c336326f 100644
--- a/ghost/webmentions/test/MentionDiscoveryService.test.js
+++ b/ghost/webmentions/test/MentionDiscoveryService.test.js
@@ -37,22 +37,20 @@ describe('MentionDiscoveryService', function () {
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/');
- // let nextUrl = new URL('http://testpage.com/');
+ nock(url.href)
+ .intercept('/', 'HEAD')
+ .reply(301, undefined, {location: nextUrl.href})
+ .get('/')
+ .reply(200, 'Very cool site', {'content-type': 'text/html'});
- // let mockRedirect = nock(url.href)
- // .intercept("/", "HEAD")
- // .reply(301, undefined, { location: nextUrl.href })
- // .get('/')
- // .reply(200, 'Very cool site', { 'content-type': 'text/html' });
+ let endpoint = await service.getEndpoint(url);
- // let endpoint = await service.getEndpoint(url);
-
- // assert(endpoint instanceof URL);
- // });
+ assert(endpoint instanceof URL);
+ });
describe('Can parse headers', function () {
it('Returns null for a valid non-html site', async function () {
diff --git a/ghost/webmentions/test/MentionSendingService.test.js b/ghost/webmentions/test/MentionSendingService.test.js
index bf8d70441b..99d978bc9a 100644
--- a/ghost/webmentions/test/MentionSendingService.test.js
+++ b/ghost/webmentions/test/MentionSendingService.test.js
@@ -380,6 +380,9 @@ describe('MentionSendingService', function () {
describe('send', 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')
.persist()
.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});
await service.send({
- source: new URL('https://example.com/source'),
- target: new URL('https://target.com/target'),
- endpoint: new URL('https://example.org/webmentions-test')
+ source: source,
+ target: target,
+ endpoint: endpoint
});
assert(scope.isDone());
});
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')
.persist()
.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});
await service.send({
- source: new URL('https://example.com/source'),
- target: new URL('https://target.com/target'),
- endpoint: new URL('https://example.org/webmentions-test')
+ source: source,
+ target: target,
+ endpoint: endpoint
});
assert(scope.isDone());
});
@@ -439,6 +445,28 @@ describe('MentionSendingService', function () {
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 () {
const scope = nock('https://example.org')
.persist()
@@ -454,41 +482,6 @@ describe('MentionSendingService', function () {
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 () {
// Test that we don't make a request when a domain resolves to a private IP
// domaincontrol.com -> 127.0.0.1