Add SSRF protection

This commit is contained in:
Chocobozzz 2024-08-14 15:32:25 +02:00
parent af9f20d60c
commit d24d221550
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
21 changed files with 160 additions and 43 deletions

View File

@ -436,6 +436,10 @@ federation:
# Enable ActivityPub endpoints (inbox/outbox) # Enable ActivityPub endpoints (inbox/outbox)
enabled: true 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 # Some federated software such as Mastodon may require an HTTP signature to access content
sign_federated_fetches: true sign_federated_fetches: true

View File

@ -434,6 +434,10 @@ federation:
# Enable ActivityPub endpoints (inbox/outbox) # Enable ActivityPub endpoints (inbox/outbox)
enabled: true 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 # Some federated software such as Mastodon may require an HTTP signature to access content
sign_federated_fetches: true sign_federated_fetches: true

View File

@ -140,6 +140,9 @@ plugins:
check_latest_versions_interval: '10 minutes' check_latest_versions_interval: '10 minutes'
federation: federation:
# We use internal IP address in PeerTube tests
prevent_ssrf: false
videos: videos:
federate_unlisted: true federate_unlisted: true
cleanup_remote_interactions: false cleanup_remote_interactions: false

View File

@ -136,6 +136,7 @@
"fluent-ffmpeg": "^2.1.0", "fluent-ffmpeg": "^2.1.0",
"fs-extra": "^11.1.0", "fs-extra": "^11.1.0",
"got": "^13.0.0", "got": "^13.0.0",
"got-ssrf": "^3.0.0",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"hpagent": "^1.0.0", "hpagent": "^1.0.0",
"http-problem-details": "^0.1.5", "http-problem-details": "^0.1.5",

View File

@ -14,6 +14,7 @@ import './logs.js'
import './reverse-proxy.js' import './reverse-proxy.js'
import './services.js' import './services.js'
import './slow-follows.js' import './slow-follows.js'
import './ssrf.js'
import './stats.js' import './stats.js'
import './tracker.js' import './tracker.js'
import './no-client.js' import './no-client.js'

View File

@ -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)
})
})

View File

@ -8,6 +8,11 @@ export class MockHTTP {
async initialize () { async initialize () {
const app = express() 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) => { app.get('/*', (req: express.Request, res: express.Response, next: express.NextFunction) => {
return res.sendStatus(200) return res.sendStatus(200)
}) })

View File

@ -78,8 +78,8 @@ async function searchVideoChannelsIndex (query: VideoChannelsSearchQueryAfterSan
try { try {
logger.debug('Doing video channels search index request on %s.', url, { body }) 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 searchIndexResult = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body, preventSSRF: false })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result') const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.video-channels.index.list.result')
return res.json(jsonResult) return res.json(jsonResult)
} catch (err) { } catch (err) {

View File

@ -69,8 +69,8 @@ async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQueryAfterS
try { try {
logger.debug('Doing video playlists search index request on %s.', url, { body }) 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 searchIndexResult = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body, preventSSRF: false })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result') const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.video-playlists.index.list.result')
return res.json(jsonResult) return res.json(jsonResult)
} catch (err) { } catch (err) {

View File

@ -87,8 +87,8 @@ async function searchVideosIndex (query: VideosSearchQueryAfterSanitize, res: ex
try { try {
logger.debug('Doing videos search index request on %s.', url, { body }) logger.debug('Doing videos search index request on %s.', url, { body })
const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body }) const searchIndexResult = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body, preventSSRF: false })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result') const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.videos.index.list.result')
return res.json(jsonResult) return res.json(jsonResult)
} catch (err) { } catch (err) {

View File

@ -6,7 +6,7 @@ import maxmind, { CityResponse, CountryResponse, Reader } from 'maxmind'
import { join } from 'path' import { join } from 'path'
import { isArray } from './custom-validators/misc.js' import { isArray } from './custom-validators/misc.js'
import { logger, loggerTagsFactory } from './logger.js' import { logger, loggerTagsFactory } from './logger.js'
import { isBinaryResponse, peertubeGot } from './requests.js' import { isBinaryResponse, unsafeSSRFGot } from './requests.js'
const lTags = loggerTagsFactory('geo-ip') const lTags = loggerTagsFactory('geo-ip')
@ -95,7 +95,7 @@ export class GeoIP {
const gotOptions = { context: { bodyKBLimit: 800_000 }, responseType: 'buffer' as 'buffer' } const gotOptions = { context: { bodyKBLimit: 800_000 }, responseType: 'buffer' as 'buffer' }
try { try {
const gotResult = await peertubeGot(url, gotOptions) const gotResult = await unsafeSSRFGot(url, gotOptions)
if (!isBinaryResponse(gotResult)) { if (!isBinaryResponse(gotResult)) {
throw new Error('Not a binary response') throw new Error('Not a binary response')

View File

@ -1,7 +1,9 @@
import httpSignature from '@peertube/http-signature' import httpSignature from '@peertube/http-signature'
import { CONFIG } from '@server/initializers/config.js'
import { createWriteStream } from 'fs' import { createWriteStream } from 'fs'
import { remove } from 'fs-extra/esm' import { remove } from 'fs-extra/esm'
import got, { CancelableRequest, OptionsInit, OptionsOfTextResponseBody, OptionsOfUnknownResponseBody, RequestError, Response } from 'got' import got, { CancelableRequest, OptionsInit, OptionsOfTextResponseBody, OptionsOfUnknownResponseBody, RequestError, Response } from 'got'
import { gotSsrf } from 'got-ssrf'
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent' import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'
import { ACTIVITY_PUB, BINARY_CONTENT_TYPES, PEERTUBE_VERSION, REQUEST_TIMEOUTS, WEBSERVER } from '../initializers/constants.js' import { ACTIVITY_PUB, BINARY_CONTENT_TYPES, PEERTUBE_VERSION, REQUEST_TIMEOUTS, WEBSERVER } from '../initializers/constants.js'
import { pipelinePromise } from './core-utils.js' import { pipelinePromise } from './core-utils.js'
@ -35,8 +37,8 @@ export type PeerTubeRequestOptions = {
followRedirect?: boolean followRedirect?: boolean
} & Pick<OptionsInit, 'headers' | 'json' | 'method' | 'searchParams'> } & Pick<OptionsInit, 'headers' | 'json' | 'method' | 'searchParams'>
export const peertubeGot = got.extend({ export const unsafeSSRFGot = got.extend({
...getAgent(), ...getProxyAgent(),
headers: { headers: {
'user-agent': getUserAgent() '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 = {}) { export function doRequest (url: string, options: PeerTubeRequestOptions = {}) {
const gotOptions = buildGotOptions(options) as OptionsOfTextResponseBody const gotOptions = buildGotOptions(options) as OptionsOfTextResponseBody
@ -123,10 +131,14 @@ export function doRequest (url: string, options: PeerTubeRequestOptions = {}) {
.catch(err => { throw buildRequestError(err) }) .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) 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) }) .catch(err => { throw buildRequestError(err) })
} }
@ -154,7 +166,7 @@ export function generateRequestStream (url: string, options: PeerTubeRequestOpti
return peertubeGot.stream(url, { ...gotOptions, isStream: true }) return peertubeGot.stream(url, { ...gotOptions, isStream: true })
} }
export function getAgent () { export function getProxyAgent () {
if (!isProxyEnabled()) return {} if (!isProxyEnabled()) return {}
const proxy = getProxy() 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>) { export function isBinaryResponse (result: Response<any>) {
return BINARY_CONTENT_TYPES.has(result.headers['content-type']) return BINARY_CONTENT_TYPES.has(result.headers['content-type'])
} }
@ -190,6 +198,10 @@ export function isBinaryResponse (result: Response<any>) {
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getUserAgent () {
return `PeerTube/${PEERTUBE_VERSION} (+${WEBSERVER.URL})`
}
function buildGotOptions (options: PeerTubeRequestOptions): OptionsOfUnknownResponseBody { function buildGotOptions (options: PeerTubeRequestOptions): OptionsOfUnknownResponseBody {
const { activityPub, bodyKBLimit = 3000 } = options const { activityPub, bodyKBLimit = 3000 } = options

View File

@ -8,7 +8,7 @@ import { OptionsOfBufferResponseBody } from 'got'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { logger, loggerTagsFactory } from '../logger.js' import { logger, loggerTagsFactory } from '../logger.js'
import { getProxy, isProxyEnabled } from '../proxy.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'> type ProcessOptions = Pick<ExecaNodeOptions, 'cwd' | 'maxBuffer'>
@ -45,7 +45,7 @@ export class YoutubeDLCLI {
} }
try { try {
let gotResult = await peertubeGot(url, gotOptions) let gotResult = await unsafeSSRFGot(url, gotOptions)
if (!isBinaryResponse(gotResult)) { if (!isBinaryResponse(gotResult)) {
const json = JSON.parse(gotResult.body.toString()) const json = JSON.parse(gotResult.body.toString())
@ -56,7 +56,7 @@ export class YoutubeDLCLI {
const releaseAsset = latest.assets.find(a => a.name === releaseName) const releaseAsset = latest.assets.find(a => a.name === releaseName)
if (!releaseAsset) throw new Error(`Cannot find appropriate release with name ${releaseName} in release assets`) 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)) { if (!isBinaryResponse(gotResult)) {

View File

@ -74,7 +74,7 @@ function checkMissedConfig () {
'feeds.videos.count', 'feeds.comments.count', 'feeds.videos.count', 'feeds.comments.count',
'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url', 'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url',
'remote_redundancy.videos.accept_from', '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', '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.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', 'search.search_index.disable_local_search', 'search.search_index.is_default_search',

View File

@ -342,6 +342,7 @@ const CONFIG = {
}, },
FEDERATION: { FEDERATION: {
ENABLED: config.get<boolean>('federation.enabled'), ENABLED: config.get<boolean>('federation.enabled'),
PREVENT_SSRF: config.get<boolean>('federation.prevent_ssrf'),
VIDEOS: { VIDEOS: {
FEDERATE_UNLISTED: config.get<boolean>('federation.videos.federate_unlisted'), FEDERATE_UNLISTED: config.get<boolean>('federation.videos.federate_unlisted'),
CLEANUP_REMOTE_INTERACTIONS: config.get<boolean>('federation.videos.cleanup_remote_interactions') CLEANUP_REMOTE_INTERACTIONS: config.get<boolean>('federation.videos.cleanup_remote_interactions')

View File

@ -40,7 +40,7 @@ async function processActivityPubFollow (job: Job) {
targetActor = await getOrCreateAPActor(actorUrl, 'all') targetActor = await getOrCreateAPActor(actorUrl, 'all')
} catch (err) { } 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 return
} }
} }

View File

@ -1,14 +1,14 @@
import type { S3Client } from '@aws-sdk/client-s3' import type { S3Client } from '@aws-sdk/client-s3'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { isProxyEnabled } from '@server/helpers/proxy.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 { CONFIG } from '@server/initializers/config.js'
import { lTags } from './logger.js' import { lTags } from './logger.js'
async function getProxyRequestHandler () { async function getProxyRequestHandler () {
if (!isProxyEnabled()) return null if (!isProxyEnabled()) return null
const { agent } = getAgent() const { agent } = getProxyAgent()
const { NodeHttpHandler } = await import('@smithy/node-http-handler') const { NodeHttpHandler } = await import('@smithy/node-http-handler')

View File

@ -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 { import {
PeerTubePluginIndex, PeerTubePluginIndex,
PeertubePluginIndexList, PeertubePluginIndexList,
@ -11,6 +5,12 @@ import {
PeertubePluginLatestVersionResponse, PeertubePluginLatestVersionResponse,
ResultList ResultList
} from '@peertube/peertube-models' } 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' import { PluginManager } from './plugin-manager.js'
async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) { async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
@ -28,7 +28,7 @@ async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList)
const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins' const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins'
try { 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 }) 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 uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version'
const { body } = await doJSONRequest<PeertubePluginLatestVersionResponse>(uri, { json: bodyRequest, method: 'POST', preventSSRF: false })
const options = {
json: bodyRequest,
method: 'POST' as 'POST'
}
const { body } = await doJSONRequest<PeertubePluginLatestVersionResponse>(uri, options)
return body return body
} }
@ -79,7 +74,6 @@ async function getLatestPluginVersion (npmName: string) {
} }
export { export {
listAvailablePluginsFromIndex, getLatestPluginsVersion, getLatestPluginVersion, listAvailablePluginsFromIndex
getLatestPluginVersion,
getLatestPluginsVersion
} }

View File

@ -39,7 +39,7 @@ export class AutoFollowIndexInstances extends AbstractScheduler {
this.lastCheck = new Date() 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) { 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 }) logger.error('Cannot auto follow instances of index %s. Please check the auto follow URL.', indexUrl, { body })
return return

View File

@ -27,7 +27,7 @@ export class PeerTubeVersionCheckScheduler extends AbstractScheduler {
logger.info('Checking latest PeerTube version.') 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) { if (!body?.peertube?.latestVersion) {
logger.warn('Cannot check latest PeerTube version: body is invalid.', { body }) logger.warn('Cannot check latest PeerTube version: body is invalid.', { body })

View File

@ -6151,6 +6151,14 @@ gopd@^1.0.1:
dependencies: dependencies:
get-intrinsic "^1.1.3" 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: got@^13.0.0:
version "13.0.0" version "13.0.0"
resolved "https://registry.yarnpkg.com/got/-/got-13.0.0.tgz#a2402862cef27a5d0d1b07c0fb25d12b58175422" 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" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 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" version "2.2.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8"
integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==