mirror of
https://github.com/plankanban/planka.git
synced 2026-03-01 11:21:46 +03:00
feat: Add configurable proxy for outgoing traffic to prevent SSRF
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
server/package-lock.json
generated
10
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
103
server/start.sh
103
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
|
||||
|
||||
Reference in New Issue
Block a user