🐛 Fixed image dimension retrieval causing Ghost requests to hang (#20589)

ref https://linear.app/tryghost/issue/ENG-1408/
- added additional safeguards to the image size dimensions probing

For some reason that requires further investigation, the
probe-image-size package was silently failing (neither resolving nor
rejecting) for a particular URL. This was causing Ghost to hang on to
serving the request, and after a few of these came in, ultimately caused
Ghost to stop being responsive.

Rather than trying to patch a dependency, we'll wrap the call to this
package and use the same timeout we pass into the package (which is
ignored in this particular case) as an additional safeguard.
This commit is contained in:
Steve Larson 2024-07-11 09:37:44 -05:00 committed by GitHub
parent 57dc5f8ded
commit e626dd9353
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 78 additions and 28 deletions

View File

@ -1,6 +1,6 @@
const debug = require('@tryghost/debug')('utils:image-size'); const debug = require('@tryghost/debug')('utils:image-size');
const sizeOf = require('image-size'); const sizeOf = require('image-size');
const probeSizeOf = require('probe-image-size');
const url = require('url'); const url = require('url');
const path = require('path'); const path = require('path');
const _ = require('lodash'); const _ = require('lodash');
@ -17,13 +17,14 @@ const FETCH_ONLY_FORMATS = [
]; ];
class ImageSize { class ImageSize {
constructor({config, storage, storageUtils, validator, urlUtils, request}) { constructor({config, storage, storageUtils, validator, urlUtils, request, probe}) {
this.config = config; this.config = config;
this.storage = storage; this.storage = storage;
this.storageUtils = storageUtils; this.storageUtils = storageUtils;
this.validator = validator; this.validator = validator;
this.urlUtils = urlUtils; this.urlUtils = urlUtils;
this.request = request; this.request = request;
this.probe = probe;
this.REQUEST_OPTIONS = { this.REQUEST_OPTIONS = {
// we need the user-agent, otherwise some https request may fail (e.g. cloudfare) // we need the user-agent, otherwise some https request may fail (e.g. cloudfare)
@ -82,7 +83,18 @@ class ImageSize {
})); }));
} }
return probeSizeOf(imageUrl, this.NEEDLE_OPTIONS); // wrap probe-image-size in a promise in case it is unresponsive/the timeout itself doesn't work
return (Promise.race([
this.probe(imageUrl, this.NEEDLE_OPTIONS),
new Promise((res, rej) => {
setTimeout(() => {
rej(new errors.InternalServerError({
message: 'Probe unresponsive.',
code: 'IMAGE_SIZE_URL'
}));
}, this.NEEDLE_OPTIONS.response_timeout);
})
]));
} }
// download full image then use image-size to get it's dimensions // download full image then use image-size to get it's dimensions

View File

@ -2,11 +2,12 @@ const BlogIcon = require('./BlogIcon');
const CachedImageSizeFromUrl = require('./CachedImageSizeFromUrl'); const CachedImageSizeFromUrl = require('./CachedImageSizeFromUrl');
const Gravatar = require('./Gravatar'); const Gravatar = require('./Gravatar');
const ImageSize = require('./ImageSize'); const ImageSize = require('./ImageSize');
const probe = require('probe-image-size');
class ImageUtils { class ImageUtils {
constructor({config, urlUtils, settingsCache, storageUtils, storage, validator, request, cacheStore}) { constructor({config, urlUtils, settingsCache, storageUtils, storage, validator, request, cacheStore}) {
this.blogIcon = new BlogIcon({config, urlUtils, settingsCache, storageUtils}); this.blogIcon = new BlogIcon({config, urlUtils, settingsCache, storageUtils});
this.imageSize = new ImageSize({config, storage, storageUtils, validator, urlUtils, request}); this.imageSize = new ImageSize({config, storage, storageUtils, validator, urlUtils, request, probe});
this.cachedImageSizeFromUrl = new CachedImageSizeFromUrl({ this.cachedImageSizeFromUrl = new CachedImageSizeFromUrl({
getImageSizeFromUrl: this.imageSize.getImageSizeFromUrl.bind(this.imageSize), getImageSizeFromUrl: this.imageSize.getImageSizeFromUrl.bind(this.imageSize),
cache: cacheStore cache: cacheStore

View File

@ -5,6 +5,7 @@ const path = require('path');
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const fs = require('fs'); const fs = require('fs');
const ImageSize = require('../../../../../core/server/lib/image/ImageSize'); const ImageSize = require('../../../../../core/server/lib/image/ImageSize');
const probe = require('probe-image-size');
describe('lib/image: image size', function () { describe('lib/image: image size', function () {
// use a 1x1 gif in nock responses because it's really small and easy to work with // use a 1x1 gif in nock responses because it's really small and easy to work with
@ -18,7 +19,7 @@ describe('lib/image: image size', function () {
it('[success] should have an image size function', function () { it('[success] should have an image size function', function () {
const imageSize = new ImageSize({config: { const imageSize = new ImageSize({config: {
get: () => {} get: () => {}
}, tpl: {}, storage: {}, storageUtils: {}, validator: {}, urlUtils: {}, request: {}}); }, tpl: {}, storage: {}, storageUtils: {}, validator: {}, urlUtils: {}, request: {}, probe});
should.exist(imageSize.getImageSizeFromUrl); should.exist(imageSize.getImageSizeFromUrl);
should.exist(imageSize.getImageSizeFromStoragePath); should.exist(imageSize.getImageSizeFromStoragePath);
}); });
@ -42,7 +43,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
@ -77,7 +78,7 @@ describe('lib/image: image size', function () {
}); });
} }
return Promise.reject(); return Promise.reject();
}}); }, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.false(); requestMock.isDone().should.be.false();
@ -107,7 +108,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
@ -138,7 +139,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMockNotFound.isDone().should.be.false(); requestMockNotFound.isDone().should.be.false();
@ -188,7 +189,7 @@ describe('lib/image: image size', function () {
}); });
} }
return Promise.reject(); return Promise.reject();
}}); }, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
@ -218,7 +219,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
@ -254,7 +255,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true(); requestMock.isDone().should.be.true();
@ -300,7 +301,7 @@ describe('lib/image: image size', function () {
}, validator: {}, urlUtils: { }, validator: {}, urlUtils: {
urlFor: urlForStub, urlFor: urlForStub,
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: {}}); }, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) { imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.false(); requestMock.isDone().should.be.false();
@ -328,7 +329,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.catch(function (err) { .catch(function (err) {
@ -366,7 +367,7 @@ describe('lib/image: image size', function () {
return Promise.reject(new NotFound()); return Promise.reject(new NotFound());
} }
return Promise.reject(); return Promise.reject();
}}); }}, probe);
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.catch(function (err) { .catch(function (err) {
@ -387,7 +388,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => false isURL: () => false
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.catch(function (err) { .catch(function (err) {
@ -398,7 +399,7 @@ describe('lib/image: image size', function () {
}).catch(done); }).catch(done);
}); });
it('[failure] will timeout', function (done) { it('[failure] will handle responses timing out', function (done) {
const url = 'https://static.wixstatic.com/media/355241_d31358572a2542c5a44738ddcb59e7ea.jpg_256'; const url = 'https://static.wixstatic.com/media/355241_d31358572a2542c5a44738ddcb59e7ea.jpg_256';
const requestMock = nock('https://static.wixstatic.com') const requestMock = nock('https://static.wixstatic.com')
@ -409,14 +410,18 @@ describe('lib/image: image size', function () {
const imageSize = new ImageSize({config: { const imageSize = new ImageSize({config: {
get: (key) => { get: (key) => {
if (key === 'times:getImageSizeTimeoutInMS') { if (key === 'times:getImageSizeTimeoutInMS') {
return 1; return 10;
} }
} }
}, tpl: {}, storage: {}, storageUtils: { }, tpl: {}, storage: {}, storageUtils: {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {},
probe(reqUrl, options) {
// simulate probe the request timing out by probe's option
return probe(reqUrl, {...options, response_timeout: 1});
}});
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.catch(function (err) { .catch(function (err) {
@ -441,7 +446,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false isLocalImage: () => false
}, validator: { }, validator: {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: {}}); }, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.then(() => { .then(() => {
@ -475,7 +480,7 @@ describe('lib/image: image size', function () {
}); });
} }
return Promise.reject(); return Promise.reject();
}}); }, probe});
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.then(() => { .then(() => {
@ -500,7 +505,7 @@ describe('lib/image: image size', function () {
isURL: () => true isURL: () => true
}, urlUtils: {}, request: () => { }, urlUtils: {}, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromUrl(url) imageSize.getImageSizeFromUrl(url)
.catch(function (err) { .catch(function (err) {
@ -510,6 +515,38 @@ describe('lib/image: image size', function () {
done(); done();
}).catch(done); }).catch(done);
}); });
it('[failure] handles probe being unresponsive', function (done) {
const url = 'http://img.stockfresh.com/files/f/feedough/x/11/1540353_20925115.jpg';
const requestMock = nock('http://img.stockfresh.com')
.get('/files/f/feedough/x/11/1540353_20925115.jpg')
.reply(200, GIF1x1);
const imageSize = new ImageSize({config: {
get: (key) => {
if (key === 'times:getImageSizeTimeoutInMS') {
return 1;
}
}
}, tpl: {}, storage: {}, storageUtils: {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {},
probe(reqUrl, options) {
// simulate probe being unresponsive by making the timeout longer than the request
return probe(reqUrl, {...options, response_timeout: 10});
}});
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
requestMock.isDone().should.be.true();
should.exist(err);
err.errorType.should.be.equal('InternalServerError');
err.message.should.be.equal('Probe unresponsive.');
done();
}).catch(done);
});
}); });
describe('getImageSizeFromStoragePath', function () { describe('getImageSizeFromStoragePath', function () {
@ -542,7 +579,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: () => { }, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) { imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res); should.exist(res);
@ -585,7 +622,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: () => { }, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) { imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res); should.exist(res);
@ -628,7 +665,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: () => { }, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) { imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res); should.exist(res);
@ -671,7 +708,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: () => { }, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) { imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res); should.exist(res);
@ -711,7 +748,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: () => { }, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromStoragePath(url) imageSize.getImageSizeFromStoragePath(url)
.catch(function (err) { .catch(function (err) {
@ -746,7 +783,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub getSubdir: urlGetSubdirStub
}, request: () => { }, request: () => {
return Promise.reject({}); return Promise.reject({});
}}); }, probe});
imageSize.getImageSizeFromStoragePath(url) imageSize.getImageSizeFromStoragePath(url)
.catch(function (err) { .catch(function (err) {