// SPDX-FileCopyrightText: 2024 John Livingston // // SPDX-License-Identifier: AGPL-3.0-only import type { RegisterServerOptions } from '@peertube/peertube-types' import type { LiveChatJSONLDAttributeV1, PeertubeXMPPServerInfos } from './types' import { URL } from 'url' /** * Use this function for incoming remote informations. * It will sanitize them, by checking everything is ok. * It can also migrate from old format to the new one. * * This function can be used when informations are incoming, * or when reading stored information (to automatically migrate them) * * @param options server options * @param chatInfos remote chat informations * @param referenceUrl optional url string. If given, we must check that urls are on the same domain, to avoid spoofing. * @returns a sanitized version of the remote chat informations */ function sanitizePeertubeLiveChatInfos ( options: RegisterServerOptions, chatInfos: any, referenceUrl?: string ): LiveChatJSONLDAttributeV1 { if (chatInfos === false) { return false } if (typeof chatInfos !== 'object') { return false } if (chatInfos.type !== 'xmpp') { return false } if ((typeof chatInfos.jid) !== 'string') { return false } if (!('xmppserver' in chatInfos)) { // V0 format, migrating on the fly to v1. return _sanitizePeertubeLiveChatInfosV0(options, chatInfos, referenceUrl) } if (!chatInfos.xmppserver || (typeof chatInfos.xmppserver !== 'object')) { return false } const xmppserver = sanitizePeertubeLiveChatServerInfos(options, chatInfos.xmppserver, referenceUrl) if (!xmppserver) { return false } let customEmojisUrl: string | undefined if (('customEmojisUrl' in chatInfos) && chatInfos.customEmojisUrl) { customEmojisUrl = sanitizeCustomEmojisUrl(options, chatInfos.customEmojisUrl, referenceUrl) } const r: LiveChatJSONLDAttributeV1 = { type: chatInfos.type, jid: chatInfos.jid, xmppserver, customEmojisUrl } return r } /** * Use this function for incoming remote server informations. * It will sanitize them, by checking everything is ok. * It can also migrate from old format to the new one. * * This function can be used when informations are incoming, * or when reading stored information (to automatically migrate them) * @param options server options * @param xmppserver remote server information * @param referenceUrl optional url string. If given, we must check that urls are on the same domain, to avoid spoofing. * @returns a sanitized version of remote server information (or false if not valid) */ function sanitizePeertubeLiveChatServerInfos ( options: RegisterServerOptions, xmppserver: any, referenceUrl?: string ): PeertubeXMPPServerInfos | false { if (!xmppserver || (typeof xmppserver !== 'object')) { return false } let checkHost: undefined | string if (referenceUrl) { checkHost = _readReferenceUrl(referenceUrl) if (!checkHost) { options.peertubeHelpers.logger.error( 'sanitizePeertubeLiveChatServerInfos: got an invalid referenceUrl: ' + referenceUrl ) return false } } if ((typeof xmppserver.host) !== 'string') { return false } const host = _validateHost(xmppserver.host, checkHost) if (!host) { return false } if ((typeof xmppserver.muc) !== 'string') { return false } const muc = _validateHost(xmppserver.muc, checkHost) if (!muc) { return false } const r: PeertubeXMPPServerInfos = { host, muc } // This comes with livechat >= 9.0.0, can be absent. const external = _validateHost(xmppserver.external, checkHost) if (external) { r.external = external } if (xmppserver.directs2s) { if ((typeof xmppserver.directs2s) === 'object') { const port = xmppserver.directs2s.port if ((typeof port === 'string') && /^\d+$/.test(port)) { r.directs2s = { port } } } } if (xmppserver.websockets2s) { if ((typeof xmppserver.websockets2s) === 'object') { const url = xmppserver.websockets2s.url if ((typeof url === 'string') && _validUrl(url, { noSearchParams: true, protocol: 'ws.', domain: checkHost })) { r.websockets2s = { url } } } } if (xmppserver.anonymous) { const virtualhost = _validateHost(xmppserver.anonymous.virtualhost, checkHost) if (virtualhost) { r.anonymous = { virtualhost } const bosh = xmppserver.anonymous.bosh if ((typeof bosh === 'string') && _validUrl(bosh, { noSearchParams: true, protocol: 'http.', domain: checkHost })) { r.anonymous.bosh = bosh } const websocket = xmppserver.anonymous.websocket if ((typeof websocket === 'string') && _validUrl(websocket, { noSearchParams: true, protocol: 'ws.', domain: checkHost })) { r.anonymous.websocket = websocket } } } return r } /** * Use this function for incoming custom emojis definition url. * It will sanitize them, by checking everything is ok. * * @param options server options * @param customEmojisUrl the value to test. * @param referenceUrl optional url string. If given, we must check that urls are on the same domain, to avoid spoofing. * @returns the url if valid, else undefined. */ function sanitizeCustomEmojisUrl ( options: RegisterServerOptions, customEmojisUrl: any, referenceUrl?: string ): string | undefined { let checkHost: undefined | string if (referenceUrl) { checkHost = _readReferenceUrl(referenceUrl) if (!checkHost) { options.peertubeHelpers.logger.error( 'sanitizeCustomEmojisUrl: got an invalid referenceUrl: ' + referenceUrl ) return undefined } } if ((typeof customEmojisUrl) !== 'string') { return undefined } if ( !_validUrl(customEmojisUrl, { noSearchParams: true, protocol: 'http.', domain: checkHost }) ) { return undefined } // No further verification. The frontend must use this url carefully (should only get JSON data). return customEmojisUrl } interface URLConstraints { protocol: 'http.' | 'ws.' noSearchParams: boolean domain?: string } function _validUrl (s: string, constraints: URLConstraints): boolean { if ((typeof s) !== 'string') { return false } if (s === '') { return false } let url: URL try { url = new URL(s) } catch (_err) { return false } if (constraints.protocol) { if (constraints.protocol === 'http.') { if (url.protocol !== 'https:' && url.protocol !== 'http:') { return false } } else if (constraints.protocol === 'ws.') { if (url.protocol !== 'wss:' && url.protocol !== 'ws:') { return false } } } if (constraints.noSearchParams) { if (url.search !== '') { return false } } if (constraints.domain) { if (url.hostname !== constraints.domain) { return false } } return true } function _validateHost (s: any, mustBeSubDomainOf?: string): false | string { try { if (typeof s !== 'string') { return false } if (s.includes('/')) { return false } const url = new URL('http://' + s) const hostname = url.hostname if (mustBeSubDomainOf && hostname !== mustBeSubDomainOf) { const parts = hostname.split('.') if (parts.length <= 2) { return false } parts.shift() if (parts.join('.') !== mustBeSubDomainOf) { return false } } return hostname } catch (_err) { return false } } function _readReferenceUrl (s: any): undefined | string { try { if (typeof s !== 'string') { return undefined } if (!s.startsWith('https://') && !s.startsWith('http://')) { s = 'http://' + s } const url = new URL(s) const host = url.hostname // Just to avoid some basic spoofing, we must check that host is not simply something like "com". // We will check if there is at least one dot. This test is not perfect, but can limit spoofing cases. if (!host.includes('.')) { return undefined } return host } catch (_err) { return undefined } } function _sanitizePeertubeLiveChatInfosV0 ( options: RegisterServerOptions, chatInfos: any, referenceUrl?: string ): LiveChatJSONLDAttributeV1 { const logger = options.peertubeHelpers.logger logger.debug('We are have to migrate data from the old JSONLD format') if (chatInfos === false) { return false } if (typeof chatInfos !== 'object') { return false } if (chatInfos.type !== 'xmpp') { return false } if ((typeof chatInfos.jid) !== 'string') { return false } // no link? invalid! dropping all. if (!Array.isArray(chatInfos.links)) { return false } let checkHost: undefined | string if (referenceUrl) { checkHost = _readReferenceUrl(referenceUrl) if (!checkHost) { options.peertubeHelpers.logger.error( '_sanitizePeertubeLiveChatInfosV0: got an invalid referenceUrl: ' + referenceUrl ) return false } } const muc = _validateHost(chatInfos.jid.split('@')[1], checkHost) if (!muc) { return false } if (!muc.startsWith('room.')) { logger.error('We expected old format host to begin with "room.". Discarding.') return false } const host = _validateHost(muc.replace(/^room\./, ''), checkHost) if (!host) { return false } const r: LiveChatJSONLDAttributeV1 = { type: chatInfos.type, jid: chatInfos.jid, xmppserver: { host, muc } } for (const link of chatInfos.links) { if ((typeof link) !== 'object') { continue } if (['xmpp-bosh-anonymous', 'xmpp-websocket-anonymous'].includes(link.type)) { if ((typeof link.jid) !== 'string') { continue } if ((typeof link.url) !== 'string') { continue } if ( !_validUrl(link.url, { noSearchParams: true, protocol: link.type === 'xmpp-websocket-anonymous' ? 'ws.' : 'http.', domain: checkHost }) ) { continue } if (!r.xmppserver.anonymous) { r.xmppserver.anonymous = { virtualhost: link.jid } } if (link.type === 'xmpp-bosh-anonymous') { r.xmppserver.anonymous.bosh = link.url } else if (link.type === 'xmpp-websocket-anonymous') { r.xmppserver.anonymous.websocket = link.url } } } return r } function sanitizeXMPPHost (options: RegisterServerOptions, host: any): false | string { return _validateHost(host) } function sanitizeXMPPHostFromInstanceUrl (_options: RegisterServerOptions, s: any): false | string { try { if (typeof s !== 'string') { return false } const url = new URL(s) return url.hostname } catch (_err) { return false } } export { sanitizePeertubeLiveChatInfos, sanitizePeertubeLiveChatServerInfos, sanitizeXMPPHost, sanitizeXMPPHostFromInstanceUrl }