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

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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=

View File

@@ -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,
});
};

View File

@@ -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,

View File

@@ -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}`);
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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