**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.
372 lines
12 KiB
TypeScript
372 lines
12 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 {
|
|
channelTermsMaxLength,
|
|
forbidSpecialCharsMaxTolerance,
|
|
forbidSpecialCharsDefaultTolerance,
|
|
noDuplicateDefaultDelay,
|
|
noDuplicateMaxDelay
|
|
} from '../../../../shared/lib/constants'
|
|
|
|
/**
|
|
* Sanitize data so that they can safely be used/stored for channel configuration configuration.
|
|
* Throw an error if the format is obviously wrong.
|
|
* Cleans data (removing empty values, ...)
|
|
* @param options Peertube server options
|
|
* @param _channelInfos Channel infos
|
|
* @param data Input data
|
|
* @param mode Sanitization mode. 'validation': when verifiying user input. 'read': when reading from disk.
|
|
*/
|
|
async function sanitizeChannelConfigurationOptions (
|
|
options: RegisterServerOptions,
|
|
_channelId: number | string,
|
|
data: unknown
|
|
): Promise<ChannelConfigurationOptions> {
|
|
if (!_assertObjectType(data)) {
|
|
throw new Error('Invalid data type')
|
|
}
|
|
|
|
const botData = data.bot ?? {}
|
|
if (!_assertObjectType(botData)) {
|
|
throw new Error('Invalid data.bot data type')
|
|
}
|
|
|
|
// slowMode not present in livechat <= 8.2.0:
|
|
const slowModeData = data.slowMode ?? {}
|
|
if (!_assertObjectType(slowModeData)) {
|
|
throw new Error('Invalid data.slowMode data type')
|
|
}
|
|
slowModeData.duration ??= slowModeData.defaultDuration ?? 0 // v8.3.0 to 8.3.2: was in defaultDuration
|
|
|
|
const moderationData = data.moderation ?? {} // comes with livechat 10.3.0
|
|
if (!_assertObjectType(moderationData)) {
|
|
throw new Error('Invalid data.moderation data type')
|
|
}
|
|
moderationData.delay ??= 0
|
|
moderationData.anonymize ??= false // comes with livechat 11.0.0
|
|
|
|
// mute not present in livechat <= 10.2.0
|
|
const mute = data.mute ?? {}
|
|
if (!_assertObjectType(mute)) {
|
|
throw new Error('Invalid data.mute data type')
|
|
}
|
|
mute.anonymous ??= false
|
|
|
|
// forbidSpecialChars comes with livechat 12.0.0
|
|
botData.forbidSpecialChars ??= {
|
|
enabled: false,
|
|
reason: '',
|
|
tolerance: forbidSpecialCharsDefaultTolerance,
|
|
applyToModerators: false
|
|
}
|
|
if (!_assertObjectType(botData.forbidSpecialChars)) {
|
|
throw new Error('Invalid data.bot.forbidSpecialChars data type')
|
|
}
|
|
|
|
// noDuplicate comes with livechat 12.0.0
|
|
botData.noDuplicate ??= {
|
|
enabled: false,
|
|
reason: '',
|
|
delay: noDuplicateDefaultDelay,
|
|
applyToModerators: false
|
|
}
|
|
if (!_assertObjectType(botData.noDuplicate)) {
|
|
throw new Error('Invalid data.bot.noDuplicate data type')
|
|
}
|
|
|
|
// terms not present in livechat <= 10.2.0
|
|
let terms = data.terms ?? undefined
|
|
if (terms !== undefined && (typeof terms !== 'string')) {
|
|
throw new Error('Invalid data.terms data type')
|
|
}
|
|
if (terms && terms.length > channelTermsMaxLength) {
|
|
throw new Error('data.terms value too long')
|
|
}
|
|
if (terms === '') { terms = undefined }
|
|
|
|
const result: ChannelConfigurationOptions = {
|
|
bot: {
|
|
enabled: _readBoolean(botData, 'enabled'),
|
|
nickname: _readSimpleInput(botData, 'nickname', true),
|
|
forbiddenWords: await _readForbiddenWords(options, botData),
|
|
forbidSpecialChars: await _readForbidSpecialChars(botData),
|
|
noDuplicate: await _readNoDuplicate(botData),
|
|
quotes: _readQuotes(botData),
|
|
commands: _readCommands(botData)
|
|
// TODO: bannedJIDs
|
|
},
|
|
slowMode: {
|
|
duration: _readInteger(slowModeData, 'duration', 0, 1000)
|
|
},
|
|
mute: {
|
|
anonymous: _readBoolean(mute, 'anonymous')
|
|
},
|
|
moderation: {
|
|
delay: _readInteger(moderationData, 'delay', 0, 60),
|
|
anonymize: _readBoolean(moderationData, 'anonymize')
|
|
}
|
|
}
|
|
if (terms !== undefined) {
|
|
result.terms = terms
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function _assertObjectType (data: unknown): data is Record<string, unknown> {
|
|
return !!data && (typeof data === 'object') && Object.keys(data).every(k => typeof k === 'string')
|
|
}
|
|
|
|
function _readBoolean (data: Record<string, unknown>, f: string): boolean {
|
|
if (!(f in data)) {
|
|
return false
|
|
}
|
|
if (typeof data[f] !== 'boolean') {
|
|
throw new Error('Invalid data type for field ' + f)
|
|
}
|
|
return data[f]
|
|
}
|
|
|
|
function _readInteger (data: Record<string, unknown>, f: string, min: number, max: number): number {
|
|
if (!(f in data)) {
|
|
throw new Error('Missing integer value for field ' + f)
|
|
}
|
|
const v = typeof data[f] === 'number' ? Math.trunc(data[f]) : parseInt(data[f] as string)
|
|
if (isNaN(v)) {
|
|
throw new Error('Invalid value type for field ' + f)
|
|
}
|
|
if (v < min) {
|
|
throw new Error('Invalid value type (<min) for field ' + f)
|
|
}
|
|
if (v > max) {
|
|
throw new Error('Invalid value type (>max) for field ' + f)
|
|
}
|
|
return v
|
|
}
|
|
|
|
function _readSimpleInput (data: Record<string, unknown>, f: string, strict?: boolean, noSpace?: boolean): string {
|
|
if (!(f in data)) {
|
|
return ''
|
|
}
|
|
if (typeof data[f] !== 'string') {
|
|
throw new Error('Invalid data type for field ' + f)
|
|
}
|
|
// Removing control characters.
|
|
// eslint-disable-next-line no-control-regex
|
|
let s = data[f].replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
|
|
if (strict) {
|
|
// Replacing all invalid characters, no need to throw an error..
|
|
s = s.replace(/[^\p{L}\p{N}\p{Z}_-]$/gu, '')
|
|
}
|
|
if (noSpace) {
|
|
s = s.replace(/\s+/g, '')
|
|
}
|
|
return s
|
|
}
|
|
|
|
function _readStringArray (data: Record<string, unknown>, f: string): string[] {
|
|
if (!(f in data)) {
|
|
return []
|
|
}
|
|
if (!Array.isArray(data[f])) {
|
|
throw new Error('Invalid data type for field ' + f)
|
|
}
|
|
const result: string[] = []
|
|
for (const v of data[f]) {
|
|
if (typeof v !== 'string') {
|
|
throw new Error('Invalid data type in a value of field ' + f)
|
|
}
|
|
if (v === '' || /^\s+$/.test(v)) {
|
|
// ignore empty values
|
|
continue
|
|
}
|
|
result.push(v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function _readMultiLineString (data: Record<string, unknown>, f: string): string {
|
|
if (!(f in data)) {
|
|
return ''
|
|
}
|
|
if (typeof data[f] !== 'string') {
|
|
throw new Error('Invalid data type for field ' + f)
|
|
}
|
|
// Removing control characters (must authorize \u001A: line feed)
|
|
// eslint-disable-next-line no-control-regex
|
|
const s = data[f].replace(/[\u0000-\u0009\u001B-\u001F\u007F-\u009F]/g, '')
|
|
return s
|
|
}
|
|
|
|
async function _readRegExpArray (data: Record<string, unknown>, f: string): Promise<string[]> {
|
|
// Note: this function can instanciate a lot of RegExp.
|
|
// To avoid freezing the server, we make it async, and will validate each regexp in a separate tick.
|
|
if (!(f in data)) {
|
|
return []
|
|
}
|
|
if (!Array.isArray(data[f])) {
|
|
throw new Error('Invalid data type for field ' + f)
|
|
}
|
|
const result: string[] = []
|
|
for (const v of data[f]) {
|
|
if (typeof v !== 'string') {
|
|
throw new Error('Invalid data type in a value of field ' + f)
|
|
}
|
|
if (v === '' || /^\s+$/.test(v)) {
|
|
// ignore empty values
|
|
continue
|
|
}
|
|
// value must be a valid regexp
|
|
try {
|
|
async function _validate (v: string): Promise<void> {
|
|
// eslint-disable-next-line no-new
|
|
new RegExp(v)
|
|
}
|
|
await _validate(v)
|
|
} catch (err: any) {
|
|
throw new ChannelConfigurationValidationError('Invalid value in field ' + f, err.toString() as string)
|
|
}
|
|
result.push(v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function _readForbiddenWords (
|
|
options: RegisterServerOptions,
|
|
botData: Record<string, unknown>
|
|
): Promise<ChannelConfigurationOptions['bot']['forbiddenWords']> {
|
|
const enableUsersRegexp = (await options.settingsManager.getSetting('enable-users-regexp')) === true
|
|
|
|
if (!Array.isArray(botData.forbiddenWords)) {
|
|
throw new Error('Invalid forbiddenWords data')
|
|
}
|
|
const result: ChannelConfigurationOptions['bot']['forbiddenWords'] = []
|
|
for (const fw of botData.forbiddenWords) {
|
|
if (!_assertObjectType(fw)) {
|
|
throw new Error('Invalid entry in botData.forbiddenWords')
|
|
}
|
|
const regexp = !!fw.regexp
|
|
|
|
let entries
|
|
if (regexp) {
|
|
entries = await _readRegExpArray(fw, 'entries')
|
|
} else {
|
|
entries = _readStringArray(fw, 'entries')
|
|
}
|
|
const applyToModerators = _readBoolean(fw, 'applyToModerators')
|
|
const label = fw.label ? _readSimpleInput(fw, 'label') : undefined
|
|
const reason = fw.reason ? _readSimpleInput(fw, 'reason') : undefined
|
|
const comments = fw.comments ? _readMultiLineString(fw, 'comments') : undefined
|
|
|
|
// Enabled was introduced in v14. So we must set to true if not present.
|
|
let enabled = !('enabled' in fw) ? true : _readBoolean(fw, 'enabled')
|
|
if (enabled && regexp && !enableUsersRegexp) {
|
|
// here we don't fail, we just change the value.
|
|
// This is usefull when the settings changes:
|
|
// RoomChannel.singleton().rebuildData() will automatically update data.
|
|
enabled = false
|
|
}
|
|
|
|
result.push({
|
|
enabled,
|
|
regexp,
|
|
entries,
|
|
applyToModerators,
|
|
label,
|
|
reason,
|
|
comments
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function _readForbidSpecialChars (
|
|
botData: Record<string, unknown>
|
|
): Promise<ChannelConfigurationOptions['bot']['forbidSpecialChars']> {
|
|
if (!_assertObjectType(botData.forbidSpecialChars)) {
|
|
throw new Error('Invalid forbidSpecialChars data')
|
|
}
|
|
const result: ChannelConfigurationOptions['bot']['forbidSpecialChars'] = {
|
|
enabled: _readBoolean(botData.forbidSpecialChars, 'enabled'),
|
|
reason: _readSimpleInput(botData.forbidSpecialChars, 'reason'),
|
|
tolerance: _readInteger(botData.forbidSpecialChars, 'tolerance', 0, forbidSpecialCharsMaxTolerance),
|
|
applyToModerators: _readBoolean(botData.forbidSpecialChars, 'applyToModerators')
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function _readNoDuplicate (
|
|
botData: Record<string, unknown>
|
|
): Promise<ChannelConfigurationOptions['bot']['noDuplicate']> {
|
|
if (!_assertObjectType(botData.noDuplicate)) {
|
|
throw new Error('Invalid forbidSpecialChars data')
|
|
}
|
|
const result: ChannelConfigurationOptions['bot']['noDuplicate'] = {
|
|
enabled: _readBoolean(botData.noDuplicate, 'enabled'),
|
|
reason: _readSimpleInput(botData.noDuplicate, 'reason'),
|
|
delay: _readInteger(botData.noDuplicate, 'delay', 0, noDuplicateMaxDelay),
|
|
applyToModerators: _readBoolean(botData.noDuplicate, 'applyToModerators')
|
|
}
|
|
return result
|
|
}
|
|
|
|
function _readQuotes (botData: Record<string, unknown>): ChannelConfigurationOptions['bot']['quotes'] {
|
|
if (!Array.isArray(botData.quotes)) {
|
|
throw new Error('Invalid quotes data')
|
|
}
|
|
const result: ChannelConfigurationOptions['bot']['quotes'] = []
|
|
for (const qs of botData.quotes) {
|
|
if (!_assertObjectType(qs)) {
|
|
throw new Error('Invalid entry in botData.quotes')
|
|
}
|
|
const messages = _readStringArray(qs, 'messages')
|
|
const delay = _readInteger(qs, 'delay', 1, 6000)
|
|
|
|
result.push({
|
|
messages,
|
|
delay
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
function _readCommands (botData: Record<string, unknown>): ChannelConfigurationOptions['bot']['commands'] {
|
|
if (!Array.isArray(botData.commands)) {
|
|
throw new Error('Invalid commands data')
|
|
}
|
|
const result: ChannelConfigurationOptions['bot']['commands'] = []
|
|
for (const cs of botData.commands) {
|
|
if (!_assertObjectType(cs)) {
|
|
throw new Error('Invalid entry in botData.commands')
|
|
}
|
|
const message = _readSimpleInput(cs, 'message')
|
|
const command = _readSimpleInput(cs, 'command', false, true)
|
|
|
|
result.push({
|
|
message,
|
|
command
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
class ChannelConfigurationValidationError extends Error {
|
|
/**
|
|
* The message for the frontend.
|
|
*/
|
|
public validationErrorMessage: string
|
|
|
|
constructor (message: string | undefined, validationErrorMessage: string) {
|
|
super(message)
|
|
this.validationErrorMessage = validationErrorMessage
|
|
}
|
|
}
|
|
|
|
export {
|
|
sanitizeChannelConfigurationOptions
|
|
}
|