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

View File

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

View File

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

View File

@ -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",

View File

@ -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'

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 () {
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)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

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 {
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
}

View File

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

View File

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

View File

@ -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==