From c7423ffb2d0914bc00fd40173082c819dd077f1e Mon Sep 17 00:00:00 2001 From: Zhu Lei <123629497+kabishou11@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:22:12 +0000 Subject: [PATCH] fix(ssrf): apply private allowlist to agent_ssrf_proxy (#41870) --- docker/docker-compose-template.yaml | 2 + docker/docker-compose.yaml | 2 + docker/ssrf_proxy/docker-agent-entrypoint.sh | 34 +++++++ docker/ssrf_proxy/squid-agent.conf.template | 1 + .../test_agent_ssrf_allowlist_unit.sh | 97 +++++++++++++++++++ docker/ssrf_proxy/test_ssrf_proxy_config.sh | 17 ++++ 6 files changed, 153 insertions(+) mode change 100644 => 100755 docker/ssrf_proxy/docker-agent-entrypoint.sh create mode 100755 docker/ssrf_proxy/test_agent_ssrf_allowlist_unit.sh diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 04a0c9b9fa1..3ce3358c569 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -706,6 +706,8 @@ services: environment: HTTP_PORT: ${SSRF_HTTP_PORT:-3128} 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: # Needs to reach api and agent_backend as forward-proxy destinations. - default diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0ad2271b29d..fd59abaa3f9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -712,6 +712,8 @@ services: environment: HTTP_PORT: ${SSRF_HTTP_PORT:-3128} 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: # Needs to reach api and agent_backend as forward-proxy destinations. - default diff --git a/docker/ssrf_proxy/docker-agent-entrypoint.sh b/docker/ssrf_proxy/docker-agent-entrypoint.sh old mode 100644 new mode 100755 index 29dee3c48e3..0ea6adbfec9 --- a/docker/ssrf_proxy/docker-agent-entrypoint.sh +++ b/docker/ssrf_proxy/docker-agent-entrypoint.sh @@ -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/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() { awk '{ while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { diff --git a/docker/ssrf_proxy/squid-agent.conf.template b/docker/ssrf_proxy/squid-agent.conf.template index 3a00d73dc85..ef78ef07aab 100644 --- a/docker/ssrf_proxy/squid-agent.conf.template +++ b/docker/ssrf_proxy/squid-agent.conf.template @@ -17,5 +17,6 @@ http_access deny !Safe_ports http_access deny CONNECT !SSL_ports http_access allow dst_agent_backend path_agent_stub http_access allow dst_dify_api path_files +include /etc/squid/dify_allow_private.conf http_access deny to_private_networks http_access allow all diff --git a/docker/ssrf_proxy/test_agent_ssrf_allowlist_unit.sh b/docker/ssrf_proxy/test_agent_ssrf_allowlist_unit.sh new file mode 100755 index 00000000000..6980ffa1690 --- /dev/null +++ b/docker/ssrf_proxy/test_agent_ssrf_allowlist_unit.sh @@ -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." diff --git a/docker/ssrf_proxy/test_ssrf_proxy_config.sh b/docker/ssrf_proxy/test_ssrf_proxy_config.sh index e2e6f435b49..1c031e45c0d 100755 --- a/docker/ssrf_proxy/test_ssrf_proxy_config.sh +++ b/docker/ssrf_proxy/test_ssrf_proxy_config.sh @@ -9,6 +9,7 @@ SANDBOX_CONTAINER_NAME="${CONTAINER_NAME}-sandbox" AGENT_PROXY_CONTAINER_NAME="${CONTAINER_NAME}-agent-proxy" API_CONTAINER_NAME="${CONTAINER_NAME}-api" 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-$$}" 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 "$API_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 } @@ -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" \ >/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 \ --detach \ --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" \ --env HTTP_PORT=3128 \ --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" \ -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 @@ -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://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. assert_public_target_allowed "$agent_proxy_url" "http://agent_backend:5050/agent-stub/config"