From 52edbcc2f8c28fc71d5b1246644a1902886d5424 Mon Sep 17 00:00:00 2001 From: Symon Date: Sun, 26 Apr 2026 23:41:13 +0300 Subject: [PATCH] feat: add operator config options for Apprise notifications (closes #1576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new environment variables that give instance operators control over Apprise notification services, addressing GDPR and compliance concerns around third-party data transfers. ## New environment variables - APPRISE_ENABLED (default: true) Set to "false" to globally prevent users from creating Apprise notification services and to silently skip sending any Apprise notifications. Existing services in the database are preserved but not used while disabled. - APPRISE_ALLOWED_SCHEMAS (default: empty — no restriction) Comma-separated allowlist of Apprise URL schemas (e.g. "slack,tgram"). When set, only services whose URL schema appears in this list can be created or will be sent. Takes priority over APPRISE_BLOCKED_SCHEMAS. Uses Apprise's internal schema names (e.g. "tgram" for Telegram). - APPRISE_BLOCKED_SCHEMAS (default: empty — built-in list applies) Comma-separated blocklist of Apprise URL schemas (e.g. "discord,slack"). When set, replaces the built-in blocked list entirely. When empty, the built-in list (syslog, dbus, kde, qt, glib, gnome, macosx, windows) continues to apply as a fallback. ## Enforcement Validation happens at two layers: 1. API layer (create endpoints for user and board notification services) Returns HTTP 403 when Apprise is disabled, HTTP 422 when the URL schema is not permitted. This gives users immediate feedback in the UI. 2. Python send layer (send_notifications.py) The schema config is passed as a JSON argument so the same allow/block rules apply at send time, guarding against services that were created before the config was tightened. ## Files changed - server/config/custom.js — parse new env vars via existing envToArray helper - server/utils/send_notifications.py — accept schemaConfig as argv[4]; replace hard-coded BLOCKED_SCHEMAS_SET with dynamic allowed/blocked resolution - server/api/helpers/utils/send-notifications.js — short-circuit when disabled; pass schemaConfig JSON to the Python script - server/api/helpers/notification-services/create-one-in-{user,board}.js — add appriseDisabled and schemaNotAllowed exits with schema validation logic - server/api/controllers/notification-services/create-in-{user,board}.js — wire new exits to forbidden (403) and unprocessableEntity (422) responses - server/.env.sample — document all three new variables with comments --- server/.env.sample | 9 +++++++++ .../notification-services/create-in-board.js | 14 ++++++++++++++ .../notification-services/create-in-user.js | 14 ++++++++++++++ .../create-one-in-board.js | 17 +++++++++++++++++ .../notification-services/create-one-in-user.js | 17 +++++++++++++++++ server/api/helpers/utils/send-notifications.js | 12 ++++++++++++ server/config/custom.js | 4 ++++ server/utils/send_notifications.py | 13 +++++++++++-- 8 files changed, 98 insertions(+), 2 deletions(-) diff --git a/server/.env.sample b/server/.env.sample index c3263b32..022c7b98 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -42,6 +42,15 @@ SECRET_KEY=notsecretkey # will be sent through this proxy if set. # OUTGOING_PROXY=http://proxy:3128 +# Apprise notifications configuration. +# Set to false to prevent users from configuring and sending Apprise notifications. +# APPRISE_ENABLED=false +# Comma-separated allowlist of Apprise schemas users may use (e.g. slack,telegram). +# When set, only schemas in this list are accepted; APPRISE_BLOCKED_SCHEMAS is ignored. +# APPRISE_ALLOWED_SCHEMAS= +# Comma-separated blocklist of Apprise schemas. Replaces the built-in blocked list. +# APPRISE_BLOCKED_SCHEMAS=syslog,dbus,kde,qt,glib,gnome,macosx,windows + # Set to true to expose the Swagger specification at /swagger.json # SWAGGER_EXPOSED=false diff --git a/server/api/controllers/notification-services/create-in-board.js b/server/api/controllers/notification-services/create-in-board.js index 61e16e0e..6896b43e 100644 --- a/server/api/controllers/notification-services/create-in-board.js +++ b/server/api/controllers/notification-services/create-in-board.js @@ -68,6 +68,12 @@ const Errors = { BOARD_NOT_FOUND: { boardNotFound: 'Board not found', }, + APPRISE_DISABLED: { + appriseDisabled: 'Apprise notifications are disabled', + }, + SCHEMA_NOT_ALLOWED: { + schemaNotAllowed: 'Notification service schema is not allowed', + }, LIMIT_REACHED: { limitReached: 'Limit reached', }, @@ -95,6 +101,12 @@ module.exports = { boardNotFound: { responseType: 'notFound', }, + appriseDisabled: { + responseType: 'forbidden', + }, + schemaNotAllowed: { + responseType: 'unprocessableEntity', + }, limitReached: { responseType: 'conflict', }, @@ -125,6 +137,8 @@ module.exports = { actorUser: currentUser, request: this.req, }) + .intercept('appriseDisabled', () => Errors.APPRISE_DISABLED) + .intercept('schemaNotAllowed', () => Errors.SCHEMA_NOT_ALLOWED) .intercept('limitReached', () => Errors.LIMIT_REACHED); return { diff --git a/server/api/controllers/notification-services/create-in-user.js b/server/api/controllers/notification-services/create-in-user.js index 2d627018..5eaddbfe 100644 --- a/server/api/controllers/notification-services/create-in-user.js +++ b/server/api/controllers/notification-services/create-in-user.js @@ -68,6 +68,12 @@ const Errors = { USER_NOT_FOUND: { userNotFound: 'User not found', }, + APPRISE_DISABLED: { + appriseDisabled: 'Apprise notifications are disabled', + }, + SCHEMA_NOT_ALLOWED: { + schemaNotAllowed: 'Notification service schema is not allowed', + }, LIMIT_REACHED: { limitReached: 'Limit reached', }, @@ -95,6 +101,12 @@ module.exports = { userNotFound: { responseType: 'notFound', }, + appriseDisabled: { + responseType: 'forbidden', + }, + schemaNotAllowed: { + responseType: 'unprocessableEntity', + }, limitReached: { responseType: 'conflict', }, @@ -118,6 +130,8 @@ module.exports = { actorUser: currentUser, request: this.req, }) + .intercept('appriseDisabled', () => Errors.APPRISE_DISABLED) + .intercept('schemaNotAllowed', () => Errors.SCHEMA_NOT_ALLOWED) .intercept('limitReached', () => Errors.LIMIT_REACHED); return { diff --git a/server/api/helpers/notification-services/create-one-in-board.js b/server/api/helpers/notification-services/create-one-in-board.js index b81282f1..ca1b5b2d 100644 --- a/server/api/helpers/notification-services/create-one-in-board.js +++ b/server/api/helpers/notification-services/create-one-in-board.js @@ -23,11 +23,28 @@ module.exports = { }, exits: { + appriseDisabled: {}, + schemaNotAllowed: {}, limitReached: {}, }, async fn(inputs) { const { values } = inputs; + const { appriseEnabled, appriseAllowedSchemas, appriseBlockedSchemas } = sails.config.custom; + + if (!appriseEnabled) { + throw 'appriseDisabled'; + } + + const schema = values.url.split(':')[0]; + + if (appriseAllowedSchemas.length > 0 && !appriseAllowedSchemas.includes(schema)) { + throw 'schemaNotAllowed'; + } + + if (appriseAllowedSchemas.length === 0 && appriseBlockedSchemas.includes(schema)) { + throw 'schemaNotAllowed'; + } const notificationServicesTotal = await sails.helpers.boards.getNotificationServicesTotal( values.board.id, diff --git a/server/api/helpers/notification-services/create-one-in-user.js b/server/api/helpers/notification-services/create-one-in-user.js index 8030fda5..e3171a24 100644 --- a/server/api/helpers/notification-services/create-one-in-user.js +++ b/server/api/helpers/notification-services/create-one-in-user.js @@ -19,11 +19,28 @@ module.exports = { }, exits: { + appriseDisabled: {}, + schemaNotAllowed: {}, limitReached: {}, }, async fn(inputs) { const { values } = inputs; + const { appriseEnabled, appriseAllowedSchemas, appriseBlockedSchemas } = sails.config.custom; + + if (!appriseEnabled) { + throw 'appriseDisabled'; + } + + const schema = values.url.split(':')[0]; + + if (appriseAllowedSchemas.length > 0 && !appriseAllowedSchemas.includes(schema)) { + throw 'schemaNotAllowed'; + } + + if (appriseAllowedSchemas.length === 0 && appriseBlockedSchemas.includes(schema)) { + throw 'schemaNotAllowed'; + } const notificationServicesTotal = await sails.helpers.users.getNotificationServicesTotal( values.user.id, diff --git a/server/api/helpers/utils/send-notifications.js b/server/api/helpers/utils/send-notifications.js index 9d064c52..d1840a83 100644 --- a/server/api/helpers/utils/send-notifications.js +++ b/server/api/helpers/utils/send-notifications.js @@ -31,6 +31,17 @@ module.exports = { }, async fn(inputs) { + const { appriseEnabled, appriseAllowedSchemas, appriseBlockedSchemas } = sails.config.custom; + + if (!appriseEnabled) { + return; + } + + const schemaConfig = JSON.stringify({ + allowedSchemas: appriseAllowedSchemas, + blockedSchemas: appriseBlockedSchemas, + }); + try { await promisifyExecFile( PYTHON_PATH, @@ -39,6 +50,7 @@ module.exports = { JSON.stringify(inputs.services), inputs.title, JSON.stringify(inputs.bodyByFormat), + schemaConfig, ], { env: { diff --git a/server/config/custom.js b/server/config/custom.js index 4422fd79..987d3b41 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -111,6 +111,10 @@ module.exports.custom = { smtpPassword: process.env.SMTP_PASSWORD, smtpFrom: process.env.SMTP_FROM, + appriseEnabled: process.env.APPRISE_ENABLED !== 'false', + appriseAllowedSchemas: envToArray(process.env.APPRISE_ALLOWED_SCHEMAS), + appriseBlockedSchemas: envToArray(process.env.APPRISE_BLOCKED_SCHEMAS), + gravatarBaseUrl: process.env.GRAVATAR_BASE_URL, /* Internal */ diff --git a/server/utils/send_notifications.py b/server/utils/send_notifications.py index fe6bef5e..fba14bdb 100644 --- a/server/utils/send_notifications.py +++ b/server/utils/send_notifications.py @@ -7,7 +7,7 @@ import logging import apprise -BLOCKED_SCHEMAS_SET = { +DEFAULT_BLOCKED_SCHEMAS_SET = { 'syslog', 'dbus', 'kde', @@ -45,13 +45,22 @@ if __name__ == '__main__': services = json.loads(sys.argv[1]) title = sys.argv[2] body_by_format = json.loads(sys.argv[3]) + schema_config = json.loads(sys.argv[4]) if len(sys.argv) > 4 else {} + + allowed_schemas = set(schema_config.get('allowedSchemas', [])) + custom_blocked = schema_config.get('blockedSchemas', []) + blocked_schemas = set(custom_blocked) if custom_blocked else DEFAULT_BLOCKED_SCHEMAS_SET errors = [] for service in services: url = service['url'] schema = url.split(':')[0] - if schema in BLOCKED_SCHEMAS_SET: + if allowed_schemas and schema not in allowed_schemas: + errors.append(f'[{schema}] Schema not in allowed list') + continue + + if schema in blocked_schemas: errors.append(f'[{schema}] Blocked service schema') continue