fix(ssrf): apply private allowlist to agent_ssrf_proxy (#41870)

This commit is contained in:
Zhu Lei 2026-09-07 07:22:12 +00:00 committed by GitHub
parent 5c0d3c4393
commit c7423ffb2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 153 additions and 0 deletions

View File

@ -706,6 +706,8 @@ services:
environment: environment:
HTTP_PORT: ${SSRF_HTTP_PORT:-3128} HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}
networks: networks:
# Needs to reach api and agent_backend as forward-proxy destinations. # Needs to reach api and agent_backend as forward-proxy destinations.
- default - default

View File

@ -712,6 +712,8 @@ services:
environment: environment:
HTTP_PORT: ${SSRF_HTTP_PORT:-3128} HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}
networks: networks:
# Needs to reach api and agent_backend as forward-proxy destinations. # Needs to reach api and agent_backend as forward-proxy destinations.
- default - default

34
docker/ssrf_proxy/docker-agent-entrypoint.sh Normal file → Executable file
View File

@ -5,6 +5,40 @@ tail -F /var/log/squid/error.log 2>/dev/null &
tail -F /var/log/squid/store.log 2>/dev/null & tail -F /var/log/squid/store.log 2>/dev/null &
tail -F /var/log/squid/cache.log 2>/dev/null & tail -F /var/log/squid/cache.log 2>/dev/null &
ALLOW_PRIVATE_CONF=/etc/squid/dify_allow_private.conf
write_optional_private_allowlist() {
local env_name="$1"
local acl_name="$2"
local acl_type="$3"
local raw_values="${!env_name:-}"
local -a tokens=()
raw_values="${raw_values//,/ }"
raw_values="${raw_values//$'\n'/ }"
raw_values="${raw_values//$'\r'/ }"
if [ -z "${raw_values//[[:space:]]/}" ]; then
return
fi
read -r -a tokens <<< "$raw_values"
printf 'acl %s %s' "$acl_name" "$acl_type" >> "$ALLOW_PRIVATE_CONF"
for value in "${tokens[@]}"; do
[ -z "${value//[[:space:]]/}" ] && continue
printf ' %s' "$value" >> "$ALLOW_PRIVATE_CONF"
done
printf '\nhttp_access allow client_localnet %s\n' "$acl_name" >> "$ALLOW_PRIVATE_CONF"
}
{
echo "# Generated by docker-agent-entrypoint.sh."
echo "# Allows selected private targets before the default private-network deny rule."
} > "$ALLOW_PRIVATE_CONF"
write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_IPS" "dify_allowed_private_networks" "dst"
write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS" "dify_allowed_private_domains" "dstdomain"
expand_env() { expand_env() {
awk '{ awk '{
while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) {

View File

@ -17,5 +17,6 @@ http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports http_access deny CONNECT !SSL_ports
http_access allow dst_agent_backend path_agent_stub http_access allow dst_agent_backend path_agent_stub
http_access allow dst_dify_api path_files http_access allow dst_dify_api path_files
include /etc/squid/dify_allow_private.conf
http_access deny to_private_networks http_access deny to_private_networks
http_access allow all http_access allow all

View File

@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
ALLOW_PRIVATE_CONF="$WORK_DIR/dify_allow_private.conf"
export ALLOW_PRIVATE_CONF
write_optional_private_allowlist() {
local env_name="$1"
local acl_name="$2"
local acl_type="$3"
local raw_values="${!env_name:-}"
local -a tokens=()
raw_values="${raw_values//,/ }"
raw_values="${raw_values//$'\n'/ }"
raw_values="${raw_values//$'\r'/ }"
if [ -z "${raw_values//[[:space:]]/}" ]; then
return
fi
read -r -a tokens <<< "$raw_values"
printf 'acl %s %s' "$acl_name" "$acl_type" >> "$ALLOW_PRIVATE_CONF"
for value in "${tokens[@]}"; do
[ -z "${value//[[:space:]]/}" ] && continue
printf ' %s' "$value" >> "$ALLOW_PRIVATE_CONF"
done
printf '\nhttp_access allow client_localnet %s\n' "$acl_name" >> "$ALLOW_PRIVATE_CONF"
}
generate_allowlist_config() {
{
echo "# Generated by docker-agent-entrypoint.sh."
echo "# Allows selected private targets before the default private-network deny rule."
} > "$ALLOW_PRIVATE_CONF"
write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_IPS" "dify_allowed_private_networks" "dst"
write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS" "dify_allowed_private_domains" "dstdomain"
}
assert_contains() {
local file="$1"
local expected="$2"
if ! grep -Fq "$expected" "$file"; then
echo "Expected generated config to contain: $expected"
cat "$file" >&2 || true
exit 1
fi
}
assert_not_contains() {
local file="$1"
local unexpected="$2"
if grep -Fq "$unexpected" "$file"; then
echo "Expected generated config NOT to contain: $unexpected"
cat "$file" >&2 || true
exit 1
fi
}
# Empty env vars should produce only the header comments.
unset SSRF_PROXY_ALLOW_PRIVATE_IPS SSRF_PROXY_ALLOW_PRIVATE_DOMAINS
generate_allowlist_config
assert_not_contains "$ALLOW_PRIVATE_CONF" "dify_allowed_private_networks"
assert_not_contains "$ALLOW_PRIVATE_CONF" "dify_allowed_private_domains"
# IP and domain allowlists should match the workflow ssrf_proxy entrypoint format.
export SSRF_PROXY_ALLOW_PRIVATE_IPS="172.21.0.0/16,10.0.0.1"
export SSRF_PROXY_ALLOW_PRIVATE_DOMAINS="internal_api,.corp.local"
generate_allowlist_config
assert_contains "$ALLOW_PRIVATE_CONF" "acl dify_allowed_private_networks dst 172.21.0.0/16 10.0.0.1"
assert_contains "$ALLOW_PRIVATE_CONF" "http_access allow client_localnet dify_allowed_private_networks"
assert_contains "$ALLOW_PRIVATE_CONF" "acl dify_allowed_private_domains dstdomain internal_api .corp.local"
assert_contains "$ALLOW_PRIVATE_CONF" "http_access allow client_localnet dify_allowed_private_domains"
# Glob-like tokens and newline separators must not be expanded or split by the shell.
export SSRF_PROXY_ALLOW_PRIVATE_IPS=$'172.21.0.0/16\n10.0.0.1'
export SSRF_PROXY_ALLOW_PRIVATE_DOMAINS='*.corp.local,?internal.example'
generate_allowlist_config
assert_contains "$ALLOW_PRIVATE_CONF" "acl dify_allowed_private_networks dst 172.21.0.0/16 10.0.0.1"
assert_contains "$ALLOW_PRIVATE_CONF" "acl dify_allowed_private_domains dstdomain *.corp.local ?internal.example"
if ! grep -Fq "include /etc/squid/dify_allow_private.conf" "$ROOT_DIR/squid-agent.conf.template"; then
echo "squid-agent.conf.template must include dify_allow_private.conf before the private-network deny rule."
exit 1
fi
if ! grep -A1 "include /etc/squid/dify_allow_private.conf" "$ROOT_DIR/squid-agent.conf.template" | grep -Fq "http_access deny to_private_networks"; then
echo "dify_allow_private.conf must be included before http_access deny to_private_networks."
exit 1
fi
echo "Agent SSRF allowlist unit tests passed."

View File

@ -9,6 +9,7 @@ SANDBOX_CONTAINER_NAME="${CONTAINER_NAME}-sandbox"
AGENT_PROXY_CONTAINER_NAME="${CONTAINER_NAME}-agent-proxy" AGENT_PROXY_CONTAINER_NAME="${CONTAINER_NAME}-agent-proxy"
API_CONTAINER_NAME="${CONTAINER_NAME}-api" API_CONTAINER_NAME="${CONTAINER_NAME}-api"
AGENT_BACKEND_CONTAINER_NAME="${CONTAINER_NAME}-agent-backend" AGENT_BACKEND_CONTAINER_NAME="${CONTAINER_NAME}-agent-backend"
INTERNAL_SERVICE_CONTAINER_NAME="${CONTAINER_NAME}-internal-service"
NETWORK_NAME="${SSRF_PROXY_TEST_NETWORK:-dify-ssrf-proxy-test-$$}" NETWORK_NAME="${SSRF_PROXY_TEST_NETWORK:-dify-ssrf-proxy-test-$$}"
RUN_PUBLIC_CHECK="${SSRF_PROXY_TEST_PUBLIC_CHECK:-true}" RUN_PUBLIC_CHECK="${SSRF_PROXY_TEST_PUBLIC_CHECK:-true}"
@ -18,6 +19,7 @@ cleanup() {
docker rm -f "$AGENT_PROXY_CONTAINER_NAME" >/dev/null 2>&1 || true docker rm -f "$AGENT_PROXY_CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$API_CONTAINER_NAME" >/dev/null 2>&1 || true docker rm -f "$API_CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$AGENT_BACKEND_CONTAINER_NAME" >/dev/null 2>&1 || true docker rm -f "$AGENT_BACKEND_CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$INTERNAL_SERVICE_CONTAINER_NAME" >/dev/null 2>&1 || true
docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
} }
@ -204,6 +206,16 @@ docker run \
sh -c "mkdir -p /www/agent-stub && echo stub-ok > /www/agent-stub/config && echo denied > /www/index.html && httpd -f -p 5050 -h /www" \ sh -c "mkdir -p /www/agent-stub && echo stub-ok > /www/agent-stub/config && echo denied > /www/index.html && httpd -f -p 5050 -h /www" \
>/dev/null >/dev/null
# Mock internal service reachable only on the private Docker network.
docker run \
--detach \
--name "$INTERNAL_SERVICE_CONTAINER_NAME" \
--network "$NETWORK_NAME" \
--network-alias internal_api \
"$CLIENT_IMAGE" \
sh -c "mkdir -p /www && echo internal-ok > /www/health && httpd -f -p 8080 -h /www" \
>/dev/null
docker run \ docker run \
--detach \ --detach \
--name "$AGENT_PROXY_CONTAINER_NAME" \ --name "$AGENT_PROXY_CONTAINER_NAME" \
@ -214,6 +226,8 @@ docker run \
--volume "$ROOT_DIR/docker/ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \ --volume "$ROOT_DIR/docker/ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \
--env HTTP_PORT=3128 \ --env HTTP_PORT=3128 \
--env COREDUMP_DIR=/var/spool/squid \ --env COREDUMP_DIR=/var/spool/squid \
--env "SSRF_PROXY_ALLOW_PRIVATE_IPS=${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}" \
--env "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-internal_api}" \
"$IMAGE" \ "$IMAGE" \
-c "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh" \ -c "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh" \
>/dev/null >/dev/null
@ -237,6 +251,9 @@ fi
assert_private_target_blocked "$agent_proxy_url" "http://127.0.0.1:80/" assert_private_target_blocked "$agent_proxy_url" "http://127.0.0.1:80/"
assert_private_target_blocked "$agent_proxy_url" "http://169.254.169.254/latest/meta-data/" assert_private_target_blocked "$agent_proxy_url" "http://169.254.169.254/latest/meta-data/"
# Allowlisted private domains must be reachable for agent skills.
assert_public_target_allowed "$agent_proxy_url" "http://internal_api:8080/health"
# agent_backend /agent-stub/* must be allowed. # agent_backend /agent-stub/* must be allowed.
assert_public_target_allowed "$agent_proxy_url" "http://agent_backend:5050/agent-stub/config" assert_public_target_allowed "$agent_proxy_url" "http://agent_backend:5050/agent-stub/config"