Security Fix: mitigate ReDOS attacks on the chat bot.

This commit is contained in:
John Livingston 2025-06-06 16:37:06 +02:00
parent 98dc729447
commit 0be11fb2ae
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
9 changed files with 2068 additions and 55 deletions

View File

@ -2,14 +2,32 @@
## 13.0.0 (Not Released Yet)
### Security Fix
Severity: low.
[Radically Open Security](radicallyopensecurity.com) reported a security vulnerability: a malicious user can forge a malicious Regular Expression to cause a [ReDOS](https://en.wikipedia.org/wiki/ReDoS) on the Chat Bot.
Such attack would only make the bot unresponsive, and won't affect the Peertube server or the XMPP server.
This version mitigates the attack by using the [RE2](https://github.com/google/re2) regular expression library.
### Breaking changes
#### Bot timers
There was a regression some months ago in the "bot timer" functionnality.
In the channels settings, the delay between two quotes is supposed to be in minutes, but in fact we applied seconds.
We don't have any way to detect if the user meant seconds or minutes when they configured their channels (it depends if it was before or after the regression).
So we encourage all streamers to go through their channel settings, check the frequency of their bot timers (if enabled), set them to the correct value, and save the form.
Users must save the form to be sure to apply the correct value.
#### Bot forbidden words
When using regular expressions for the forbidden words, the chat bot now uses the [RE2](https://github.com/google/re2) regular expression library.
This library does not support all character classes, and all regular expressions that was previously possible (with the Javascript RegExp class).
If you configured such regular expressions, the bot will just ignore them, and log an error.
When saving channel's preference, if such regular expression is used, an error will be shown.
### Minor changes and fixes
* Translations updates.

View File

@ -192,6 +192,14 @@ export class ChannelDetailsService {
)
if (!response.ok) {
let e
try {
// checking if there are some json data in the response, with custom error message.
e = await response.json()
} catch (_err) {}
if (e?.validationErrorMessage && (typeof e.validationErrorMessage === 'string')) {
throw new Error('Failed to save configuration options: ' + e.validationErrorMessage)
}
throw new Error('Failed to save configuration options.')
}

1909
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -34,9 +34,10 @@
"http-proxy": "^1.18.1",
"log-rotate": "^0.2.8",
"openid-client": "^5.7.1",
"re2": "^1.22.1",
"short-uuid": "^5.2.0",
"validate-color": "^2.2.4",
"xmppjs-chat-bot": "^0.5.0"
"xmppjs-chat-bot": "^0.6.0"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
@ -54,6 +55,7 @@
"@types/got": "^9.6.12",
"@types/http-proxy": "^1.17.16",
"@types/node": "^16.18.126",
"@types/re2": "^1.10.0",
"@types/winston": "^2.4.4",
"@types/xmpp__jid": "^1.3.5",
"@typescript-eslint/parser": "^8.4.0",

View File

@ -11,6 +11,9 @@ import {
noDuplicateDefaultDelay,
noDuplicateMaxDelay
} from '../../../../shared/lib/constants'
import * as RE2 from 're2'
type SanitizeMode = 'validation' | 'read'
/**
* Sanitize data so that they can safely be used/stored for channel configuration configuration.
@ -19,11 +22,13 @@ import {
* @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
data: unknown,
mode: SanitizeMode
): Promise<ChannelConfigurationOptions> {
if (!_assertObjectType(data)) {
throw new Error('Invalid data type')
@ -91,7 +96,7 @@ async function sanitizeChannelConfigurationOptions (
bot: {
enabled: _readBoolean(botData, 'enabled'),
nickname: _readSimpleInput(botData, 'nickname', true),
forbiddenWords: await _readForbiddenWords(botData),
forbiddenWords: await _readForbiddenWords(botData, mode),
forbidSpecialChars: await _readForbidSpecialChars(botData),
noDuplicate: await _readNoDuplicate(botData),
quotes: _readQuotes(botData),
@ -201,7 +206,7 @@ function _readMultiLineString (data: Record<string, unknown>, f: string): string
return s
}
async function _readRegExpArray (data: Record<string, unknown>, f: string): Promise<string[]> {
async function _readRegExpArray (data: Record<string, unknown>, f: string, mode: SanitizeMode): 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)) {
@ -219,15 +224,28 @@ async function _readRegExpArray (data: Record<string, unknown>, f: string): Prom
// ignore empty values
continue
}
// value must be a valid regexp
// value must be a valid RE2 regexp
try {
async function _validate (v: string): Promise<void> {
// eslint-disable-next-line no-new
new RegExp(v)
// Before livechat v13, the bot was using RegExp.
// Now it is using RE2, to avoid ReDOS attacks.
// RE2 does not accept all regular expressions.
// So, here come the question about settings saved before...
// So we introduce the "mode" parameter.
// When reading from disk, we want to be more permissive.
// When validating frontend data, we want to be more restrictive.
// Note: the bot will simply ignore any invalid RE2 expression, and generate an error log on loading.
if (mode === 'read') {
// eslint-disable-next-line no-new
new RegExp(v)
} else {
// eslint-disable-next-line no-new, new-cap
new RE2.default(v)
}
}
await _validate(v)
} catch (_err) {
throw new Error('Invalid value in field ' + f)
} catch (err: any) {
throw new ChannelConfigurationValidationError('Invalid value in field ' + f, err.toString() as string)
}
result.push(v)
}
@ -235,7 +253,8 @@ async function _readRegExpArray (data: Record<string, unknown>, f: string): Prom
}
async function _readForbiddenWords (
botData: Record<string, unknown>
botData: Record<string, unknown>,
mode: SanitizeMode
): Promise<ChannelConfigurationOptions['bot']['forbiddenWords']> {
if (!Array.isArray(botData.forbiddenWords)) {
throw new Error('Invalid forbiddenWords data')
@ -248,7 +267,7 @@ async function _readForbiddenWords (
const regexp = !!fw.regexp
let entries
if (regexp) {
entries = await _readRegExpArray(fw, 'entries')
entries = await _readRegExpArray(fw, 'entries', mode)
} else {
entries = _readStringArray(fw, 'entries')
}
@ -339,6 +358,18 @@ function _readCommands (botData: Record<string, unknown>): ChannelConfigurationO
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
}

View File

@ -38,7 +38,7 @@ async function getChannelConfigurationOptions (
const content = await fs.promises.readFile(filePath, {
encoding: 'utf-8'
})
const sanitized = await sanitizeChannelConfigurationOptions(options, channelId, JSON.parse(content))
const sanitized = await sanitizeChannelConfigurationOptions(options, channelId, JSON.parse(content), 'read')
return sanitized
}
@ -262,6 +262,7 @@ function _getForbidSpecialCharsHandler (
name: id,
regexp,
modifiers: 'us',
regexp_engine: 'regexp', // FIXME: node-re2 is not compatible with \p{Emoji} and co, so we ensure to use RegExp here
reason: forbidSpecialChars.reason
}
handler.options.rules.push(rule)

View File

@ -0,0 +1,105 @@
// 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 * as path from 'path'
import * as fs from 'fs'
/**
* Livechat v13.0.0: now using xmppjs-chat-bot 0.6.0, which replaced RegExp by RE2.
* we must change the forbidspecialchar regexp configuration, to be compatible.
*
* This script will only be launched one time.
*/
async function updateForbidSpecialCharsHandler (options: RegisterServerOptions): Promise<void> {
const logger = options.peertubeHelpers.logger
// First, detect if we already run this script.
const doneFilePath = path.resolve(options.peertubeHelpers.plugin.getDataDirectoryPath(), 'fix-v13-forbidspecialchars')
if (fs.existsSync(doneFilePath)) {
logger.debug('[migratev13_ForbidSpecialChars] Special Chars Regex already updated.')
return
}
logger.info('[migratev13_ForbidSpecialChars] Updating Special Chars Regex')
const confDir = path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'bot',
)
// In this directory, we should find a subdir named as the mucDomain.
// To be sure to migrate everything, including in case of instance name change,
// we will loop on this dir content.
let directories: fs.Dirent[]
try {
directories = await fs.promises.readdir(confDir, { withFileTypes: true })
} catch (_err) {
logger.info('[migratev13_ForbidSpecialChars] can\'t read config dir, probably a fresh install.')
directories = []
}
for (const dirent of directories) {
if (!dirent.isDirectory()) { continue }
const dir = path.resolve(confDir, dirent.name, 'rooms')
logger.debug('[migratev13_ForbidSpecialChars] Checking directory ' + dir)
let files: string[]
try {
files = await fs.promises.readdir(dir)
} catch (_err) {
logger.info('[migratev13_ForbidSpecialChars] can\'t read dir ' + dir)
files = []
}
logger.debug('[migratev13_ForbidSpecialChars] Found ' + files.length.toString() + ' files.')
for (const file of files) {
if (!file.endsWith('.json')) { continue }
const filePath = path.join(dir, file)
try {
logger.debug('[migratev13_ForbidSpecialChars] check file ' + filePath)
const content = (await fs.promises.readFile(filePath, {
encoding: 'utf-8'
})).toString()
const config = JSON.parse(content)
const handlers = config?.handlers ?? []
let modified = false
for (const handler of handlers) {
if (handler?.type === 'moderate' && handler?.id === 'forbid_special_chars') {
for (const r of handler.options?.rules ?? []) {
if (r.name === 'forbid_special_chars') {
if (r.regexp_engine !== 'regexp') {
r.regexp_engine = 'regexp'
modified = true
}
}
}
}
}
if (modified) {
logger.info('[migratev13_ForbidSpecialChars] Must fix file ' + filePath)
await fs.promises.writeFile(filePath, JSON.stringify(config), {
encoding: 'utf-8'
})
}
} catch (err) {
logger.error(
'[migratev13_ForbidSpecialChars] Failed to fix file ' +
filePath + ', skipping. Error: ' + (err as string)
)
continue
}
}
}
await fs.promises.writeFile(doneFilePath, '')
}
export {
updateForbidSpecialCharsHandler
}

View File

@ -110,10 +110,17 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route
// req.body.bot.forbidSpecialChars.enabled = false
// ... NoDuplicate...
// }
channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body)
} catch (err) {
logger.warn(err)
res.sendStatus(400)
channelOptions = await sanitizeChannelConfigurationOptions(options, channelInfos.id, req.body, 'validation')
} catch (err: any) {
logger.warn(err.message as string)
if (err.validationErrorMessage && (typeof err.validationErrorMessage === 'string')) {
res.status(400)
res.json({
validationErrorMessage: err.validationErrorMessage
})
} else {
res.sendStatus(400)
}
return
}

View File

@ -19,6 +19,7 @@ import { BotsCtl } from './lib/bots/ctl'
import { ExternalAuthOIDC } from './lib/external-auth/oidc'
import { migrateMUCAffiliations } from './lib/prosody/migration/migrateV10'
import { updateProsodyChannelEmojisRegex } from './lib/prosody/migration/migrateV12'
import { updateForbidSpecialCharsHandler } from './lib/prosody/migration/migrateV13'
import { Emojis } from './lib/emojis'
import { LivechatProsodyAuth } from './lib/prosody/auth'
import decache from 'decache'
@ -38,6 +39,15 @@ async function register (options: RegisterServerOptions): Promise<any> {
// First: load languages files, so we can localize strings.
await loadLoc()
try {
// livechat v13 migration:
// we must change the config for forbidden special chars. We must do this before BotConfiguration.initSingleton.
await updateForbidSpecialCharsHandler(options)
} catch (err: any) {
logger.error(err)
}
// Then load the BotConfiguration singleton
await BotConfiguration.initSingleton(options)
// Then load the RoomChannel singleton