feat: Add configurable proxy for outgoing traffic to prevent SSRF

This commit is contained in:
Maksim Eltyshev
2026-02-09 13:33:27 +01:00
parent aa3ebd5add
commit 538280d197
12 changed files with 174 additions and 8 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ RUN npm install npm --global \
FROM node:22-alpine FROM node:22-alpine
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add bash python3 --no-cache \ && apk add bash python3 squid --no-cache \
&& npm install npm --global && npm install npm --global
USER node USER node
+4
View File
@@ -50,6 +50,10 @@ services:
# It should not be enabled without a rate limiter for security reasons. # It should not be enabled without a rate limiter for security reasons.
# - SHOW_DETAILED_AUTH_ERRORS=false # - 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_ENDPOINT=
# - S3_REGION= # - S3_REGION=
# - S3_ACCESS_KEY_ID= # - S3_ACCESS_KEY_ID=
+18
View File
@@ -61,6 +61,12 @@ services:
# It should not be enabled without a rate limiter for security reasons. # It should not be enabled without a rate limiter for security reasons.
# - SHOW_DETAILED_AUTH_ERRORS=false # - 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_ENDPOINT=
# - S3_REGION= # - S3_REGION=
# - S3_ACCESS_KEY_ID= # - S3_ACCESS_KEY_ID=
@@ -110,6 +116,18 @@ services:
# Using Gravatar directly exposes user IPs and hashed emails to a third party (GDPR risk). # 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. # Use a proxy you control for privacy, or leave commented out or empty to disable.
# - GRAVATAR_BASE_URL=https://www.gravatar.com/avatar/ # - 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+4
View File
@@ -41,6 +41,10 @@ SECRET_KEY=notsecretkey
# It should not be enabled without a rate limiter for security reasons. # It should not be enabled without a rate limiter for security reasons.
# SHOW_DETAILED_AUTH_ERRORS=false # 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_ENDPOINT=
# S3_REGION= # S3_REGION=
# S3_ACCESS_KEY_ID= # S3_ACCESS_KEY_ID=
@@ -4,6 +4,7 @@
*/ */
const { URL } = require('url'); const { URL } = require('url');
const { ProxyAgent } = require('undici');
const icoToPng = require('ico-to-png'); const icoToPng = require('ico-to-png');
const sharp = require('sharp'); const sharp = require('sharp');
@@ -20,6 +21,9 @@ const fetchWithTimeout = (url) => {
return fetch(url, { return fetch(url, {
signal: abortController.signal, signal: abortController.signal,
dispatcher: sails.config.custom.outgoingProxy
? new ProxyAgent(sails.config.custom.outgoingProxy)
: undefined,
}); });
}; };
@@ -23,6 +23,11 @@ module.exports = {
if (config.smtpHost) { if (config.smtpHost) {
sourceConfig = config; 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: { tls: {
rejectUnauthorized: sourceConfig.smtpTlsRejectUnauthorized, rejectUnauthorized: sourceConfig.smtpTlsRejectUnauthorized,
}, },
proxy:
sails.config.custom.outgoingProxy && !sails.config.custom.smtpHost
? sails.config.custom.outgoingProxy
: undefined,
}, },
{ {
from: sourceConfig.smtpFrom, from: sourceConfig.smtpFrom,
+15 -6
View File
@@ -26,12 +26,21 @@ module.exports = {
async fn(inputs) { async fn(inputs) {
try { try {
await promisifyExecFile(`${sails.config.appPath}/.venv/bin/python3`, [ await promisifyExecFile(
`${sails.config.appPath}/utils/send_notifications.py`, `${sails.config.appPath}/.venv/bin/python3`,
JSON.stringify(inputs.services), [
inputs.title, `${sails.config.appPath}/utils/send_notifications.py`,
JSON.stringify(inputs.bodyByFormat), 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) { } catch (error) {
sails.log.error(`Error sending notifications: ${error.stderr || error.message}`); sails.log.error(`Error sending notifications: ${error.stderr || error.message}`);
} }
@@ -3,6 +3,8 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
const { ProxyAgent } = require('undici');
const Webhook = require('../../models/Webhook'); const Webhook = require('../../models/Webhook');
/** /**
@@ -61,6 +63,9 @@ async function sendWebhook(webhook, event, data, prevData, user) {
headers, headers,
body, body,
method: 'POST', method: 'POST',
dispatcher: sails.config.custom.outgoingProxy
? new ProxyAgent(sails.config.custom.outgoingProxy)
: undefined,
}); });
if (!response.ok) { if (!response.ok) {
+1
View File
@@ -66,6 +66,7 @@ module.exports.custom = {
demoMode: process.env.DEMO_MODE === 'true', demoMode: process.env.DEMO_MODE === 'true',
showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true', showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true',
outgoingProxy: process.env.OUTGOING_PROXY,
s3Endpoint: process.env.S3_ENDPOINT, s3Endpoint: process.env.S3_ENDPOINT,
s3Region: process.env.S3_REGION, s3Region: process.env.S3_REGION,
+10
View File
@@ -38,6 +38,7 @@
"sails-postgresql": "^5.0.1", "sails-postgresql": "^5.0.1",
"serve-static": "^2.2.1", "serve-static": "^2.2.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"undici": "^7.21.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"validator": "^13.15.26", "validator": "^13.15.26",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -11381,6 +11382,15 @@
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==",
"license": "MIT" "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": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+1
View File
@@ -83,6 +83,7 @@
"sails-postgresql": "^5.0.1", "sails-postgresql": "^5.0.1",
"serve-static": "^2.2.1", "serve-static": "^2.2.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"undici": "^7.21.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"validator": "^13.15.26", "validator": "^13.15.26",
"winston": "^3.19.0", "winston": "^3.19.0",
+102 -1
View File
@@ -18,7 +18,106 @@ load_secret() {
fi 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 if [[ -z "${DATABASE_PASSWORD:-}" && -e "${DATABASE_PASSWORD__FILE:-}" ]]; then
DATABASE_PASSWORD="$(read_secret "${DATABASE_PASSWORD__FILE}")" DATABASE_PASSWORD="$(read_secret "${DATABASE_PASSWORD__FILE}")"
export DATABASE_URL="${DATABASE_URL/\$\{DATABASE_PASSWORD\}/${DATABASE_PASSWORD}}" 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 OIDC_CLIENT_SECRET
load_secret SMTP_PASSWORD load_secret SMTP_PASSWORD
start_outgoing_proxy_if_needed
export NODE_ENV=production export NODE_ENV=production
node db/init.js node db/init.js