0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

🔒 Fixed SVG sanitization for staff profile pictures (#21798)

closes https://linear.app/ghost/issue/ENG-1506

- when uploading a SVG image as staff profile picture, we previously had
a validation against malicious `<script>` tags or `on*` attributes
- this has proven to be unsufficient, as malicious scripts can be added
via other tags (e.g. `<foreignObject>`) and other attributes (e.g.
`xlink:href`)
- we now satinize SVGs using the DOMPurify library during validation
- if the file is invalid and cannot be sanitized, we show an error to
the user
- also added support for sanitizing `.svgz` files
This commit is contained in:
Sag 2024-12-05 17:36:04 +08:00 committed by GitHub
parent aecc32e151
commit a686d64029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 330 additions and 51 deletions

View file

@ -2,11 +2,16 @@ const path = require('path');
const os = require('os');
const multer = require('multer');
const fs = require('fs-extra');
const zlib = require('zlib');
const util = require('util');
const errors = require('@tryghost/errors');
const config = require('../../../../shared/config');
const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
const gunzip = util.promisify(zlib.gunzip);
const gzip = util.promisify(zlib.gzip);
const messages = {
db: {
missingFile: 'Please select a database file to import.',
@ -32,6 +37,10 @@ const messages = {
missingFile: 'Please select an image.',
invalidFile: 'Please select a valid image.'
},
svg: {
missingFile: 'Please select a SVG image.',
invalidFile: 'Please select a valid SVG image'
},
icons: {
missingFile: 'Please select an icon.',
invalidFile: 'Icon must be a square .ico or .png file between 60px 1,000px, under 100kb.'
@ -144,39 +153,99 @@ const checkFileExists = (fileData) => {
const checkFileIsValid = (fileData, types, extensions) => {
const type = fileData.mimetype;
if (types.includes(type) && extensions.includes(fileData.ext)) {
return true;
}
return false;
};
/**
*
* @param {String} filepath
* @returns {Boolean}
*
* Checks for the presence of <script> tags or 'on' attributes in an SVG file
* @returns {String | null}
*
* Reads the SVG file, sanitizes it, and writes the sanitized content back to the file.
* Returns the sanitized content or null if the SVG could not be sanitized.
*/
const isSvgSafe = (filepath) => {
const {JSDOM} = require('jsdom');
const fileContent = fs.readFileSync(filepath, 'utf8');
const document = new JSDOM(fileContent).window.document;
document.body.innerHTML = fileContent;
const svgEl = document.body.firstElementChild;
const sanitizeSvg = async (filepath, isZipped = false) => {
try {
const original = await readSvg(filepath, isZipped);
const sanitized = sanitizeSvgContent(original);
if (!svgEl) {
return false;
if (!sanitized) {
return null;
}
await writeSvg(filepath, sanitized, isZipped);
return sanitized;
} catch (error) {
logging.error('Error sanitizing SVG:', error);
return null;
}
const attributes = Array.from(svgEl.attributes).map(({name}) => name);
const hasScriptAttr = !!attributes.find(attr => attr.startsWith('on'));
const scripts = svgEl.getElementsByTagName('script');
return scripts.length === 0 && !hasScriptAttr ? true : false;
};
/**
*
* @param {String} content
* @returns {String | null}
*
* Returns sanitized SVG content, or null if the content is invalid.
*
*/
const sanitizeSvgContent = (content) => {
const {JSDOM} = require('jsdom');
const createDOMPurify = require('dompurify');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const sanitized = DOMPurify.sanitize(content, {USE_PROFILES: {svg: true, svgFilters: true}});
// Check whether the sanitized content still contains a non-empty <svg> tag
const validSvgTag = sanitized?.match(/<svg[^>]*>\s*[\S]+[\S\s]*<\/svg>/);
if (!sanitized || sanitized.trim() === '' || !validSvgTag) {
return null;
}
return sanitized;
};
/**
*
* @param {String} filepath
* @param {Boolean} isZipped
* @returns {String | null}
*
* Reads .svg or .svgz files and returns the content as a string.
*
*/
const readSvg = async (filepath, isZipped = false) => {
if (isZipped) {
const compressed = await fs.readFile(filepath);
return (await gunzip(compressed)).toString();
}
return await fs.readFile(filepath, 'utf8');
};
/**
*
* @param {String} filepath
* @param {String} content
* @param {Boolean} isZipped
*
* Writes SVG content to a .svg or .svgz file.
*/
const writeSvg = async (filepath, content, isZipped = false) => {
if (isZipped) {
const compressed = await gzip(content);
return await fs.writeFile(filepath, compressed);
}
return await fs.writeFile(filepath, content);
};
/**
*
* @param {Object} options
@ -191,7 +260,7 @@ const validation = function ({type}) {
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
return function uploadValidation(req, res, next) {
return async function uploadValidation(req, res, next) {
const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || [];
const contentTypes = (config.get('uploads')[type] && config.get('uploads')[type].contentTypes) || [];
@ -215,10 +284,13 @@ const validation = function ({type}) {
}));
}
if (req.file.ext === '.svg') {
if (!isSvgSafe(req.file.path)) {
// Sanitize SVG files
if (req.file.ext === '.svg' || req.file.ext === '.svgz') {
const sanitized = await sanitizeSvg(req.file.path, req.file.ext === '.svgz');
if (!sanitized) {
return next(new errors.UnsupportedMediaTypeError({
message: 'SVG files cannot contain <script> tags or "on" attributes.'
message: tpl(messages.svg.invalidFile)
}));
}
}
@ -295,5 +367,5 @@ module.exports = {
module.exports._test = {
checkFileExists,
checkFileIsValid,
isSvgSafe
sanitizeSvgContent
};

View file

@ -179,6 +179,7 @@
"connect-slashes": "1.4.0",
"cookie-session": "2.1.0",
"cors": "2.8.5",
"dompurify": "^3.2.2",
"downsize": "0.0.8",
"express": "4.21.1",
"express-brute": "1.0.1",

View file

@ -162,6 +162,42 @@ Object {
}
`;
exports[`Images API Errors when uploading an invalid SVG 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": null,
"context": null,
"details": null,
"ghostErrorCode": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Please select a valid SVG image",
"property": null,
"type": "UnsupportedMediaTypeError",
},
],
}
`;
exports[`Images API Errors when uploading an invalid zipped SVG 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": null,
"context": null,
"details": null,
"ghostErrorCode": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Please select a valid SVG image",
"property": null,
"type": "UnsupportedMediaTypeError",
},
],
}
`;
exports[`Images API Will error when filename is too long 1: [body] 1`] = `
Object {
"errors": Array [

View file

@ -72,8 +72,23 @@ const uploadImageCheck = async ({path, filename, contentType, expectedFileName,
// Check the image is saved to disk
const saved = await fs.readFile(originalFilePath);
assert.equal(saved.length, fileContents.length);
assert.deepEqual(saved, fileContents);
// Check the content of the saved image:
// - SVGs are sanitized before save, so their content is smaller or equal than the original file
// - Non-SVGs are saved as-is, so their content should be the same as the original file
if (contentType.includes('svg')) {
assert.ok(saved.length <= fileContents.length);
assert.ok(!saved.includes('<script'));
assert.ok(!saved.includes('<foreignObject'));
assert.ok(!saved.includes('<iframe'));
assert.ok(!saved.includes('<embed'));
assert.ok(!saved.includes('onclick'));
assert.ok(!saved.includes('href'));
assert.ok(!saved.includes('xlink:href'));
} else {
assert.equal(saved.length, fileContents.length);
assert.deepEqual(saved, fileContents);
}
const savedResized = await fs.readFile(filePath);
assert.ok(savedResized.length <= fileContents.length); // should always be smaller
@ -176,9 +191,50 @@ describe('Images API', function () {
await uploadImageCheck({path: originalFilePath, filename: 'ghosticon.webp', contentType: 'image/webp'});
});
it('Can upload a svg', async function () {
it('Can upload a valid svg', async function () {
const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/ghost-logo.svg');
await uploadImageCheck({path: originalFilePath, filename: 'ghost.svg', contentType: 'image/svg+xml', skipOriginal: true});
await uploadImageCheck({path: originalFilePath, filename: 'ghost-logo.svg', contentType: 'image/svg+xml', skipOriginal: true});
});
it('Can upload a svg that needs sanitization', async function () {
const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/svg-with-unsafe-script.svg');
await uploadImageCheck({path: originalFilePath, filename: 'svg-with-unsafe-script.svg', contentType: 'image/svg+xml', skipOriginal: true});
});
it('Errors when uploading an invalid SVG', async function () {
const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/svg-malformed.svg');
const fileContents = await fs.readFile(originalFilePath);
await uploadImageRequest({fileContents, filename: 'svg-malformed.svg', contentType: 'image/svg+xml'})
.expectStatus(415)
.matchBodySnapshot({
errors: [{
id: anyErrorId,
message: 'Please select a valid SVG image'
}]
});
});
it('Can upload a valid zipped SVG', async function () {
const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/ghost-logo.svgz');
await uploadImageCheck({path: originalFilePath, filename: 'ghost-logo.svgz', contentType: 'image/svg+xml', skipOriginal: true});
});
it('Can upload a zipped svg that needs sanitization', async function () {
const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/svgz-with-unsafe-script.svgz');
await uploadImageCheck({path: originalFilePath, filename: 'svg-with-unsafe-script.svgz', contentType: 'image/svg+xml', skipOriginal: true});
});
it('Errors when uploading an invalid zipped SVG', async function () {
const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/svgz-malformed.svgz');
const fileContents = await fs.readFile(originalFilePath);
await uploadImageRequest({fileContents, filename: 'svgz-malformed.svgz', contentType: 'image/svg+xml'})
.expectStatus(415)
.matchBodySnapshot({
errors: [{
id: anyErrorId,
message: 'Please select a valid SVG image'
}]
});
});
it('Can upload a square profile image', async function () {

View file

@ -3,6 +3,7 @@ const validation = require('../../../../../../core/server/web/api/middleware/upl
const imageFixturePath = ('../../../../../utils/fixtures/images/');
const fs = require('fs');
const path = require('path');
const assert = require('assert/strict');
describe('web utils', function () {
describe('checkFileExists', function () {
@ -44,29 +45,84 @@ describe('web utils', function () {
});
});
describe('isSvgSafe', function () {
it('detects a <script> tag in a svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-script.svg');
const dirtySvgContent = fs.readFileSync(filepath, 'utf8');
dirtySvgContent.should.containEql('<script');
validation.isSvgSafe(filepath).should.be.false;
});
it('detects a on attribute in a svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-script2.svg');
const dirtySvgContent = fs.readFileSync(filepath, 'utf8');
dirtySvgContent.should.containEql('onclick');
validation.isSvgSafe(filepath).should.be.false;
});
it('returns true for a safe svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'ghost-logo.svg');
const dirtySvgContent = fs.readFileSync(filepath, 'utf8');
dirtySvgContent.should.not.containEql('<script');
validation.isSvgSafe(filepath).should.be.true;
describe('sanitizeSvgContent', function () {
it('it removes <script> tags from SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-unsafe-script.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(original.includes('<script'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(!sanitized.includes('<script'), 'Sanitized SVG should not contain a <script> tag');
});
it('returns false for malformed svg', async function () {
it('it removes <foreignObject> tags from SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-unsafe-foreign-object.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(original.includes('<foreignObject'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(!sanitized.includes('<foreignObject'), 'Sanitized SVG should not contain a <foreignObject> tag');
});
it('it removes <embed> tags from SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-unsafe-embed.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(original.includes('<embed'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(!sanitized.includes('<embed'), 'Sanitized SVG should not contain a <embed> tag');
});
it('it removes on* attributes from SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-unsafe-onclick.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(original.includes('onclick'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(!sanitized.includes('onclick'), 'Sanitized SVG should not contain an onclick attribute');
});
it('it removes href attributes from SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-unsafe-href.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(original.includes('href'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(!sanitized.includes('href'), 'Sanitized SVG should not contain an href attribute');
});
it('it removes xlink:href attributes from SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-with-unsafe-xlink-href.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(original.includes('xlink:href'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(!sanitized.includes('xlink:href'), 'Sanitized SVG should not contain an xlink:href attribute');
});
it('it returns null for malformed SVGs', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'svg-malformed.svg');
validation.isSvgSafe(filepath).should.be.false;
const original = fs.readFileSync(filepath, 'utf8');
const sanitized = validation.sanitizeSvgContent(original);
assert.equal(sanitized, null, 'Malformed SVG should return null after sanitization');
});
it('returns true for a safe svg file', async function () {
const filepath = path.join(__dirname, imageFixturePath, 'ghost-logo.svg');
const original = fs.readFileSync(filepath, 'utf8');
assert.ok(!original.includes('<script'));
assert.ok(!original.includes('onclick'));
const sanitized = validation.sanitizeSvgContent(original);
assert.ok(sanitized, 'Safe SVG should return a string after sanitization');
});
});
});

Binary file not shown.

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
<embed src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnVW5zYWZlIGVtYmVkJyk7PC9zY3JpcHQ+" />
</svg>

After

Width:  |  Height:  |  Size: 265 B

View file

@ -0,0 +1,6 @@
<svg width="500" height="500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<text x="20" y="35">test</text>
<foreignObject width="500" height="500">
<iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:confirm(document.domain);" width="400" height="250"/>
</foreignObject>
</svg>

After

Width:  |  Height:  |  Size: 341 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<a href="javascript:alert('Unsafe href!');">
<text x="10" y="50" font-size="20">Unsafe Link</text>
</a>
</svg>

After

Width:  |  Height:  |  Size: 193 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<image href="javascript:alert('Unsafe image!');" width="100" height="100" />
</svg>

After

Width:  |  Height:  |  Size: 154 B

View file

@ -3,4 +3,4 @@
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
<button onclick="alert(1)">Click me</button>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 362 B

View file

@ -5,4 +5,4 @@
<script type="text/javascript">
alert(1);
</script>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 378 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
<rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
<use xlink:href="javascript:alert('Unsafe xlink:href!');" />
</svg>

After

Width:  |  Height:  |  Size: 277 B

View file

@ -8528,6 +8528,11 @@
dependencies:
"@types/jest" "*"
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@types/unist@^2.0.0", "@types/unist@^2.0.2":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
@ -14441,6 +14446,13 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
dompurify@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.2.tgz#6c0518745e81686c74a684f5af1e5613e7cc0246"
integrity sha512-YMM+erhdZ2nkZ4fTNRTSI94mb7VG7uVF5vj5Zde7tImgnhZE3R6YW/IACGIHb2ux+QkEXMhe591N+5jWOmL4Zw==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
domutils@1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
@ -19241,7 +19253,17 @@ htmlparser2@^6.1.0:
domutils "^2.5.2"
entities "^2.0.0"
htmlparser2@^8.0.0, htmlparser2@^8.0.1:
htmlparser2@^8.0.0:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
entities "^4.4.0"
htmlparser2@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.1.tgz#abaa985474fcefe269bc761a779b544d7196d010"
integrity sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==
@ -25230,7 +25252,7 @@ picocolors@^0.2.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f"
integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==
picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0:
picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@ -26250,7 +26272,7 @@ postcss-values-parser@^4.0.0:
is-url-superb "^4.0.0"
postcss "^7.0.5"
postcss@8.4.39, postcss@^8.1.4, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.11, postcss@^8.4.19, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.4:
postcss@8.4.39, postcss@^8.1.4, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.4.19, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.4:
version "8.4.39"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.39.tgz#aa3c94998b61d3a9c259efa51db4b392e1bde0e3"
integrity sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==
@ -26267,6 +26289,15 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2
picocolors "^0.2.1"
source-map "^0.6.1"
postcss@^8.3.11:
version "8.4.49"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
dependencies:
nanoid "^3.3.7"
picocolors "^1.1.1"
source-map-js "^1.2.1"
prebuild-install@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45"
@ -28821,6 +28852,11 @@ source-map-js@^1.0.1, source-map-js@^1.2.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
source-map-resolve@^0.5.0:
version "0.5.3"
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"