Files
peertube-plugin-livechat/server/lib/configuration/channel/storage.ts
John Livingston 3624dd5c3c Reverting usage of RE2 (WIP):
**Breaking changes**

The livechat v13 introduced a new library to handle regular expressions in forbidden words, to avoid
[ReDOS](https://en.wikipedia.org/wiki/ReDoS) attacks.
Unfortunately, this library was not able to install itself properly on some systems, and some admins were not able
to install the livechat plugin.

That's why we have disabled this library in v14, and introduce a new settings to enable regexp in forbidden words.
By default this settings is disabled, and your users won't be able to use regexp in their forbidden words.

The risk by enabling this feature is that a malicious user could cause a denial of service for the chat bot, by using a
special crafted regular expression in their channel options, and sending a special crafter message in one of their
rooms. If you trust your users (those who have rights to livestream), you can enable the settings. Otherwise it is not
recommanded. See the documentation for more informations.

**Minor changes and fixes**

* Channel's forbidden words: new "enable" column.
* New settings to enable regular expressions for channel forbidden words.
* "Channel advanced configuration" settings: removing the "experimental feature" label.
2025-06-19 17:11:13 +02:00

371 lines
11 KiB
TypeScript

// SPDX-FileCopyrightText: 2024-2025 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { ChannelConfigurationOptions } from '../../../../shared/lib/types'
import type { ChannelCommonRoomConf } from '../../configuration/bot'
import { RoomChannel } from '../../room-channel'
import { sanitizeChannelConfigurationOptions } from '../../configuration/channel/sanitize'
import {
forbidSpecialCharsDefaultTolerance,
noDuplicateDefaultDelay
} from '../../../../shared/lib/constants'
import * as fs from 'fs'
import * as path from 'path'
// FIXME: should be exported by xmppjs-chat-bot
type ConfigHandlers = ChannelCommonRoomConf['handlers']
type ConfigHandler = ConfigHandlers[0]
/**
* Get saved configuration options for the given channel.
* Can throw an exception.
* @param options Peertube server options
* @param channelInfos Info from channel from which we want to get infos
* @returns Channel configuration data, or null if nothing is stored
*/
async function getChannelConfigurationOptions (
options: RegisterServerOptions,
channelId: number | string
): Promise<ChannelConfigurationOptions | null> {
const logger = options.peertubeHelpers.logger
const filePath = _getFilePath(options, channelId)
if (!fs.existsSync(filePath)) {
logger.debug('No stored data for channel, returning null')
return null
}
const content = await fs.promises.readFile(filePath, {
encoding: 'utf-8'
})
const sanitized = await sanitizeChannelConfigurationOptions(options, channelId, JSON.parse(content))
return sanitized
}
function getDefaultChannelConfigurationOptions (_options: RegisterServerOptions): ChannelConfigurationOptions {
return {
bot: {
enabled: false,
nickname: 'Sepia',
forbiddenWords: [],
forbidSpecialChars: {
enabled: false,
reason: '',
tolerance: forbidSpecialCharsDefaultTolerance,
applyToModerators: false
},
noDuplicate: {
enabled: false,
reason: '',
delay: noDuplicateDefaultDelay,
applyToModerators: false
},
quotes: [],
commands: []
},
slowMode: {
duration: 0
},
mute: {
anonymous: false
},
moderation: {
delay: 0,
anonymize: false
},
terms: undefined
}
}
/**
* Save channel configuration options.
* Can throw an exception.
* @param options Peertube server options
* @param ChannelConfigurationOptions data to save
*/
async function storeChannelConfigurationOptions (
options: RegisterServerOptions,
channelId: number | string,
channelConfigurationOptions: ChannelConfigurationOptions
): Promise<void> {
const filePath = _getFilePath(options, channelId)
if (!fs.existsSync(filePath)) {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
const jsonContent = JSON.stringify(channelConfigurationOptions)
await fs.promises.writeFile(filePath, jsonContent, {
encoding: 'utf-8'
})
RoomChannel.singleton().refreshChannelConfigurationOptions(channelId)
}
/**
* Converts the channel configuration to the bot room configuration object (minus the room JID and domain)
* @param options server options
* @param channelConfigurationOptions The channel configuration
* @param previousRoomConf the previous saved room conf, if available. Used to merge handlers.
* @returns Partial bot room configuration
*/
function channelConfigurationOptionsToBotRoomConf (
options: RegisterServerOptions,
channelConfigurationOptions: ChannelConfigurationOptions,
previousRoomConf: ChannelCommonRoomConf | null
): ChannelCommonRoomConf {
// Note concerning handlers:
// If we want the bot to correctly enable/disable the handlers,
// we must always define all handlers, even if not used.
// That's why we are gathering handlers ids in handlersId, and disabling missing handlers at the end of this function.
const handlersIds: Map<string, true> = new Map<string, true>()
const handlers: ConfigHandlers = []
channelConfigurationOptions.bot.forbiddenWords.forEach((v, i) => {
const id = 'forbidden_words_' + i.toString()
handlersIds.set(id, true)
handlers.push(_getForbiddenWordsHandler(id, v))
})
if (channelConfigurationOptions.bot.forbidSpecialChars.enabled) {
const id = 'forbid_special_chars'
handlersIds.set(id, true)
handlers.push(_getForbidSpecialCharsHandler(id, channelConfigurationOptions.bot.forbidSpecialChars))
}
if (channelConfigurationOptions.bot.noDuplicate.enabled) {
const id = 'no_duplicate'
handlersIds.set(id, true)
handlers.push(_getNoDuplicateHandler(id, channelConfigurationOptions.bot.noDuplicate))
}
channelConfigurationOptions.bot.quotes.forEach((v, i) => {
const id = 'quote_' + i.toString()
handlersIds.set(id, true)
handlers.push(_getQuotesHandler(id, v))
})
channelConfigurationOptions.bot.commands.forEach((v, i) => {
const id = 'command_' + i.toString()
handlersIds.set(id, true)
handlers.push(_getCommandsHandler(id, v))
})
// Disabling missing handlers:
if (previousRoomConf) {
for (const handler of previousRoomConf.handlers) {
if (!handlersIds.has(handler.id)) {
// cloning to avoid issues...
const disabledHandler = JSON.parse(JSON.stringify(handler)) as typeof handler
disabledHandler.enabled = false
handlers.push(disabledHandler)
}
}
}
const roomConf: ChannelCommonRoomConf = {
enabled: channelConfigurationOptions.bot.enabled,
handlers
}
if (channelConfigurationOptions.bot.nickname && channelConfigurationOptions.bot.nickname !== '') {
roomConf.nick = channelConfigurationOptions.bot.nickname
}
return roomConf
}
function _getForbiddenWordsHandler (
id: string,
forbiddenWords: ChannelConfigurationOptions['bot']['forbiddenWords'][0]
): ConfigHandler {
const handler: ConfigHandler = {
type: 'moderate',
id,
enabled: false,
options: {
rules: []
}
}
if (forbiddenWords.entries.length === 0) {
return handler
}
handler.enabled = forbiddenWords.enabled
const rule: any = {
name: id
}
if (forbiddenWords.regexp) {
// Note: on the Peertube frontend, channelConfigurationOptions.forbiddenWords
// is an array of RegExp definition (strings).
// They are validated one by bone.
// To increase the bot performance, we will join them all (hopping the bot will optimize them).
rule.regexp = '(?:' + forbiddenWords.entries.join(')|(?:') + ')'
} else {
// Here we must add word-breaks and escape entries.
// We join all entries in one Regexp (for the same reason as above).
rule.regexp = '(?:' + forbiddenWords.entries.map(
s => {
s = _stringToWordRegexp(s)
// Must add the \b...
// ... but... won't work if the first (or last) char is an emoji.
// So, doing this trick:
if (/^\w/.test(s)) {
s = '\\b' + s
}
if (/\w$/.test(s)) {
s = s + '\\b'
}
// FIXME: this solution wont work for non-latin charsets.
return s
}
).join(')|(?:') + ')'
}
if (forbiddenWords.reason) {
rule.reason = forbiddenWords.reason
}
handler.options.rules.push(rule)
handler.options.applyToModerators = !!forbiddenWords.applyToModerators
return handler
}
function _getForbidSpecialCharsHandler (
id: string,
forbidSpecialChars: ChannelConfigurationOptions['bot']['forbidSpecialChars']
): ConfigHandler {
const handler: ConfigHandler = {
type: 'moderate',
id,
enabled: true,
options: {
rules: []
}
}
// The regexp to find one invalid character:
// (Note: Emoji_Modifier and Emoji_Component should not be matched alones, but seems a reasonnable compromise to avoid
// complex regex).
let regexp = '[^' +
'\\s\\p{Letter}\\p{Number}\\p{Punctuation}\\p{Currency_Symbol}\\p{Emoji}\\p{Emoji_Component}\\p{Emoji_Modifier}' +
']'
if (forbidSpecialChars.tolerance > 0) {
// we must repeat !
const a = []
for (let i = 0; i <= forbidSpecialChars.tolerance; i++) { // N+1 values
a.push(regexp)
}
regexp = a.join('.*')
}
const rule: any = {
name: id,
regexp,
modifiers: 'us',
reason: forbidSpecialChars.reason
}
handler.options.rules.push(rule)
handler.options.applyToModerators = !!forbidSpecialChars.applyToModerators
return handler
}
function _getNoDuplicateHandler (
id: string,
noDuplicate: ChannelConfigurationOptions['bot']['noDuplicate']
): ConfigHandler {
const handler: ConfigHandler = {
type: 'no-duplicate',
id,
enabled: true,
options: {
reason: noDuplicate.reason,
delay: noDuplicate.delay,
applyToModerators: !!noDuplicate.applyToModerators
}
}
return handler
}
function _getQuotesHandler (
id: string,
quotes: ChannelConfigurationOptions['bot']['quotes'][0]
): ConfigHandler {
const handler: ConfigHandler = {
type: 'quotes_random',
id,
enabled: false,
options: {
quotes: [],
delay: 5 * 60
}
}
if (quotes.messages.length === 0) {
return handler
}
handler.enabled = true
handler.options.quotes = quotes.messages
handler.options.delay = quotes.delay
return handler
}
function _getCommandsHandler (
id: string,
command: ChannelConfigurationOptions['bot']['commands'][0]
): ConfigHandler {
const handler: ConfigHandler = {
type: 'command_say',
id,
enabled: false,
options: {
quotes: [],
command: 'undefined' // This is arbitrary, and does not matter as enabled=false
}
}
if (!command.message || command.message === '') {
return handler
}
handler.enabled = true
handler.options.command = command.command
handler.options.quotes = [command.message]
return handler
}
const stringToWordRegexpSpecials = [
// order matters for these
'-', '[', ']',
// order doesn't matter for any of these
'/', '{', '}', '(', ')', '*', '+', '?', '.', '\\', '^', '$', '|'
]
// I choose to escape every character with '\'
// even though only some strictly require it when inside of []
const stringToWordRegexp = RegExp('[' + stringToWordRegexpSpecials.join('\\') + ']', 'g')
function _stringToWordRegexp (s: string): string {
return s.replace(stringToWordRegexp, '\\$&')
}
function _getFilePath (
options: RegisterServerOptions,
channelId: number | string
): string {
// some sanitization, just in case...
channelId = parseInt(channelId.toString())
if (isNaN(channelId)) {
throw new Error(`Invalid channelId: ${channelId}`)
}
return path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'channelConfigurationOptions',
channelId.toString() + '.json'
)
}
export {
getChannelConfigurationOptions,
getDefaultChannelConfigurationOptions,
channelConfigurationOptionsToBotRoomConf,
storeChannelConfigurationOptions
}