feat: Add configurable proxy for outgoing traffic to prevent SSRF
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user