New option to use and configure Prosody mod_firewall WIP (#97):

* new setting
* new configuration screen for Peertube admins
* include the mod_firewall module
* load mod_firewall if enabled
* sys admin can disable the firewall config editing by creating a
  special file on the disk
* user documentation
This commit is contained in:
John Livingston
2024-08-12 18:17:31 +02:00
parent 481f265a44
commit 8e99199f29
76 changed files with 7577 additions and 300 deletions

View File

@ -0,0 +1,210 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { AdminFirewallConfiguration } from '../../../shared/lib/types'
import * as path from 'path'
import * as fs from 'fs'
import {
firewallNameRegexp, maxFirewallFileSize, maxFirewallFiles, maxFirewallNameLength
} from '../../../shared/lib/admin-firewall'
/**
* Indicates if the firewall configuration can be changed in the Peertube web interface.
* Sys admins can disable this feature by creating a special file in the plugin folder.
* @param options Peertube server options
*/
export async function canEditFirewallConfig (options: RegisterServerOptions): Promise<boolean> {
const peertubeHelpers = options.peertubeHelpers
const logger = peertubeHelpers.logger
if (!peertubeHelpers.plugin) {
return false
}
const filepath = path.resolve(peertubeHelpers.plugin.getDataDirectoryPath(), 'disable_mod_firewall_editing')
try {
// Testing if file exist by reading it.
await fs.promises.readFile(filepath)
return false
} catch (err: any) {
if (('code' in err) && err.code === 'ENOENT') {
// File does not exist
return true
}
logger.error(err)
// Here it is safer to disable the editing...
return false
}
}
/**
* Returns the list of mod_firewall configuration files.
* @param options: Peertube server options.
* @param dir the path to the directory containing these configuration files.
* @param includeDisabled if true, disabled files are included in the results.
*/
export async function listModFirewallFiles (
options: RegisterServerOptions,
dir: string,
includeDisabled?: boolean
): Promise<string[]> {
try {
const files = (await fs.promises.readdir(dir, { withFileTypes: true })).filter(file => {
if (!file.isFile()) {
return false
}
if (
file.name.endsWith('.pfw') &&
// we only load valid names, to avoid having files that could not be edited from frontend
firewallNameRegexp.test(file.name.substring(0, file.name.length - 4))
) {
return true
}
if (
includeDisabled &&
file.name.endsWith('.pfw.disabled') &&
firewallNameRegexp.test(file.name.substring(0, file.name.length - 13))
) {
return true
}
return false
})
return files.map(f => path.join(dir, f.name)).sort()
} catch (err) {
// should be that the directory does not exists
return []
}
}
/**
* Returns the modFirewall configuration.
* @param options Peertube server options
* @param dir the path to the directory containing these configuration files.
* @throws will throw an error if it can't read any of the configuration file.
*/
export async function getModFirewallConfig (
options: RegisterServerOptions,
dir: string
): Promise<AdminFirewallConfiguration> {
const filePaths = await listModFirewallFiles(options, dir, true)
const files = []
for (const filePath of filePaths) {
const content = (await fs.promises.readFile(filePath)).toString()
const name = path.basename(filePath).replace(/\.pfw(\.disabled)?$/, '')
files.push({
name,
content,
enabled: !filePath.endsWith('.disabled')
})
}
const enabled = (await options.settingsManager.getSetting('prosody-firewall-enabled')) === true
return {
enabled,
files
}
}
/**
* Sanitize any data received from the frontend, to store in modFirewall configuration.
* Throws an exception if data is invalid.
* @param options Peertube server options
* @param data Incoming data
*/
export async function sanitizeModFirewallConfig (
options: RegisterServerOptions,
data: any
): Promise<AdminFirewallConfiguration> {
if (typeof data !== 'object') {
throw new Error('Invalid data type')
}
if (!Array.isArray(data.files)) {
throw new Error('Invalid data.files')
}
if (data.files.length > maxFirewallFiles) {
throw new Error('Too many files')
}
const files: AdminFirewallConfiguration['files'] = []
for (const entry of data.files) {
if (typeof entry !== 'object') {
throw new Error('Invalid data in data.files')
}
if (typeof entry.enabled !== 'boolean') {
throw new Error('Invalid data in data.files (enabled)')
}
if (typeof entry.name !== 'string') {
throw new Error('Invalid data in data.files (name)')
}
if (typeof entry.content !== 'string') {
throw new Error('Invalid data in data.files (content)')
}
if (entry.name.length > maxFirewallNameLength || !firewallNameRegexp.test(entry.name)) {
throw new Error('Invalid name in data.files')
}
if (entry.content.length > maxFirewallFileSize) {
throw new Error('File content too big in data.files')
}
files.push({
enabled: entry.enabled,
name: entry.name,
content: entry.content
})
}
const result = {
enabled: !!data.enabled, // this is not saved, so no need to check type.
files
}
return result
}
/**
* Saves the modFirewall configuration.
* FIXME: currently, if the save fails on one file, remaining files will not be saved. So there is a risk of data loss.
* @param options Peertube server options
* @param dir the path to the directory containing these configuration files.
* @param config the configuration to save
* @throws will throw an error if it can't read any of the configuration file.
*/
export async function saveModFirewallConfig (
options: RegisterServerOptions,
dir: string,
config: AdminFirewallConfiguration
): Promise<void> {
const logger = options.peertubeHelpers.logger
const previousFiles = await listModFirewallFiles(options, dir, true)
logger.debug('[mod-firewall-lib] Creating the ' + dir + ' directory.')
await fs.promises.mkdir(dir, { recursive: true })
const seen = new Map<string, true>()
for (const f of config.files) {
const filePath = path.join(
dir,
f.name + '.pfw' + (f.enabled ? '' : '.disabled')
)
logger.info('[mod-firewall-lib] Saving ' + filePath)
await fs.promises.writeFile(filePath, f.content)
seen.set(filePath, true)
}
// Removing deprecated files:
for (const p of previousFiles) {
if (seen.has(p)) { continue }
logger.info('[mod-firewall-lib] Deleting deprecated file ' + p)
await fs.promises.rm(p)
}
}

View File

@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Request, Response, NextFunction } from 'express'
import type { RequestPromiseHandler } from './async'
import { isUserAdmin } from '../helpers'
/**
* Returns a middleware handler to check if advanced configuration is not disabled
* @param options Peertube server options
* @returns middleware function
*/
function checkUserIsAdminMiddleware (options: RegisterServerOptions): RequestPromiseHandler {
return async (req: Request, res: Response, next: NextFunction) => {
const logger = options.peertubeHelpers.logger
if (!await isUserAdmin(options, res)) {
logger.warn('Current user tries to access a page only allowed for admins, and has no right.')
res.sendStatus(403)
return
}
logger.debug('User is admin, can access the page..')
next()
}
}
export {
checkUserIsAdminMiddleware
}

View File

@ -18,6 +18,7 @@ import { getRemoteServerInfosDir } from '../federation/storage'
import { BotConfiguration } from '../configuration/bot'
import { debugMucAdmins } from '../debug'
import { ExternalAuthOIDC } from '../external-auth/oidc'
import { listModFirewallFiles } from '../firewall/config'
async function getWorkingDir (options: RegisterServerOptions): Promise<string> {
const peertubeHelpers = options.peertubeHelpers
@ -139,7 +140,8 @@ async function getProsodyFilePaths (options: RegisterServerOptions): Promise<Pro
execCtl,
execCtlArgs,
appImageToExtract,
appImageExtractPath
appImageExtractPath,
modFirewallFiles: path.resolve(dir, 'mod_firewall_config')
}
}
@ -185,7 +187,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
'auto-ban-anonymous-ip',
'federation-dont-publish-remotely',
'disable-channel-configuration',
'chat-terms'
'chat-terms',
'prosody-firewall-enabled'
])
const valuesToHideInDiagnostic = new Map<string, string>()
@ -379,6 +382,13 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
config.usePoll()
if (settings['prosody-firewall-enabled'] === true) {
const modFirewallFiles = await listModFirewallFiles(options, paths.modFirewallFiles)
// We load the module, even if there is no configuration file.
// So we will be sure that a Prosody reload is enought to take into account any change.
config.useModFirewall(modFirewallFiles)
}
config.useTestModule(apikey, testApiUrl)
const debugMucAdminJids = debugMucAdmins(options)

View File

@ -553,6 +553,15 @@ class ProsodyConfigContent {
this.muc.set('poll_string_vote_instructions', loc('poll_vote_instructions_xmpp'))
}
/**
* Enable mod_firewall.
* @param files file paths to load (ordered)
*/
useModFirewall (files: string[]): void {
this.global.add('modules_enabled', 'firewall')
this.global.set('firewall_scripts', files)
}
addMucAdmins (jids: string[]): void {
for (const jid of jids) {
this.muc.add('admins', jid)

View File

@ -22,6 +22,7 @@ interface ProsodyFilePaths {
execCtlArgs: string[]
appImageToExtract?: string
appImageExtractPath: string
modFirewallFiles: string
}
export {

View File

@ -14,6 +14,7 @@ import { initFederationServerInfosApiRouter } from './api/federation-server-info
import { initConfigurationApiRouter } from './api/configuration'
import { initPromoteApiRouter } from './api/promote'
import { initEmojisRouter } from './emojis'
import { initAdminFirewallApiRouter } from './api/admin/firewall'
/**
* Initiate API routes
@ -45,6 +46,8 @@ async function initApiRouter (options: RegisterServerOptions): Promise<Router> {
await initPromoteApiRouter(options, router)
await initEmojisRouter(options, router)
await initAdminFirewallApiRouter(options, router)
if (isDebugMode(options)) {
// Only add this route if the debug mode is enabled at time of the server launch.
// Note: the isDebugMode will be tested again when the API is called.

View File

@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Router, Request, Response, NextFunction } from 'express'
import type { AdminFirewallConfiguration } from '../../../../../shared/lib/types'
import { asyncMiddleware, RequestPromiseHandler } from '../../../middlewares/async'
import { checkUserIsAdminMiddleware } from '../../../middlewares/is-admin'
import {
getModFirewallConfig, sanitizeModFirewallConfig, saveModFirewallConfig, canEditFirewallConfig
} from '../../../firewall/config'
import { getProsodyFilePaths, writeProsodyConfig } from '../../../prosody/config'
import { reloadProsody } from '../../../prosody/ctl'
function canEditFirewallConfigMiddleware (options: RegisterServerOptions): RequestPromiseHandler {
return async (req: Request, res: Response, next: NextFunction) => {
if (!await canEditFirewallConfig(options)) {
options.peertubeHelpers.logger.info('Firewall configuration editing is disabled')
res.sendStatus(403)
return
}
next()
}
}
async function initAdminFirewallApiRouter (options: RegisterServerOptions, router: Router): Promise<void> {
const logger = options.peertubeHelpers.logger
router.get('/admin/firewall', asyncMiddleware([
checkUserIsAdminMiddleware(options),
canEditFirewallConfigMiddleware(options),
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const prosodyPaths = await getProsodyFilePaths(options)
const result: AdminFirewallConfiguration = await getModFirewallConfig(options, prosodyPaths.modFirewallFiles)
res.status(200)
res.json(result)
} catch (err) {
options.peertubeHelpers.logger.error(err)
res.sendStatus(500)
}
}
]))
router.post('/admin/firewall', asyncMiddleware([
checkUserIsAdminMiddleware(options),
canEditFirewallConfigMiddleware(options),
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const prosodyPaths = await getProsodyFilePaths(options)
let data: AdminFirewallConfiguration
try {
data = await sanitizeModFirewallConfig(options, req.body)
} catch (err) {
logger.error(err)
res.sendStatus(400)
return
}
await saveModFirewallConfig(options, prosodyPaths.modFirewallFiles, data)
logger.info('Just saved a new mod_firewall const, must rewrite Prosody configuration file, and reload Prosody.')
await writeProsodyConfig(options)
await reloadProsody(options)
const result: AdminFirewallConfiguration = await getModFirewallConfig(options, prosodyPaths.modFirewallFiles)
res.status(200)
res.json(result)
} catch (err) {
options.peertubeHelpers.logger.error(err)
res.sendStatus(500)
}
}
]))
}
export {
initAdminFirewallApiRouter
}

View File

@ -11,6 +11,7 @@ import { ExternalAuthOIDC, ExternalAuthOIDCType } from './external-auth/oidc'
import { Emojis } from './emojis'
import { LivechatProsodyAuth } from './prosody/auth'
import { loc } from './loc'
import { canEditFirewallConfig } from './firewall/config'
const escapeHTML = require('escape-html')
type AvatarSet = 'sepia' | 'cat' | 'bird' | 'fenec' | 'abstract' | 'legacy' | 'none'
@ -27,7 +28,7 @@ async function initSettings (options: RegisterServerOptions): Promise<void> {
initAdvancedChannelCustomizationSettings(options)
initChatBehaviourSettings(options)
initThemingSettings(options)
initChatServerAdvancedSettings(options)
await initChatServerAdvancedSettings(options)
await ExternalAuthOIDC.initSingletons(options)
const loadOidcs = (): void => {
@ -555,7 +556,9 @@ function initThemingSettings ({ registerSetting }: RegisterServerOptions): void
* Registers settings related to the "Chat server advanded settings" section.
* @param param0 server options
*/
function initChatServerAdvancedSettings ({ registerSetting }: RegisterServerOptions): void {
async function initChatServerAdvancedSettings (options: RegisterServerOptions): Promise<void> {
const { registerSetting } = options
registerSetting({
name: 'prosody-advanced',
type: 'html',
@ -723,6 +726,23 @@ function initChatServerAdvancedSettings ({ registerSetting }: RegisterServerOpti
private: true,
descriptionHTML: loc('prosody_components_list_description')
})
registerSetting({
name: 'prosody-firewall-enabled',
label: loc('prosody_firewall_label'),
type: 'input-checkbox',
default: false,
private: true,
descriptionHTML: loc('prosody_firewall_description')
})
if (await canEditFirewallConfig(options)) {
registerSetting({
type: 'html',
name: 'prosody-firewall-configure-button',
private: true,
descriptionHTML: loc('prosody_firewall_configure_button')
})
}
}
export {