Security Fix: mitigate ReDOS attacks on the chat bot.
This commit is contained in:
parent
98dc729447
commit
0be11fb2ae
18
CHANGELOG.md
18
CHANGELOG.md
@ -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.
|
||||
|
@ -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
1909
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
105
server/lib/prosody/migration/migrateV13.ts
Normal file
105
server/lib/prosody/migration/migrateV13.ts
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user