Add SSRF protection
This commit is contained in:
parent
af9f20d60c
commit
d24d221550
|
@ -436,6 +436,10 @@ federation:
|
|||
# Enable ActivityPub endpoints (inbox/outbox)
|
||||
enabled: true
|
||||
|
||||
# Prevent SSRF requests (requests to your internal network for example) by checking the request IP address
|
||||
# More information about SSRF: https://portswigger.net/web-security/ssrf
|
||||
prevent_ssrf: true
|
||||
|
||||
# Some federated software such as Mastodon may require an HTTP signature to access content
|
||||
sign_federated_fetches: true
|
||||
|
||||
|
|
|
@ -434,6 +434,10 @@ federation:
|
|||
# Enable ActivityPub endpoints (inbox/outbox)
|
||||
enabled: true
|
||||
|
||||
# Prevent SSRF requests (requests to your internal network for example) by checking the request IP address
|
||||
# More information about SSRF: https://portswigger.net/web-security/ssrf
|
||||
prevent_ssrf: true
|
||||
|
||||
# Some federated software such as Mastodon may require an HTTP signature to access content
|
||||
sign_federated_fetches: true
|
||||
|
||||
|
|
|
@ -140,6 +140,9 @@ plugins:
|
|||
check_latest_versions_interval: '10 minutes'
|
||||
|
||||
federation:
|
||||
# We use internal IP address in PeerTube tests
|
||||
prevent_ssrf: false
|
||||
|
||||
videos:
|
||||
federate_unlisted: true
|
||||
cleanup_remote_interactions: false
|
||||
|
|
|
@ -136,6 +136,7 @@
|
|||
"fluent-ffmpeg": "^2.1.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"got": "^13.0.0",
|
||||
"got-ssrf": "^3.0.0",
|
||||
"helmet": "^7.0.0",
|
||||
"hpagent": "^1.0.0",
|
||||
"http-problem-details": "^0.1.5",
|
||||
|
|
|
@ -14,6 +14,7 @@ import './logs.js'
|
|||
import './reverse-proxy.js'
|
||||
import './services.js'
|
||||
import './slow-follows.js'
|
||||
import './ssrf.js'
|
||||
import './stats.js'
|
||||
import './tracker.js'
|
||||
import './no-client.js'
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||
|
||||
import {
|
||||
cleanupTests,
|
||||
createMultipleServers,
|
||||
doubleFollow,
|
||||
PeerTubeServer,
|
||||
setAccessTokensToServers,
|
||||
waitJobs
|
||||
} from '@peertube/peertube-server-commands'
|
||||
import { MockHTTP } from '@tests/shared/mock-servers/mock-http.js'
|
||||
import { expect } from 'chai'
|
||||
import { HttpStatusCode } from '../../../../models/src/http/http-status-codes.js'
|
||||
|
||||
describe('Test SSRF requests', function () {
|
||||
let servers: PeerTubeServer[] = []
|
||||
|
||||
let baseServerConfig: any
|
||||
|
||||
before(async function () {
|
||||
this.timeout(240000)
|
||||
|
||||
const mockHTTP = new MockHTTP()
|
||||
const port = await mockHTTP.initialize()
|
||||
|
||||
baseServerConfig = {
|
||||
plugins: {
|
||||
index: {
|
||||
url: `http://127.0.0.1:${port}/redirect/https://packages.joinpeertube.org`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
servers = await createMultipleServers(2, baseServerConfig)
|
||||
await setAccessTokensToServers(servers)
|
||||
|
||||
await doubleFollow(servers[0], servers[1])
|
||||
})
|
||||
|
||||
describe('Disabled SSRF protection', function () {
|
||||
|
||||
it('Should not forbid non-unicast federation', async function () {
|
||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
||||
await waitJobs(servers)
|
||||
|
||||
await servers[1].videos.get({ id: uuid })
|
||||
})
|
||||
|
||||
it('Should fetch plugin index', async function () {
|
||||
const { total } = await servers[0].plugins.listAvailable({ count: 10 })
|
||||
|
||||
expect(total).to.be.at.least(15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enabled SSRF protection', function () {
|
||||
before(async function () {
|
||||
await servers[0].kill()
|
||||
|
||||
await servers[0].run({
|
||||
...baseServerConfig,
|
||||
|
||||
federation: { prevent_ssrf: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('Should forbid non-unicast federation', async function () {
|
||||
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
||||
await waitJobs(servers)
|
||||
|
||||
await servers[1].videos.get({ id: uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||
})
|
||||
|
||||
it('Should still allow plugin index search on internal network', async function () {
|
||||
const { total } = await servers[0].plugins.listAvailable({ count: 10 })
|
||||
|
||||
expect(total).to.be.at.least(15)
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests(servers)
|
||||
})
|
||||
})
|
|
@ -8,6 +8,11 @@ export class MockHTTP {
|
|||
async initialize () {
|
||||
const app = express()
|
||||
|
||||
app.get('/redirect/*', (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
||||
return res.redirect(req.params[0])
|
||||
})
|
||||
|
||||
app.get('/*', (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
return res.sendStatus(200)
|
||||
})
|
||||
|
|
|
@ -78,8 +78,8 @@ async function searchVideoChannelsIndex (query: VideoChannelsSearchQueryAfterSan
|
|||
try {
|
||||
logger.debug('Doing video channels search index request on %s.', url, { body })
|
||||
|
||||
const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
|
||||
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
|
||||
const searchIndexResult = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body, preventSSRF: false })
|
||||
const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.video-channels.index.list.result')
|
||||
|
||||
return res.json(jsonResult)
|
||||
} catch (err) {
|
||||
|
|
|
@ -69,8 +69,8 @@ async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQueryAfterS
|
|||
try {
|
||||
logger.debug('Doing video playlists search index request on %s.', url, { body })
|
||||
|
||||
const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body })
|
||||
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result')
|
||||
const searchIndexResult = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body, preventSSRF: false })
|
||||
const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.video-playlists.index.list.result')
|
||||
|
||||
return res.json(jsonResult)
|
||||
} catch (err) {
|
||||
|
|
|
@ -87,8 +87,8 @@ async function searchVideosIndex (query: VideosSearchQueryAfterSanitize, res: ex
|
|||
try {
|
||||
logger.debug('Doing videos search index request on %s.', url, { body })
|
||||
|
||||
const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
|
||||
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
|
||||
const searchIndexResult = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body, preventSSRF: false })
|
||||
const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.videos.index.list.result')
|
||||
|
||||
return res.json(jsonResult)
|
||||
} catch (err) {
|
||||
|
|
|
@ -6,7 +6,7 @@ import maxmind, { CityResponse, CountryResponse, Reader } from 'maxmind'
|
|||
import { join } from 'path'
|
||||
import { isArray } from './custom-validators/misc.js'
|
||||
import { logger, loggerTagsFactory } from './logger.js'
|
||||
import { isBinaryResponse, peertubeGot } from './requests.js'
|
||||
import { isBinaryResponse, unsafeSSRFGot } from './requests.js'
|
||||
|
||||
const lTags = loggerTagsFactory('geo-ip')
|
||||
|
||||
|
@ -95,7 +95,7 @@ export class GeoIP {
|
|||
const gotOptions = { context: { bodyKBLimit: 800_000 }, responseType: 'buffer' as 'buffer' }
|
||||
|
||||
try {
|
||||
const gotResult = await peertubeGot(url, gotOptions)
|
||||
const gotResult = await unsafeSSRFGot(url, gotOptions)
|
||||
|
||||
if (!isBinaryResponse(gotResult)) {
|
||||
throw new Error('Not a binary response')
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import httpSignature from '@peertube/http-signature'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import got, { CancelableRequest, OptionsInit, OptionsOfTextResponseBody, OptionsOfUnknownResponseBody, RequestError, Response } from 'got'
|
||||
import { gotSsrf } from 'got-ssrf'
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'
|
||||
import { ACTIVITY_PUB, BINARY_CONTENT_TYPES, PEERTUBE_VERSION, REQUEST_TIMEOUTS, WEBSERVER } from '../initializers/constants.js'
|
||||
import { pipelinePromise } from './core-utils.js'
|
||||
|
@ -35,8 +37,8 @@ export type PeerTubeRequestOptions = {
|
|||
followRedirect?: boolean
|
||||
} & Pick<OptionsInit, 'headers' | 'json' | 'method' | 'searchParams'>
|
||||
|
||||
export const peertubeGot = got.extend({
|
||||
...getAgent(),
|
||||
export const unsafeSSRFGot = got.extend({
|
||||
...getProxyAgent(),
|
||||
|
||||
headers: {
|
||||
'user-agent': getUserAgent()
|
||||
|
@ -116,6 +118,12 @@ export const peertubeGot = got.extend({
|
|||
}
|
||||
})
|
||||
|
||||
export const peertubeGot = CONFIG.FEDERATION.PREVENT_SSRF
|
||||
? got.extend(gotSsrf, unsafeSSRFGot)
|
||||
: unsafeSSRFGot
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function doRequest (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
const gotOptions = buildGotOptions(options) as OptionsOfTextResponseBody
|
||||
|
||||
|
@ -123,10 +131,14 @@ export function doRequest (url: string, options: PeerTubeRequestOptions = {}) {
|
|||
.catch(err => { throw buildRequestError(err) })
|
||||
}
|
||||
|
||||
export function doJSONRequest <T> (url: string, options: PeerTubeRequestOptions = {}) {
|
||||
export function doJSONRequest <T> (url: string, options: PeerTubeRequestOptions & { preventSSRF?: false } = {}) {
|
||||
const gotOptions = buildGotOptions(options)
|
||||
|
||||
return peertubeGot<T>(url, { ...gotOptions, responseType: 'json' })
|
||||
const gotInstance = options.preventSSRF === false
|
||||
? unsafeSSRFGot
|
||||
: peertubeGot
|
||||
|
||||
return gotInstance<T>(url, { ...gotOptions, responseType: 'json' })
|
||||
.catch(err => { throw buildRequestError(err) })
|
||||
}
|
||||
|
||||
|
@ -154,7 +166,7 @@ export function generateRequestStream (url: string, options: PeerTubeRequestOpti
|
|||
return peertubeGot.stream(url, { ...gotOptions, isStream: true })
|
||||
}
|
||||
|
||||
export function getAgent () {
|
||||
export function getProxyAgent () {
|
||||
if (!isProxyEnabled()) return {}
|
||||
|
||||
const proxy = getProxy()
|
||||
|
@ -178,10 +190,6 @@ export function getAgent () {
|
|||
}
|
||||
}
|
||||
|
||||
export function getUserAgent () {
|
||||
return `PeerTube/${PEERTUBE_VERSION} (+${WEBSERVER.URL})`
|
||||
}
|
||||
|
||||
export function isBinaryResponse (result: Response<any>) {
|
||||
return BINARY_CONTENT_TYPES.has(result.headers['content-type'])
|
||||
}
|
||||
|
@ -190,6 +198,10 @@ export function isBinaryResponse (result: Response<any>) {
|
|||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getUserAgent () {
|
||||
return `PeerTube/${PEERTUBE_VERSION} (+${WEBSERVER.URL})`
|
||||
}
|
||||
|
||||
function buildGotOptions (options: PeerTubeRequestOptions): OptionsOfUnknownResponseBody {
|
||||
const { activityPub, bodyKBLimit = 3000 } = options
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import { OptionsOfBufferResponseBody } from 'got'
|
|||
import { dirname, join } from 'path'
|
||||
import { logger, loggerTagsFactory } from '../logger.js'
|
||||
import { getProxy, isProxyEnabled } from '../proxy.js'
|
||||
import { isBinaryResponse, peertubeGot } from '../requests.js'
|
||||
import { isBinaryResponse, unsafeSSRFGot } from '../requests.js'
|
||||
|
||||
type ProcessOptions = Pick<ExecaNodeOptions, 'cwd' | 'maxBuffer'>
|
||||
|
||||
|
@ -45,7 +45,7 @@ export class YoutubeDLCLI {
|
|||
}
|
||||
|
||||
try {
|
||||
let gotResult = await peertubeGot(url, gotOptions)
|
||||
let gotResult = await unsafeSSRFGot(url, gotOptions)
|
||||
|
||||
if (!isBinaryResponse(gotResult)) {
|
||||
const json = JSON.parse(gotResult.body.toString())
|
||||
|
@ -56,7 +56,7 @@ export class YoutubeDLCLI {
|
|||
const releaseAsset = latest.assets.find(a => a.name === releaseName)
|
||||
if (!releaseAsset) throw new Error(`Cannot find appropriate release with name ${releaseName} in release assets`)
|
||||
|
||||
gotResult = await peertubeGot(releaseAsset.browser_download_url, gotOptions)
|
||||
gotResult = await unsafeSSRFGot(releaseAsset.browser_download_url, gotOptions)
|
||||
}
|
||||
|
||||
if (!isBinaryResponse(gotResult)) {
|
||||
|
|
|
@ -74,7 +74,7 @@ function checkMissedConfig () {
|
|||
'feeds.videos.count', 'feeds.comments.count',
|
||||
'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url',
|
||||
'remote_redundancy.videos.accept_from',
|
||||
'federation.enabled', 'federation.videos.federate_unlisted', 'federation.videos.cleanup_remote_interactions',
|
||||
'federation.enabled', 'federation.prevent_ssrf', 'federation.videos.federate_unlisted', 'federation.videos.cleanup_remote_interactions',
|
||||
'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url',
|
||||
'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
|
||||
'search.search_index.disable_local_search', 'search.search_index.is_default_search',
|
||||
|
|
|
@ -342,6 +342,7 @@ const CONFIG = {
|
|||
},
|
||||
FEDERATION: {
|
||||
ENABLED: config.get<boolean>('federation.enabled'),
|
||||
PREVENT_SSRF: config.get<boolean>('federation.prevent_ssrf'),
|
||||
VIDEOS: {
|
||||
FEDERATE_UNLISTED: config.get<boolean>('federation.videos.federate_unlisted'),
|
||||
CLEANUP_REMOTE_INTERACTIONS: config.get<boolean>('federation.videos.cleanup_remote_interactions')
|
||||
|
|
|
@ -40,7 +40,7 @@ async function processActivityPubFollow (job: Job) {
|
|||
|
||||
targetActor = await getOrCreateAPActor(actorUrl, 'all')
|
||||
} catch (err) {
|
||||
logger.warn(`Do not follow ${handle} because we could not find the actor URL (in database or using webfinger)`)
|
||||
logger.warn(`Do not follow ${handle} because we could not find the actor URL (in database or using webfinger)`, { err })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import type { S3Client } from '@aws-sdk/client-s3'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { isProxyEnabled } from '@server/helpers/proxy.js'
|
||||
import { getAgent } from '@server/helpers/requests.js'
|
||||
import { getProxyAgent } from '@server/helpers/requests.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { lTags } from './logger.js'
|
||||
|
||||
async function getProxyRequestHandler () {
|
||||
if (!isProxyEnabled()) return null
|
||||
|
||||
const { agent } = getAgent()
|
||||
const { agent } = getProxyAgent()
|
||||
|
||||
const { NodeHttpHandler } = await import('@smithy/node-http-handler')
|
||||
|
||||
|
|
|
@ -1,9 +1,3 @@
|
|||
import { sanitizeUrl } from '@server/helpers/core-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doJSONRequest } from '@server/helpers/requests.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { PEERTUBE_VERSION } from '@server/initializers/constants.js'
|
||||
import { PluginModel } from '@server/models/server/plugin.js'
|
||||
import {
|
||||
PeerTubePluginIndex,
|
||||
PeertubePluginIndexList,
|
||||
|
@ -11,6 +5,12 @@ import {
|
|||
PeertubePluginLatestVersionResponse,
|
||||
ResultList
|
||||
} from '@peertube/peertube-models'
|
||||
import { sanitizeUrl } from '@server/helpers/core-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doJSONRequest } from '@server/helpers/requests.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { PEERTUBE_VERSION } from '@server/initializers/constants.js'
|
||||
import { PluginModel } from '@server/models/server/plugin.js'
|
||||
import { PluginManager } from './plugin-manager.js'
|
||||
|
||||
async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
|
||||
|
@ -28,7 +28,7 @@ async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList)
|
|||
const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins'
|
||||
|
||||
try {
|
||||
const { body } = await doJSONRequest<any>(uri, { searchParams })
|
||||
const { body } = await doJSONRequest<any>(uri, { searchParams, preventSSRF: false })
|
||||
|
||||
logger.debug('Got result from PeerTube index.', { body })
|
||||
|
||||
|
@ -57,12 +57,7 @@ async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePlu
|
|||
}
|
||||
|
||||
const uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version'
|
||||
|
||||
const options = {
|
||||
json: bodyRequest,
|
||||
method: 'POST' as 'POST'
|
||||
}
|
||||
const { body } = await doJSONRequest<PeertubePluginLatestVersionResponse>(uri, options)
|
||||
const { body } = await doJSONRequest<PeertubePluginLatestVersionResponse>(uri, { json: bodyRequest, method: 'POST', preventSSRF: false })
|
||||
|
||||
return body
|
||||
}
|
||||
|
@ -79,7 +74,6 @@ async function getLatestPluginVersion (npmName: string) {
|
|||
}
|
||||
|
||||
export {
|
||||
listAvailablePluginsFromIndex,
|
||||
getLatestPluginVersion,
|
||||
getLatestPluginsVersion
|
||||
getLatestPluginsVersion, getLatestPluginVersion, listAvailablePluginsFromIndex
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ export class AutoFollowIndexInstances extends AbstractScheduler {
|
|||
|
||||
this.lastCheck = new Date()
|
||||
|
||||
const { body } = await doJSONRequest<any>(indexUrl, { searchParams })
|
||||
const { body } = await doJSONRequest<any>(indexUrl, { searchParams, preventSSRF: false })
|
||||
if (!body.data || Array.isArray(body.data) === false) {
|
||||
logger.error('Cannot auto follow instances of index %s. Please check the auto follow URL.', indexUrl, { body })
|
||||
return
|
||||
|
|
|
@ -27,7 +27,7 @@ export class PeerTubeVersionCheckScheduler extends AbstractScheduler {
|
|||
|
||||
logger.info('Checking latest PeerTube version.')
|
||||
|
||||
const { body } = await doJSONRequest<JoinPeerTubeVersions>(CONFIG.PEERTUBE.CHECK_LATEST_VERSION.URL)
|
||||
const { body } = await doJSONRequest<JoinPeerTubeVersions>(CONFIG.PEERTUBE.CHECK_LATEST_VERSION.URL, { preventSSRF: false })
|
||||
|
||||
if (!body?.peertube?.latestVersion) {
|
||||
logger.warn('Cannot check latest PeerTube version: body is invalid.', { body })
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -6151,6 +6151,14 @@ gopd@^1.0.1:
|
|||
dependencies:
|
||||
get-intrinsic "^1.1.3"
|
||||
|
||||
got-ssrf@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/got-ssrf/-/got-ssrf-3.0.0.tgz#400962f6157ed7ca86024adc274f7c49814d6f3f"
|
||||
integrity sha512-Cg9lmGkaNzT8cUSDbnb1GIgetnsrwCSA8S6ZW6NiY37cEbJ/HoDs/LIradAzLcrL62XPcK6eP1442OFvrNzoIg==
|
||||
dependencies:
|
||||
debug "^4.3.2"
|
||||
ipaddr.js "^2.0.1"
|
||||
|
||||
got@^13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/got/-/got-13.0.0.tgz#a2402862cef27a5d0d1b07c0fb25d12b58175422"
|
||||
|
@ -6599,7 +6607,7 @@ ipaddr.js@1.9.1:
|
|||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
|
||||
|
||||
ipaddr.js@2.2.0, "ipaddr.js@>= 0.1.5", ipaddr.js@^2.0.0:
|
||||
ipaddr.js@2.2.0, "ipaddr.js@>= 0.1.5", ipaddr.js@^2.0.0, ipaddr.js@^2.0.1:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8"
|
||||
integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==
|
||||
|
|
Loading…
Reference in New Issue