From fd6b6b5931f2bbb95601509bc92a90a94711b73e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 24 Mar 2025 15:12:27 +0100 Subject: [PATCH 1/9] Ensure channel is owned by the account --- packages/models/src/server/job.model.ts | 41 ++++++------ .../src/api/check-params/video-playlists.ts | 23 ++++++- .../tests/src/api/videos/video-playlists.ts | 12 +--- .../lib/activitypub/actors/check-actor.ts | 8 +++ server/core/lib/activitypub/actors/get.ts | 2 +- server/core/lib/activitypub/actors/index.ts | 1 + .../activitypub/playlists/create-update.ts | 64 ++++++++++++------- server/core/lib/activitypub/playlists/get.ts | 12 +--- .../core/lib/activitypub/playlists/refresh.ts | 12 ++-- .../shared/object-to-model-attributes.ts | 2 +- .../lib/activitypub/process/process-create.ts | 3 +- .../lib/activitypub/process/process-update.ts | 2 +- server/core/lib/activitypub/videos/updater.ts | 18 +++--- .../handlers/activitypub-http-fetcher.ts | 12 ++-- .../middlewares/validators/shared/videos.ts | 6 +- .../validators/videos/video-playlists.ts | 37 +++++------ 16 files changed, 144 insertions(+), 111 deletions(-) create mode 100644 server/core/lib/activitypub/actors/check-actor.ts diff --git a/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts index 386c80e2e..7ff8660ff 100644 --- a/packages/models/src/server/job.model.ts +++ b/packages/models/src/server/job.model.ts @@ -72,6 +72,7 @@ export type ActivitypubHttpFetcherPayload = { uri: string type: FetchType videoId?: number + accountId?: number } export type ActivitypubHttpUnicastPayload = { @@ -129,19 +130,18 @@ export type VideoRedundancyPayload = { videoId: number } -export type ManageVideoTorrentPayload = - { - action: 'create' - videoId: number - videoFileId: number - } | { - action: 'update-metadata' +export type ManageVideoTorrentPayload = { + action: 'create' + videoId: number + videoFileId: number +} | { + action: 'update-metadata' - videoId?: number - streamingPlaylistId?: number + videoId?: number + streamingPlaylistId?: number - videoFileId: number - } + videoFileId: number +} // Video transcoding payloads @@ -182,7 +182,7 @@ export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { } export type VideoTranscodingPayload = - HLSTranscodingPayload + | HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload | OptimizeTranscodingPayload | MergeAudioTranscodingPayload @@ -255,10 +255,10 @@ export type VideoStudioTaskWatermarkPayload = { } export type VideoStudioTaskPayload = - VideoStudioTaskCutPayload | - VideoStudioTaskIntroPayload | - VideoStudioTaskOutroPayload | - VideoStudioTaskWatermarkPayload + | VideoStudioTaskCutPayload + | VideoStudioTaskIntroPayload + | VideoStudioTaskOutroPayload + | VideoStudioTaskWatermarkPayload export interface VideoStudioEditionPayload { videoUUID: string @@ -280,11 +280,10 @@ export interface AfterVideoChannelImportPayload { // --------------------------------------------------------------------------- -export type NotifyPayload = - { - action: 'new-video' - videoUUID: string - } +export type NotifyPayload = { + action: 'new-video' + videoUUID: string +} // --------------------------------------------------------------------------- diff --git a/packages/tests/src/api/check-params/video-playlists.ts b/packages/tests/src/api/check-params/video-playlists.ts index 7f5be18d4..4f516ae21 100644 --- a/packages/tests/src/api/check-params/video-playlists.ts +++ b/packages/tests/src/api/check-params/video-playlists.ts @@ -26,6 +26,7 @@ describe('Test video playlists API validator', function () { let userAccessToken: string let playlist: VideoPlaylistCreateResult + let userPlaylist: VideoPlaylistCreateResult let privatePlaylistUUID: string let watchLaterPlaylistId: number @@ -45,6 +46,8 @@ describe('Test video playlists API validator', function () { await setDefaultVideoChannel([ server ]) userAccessToken = await server.users.generateUserAndToken('user1') + const user = await server.users.getMyInfo({ token: userAccessToken }) + videoId = (await server.videos.quickUpload({ name: 'video 1' })).id command = server.playlists @@ -70,6 +73,17 @@ describe('Test video playlists API validator', function () { }) } + { + userPlaylist = await command.create({ + token: userAccessToken, + attributes: { + displayName: 'user playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: user.videoChannels[0].id + } + }) + } + { const created = await command.create({ attributes: { @@ -246,7 +260,7 @@ describe('Test video playlists API validator', function () { }) it('Should fail with an unknown video channel id', async function () { - const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) await command.create(params) await command.update(getUpdate(params, playlist.shortUUID)) @@ -292,6 +306,13 @@ describe('Test video playlists API validator', function () { )) }) + it('Should fail to set a playlist to a channel owned by another user', async function () { + const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + await command.create(params) + await command.update(getUpdate(params, userPlaylist.shortUUID)) + }) + it('Should fail to update the watch later playlist', async function () { await command.update(getUpdate( getBase({}, { expectedStatus: HttpStatusCode.BAD_REQUEST_400 }), diff --git a/packages/tests/src/api/videos/video-playlists.ts b/packages/tests/src/api/videos/video-playlists.ts index d418a7b3f..a2c53edd1 100644 --- a/packages/tests/src/api/videos/video-playlists.ts +++ b/packages/tests/src/api/videos/video-playlists.ts @@ -117,7 +117,6 @@ describe('Test video playlists', function () { }) describe('Check playlists filters and privacies', function () { - it('Should list video playlist privacies', async function () { const privacies = await commands[0].getPrivacies() @@ -169,7 +168,6 @@ describe('Test video playlists', function () { let playlist: VideoPlaylist = null for (const body of [ bodyList, bodyChannel ]) { - expect(body.total).to.equal(1) expect(body.data).to.have.lengthOf(1) @@ -218,7 +216,6 @@ describe('Test video playlists', function () { }) describe('Create and federate playlists', function () { - it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { this.timeout(30000) @@ -345,7 +342,6 @@ describe('Test video playlists', function () { }) describe('List playlists', function () { - it('Should correctly list the playlists', async function () { this.timeout(30000) @@ -495,7 +491,6 @@ describe('Test video playlists', function () { }) describe('Update playlists', function () { - it('Should update a playlist', async function () { this.timeout(30000) @@ -535,7 +530,6 @@ describe('Test video playlists', function () { }) describe('Element timestamps', function () { - it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () { this.timeout(120000) @@ -659,12 +653,14 @@ describe('Test video playlists', function () { group1 = [ servers[0] ] group2 = [ servers[1], servers[2] ] + const myInfo = await servers[0].users.getMyInfo({ token: userTokenServer1 }) + const playlist = await commands[0].create({ token: userTokenServer1, attributes: { displayName: 'playlist 56', privacy: VideoPlaylistPrivacy.PUBLIC, - videoChannelId: servers[0].store.channel.id + videoChannelId: myInfo.videoChannels[0].id } }) @@ -820,7 +816,6 @@ describe('Test video playlists', function () { }) describe('Managing playlist elements', function () { - it('Should reorder the playlist', async function () { this.timeout(30000) @@ -1094,7 +1089,6 @@ describe('Test video playlists', function () { }) describe('Playlist deletion', function () { - it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () { this.timeout(30000) diff --git a/server/core/lib/activitypub/actors/check-actor.ts b/server/core/lib/activitypub/actors/check-actor.ts new file mode 100644 index 000000000..4dd5ba4dc --- /dev/null +++ b/server/core/lib/activitypub/actors/check-actor.ts @@ -0,0 +1,8 @@ +import { MActorHostOnly } from '@server/types/models/index.js' + +export function haveActorsSameRemoteHost (base: MActorHostOnly, other: MActorHostOnly) { + if (!base.serverId || !other.serverId) return false + if (base.serverId !== other.serverId) return false + + return true +} diff --git a/server/core/lib/activitypub/actors/get.ts b/server/core/lib/activitypub/actors/get.ts index 8838fb43a..6ba67873a 100644 --- a/server/core/lib/activitypub/actors/get.ts +++ b/server/core/lib/activitypub/actors/get.ts @@ -143,7 +143,7 @@ async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, ref async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) { // We created a new account: fetch the playlists if (created === true && actor.Account && accountPlaylistsUrl) { - const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' } + const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists', accountId: actor.Account.id } await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) } } diff --git a/server/core/lib/activitypub/actors/index.ts b/server/core/lib/activitypub/actors/index.ts index 9062e1394..aaffa39dc 100644 --- a/server/core/lib/activitypub/actors/index.ts +++ b/server/core/lib/activitypub/actors/index.ts @@ -1,3 +1,4 @@ +export * from './check-actor.js' export * from './get.js' export * from './image.js' export * from './keys.js' diff --git a/server/core/lib/activitypub/playlists/create-update.ts b/server/core/lib/activitypub/playlists/create-update.ts index c336219b1..f604e2dfb 100644 --- a/server/core/lib/activitypub/playlists/create-update.ts +++ b/server/core/lib/activitypub/playlists/create-update.ts @@ -1,5 +1,4 @@ import { HttpStatusCode, PlaylistObject } from '@peertube/peertube-models' -import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { isArray } from '@server/helpers/custom-validators/misc.js' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' @@ -10,11 +9,18 @@ import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail.js' import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js' import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' import { FilteredModelAttributes } from '@server/types/index.js' -import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js' +import { + MAccountHost, + MThumbnail, + MVideoPlaylist, + MVideoPlaylistFull, + MVideoPlaylistVideosLength +} from '@server/types/models/index.js' import Bluebird from 'bluebird' import { getAPId } from '../activity.js' import { getOrCreateAPActor } from '../actors/index.js' import { crawlCollectionPage } from '../crawl.js' +import { checkUrlsSameHost } from '../url.js' import { getOrCreateAPVideo } from '../videos/index.js' import { fetchRemotePlaylistElement, @@ -22,11 +28,17 @@ import { playlistElementObjectToDBAttributes, playlistObjectToDBAttributes } from './shared/index.js' +import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js' const lTags = loggerTagsFactory('ap', 'video-playlist') -async function createAccountPlaylists (playlistUrls: string[]) { +export async function createAccountPlaylists (playlistUrls: string[], account: MAccountHost) { await Bluebird.map(playlistUrls, async playlistUrl => { + if (!checkUrlsSameHost(playlistUrl, account.Actor.url)) { + logger.warn(`Playlist ${playlistUrl} is not on the same host as owner account ${account.Actor.url}`, lTags(playlistUrl)) + return + } + try { const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) if (exists === true) return @@ -37,17 +49,23 @@ async function createAccountPlaylists (playlistUrls: string[]) { throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) } - return createOrUpdateVideoPlaylist(playlistObject) + return createOrUpdateVideoPlaylist({ playlistObject }) } catch (err) { logger.warn(`Cannot create or update playlist ${playlistUrl}`, { err, ...lTags(playlistUrl) }) } }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) } -async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) { +export async function createOrUpdateVideoPlaylist (options: { + playlistObject: PlaylistObject + to?: string[] +}) { + const { playlistObject, to } = options const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to) - await setVideoChannel(playlistObject, playlistAttributes) + const channel = await getRemotePlaylistChannel(playlistObject) + playlistAttributes.videoChannelId = channel.id + playlistAttributes.ownerAccountId = channel.accountId const [ upsertPlaylist ] = await VideoPlaylistModel.upsert(playlistAttributes, { returning: true }) @@ -64,31 +82,29 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: return playlist } -// --------------------------------------------------------------------------- - -export { - createAccountPlaylists, - createOrUpdateVideoPlaylist -} - -// --------------------------------------------------------------------------- - -async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly) { +export async function getRemotePlaylistChannel (playlistObject: PlaylistObject) { if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) { throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject)) } - const actor = await getOrCreateAPActor(getAPId(playlistObject.attributedTo[0]), 'all') - - if (!actor.VideoChannel) { - logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) - return + const channelUrl = getAPId(playlistObject.attributedTo[0]) + if (!checkUrlsSameHost(channelUrl, playlistObject.id)) { + throw new Error(`Playlist ${playlistObject.id} and "attributedTo" channel ${channelUrl} are not on the same host`) } - playlistAttributes.videoChannelId = actor.VideoChannel.id - playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id + const actor = await getOrCreateAPActor(channelUrl, 'all') + + if (!actor.VideoChannel) { + throw new Error(`Playlist ${playlistObject.id} "attributedTo" is not a video channel.`) + } + + return actor.VideoChannel } +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + async function fetchElementUrls (playlistObject: PlaylistObject) { let accItems: string[] = [] await crawlCollectionPage(playlistObject.id, items => { @@ -97,7 +113,7 @@ async function fetchElementUrls (playlistObject: PlaylistObject) { return Promise.resolve() }) - return accItems + return accItems.filter(i => isActivityPubUrlValid(i)) } async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) { diff --git a/server/core/lib/activitypub/playlists/get.ts b/server/core/lib/activitypub/playlists/get.ts index 645ab5cdb..03cdf4374 100644 --- a/server/core/lib/activitypub/playlists/get.ts +++ b/server/core/lib/activitypub/playlists/get.ts @@ -1,12 +1,12 @@ +import { APObjectId } from '@peertube/peertube-models' import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' import { MVideoPlaylistFullSummary } from '@server/types/models/index.js' -import { APObjectId } from '@peertube/peertube-models' import { getAPId } from '../activity.js' import { createOrUpdateVideoPlaylist } from './create-update.js' import { scheduleRefreshIfNeeded } from './refresh.js' import { fetchRemoteVideoPlaylist } from './shared/index.js' -async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise { +export async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise { const playlistUrl = getAPId(playlistObjectArg) const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl) @@ -23,13 +23,7 @@ async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promi // playlistUrl is just an alias/redirection, so process object id instead if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject) - const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject) + const playlistCreated = await createOrUpdateVideoPlaylist({ playlistObject }) return playlistCreated } - -// --------------------------------------------------------------------------- - -export { - getOrCreateAPVideoPlaylist -} diff --git a/server/core/lib/activitypub/playlists/refresh.ts b/server/core/lib/activitypub/playlists/refresh.ts index 288597da7..4781e3ff6 100644 --- a/server/core/lib/activitypub/playlists/refresh.ts +++ b/server/core/lib/activitypub/playlists/refresh.ts @@ -1,8 +1,8 @@ +import { HttpStatusCode } from '@peertube/peertube-models' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { PeerTubeRequestError } from '@server/helpers/requests.js' import { JobQueue } from '@server/lib/job-queue/index.js' -import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models/index.js' -import { HttpStatusCode } from '@peertube/peertube-models' +import { MVideoPlaylist, MVideoPlaylistOwnerDefault } from '@server/types/models/index.js' import { createOrUpdateVideoPlaylist } from './create-update.js' import { fetchRemoteVideoPlaylist } from './shared/index.js' @@ -12,7 +12,7 @@ function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) { JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } }) } -async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise { +async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwnerDefault): Promise { if (!videoPlaylist.isOutdated()) return videoPlaylist const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url) @@ -29,7 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner) return videoPlaylist } - await createOrUpdateVideoPlaylist(playlistObject) + await createOrUpdateVideoPlaylist({ playlistObject }) return videoPlaylist } catch (err) { @@ -50,6 +50,6 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner) } export { - scheduleRefreshIfNeeded, - refreshVideoPlaylistIfNeeded + refreshVideoPlaylistIfNeeded, + scheduleRefreshIfNeeded } diff --git a/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts index 449edb615..3eae01579 100644 --- a/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts @@ -16,8 +16,8 @@ export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to privacy, url: playlistObject.id, uuid: playlistObject.uuid, - ownerAccountId: null, videoChannelId: null, + ownerAccountId: null, createdAt: new Date(playlistObject.published), updatedAt: new Date(playlistObject.updated) } as AttributesOnly diff --git a/server/core/lib/activitypub/process/process-create.ts b/server/core/lib/activitypub/process/process-create.ts index 40a3fe15e..e0aec2c1d 100644 --- a/server/core/lib/activitypub/process/process-create.ts +++ b/server/core/lib/activitypub/process/process-create.ts @@ -184,8 +184,7 @@ async function processCreatePlaylist ( byActor: MActorSignature ) { const byAccount = byActor.Account - if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) - await createOrUpdateVideoPlaylist(playlistObject, activity.to) + await createOrUpdateVideoPlaylist({ playlistObject, to: activity.to }) } diff --git a/server/core/lib/activitypub/process/process-update.ts b/server/core/lib/activitypub/process/process-update.ts index 9a9f04ed1..2a3e0db07 100644 --- a/server/core/lib/activitypub/process/process-update.ts +++ b/server/core/lib/activitypub/process/process-update.ts @@ -127,5 +127,5 @@ async function processUpdatePlaylist ( const byAccount = byActor.Account if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) - await createOrUpdateVideoPlaylist(playlistObject, activity.to) + await createOrUpdateVideoPlaylist({ playlistObject, to: activity.to }) } diff --git a/server/core/lib/activitypub/videos/updater.ts b/server/core/lib/activitypub/videos/updater.ts index 3c7058644..ca8b01617 100644 --- a/server/core/lib/activitypub/videos/updater.ts +++ b/server/core/lib/activitypub/videos/updater.ts @@ -1,4 +1,3 @@ -import { Transaction } from 'sequelize' import { VideoObject, VideoPrivacy } from '@peertube/peertube-models' import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js' import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js' @@ -8,12 +7,14 @@ import { Hooks } from '@server/lib/plugins/hooks.js' import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js' import { VideoLiveModel } from '@server/models/video/video-live.js' import { - MActor, + MActorHost, MChannelAccountLight, MChannelId, MVideoAccountLightBlacklistAllFiles, MVideoFullLight } from '@server/types/models/index.js' +import { Transaction } from 'sequelize' +import { haveActorsSameRemoteHost } from '../actors/check-actor.js' import { APVideoAbstractBuilder, getVideoAttributesFromObject, updateVideoRates } from './shared/index.js' export class APVideoUpdater extends APVideoAbstractBuilder { @@ -40,7 +41,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder { async update (overrideTo?: string[]) { logger.debug( - 'Updating remote video "%s".', this.videoObject.uuid, + 'Updating remote video "%s".', + this.videoObject.uuid, { videoObject: this.videoObject, ...this.lTags() } ) @@ -111,13 +113,9 @@ export class APVideoUpdater extends APVideoAbstractBuilder { } // Check we can update the channel: we trust the remote server - private checkChannelUpdateOrThrow (newChannelActor: MActor) { - if (!this.oldVideoChannel.Actor.serverId || !newChannelActor.serverId) { - throw new Error('Cannot check old channel/new channel validity because `serverId` is null') - } - - if (this.oldVideoChannel.Actor.serverId !== newChannelActor.serverId) { - throw new Error(`New channel ${newChannelActor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`) + private checkChannelUpdateOrThrow (newChannelActor: MActorHost) { + if (haveActorsSameRemoteHost(this.oldVideoChannel.Actor, newChannelActor) !== true) { + throw new Error(`Actor ${this.oldVideoChannel.Actor.url} is not on the same host as ${newChannelActor.url}`) } } diff --git a/server/core/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/core/lib/job-queue/handlers/activitypub-http-fetcher.ts index 649d23650..2826f100b 100644 --- a/server/core/lib/job-queue/handlers/activitypub-http-fetcher.ts +++ b/server/core/lib/job-queue/handlers/activitypub-http-fetcher.ts @@ -4,12 +4,13 @@ import { logger } from '../../../helpers/logger.js' import { VideoModel } from '../../../models/video/video.js' import { VideoCommentModel } from '../../../models/video/video-comment.js' import { VideoShareModel } from '../../../models/video/video-share.js' -import { MVideoFullLight } from '../../../types/models/index.js' +import { MAccountDefault, MVideoFullLight } from '../../../types/models/index.js' import { crawlCollectionPage } from '../../activitypub/crawl.js' import { createAccountPlaylists } from '../../activitypub/playlists/index.js' import { processActivities } from '../../activitypub/process/index.js' import { addVideoShares } from '../../activitypub/share.js' import { addVideoComments } from '../../activitypub/video-comments.js' +import { AccountModel } from '@server/models/account/account.js' async function processActivityPubHttpFetcher (job: Job) { logger.info('Processing ActivityPub fetcher in job %s.', job.id) @@ -19,14 +20,17 @@ async function processActivityPubHttpFetcher (job: Job) { let video: MVideoFullLight if (payload.videoId) video = await VideoModel.loadFull(payload.videoId) - const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise } = { + let account: MAccountDefault + if (payload.accountId) account = await AccountModel.load(payload.accountId) + + const fetcherType: { [id in FetchType]: (items: any[]) => Promise } = { 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }), 'video-shares': items => addVideoShares(items, video), 'video-comments': items => addVideoComments(items), - 'account-playlists': items => createAccountPlaylists(items) + 'account-playlists': items => createAccountPlaylists(items, account) } - const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise } = { + const cleanerType: { [id in FetchType]?: (crawlStartDate: Date) => Promise } = { 'video-shares': crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate), 'video-comments': crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate) } diff --git a/server/core/middlewares/validators/shared/videos.ts b/server/core/middlewares/validators/shared/videos.ts index e016fe968..e1ff1ad90 100644 --- a/server/core/middlewares/validators/shared/videos.ts +++ b/server/core/middlewares/validators/shared/videos.ts @@ -83,7 +83,7 @@ export async function doesVideoChannelOfAccountExist (channelId: number, user: M const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) if (videoChannel === null) { - res.fail({ message: 'Unknown video "video channel" for this instance.' }) + res.fail({ message: `Unknown ${channelId} on this instance.` }) return false } @@ -94,9 +94,7 @@ export async function doesVideoChannelOfAccountExist (channelId: number, user: M } if (videoChannel.Account.id !== user.Account.id) { - res.fail({ - message: 'Unknown video "video channel" for this account.' - }) + res.fail({ message: `Unknown channel ${channelId} for this account.` }) return false } diff --git a/server/core/middlewares/validators/videos/video-playlists.ts b/server/core/middlewares/validators/videos/video-playlists.ts index 45d87910a..9a203af2d 100644 --- a/server/core/middlewares/validators/videos/video-playlists.ts +++ b/server/core/middlewares/validators/videos/video-playlists.ts @@ -1,5 +1,3 @@ -import express from 'express' -import { body, param, query, ValidationChain } from 'express-validator' import { forceNumber } from '@peertube/peertube-core-utils' import { HttpStatusCode, @@ -12,6 +10,8 @@ import { } from '@peertube/peertube-models' import { ExpressPromiseHandler } from '@server/types/express-handler.js' import { MUserAccountId } from '@server/types/models/index.js' +import express from 'express' +import { body, param, query, ValidationChain } from 'express-validator' import { isArrayOf, isIdOrUUIDValid, @@ -37,7 +37,7 @@ import { MVideoPlaylist } from '../../../types/models/video/video-playlist.js' import { authenticatePromise } from '../../auth.js' import { areValidationErrors, - doesVideoChannelIdExist, + doesVideoChannelOfAccountExist, doesVideoExist, doesVideoPlaylistExist, isValidPlaylistIdParam, @@ -52,7 +52,9 @@ const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ if (areValidationErrors(req, res)) return cleanUpReqFiles(req) const body: VideoPlaylistCreate = req.body - if (body.videoChannelId && !await doesVideoChannelIdExist(body.videoChannelId, res)) return cleanUpReqFiles(req) + if (body.videoChannelId && !await doesVideoChannelOfAccountExist(body.videoChannelId, res.locals.oauth.token.User, res)) { + return cleanUpReqFiles(req) + } if ( !body.videoChannelId && @@ -88,7 +90,8 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([ const body: VideoPlaylistUpdate = req.body const newPrivacy = body.privacy || videoPlaylist.privacy - if (newPrivacy === VideoPlaylistPrivacy.PUBLIC && + if ( + newPrivacy === VideoPlaylistPrivacy.PUBLIC && ( (!videoPlaylist.videoChannelId && !body.videoChannelId) || body.videoChannelId === null @@ -105,7 +108,9 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([ return res.fail({ message: 'Cannot update a watch later playlist.' }) } - if (body.videoChannelId && !await doesVideoChannelIdExist(body.videoChannelId, res)) return cleanUpReqFiles(req) + if (body.videoChannelId && !await doesVideoChannelOfAccountExist(body.videoChannelId, res.locals.oauth.token.User, res)) { + return cleanUpReqFiles(req) + } return next() } @@ -350,21 +355,17 @@ const doVideosInPlaylistExistValidator = [ // --------------------------------------------------------------------------- export { + commonVideoPlaylistFiltersValidator, + doVideosInPlaylistExistValidator, + videoPlaylistElementAPGetValidator, videoPlaylistsAddValidator, - videoPlaylistsUpdateValidator, + videoPlaylistsAddVideoValidator, videoPlaylistsDeleteValidator, videoPlaylistsGetValidator, - videoPlaylistsSearchValidator, - - videoPlaylistsAddVideoValidator, - videoPlaylistsUpdateOrRemoveVideoValidator, videoPlaylistsReorderVideosValidator, - - videoPlaylistElementAPGetValidator, - - commonVideoPlaylistFiltersValidator, - - doVideosInPlaylistExistValidator + videoPlaylistsSearchValidator, + videoPlaylistsUpdateOrRemoveVideoValidator, + videoPlaylistsUpdateValidator } // --------------------------------------------------------------------------- @@ -375,7 +376,7 @@ function getCommonPlaylistEditAttributes () { .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')) .withMessage( 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' + - CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ') + CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ') ), body('description') From 76226d85685220db1495025300eca784d0336f7d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 24 Mar 2025 15:14:52 +0100 Subject: [PATCH 2/9] Fix infinite loop in AP crawl --- server/core/lib/activitypub/crawl.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/server/core/lib/activitypub/crawl.ts b/server/core/lib/activitypub/crawl.ts index bc773279a..d7c645fbd 100644 --- a/server/core/lib/activitypub/crawl.ts +++ b/server/core/lib/activitypub/crawl.ts @@ -6,10 +6,10 @@ import { logger } from '../../helpers/logger.js' import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants.js' import { fetchAP } from './activity.js' -type HandlerFunction = (items: T[]) => (Promise | Bluebird) +type HandlerFunction = (items: T[]) => Promise | Bluebird type CleanerFunction = (startedDate: Date) => Promise -async function crawlCollectionPage (argUrl: string, handler: HandlerFunction, cleaner?: CleanerFunction) { +export async function crawlCollectionPage (argUrl: string, handler: HandlerFunction, cleaner?: CleanerFunction) { let url = argUrl logger.info('Crawling ActivityPub data on %s.', url) @@ -23,6 +23,8 @@ async function crawlCollectionPage (argUrl: string, handler: HandlerFunction let i = 0 let nextLink = firstBody.first while (nextLink && i < limit) { + i++ + let body: any if (typeof nextLink === 'string') { @@ -40,7 +42,6 @@ async function crawlCollectionPage (argUrl: string, handler: HandlerFunction } nextLink = body.next - i++ if (Array.isArray(body.orderedItems)) { const items = body.orderedItems @@ -52,7 +53,3 @@ async function crawlCollectionPage (argUrl: string, handler: HandlerFunction if (cleaner) await retryTransactionWrapper(cleaner, startDate) } - -export { - crawlCollectionPage -} From 0fc3f91d83994861cfcb93b5cb3f1fd7060aac6b Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 3 Apr 2025 10:19:37 +0200 Subject: [PATCH 3/9] Ensure playlist is owned by actor/instance --- .../activitypub/playlists/create-update.ts | 22 +++++++++++++------ server/core/lib/activitypub/playlists/get.ts | 9 +++----- .../core/lib/activitypub/playlists/refresh.ts | 2 +- .../lib/activitypub/process/process-create.ts | 2 +- .../lib/activitypub/process/process-update.ts | 2 +- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/server/core/lib/activitypub/playlists/create-update.ts b/server/core/lib/activitypub/playlists/create-update.ts index f604e2dfb..746853f75 100644 --- a/server/core/lib/activitypub/playlists/create-update.ts +++ b/server/core/lib/activitypub/playlists/create-update.ts @@ -49,7 +49,7 @@ export async function createAccountPlaylists (playlistUrls: string[], account: M throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) } - return createOrUpdateVideoPlaylist({ playlistObject }) + return createOrUpdateVideoPlaylist({ playlistObject, contextUrl: playlistUrl }) } catch (err) { logger.warn(`Cannot create or update playlist ${playlistUrl}`, { err, ...lTags(playlistUrl) }) } @@ -58,9 +58,17 @@ export async function createAccountPlaylists (playlistUrls: string[], account: M export async function createOrUpdateVideoPlaylist (options: { playlistObject: PlaylistObject + // Which is the context where we retrieved the playlist + // Can be the actor that signed the activity URL or the playlist URL we fetched + contextUrl: string to?: string[] }) { - const { playlistObject, to } = options + const { playlistObject, contextUrl, to } = options + + if (!checkUrlsSameHost(playlistObject.id, contextUrl)) { + throw new Error(`Playlist ${playlistObject.id} is not on the same host as context URL ${contextUrl}`) + } + const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to) const channel = await getRemotePlaylistChannel(playlistObject) @@ -82,7 +90,11 @@ export async function createOrUpdateVideoPlaylist (options: { return playlist } -export async function getRemotePlaylistChannel (playlistObject: PlaylistObject) { +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function getRemotePlaylistChannel (playlistObject: PlaylistObject) { if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) { throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject)) } @@ -101,10 +113,6 @@ export async function getRemotePlaylistChannel (playlistObject: PlaylistObject) return actor.VideoChannel } -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - async function fetchElementUrls (playlistObject: PlaylistObject) { let accItems: string[] = [] await crawlCollectionPage(playlistObject.id, items => { diff --git a/server/core/lib/activitypub/playlists/get.ts b/server/core/lib/activitypub/playlists/get.ts index 03cdf4374..32d1f4b6e 100644 --- a/server/core/lib/activitypub/playlists/get.ts +++ b/server/core/lib/activitypub/playlists/get.ts @@ -1,4 +1,3 @@ -import { APObjectId } from '@peertube/peertube-models' import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' import { MVideoPlaylistFullSummary } from '@server/types/models/index.js' import { getAPId } from '../activity.js' @@ -6,9 +5,7 @@ import { createOrUpdateVideoPlaylist } from './create-update.js' import { scheduleRefreshIfNeeded } from './refresh.js' import { fetchRemoteVideoPlaylist } from './shared/index.js' -export async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise { - const playlistUrl = getAPId(playlistObjectArg) - +export async function getOrCreateAPVideoPlaylist (playlistUrl: string): Promise { const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl) if (playlistFromDatabase) { @@ -21,9 +18,9 @@ export async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId) if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl) // playlistUrl is just an alias/redirection, so process object id instead - if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject) + if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(getAPId(playlistObject)) - const playlistCreated = await createOrUpdateVideoPlaylist({ playlistObject }) + const playlistCreated = await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: playlistUrl }) return playlistCreated } diff --git a/server/core/lib/activitypub/playlists/refresh.ts b/server/core/lib/activitypub/playlists/refresh.ts index 4781e3ff6..1e749f281 100644 --- a/server/core/lib/activitypub/playlists/refresh.ts +++ b/server/core/lib/activitypub/playlists/refresh.ts @@ -29,7 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwnerD return videoPlaylist } - await createOrUpdateVideoPlaylist({ playlistObject }) + await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: videoPlaylist.url }) return videoPlaylist } catch (err) { diff --git a/server/core/lib/activitypub/process/process-create.ts b/server/core/lib/activitypub/process/process-create.ts index e0aec2c1d..9ddcf82be 100644 --- a/server/core/lib/activitypub/process/process-create.ts +++ b/server/core/lib/activitypub/process/process-create.ts @@ -186,5 +186,5 @@ async function processCreatePlaylist ( const byAccount = byActor.Account if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) - await createOrUpdateVideoPlaylist({ playlistObject, to: activity.to }) + await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: activity.to }) } diff --git a/server/core/lib/activitypub/process/process-update.ts b/server/core/lib/activitypub/process/process-update.ts index 2a3e0db07..8dacf2e80 100644 --- a/server/core/lib/activitypub/process/process-update.ts +++ b/server/core/lib/activitypub/process/process-update.ts @@ -127,5 +127,5 @@ async function processUpdatePlaylist ( const byAccount = byActor.Account if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) - await createOrUpdateVideoPlaylist({ playlistObject, to: activity.to }) + await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: activity.to }) } From 71744313f0fb99f735bbe51920d107e4845cf382 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 3 Apr 2025 10:27:18 +0200 Subject: [PATCH 4/9] Fix infinite server crash on invalid zip import --- packages/tests/fixtures/export-crash.zip | Bin 0 -> 168 bytes .../tests/src/api/check-params/user-import.ts | 8 ++++---- server/core/helpers/unzip.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 packages/tests/fixtures/export-crash.zip diff --git a/packages/tests/fixtures/export-crash.zip b/packages/tests/fixtures/export-crash.zip new file mode 100644 index 0000000000000000000000000000000000000000..0c4b0e711185bb5fb6508383c913a518b5ac870c GIT binary patch literal 168 zcmWIWW@Zs#0D)DJjZq2)R%UEKHV7*Kv7VkjR#cK&QIeLKlbVs5latRC;LXS+!i?J# hpg~~pzY#=XH4rEWGc&-O6=XdFBM|xlX%`TO0RULX8)g6i literal 0 HcmV?d00001 diff --git a/packages/tests/src/api/check-params/user-import.ts b/packages/tests/src/api/check-params/user-import.ts index 6c4312bb1..4abd9e0cf 100644 --- a/packages/tests/src/api/check-params/user-import.ts +++ b/packages/tests/src/api/check-params/user-import.ts @@ -3,7 +3,8 @@ import { HttpStatusCode } from '@peertube/peertube-models' import { cleanupTests, - createSingleServer, PeerTubeServer, + createSingleServer, + PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' @@ -37,7 +38,6 @@ describe('Test user import API validators', function () { }) describe('Request import', function () { - it('Should fail if import is disabled', async function () { await server.config.disableUserImport() @@ -121,7 +121,8 @@ describe('Test user import API validators', function () { 'export-bad-video.zip', 'export-without-videos.zip', 'export-bad-structure.zip', - 'export-bad-structure.zip' + 'export-bad-structure.zip', + 'export-crash.zip' ] const tokens: string[] = [] @@ -141,7 +142,6 @@ describe('Test user import API validators', function () { }) describe('Get latest import status', function () { - it('Should fail without token', async function () { await server.userImports.getLatestImport({ userId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) diff --git a/server/core/helpers/unzip.ts b/server/core/helpers/unzip.ts index 8a892be7c..00defd69a 100644 --- a/server/core/helpers/unzip.ts +++ b/server/core/helpers/unzip.ts @@ -16,6 +16,8 @@ export async function unzip (source: string, destination: string) { yauzl.open(source, { lazyEntries: true }, (err, zipFile) => { if (err) return rej(err) + zipFile.on('error', err => rej(err)) + zipFile.readEntry() zipFile.on('entry', async entry => { From 69c851c8e6805323a80be521f28107ca80278ff6 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 3 Apr 2025 10:54:13 +0200 Subject: [PATCH 5/9] Fix path traversal when getting a private playlist --- packages/tests/src/api/check-params/index.ts | 1 + packages/tests/src/api/check-params/static.ts | 94 +++++++++++++++++++ .../tests/src/api/check-params/video-files.ts | 2 +- server/core/controllers/static.ts | 6 +- server/core/middlewares/validators/static.ts | 36 +++---- 5 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 packages/tests/src/api/check-params/static.ts diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts index a045490b1..d9752eaab 100644 --- a/packages/tests/src/api/check-params/index.ts +++ b/packages/tests/src/api/check-params/index.ts @@ -21,6 +21,7 @@ import './registrations.js' import './runners.js' import './search.js' import './services.js' +import './static.js' import './transcoding.js' import './two-factor.js' import './upload-quota.js' diff --git a/packages/tests/src/api/check-params/static.ts b/packages/tests/src/api/check-params/static.ts new file mode 100644 index 000000000..a07d2c829 --- /dev/null +++ b/packages/tests/src/api/check-params/static.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { getHLS } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { expect } from 'chai' +import { basename } from 'path' + +describe('Test static endpoints validators', function () { + let server: PeerTubeServer + + let privateVideo: VideoDetails + let privateM3U8: string + + let publicVideo: VideoDetails + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(300_000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await server.config.enableMinimumTranscoding({ hls: true }) + + { + const { uuid } = await server.videos.quickUpload({ name: 'video 1', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + privateVideo = await server.videos.getWithToken({ id: uuid }) + privateM3U8 = basename(getHLS(privateVideo).playlistUrl) + } + + { + const { uuid } = await server.videos.quickUpload({ name: 'video 2', privacy: VideoPrivacy.PUBLIC }) + await waitJobs([ server ]) + + publicVideo = await server.videos.get({ id: uuid }) + } + + await waitJobs([ server ]) + }) + + describe('Getting m3u8 playlist', function () { + it('Should fail with an invalid video UUID', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/static/streaming-playlists/hls/private/toto/' + privateM3U8 + }) + }) + + it('Should fail with an invalid playlist name', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/static/streaming-playlists/hls/private/' + privateVideo.uuid + '/' + privateM3U8.replace('.m3u8', '.mp4') + }) + }) + + it('Should fail with another m3u8 playlist of another video', async function () { + await makeGetRequest({ + url: server.url, + headers: { + 'x-peertube-video-password': 'fake' + }, + path: '/static/streaming-playlists/hls/private/' + publicVideo.uuid + '/..%2f' + privateVideo.uuid + '%2f' + privateM3U8 + }) + }) + + it('Should succeed with the correct params', async function () { + const { text } = await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/static/streaming-playlists/hls/private/' + privateVideo.uuid + '/' + privateM3U8, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(text).to.contain('#EXTM3U') + expect(text).to.contain(basename(getHLS(privateVideo).files[0].playlistUrl)) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-files.ts b/packages/tests/src/api/check-params/video-files.ts index 57882881b..a53016509 100644 --- a/packages/tests/src/api/check-params/video-files.ts +++ b/packages/tests/src/api/check-params/video-files.ts @@ -12,7 +12,7 @@ import { waitJobs } from '@peertube/peertube-server-commands' -describe('Test videos files', function () { +describe('Test videos files API validators', function () { let servers: PeerTubeServer[] let userToken: string diff --git a/server/core/controllers/static.ts b/server/core/controllers/static.ts index 6c6894536..d29c4db58 100644 --- a/server/core/controllers/static.ts +++ b/server/core/controllers/static.ts @@ -55,7 +55,7 @@ const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AU : [] staticRouter.use( - STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistName.m3u8', + STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistNameWithoutExtension.m3u8', ...privateHLSStaticMiddlewares, asyncMiddleware(servePrivateM3U8) ) @@ -81,8 +81,8 @@ export { // --------------------------------------------------------------------------- async function servePrivateM3U8 (req: express.Request, res: express.Response) { - const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistName + '.m3u8') - const filename = req.params.playlistName + '.m3u8' + const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistNameWithoutExtension + '.m3u8') + const filename = req.params.playlistNameWithoutExtension + '.m3u8' let playlistContent: string diff --git a/server/core/middlewares/validators/static.ts b/server/core/middlewares/validators/static.ts index 6c5eccb64..b0b1e98ff 100644 --- a/server/core/middlewares/validators/static.ts +++ b/server/core/middlewares/validators/static.ts @@ -1,21 +1,27 @@ import { HttpStatusCode } from '@peertube/peertube-models' -import { exists, isSafePeerTubeFilenameWithoutExtension, isUUIDValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js' +import { + exists, + isSafePeerTubeFilenameWithoutExtension, + isUUIDValid, + toBooleanOrNull +} from '@server/helpers/custom-validators/misc.js' import { logger } from '@server/helpers/logger.js' import { LRU_CACHE } from '@server/initializers/constants.js' import { VideoFileModel } from '@server/models/video/video-file.js' import { VideoModel } from '@server/models/video/video.js' import { MStreamingPlaylist, MVideoFile, MVideoThumbnailBlacklist } from '@server/types/models/index.js' import express from 'express' -import { query } from 'express-validator' +import { param, query } from 'express-validator' import { LRUCache } from 'lru-cache' -import { basename, dirname } from 'path' +import { basename } from 'path' import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared/index.js' type LRUValue = { allowed: boolean video?: MVideoThumbnailBlacklist file?: MVideoFile - playlist?: MStreamingPlaylist } + playlist?: MStreamingPlaylist +} const staticFileTokenBypass = new LRUCache({ max: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.MAX_SIZE, @@ -62,6 +68,13 @@ const ensureCanAccessVideoPrivateWebVideoFiles = [ ] const ensureCanAccessPrivateVideoHLSFiles = [ + param('videoUUID') + .custom(isUUIDValid), + + param('playlistNameWithoutExtension') + .optional() + .custom(v => isSafePeerTubeFilenameWithoutExtension(v)), + query('videoFileToken') .optional() .custom(exists), @@ -71,22 +84,12 @@ const ensureCanAccessPrivateVideoHLSFiles = [ .customSanitizer(toBooleanOrNull) .isBoolean().withMessage('Should be a valid reinjectVideoFileToken boolean'), - query('playlistName') - .optional() - .customSanitizer(isSafePeerTubeFilenameWithoutExtension), - isValidVideoPasswordHeader(), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - const videoUUID = basename(dirname(req.originalUrl)) - - if (!isUUIDValid(videoUUID)) { - logger.debug('Path does not contain valid video UUID to serve static file %s', req.originalUrl) - - return res.sendStatus(HttpStatusCode.FORBIDDEN_403) - } + const videoUUID = req.params.videoUUID const token = extractTokenOrDie(req, res) if (!token) return @@ -122,7 +125,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [ ] export { - ensureCanAccessPrivateVideoHLSFiles, ensureCanAccessVideoPrivateWebVideoFiles + ensureCanAccessPrivateVideoHLSFiles, + ensureCanAccessVideoPrivateWebVideoFiles } // --------------------------------------------------------------------------- From 473cd4f7efaef2a530329898e79e5cbfe2820468 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 7 Apr 2025 08:27:38 +0200 Subject: [PATCH 6/9] Check max ZIP uncompressed size --- packages/tests/fixtures/zip-bomb.zip | Bin 0 -> 42374 bytes .../tests/src/api/check-params/user-import.ts | 3 ++- server/core/helpers/unzip.ts | 25 +++++++++++++++++- .../lib/user-import-export/user-importer.ts | 12 +++++++-- 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/tests/fixtures/zip-bomb.zip diff --git a/packages/tests/fixtures/zip-bomb.zip b/packages/tests/fixtures/zip-bomb.zip new file mode 100644 index 0000000000000000000000000000000000000000..b4d00682f287342d9afab1d70c03075d9a48eed1 GIT binary patch literal 42374 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 DBG6lk literal 0 HcmV?d00001 diff --git a/packages/tests/src/api/check-params/user-import.ts b/packages/tests/src/api/check-params/user-import.ts index 4abd9e0cf..545a3e46f 100644 --- a/packages/tests/src/api/check-params/user-import.ts +++ b/packages/tests/src/api/check-params/user-import.ts @@ -122,7 +122,8 @@ describe('Test user import API validators', function () { 'export-without-videos.zip', 'export-bad-structure.zip', 'export-bad-structure.zip', - 'export-crash.zip' + 'export-crash.zip', + 'zip-bomb.zip' ] const tokens: string[] = [] diff --git a/server/core/helpers/unzip.ts b/server/core/helpers/unzip.ts index 00defd69a..5e04d9aaf 100644 --- a/server/core/helpers/unzip.ts +++ b/server/core/helpers/unzip.ts @@ -7,7 +7,14 @@ import { logger, loggerTagsFactory } from './logger.js' const lTags = loggerTagsFactory('unzip') -export async function unzip (source: string, destination: string) { +export async function unzip (options: { + source: string + destination: string + maxSize: number // in bytes + maxFiles: number +}) { + const { source, destination } = options + await ensureDir(destination) logger.info(`Unzip ${source} to ${destination}`, lTags()) @@ -18,9 +25,25 @@ export async function unzip (source: string, destination: string) { zipFile.on('error', err => rej(err)) + let decompressedSize = 0 + let entries = 0 + zipFile.readEntry() zipFile.on('entry', async entry => { + decompressedSize += entry.uncompressedSize + entries++ + + if (decompressedSize > options.maxSize) { + zipFile.close() + return rej(new Error(`Unzipped size exceeds ${options.maxSize} bytes`)) + } + + if (entries > options.maxFiles) { + zipFile.close() + return rej(new Error(`Unzipped files count exceeds ${options.maxFiles}`)) + } + const entryPath = join(destination, entry.fileName) try { diff --git a/server/core/lib/user-import-export/user-importer.ts b/server/core/lib/user-import-export/user-importer.ts index f48344c56..53f0a2055 100644 --- a/server/core/lib/user-import-export/user-importer.ts +++ b/server/core/lib/user-import-export/user-importer.ts @@ -1,5 +1,5 @@ import { UserImportResultSummary, UserImportState } from '@peertube/peertube-models' -import { getFilenameWithoutExt } from '@peertube/peertube-node-utils' +import { getFilenameWithoutExt, getFileSize } from '@peertube/peertube-node-utils' import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { unzip } from '@server/helpers/unzip.js' @@ -20,6 +20,7 @@ import { UserVideoHistoryImporter } from './importers/user-video-history-importe import { VideoPlaylistsImporter } from './importers/video-playlists-importer.js' import { VideosImporter } from './importers/videos-importer.js' import { WatchedWordsListsImporter } from './importers/watched-words-lists-importer.js' +import { parseBytes } from '@server/helpers/core-utils.js' const lTags = loggerTagsFactory('user-import') @@ -51,7 +52,14 @@ export class UserImporter { const inputZip = getFSUserImportFilePath(importModel) this.extractedDirectory = join(dirname(inputZip), getFilenameWithoutExt(inputZip)) - await unzip(inputZip, this.extractedDirectory) + await unzip({ + source: inputZip, + destination: this.extractedDirectory, + // Videos that take a lot of space don't have a good compression ratio + // Keep a minimum of 1GB if the archive doesn't contain video files + maxSize: Math.max(await getFileSize(inputZip) * 2, parseBytes('1GB')), + maxFiles: 10000 + }) const user = await UserModel.loadByIdFull(importModel.userId) From 94deeb0a8f97cb1a4d6735f8e7500a291aeb0147 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 8 Apr 2025 07:16:54 +0200 Subject: [PATCH 7/9] Fix HLS private static path --- packages/tests/src/api/check-params/static.ts | 3 +- server/core/controllers/static.ts | 21 +++++++--- server/core/middlewares/validators/static.ts | 41 +++++++++++++------ 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/packages/tests/src/api/check-params/static.ts b/packages/tests/src/api/check-params/static.ts index a07d2c829..12136a2ac 100644 --- a/packages/tests/src/api/check-params/static.ts +++ b/packages/tests/src/api/check-params/static.ts @@ -61,7 +61,8 @@ describe('Test static endpoints validators', function () { await makeGetRequest({ url: server.url, token: server.accessToken, - path: '/static/streaming-playlists/hls/private/' + privateVideo.uuid + '/' + privateM3U8.replace('.m3u8', '.mp4') + path: '/static/streaming-playlists/hls/private/' + privateVideo.uuid + '/' + privateM3U8.replace('.m3u8', '.mp4'), + expectedStatus: HttpStatusCode.NOT_FOUND_404 }) }) diff --git a/server/core/controllers/static.ts b/server/core/controllers/static.ts index d29c4db58..10479caf4 100644 --- a/server/core/controllers/static.ts +++ b/server/core/controllers/static.ts @@ -5,7 +5,9 @@ import { ensureCanAccessPrivateVideoHLSFiles, ensureCanAccessVideoPrivateWebVideoFiles, handleStaticError, - optionalAuthenticate + optionalAuthenticate, + privateHLSFileValidator, + privateM3U8PlaylistValidator } from '@server/middlewares/index.js' import cors from 'cors' import express from 'express' @@ -55,17 +57,20 @@ const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AU : [] staticRouter.use( - STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistNameWithoutExtension.m3u8', + STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistNameWithoutExtension([a-z0-9-]+).m3u8', + privateM3U8PlaylistValidator, ...privateHLSStaticMiddlewares, asyncMiddleware(servePrivateM3U8) ) staticRouter.use( - STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, + STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:filename', + privateHLSFileValidator, ...privateHLSStaticMiddlewares, - express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, { fallthrough: false }), - handleStaticError + servePrivateHLSFile ) +// --------------------------------------------------------------------------- + staticRouter.use( STATIC_PATHS.STREAMING_PLAYLISTS.HLS, express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, { fallthrough: false }), @@ -80,6 +85,12 @@ export { // --------------------------------------------------------------------------- +function servePrivateHLSFile (req: express.Request, res: express.Response) { + const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.filename) + + return res.sendFile(path) +} + async function servePrivateM3U8 (req: express.Request, res: express.Response) { const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistNameWithoutExtension + '.m3u8') const filename = req.params.playlistNameWithoutExtension + '.m3u8' diff --git a/server/core/middlewares/validators/static.ts b/server/core/middlewares/validators/static.ts index b0b1e98ff..f186d36dc 100644 --- a/server/core/middlewares/validators/static.ts +++ b/server/core/middlewares/validators/static.ts @@ -1,6 +1,7 @@ import { HttpStatusCode } from '@peertube/peertube-models' import { exists, + isSafeFilename, isSafePeerTubeFilenameWithoutExtension, isUUIDValid, toBooleanOrNull @@ -28,7 +29,7 @@ const staticFileTokenBypass = new LRUCache({ ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL }) -const ensureCanAccessVideoPrivateWebVideoFiles = [ +export const ensureCanAccessVideoPrivateWebVideoFiles = [ query('videoFileToken').optional().custom(exists), isValidVideoPasswordHeader(), @@ -67,23 +68,44 @@ const ensureCanAccessVideoPrivateWebVideoFiles = [ } ] -const ensureCanAccessPrivateVideoHLSFiles = [ +export const privateM3U8PlaylistValidator = [ param('videoUUID') .custom(isUUIDValid), param('playlistNameWithoutExtension') - .optional() .custom(v => isSafePeerTubeFilenameWithoutExtension(v)), - query('videoFileToken') - .optional() - .custom(exists), - query('reinjectVideoFileToken') .optional() .customSanitizer(toBooleanOrNull) .isBoolean().withMessage('Should be a valid reinjectVideoFileToken boolean'), + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] + +export const privateHLSFileValidator = [ + param('videoUUID') + .custom(isUUIDValid), + + param('filename') + .custom(v => isSafeFilename(v)), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] + +export const ensureCanAccessPrivateVideoHLSFiles = [ + query('videoFileToken') + .optional() + .custom(exists), + isValidVideoPasswordHeader(), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -124,11 +146,6 @@ const ensureCanAccessPrivateVideoHLSFiles = [ } ] -export { - ensureCanAccessPrivateVideoHLSFiles, - ensureCanAccessVideoPrivateWebVideoFiles -} - // --------------------------------------------------------------------------- async function isWebVideoAllowed (req: express.Request, res: express.Response) { From d60983bea55abb446de2e178fe43c1a4d850a5cd Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 8 Apr 2025 08:35:40 +0200 Subject: [PATCH 8/9] Update changelog --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d78b890a7..2a3504484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## v7.1.1 + +### SECURITY + + * This version fixes important vulnerabilities, that will be detailed on Tuesday, April 15 + +### Bug fixes + + * Fix playlist page margins + * Fix danger button border + * Fix unsubscribe button label for channels + * Fix remote subscribe on iOS + * Add Podcast feed to subscribe button + * Always display technical information tab in *About* page + * Fix menu button auto font-size to prevent overflow in some locales + * Correctly inject multiple `rel="me"` links with supported markdown fields + * Fix adding studio watermark with audio/video split HLS file + * Reset video state on studio failure + * Fix updating a user in administration + * Fix error when getting a S3 object with some S3 providers + * Specify charset when uploading caption files in S3 + * Fix theme color parsing with some web browsers + * Improve channel description in custom markup miniature + * Ensure ffmpeg process is killed if download is aborted + * Correctly reload playlist on playlist change in watch page + * Use `indexifembedded` in embeds instead of `noindex` + * Fix extra space on links of remote comments + * Don't convert webp images to jpeg + + ## v7.1.0 ### IMPORTANT NOTES From b9c3a4837e6a5e5d790e55759e3cf2871df4f03c Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 8 Apr 2025 08:51:31 +0200 Subject: [PATCH 9/9] Bumped to version v7.1.1 --- client/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/package.json b/client/package.json index bee49065b..dc08ffda0 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "peertube-client", - "version": "7.1.0", + "version": "7.1.1", "private": true, "license": "AGPL-3.0", "author": { diff --git a/package.json b/package.json index 3599cf4ce..a632483e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "peertube", "description": "PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.", - "version": "7.1.0", + "version": "7.1.1", "private": true, "licence": "AGPL-3.0", "engines": {