From 538280d19747e78171e82c93120539c7fd1b03de Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Mon, 9 Feb 2026 13:33:27 +0100 Subject: [PATCH] feat: Add configurable proxy for outgoing traffic to prevent SSRF --- Dockerfile | 2 +- docker-compose-dev.yml | 4 + docker-compose.yml | 18 +++ server/.env.sample | 4 + server/api/helpers/utils/download-favicon.js | 4 + .../helpers/utils/make-smtp-transporter.js | 9 ++ .../api/helpers/utils/send-notifications.js | 21 +++- server/api/helpers/utils/send-webhooks.js | 5 + server/config/custom.js | 1 + server/package-lock.json | 10 ++ server/package.json | 1 + server/start.sh | 103 +++++++++++++++++- 12 files changed, 174 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 396a16b4..575fa822 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN npm install npm --global \ FROM node:22-alpine RUN apk -U upgrade \ - && apk add bash python3 --no-cache \ + && apk add bash python3 squid --no-cache \ && npm install npm --global USER node diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index bf361c86..4bac776c 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -50,6 +50,10 @@ services: # It should not be enabled without a rate limiter for security reasons. # - SHOW_DETAILED_AUTH_ERRORS=false + # All outgoing HTTP requests (SMTP, webhooks, Apprise notifications, favicon fetching, etc.) + # will be sent through this proxy if set. + # - OUTGOING_PROXY=http://proxy:3128 + # - S3_ENDPOINT= # - S3_REGION= # - S3_ACCESS_KEY_ID= diff --git a/docker-compose.yml b/docker-compose.yml index e6b58213..523e3b25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,12 @@ services: # It should not be enabled without a rate limiter for security reasons. # - SHOW_DETAILED_AUTH_ERRORS=false + # All outgoing HTTP requests (SMTP, webhooks, Apprise notifications, favicon fetching, etc.) + # will be sent through this proxy if set. + # If commented out, an internal Squid proxy will be started inside the container, + # which you can control via OUTGOING_BLOCKED_* and OUTGOING_ALLOWED_* below. + # - OUTGOING_PROXY=http://proxy:3128 + # - S3_ENDPOINT= # - S3_REGION= # - S3_ACCESS_KEY_ID= @@ -110,6 +116,18 @@ services: # Using Gravatar directly exposes user IPs and hashed emails to a third party (GDPR risk). # Use a proxy you control for privacy, or leave commented out or empty to disable. # - GRAVATAR_BASE_URL=https://www.gravatar.com/avatar/ + + # -------------------------------------------------------------------- + # Outgoing traffic control (internal Squid proxy) + # -------------------------------------------------------------------- + + # These IPs/hostnames will always be blocked (highest priority) + # - OUTGOING_BLOCKED_IPS= + # - OUTGOING_BLOCKED_HOSTS=localhost,postgres,valkey + + # Only these IPs/hostnames will be reachable + # - OUTGOING_ALLOWED_IPS= + # - OUTGOING_ALLOWED_HOSTS= depends_on: postgres: condition: service_healthy diff --git a/server/.env.sample b/server/.env.sample index 68c3ca45..7f592343 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -41,6 +41,10 @@ SECRET_KEY=notsecretkey # It should not be enabled without a rate limiter for security reasons. # SHOW_DETAILED_AUTH_ERRORS=false +# All outgoing HTTP requests (SMTP, webhooks, Apprise notifications, favicon fetching, etc.) +# will be sent through this proxy if set. +# OUTGOING_PROXY=http://proxy:3128 + # S3_ENDPOINT= # S3_REGION= # S3_ACCESS_KEY_ID= diff --git a/server/api/helpers/utils/download-favicon.js b/server/api/helpers/utils/download-favicon.js index 89fec468..59eac8c8 100644 --- a/server/api/helpers/utils/download-favicon.js +++ b/server/api/helpers/utils/download-favicon.js @@ -4,6 +4,7 @@ */ const { URL } = require('url'); +const { ProxyAgent } = require('undici'); const icoToPng = require('ico-to-png'); const sharp = require('sharp'); @@ -20,6 +21,9 @@ const fetchWithTimeout = (url) => { return fetch(url, { signal: abortController.signal, + dispatcher: sails.config.custom.outgoingProxy + ? new ProxyAgent(sails.config.custom.outgoingProxy) + : undefined, }); }; diff --git a/server/api/helpers/utils/make-smtp-transporter.js b/server/api/helpers/utils/make-smtp-transporter.js index 86e89aca..9d273102 100644 --- a/server/api/helpers/utils/make-smtp-transporter.js +++ b/server/api/helpers/utils/make-smtp-transporter.js @@ -23,6 +23,11 @@ module.exports = { if (config.smtpHost) { sourceConfig = config; + + // TODO: hack to make it work with proxy + if (sourceConfig.smtpPort === null) { + sourceConfig.smtpPort = sourceConfig.smtpSecure ? 465 : 587; + } } } @@ -47,6 +52,10 @@ module.exports = { tls: { rejectUnauthorized: sourceConfig.smtpTlsRejectUnauthorized, }, + proxy: + sails.config.custom.outgoingProxy && !sails.config.custom.smtpHost + ? sails.config.custom.outgoingProxy + : undefined, }, { from: sourceConfig.smtpFrom, diff --git a/server/api/helpers/utils/send-notifications.js b/server/api/helpers/utils/send-notifications.js index 77dd8f1c..d9e9b44a 100644 --- a/server/api/helpers/utils/send-notifications.js +++ b/server/api/helpers/utils/send-notifications.js @@ -26,12 +26,21 @@ module.exports = { async fn(inputs) { try { - await promisifyExecFile(`${sails.config.appPath}/.venv/bin/python3`, [ - `${sails.config.appPath}/utils/send_notifications.py`, - JSON.stringify(inputs.services), - inputs.title, - JSON.stringify(inputs.bodyByFormat), - ]); + await promisifyExecFile( + `${sails.config.appPath}/.venv/bin/python3`, + [ + `${sails.config.appPath}/utils/send_notifications.py`, + JSON.stringify(inputs.services), + inputs.title, + JSON.stringify(inputs.bodyByFormat), + ], + { + env: { + HTTP_PROXY: sails.config.custom.outgoingProxy, + HTTPS_PROXY: sails.config.custom.outgoingProxy, + }, + }, + ); } catch (error) { sails.log.error(`Error sending notifications: ${error.stderr || error.message}`); } diff --git a/server/api/helpers/utils/send-webhooks.js b/server/api/helpers/utils/send-webhooks.js index d992b401..d3af93ed 100644 --- a/server/api/helpers/utils/send-webhooks.js +++ b/server/api/helpers/utils/send-webhooks.js @@ -3,6 +3,8 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { ProxyAgent } = require('undici'); + const Webhook = require('../../models/Webhook'); /** @@ -61,6 +63,9 @@ async function sendWebhook(webhook, event, data, prevData, user) { headers, body, method: 'POST', + dispatcher: sails.config.custom.outgoingProxy + ? new ProxyAgent(sails.config.custom.outgoingProxy) + : undefined, }); if (!response.ok) { diff --git a/server/config/custom.js b/server/config/custom.js index f2fd749e..70aa38ae 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -66,6 +66,7 @@ module.exports.custom = { demoMode: process.env.DEMO_MODE === 'true', showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true', + outgoingProxy: process.env.OUTGOING_PROXY, s3Endpoint: process.env.S3_ENDPOINT, s3Region: process.env.S3_REGION, diff --git a/server/package-lock.json b/server/package-lock.json index 1e30fa06..127940f5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -38,6 +38,7 @@ "sails-postgresql": "^5.0.1", "serve-static": "^2.2.1", "sharp": "^0.34.5", + "undici": "^7.21.0", "uuid": "^11.1.0", "validator": "^13.15.26", "winston": "^3.19.0", @@ -11381,6 +11382,15 @@ "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/server/package.json b/server/package.json index 7d84e14a..08396f3c 100644 --- a/server/package.json +++ b/server/package.json @@ -83,6 +83,7 @@ "sails-postgresql": "^5.0.1", "serve-static": "^2.2.1", "sharp": "^0.34.5", + "undici": "^7.21.0", "uuid": "^11.1.0", "validator": "^13.15.26", "winston": "^3.19.0", diff --git a/server/start.sh b/server/start.sh index 253fd6b5..178de420 100755 --- a/server/start.sh +++ b/server/start.sh @@ -18,7 +18,106 @@ load_secret() { fi } -if [[ -n "${DATABASE_URL}" ]]; then +start_outgoing_proxy_if_needed() { + # If a custom outgoing proxy is set, do not start internal proxy + if [[ -n "${OUTGOING_PROXY:-}" ]]; then + return + fi + + # Minimum safe defaults + if [[ -z "${OUTGOING_BLOCKED_HOSTS+x}" ]]; then + OUTGOING_BLOCKED_HOSTS="localhost,postgres" + fi + + # If no blocked/allowed rules exist, do not start internal proxy + if [[ -z "${OUTGOING_BLOCKED_IPS:-}" && -z "${OUTGOING_BLOCKED_HOSTS:-}" && -z "${OUTGOING_ALLOWED_IPS:-}" && -z "${OUTGOING_ALLOWED_HOSTS:-}" ]]; then + return + fi + + SQUID_CONF="/tmp/squid.conf" + SQUID_PID="/tmp/squid.pid" + + : > "$SQUID_CONF" + + # Basic settings + echo "pid_filename $SQUID_PID" >> "$SQUID_CONF" + echo "http_port 3128" >> "$SQUID_CONF" + echo "acl all src all" >> "$SQUID_CONF" + + # Disable caching + echo "cache deny all" >> "$SQUID_CONF" + echo "cache_mem 0" >> "$SQUID_CONF" + echo "memory_pools off" >> "$SQUID_CONF" + echo "cache_swap_low 0" >> "$SQUID_CONF" + echo "cache_swap_high 0" >> "$SQUID_CONF" + + # Disable logs + echo "access_log /tmp/test.log" >> "$SQUID_CONF" + echo "cache_store_log none" >> "$SQUID_CONF" + echo "cache_log /dev/null" >> "$SQUID_CONF" + echo "logfile_rotate 0" >> "$SQUID_CONF" + echo "debug_options ALL,1" >> "$SQUID_CONF" + + # Make it pass-through like + echo "forwarded_for delete" >> "$SQUID_CONF" + echo "via off" >> "$SQUID_CONF" + echo "request_header_access X-Forwarded-For deny all" >> "$SQUID_CONF" + echo "request_header_access Via deny all" >> "$SQUID_CONF" + echo "request_header_access Cache-Control deny all" >> "$SQUID_CONF" + + # Blocked IPs + if [[ -n "${OUTGOING_BLOCKED_IPS:-}" ]]; then + IFS=',' read -ra BLOCKED_IPS <<< "$OUTGOING_BLOCKED_IPS" + for ip in "${BLOCKED_IPS[@]}"; do + echo "acl blocked_ip dst $ip" >> "$SQUID_CONF" + done + echo "http_access deny blocked_ip" >> "$SQUID_CONF" + fi + + # Blocked hostnames + if [[ -n "${OUTGOING_BLOCKED_HOSTS:-}" ]]; then + IFS=',' read -ra BLOCKED_HOSTS <<< "$OUTGOING_BLOCKED_HOSTS" + for host in "${BLOCKED_HOSTS[@]}"; do + echo "acl blocked_host dstdomain $host" >> "$SQUID_CONF" + done + echo "http_access deny blocked_host" >> "$SQUID_CONF" + fi + + # Allowed IPs + if [[ -n "${OUTGOING_ALLOWED_IPS:-}" ]]; then + IFS=',' read -ra ALLOWED_IPS <<< "$OUTGOING_ALLOWED_IPS" + for ip in "${ALLOWED_IPS[@]}"; do + echo "acl allowed_ip dst $ip" >> "$SQUID_CONF" + done + echo "http_access allow allowed_ip" >> "$SQUID_CONF" + fi + + # Allowed hostnames + if [[ -n "${OUTGOING_ALLOWED_HOSTS:-}" ]]; then + IFS=',' read -ra ALLOWED_HOSTS <<< "$OUTGOING_ALLOWED_HOSTS" + for host in "${ALLOWED_HOSTS[@]}"; do + echo "acl allowed_host dstdomain $host" >> "$SQUID_CONF" + done + echo "http_access allow allowed_host" >> "$SQUID_CONF" + fi + + # If any allowed rules exist, everything else is denied + if [[ -n "${OUTGOING_ALLOWED_IPS+x}${OUTGOING_ALLOWED_HOSTS+x}" ]]; then + echo "http_access deny all" >> "$SQUID_CONF" + else + # If no allowed rules exist, everything else is allowed + echo "http_access allow all" >> "$SQUID_CONF" + fi + + # Start Squid + rm -f "$SQUID_PID" + squid -N -f "$SQUID_CONF" & + + # Set environment variable + export OUTGOING_PROXY="http://127.0.0.1:3128" +} + +if [[ -n "${DATABASE_URL:-}" ]]; then if [[ -z "${DATABASE_PASSWORD:-}" && -e "${DATABASE_PASSWORD__FILE:-}" ]]; then DATABASE_PASSWORD="$(read_secret "${DATABASE_PASSWORD__FILE}")" export DATABASE_URL="${DATABASE_URL/\$\{DATABASE_PASSWORD\}/${DATABASE_PASSWORD}}" @@ -31,6 +130,8 @@ load_secret S3_SECRET_ACCESS_KEY load_secret OIDC_CLIENT_SECRET load_secret SMTP_PASSWORD +start_outgoing_proxy_if_needed + export NODE_ENV=production node db/init.js