diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 37d7185e4a6..7049e237ba4 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -531,6 +531,14 @@ services: - ssrf_proxy_network # Local sandbox for Dify Agent shell workspaces. + # Network isolation: local_sandbox has NO direct route to `api`. Its only + # networks are `agent_sandbox_network` (so agent_backend can reach it on 5004 + # for shellctl, and it can reach agent_backend directly) and + # `local_sandbox_proxy_network` (so its egress is forced through + # agent_ssrf_proxy). + # All non-agent_backend/localhost traffic goes through the Squid forward proxy + # on port 3128, which only allows agent_backend /agent-stub/ and the Dify API + # /files/* endpoints (see ssrf_proxy/squid-agent.conf.template). local_sandbox: image: langgenius/dify-agent-local-sandbox:1.16.0 restart: always @@ -539,6 +547,9 @@ services: required: false environment: - SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + - HTTP_PROXY=http://agent_ssrf_proxy:3128 + - HTTPS_PROXY=http://agent_ssrf_proxy:3128 + - NO_PROXY=localhost,127.0.0.1 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] interval: 30s @@ -546,7 +557,8 @@ services: retries: 3 start_period: 10s networks: - - default + - agent_sandbox_network + - local_sandbox_proxy_network # plugin daemon plugin_daemon: @@ -672,6 +684,33 @@ services: condition: service_started networks: - default + # Shared internal network with local_sandbox so agent_backend can reach it + # on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`. + - agent_sandbox_network + + # Dedicated SSRF proxy for the dify-agent local_sandbox. + agent_ssrf_proxy: + image: ubuntu/squid:latest + restart: always + volumes: + - ./ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template + - ./ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh + 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", + ] + environment: + HTTP_PORT: ${SSRF_HTTP_PORT:-3128} + COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} + networks: + # Needs to reach api and agent_backend as forward-proxy destinations. + - default + # Only agent_ssrf_proxy and local_sandbox share this internal network, so + # the local_sandbox can reach Squid without gaining a direct route to `api`. + - local_sandbox_proxy_network # ssrf_proxy server # for more information, please refer to @@ -681,6 +720,7 @@ services: restart: always volumes: - ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template - ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh entrypoint: [ @@ -1253,6 +1293,19 @@ networks: ssrf_proxy_network: driver: bridge internal: true + # Internal network shared only by agent_ssrf_proxy and local_sandbox. + local_sandbox_proxy_network: + driver: bridge + internal: true + # shellctl control channel (agent_backend -> local_sandbox:5004). + # sandbox can access agent backend through this network, this is + # a known limitation. + # + # The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution + # is still possible through the shellctl channel. + agent_sandbox_network: + driver: bridge + internal: true milvus: driver: bridge opensearch-net: diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 01f8fbde3e3..a0334afb5d7 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -199,6 +199,7 @@ services: restart: always volumes: - ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template - ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh entrypoint: [ diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 908e979c8cf..5eae7d4d3ca 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -537,6 +537,14 @@ services: - ssrf_proxy_network # Local sandbox for Dify Agent shell workspaces. + # Network isolation: local_sandbox has NO direct route to `api`. Its only + # networks are `agent_sandbox_network` (so agent_backend can reach it on 5004 + # for shellctl, and it can reach agent_backend directly) and + # `local_sandbox_proxy_network` (so its egress is forced through + # agent_ssrf_proxy). + # All non-agent_backend/localhost traffic goes through the Squid forward proxy + # on port 3128, which only allows agent_backend /agent-stub/ and the Dify API + # /files/* endpoints (see ssrf_proxy/squid-agent.conf.template). local_sandbox: image: langgenius/dify-agent-local-sandbox:1.16.0 restart: always @@ -545,6 +553,9 @@ services: required: false environment: - SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + - HTTP_PROXY=http://agent_ssrf_proxy:3128 + - HTTPS_PROXY=http://agent_ssrf_proxy:3128 + - NO_PROXY=localhost,127.0.0.1 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] interval: 30s @@ -552,7 +563,8 @@ services: retries: 3 start_period: 10s networks: - - default + - agent_sandbox_network + - local_sandbox_proxy_network # plugin daemon plugin_daemon: @@ -678,6 +690,33 @@ services: condition: service_started networks: - default + # Shared internal network with local_sandbox so agent_backend can reach it + # on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`. + - agent_sandbox_network + + # Dedicated SSRF proxy for the dify-agent local_sandbox. + agent_ssrf_proxy: + image: ubuntu/squid:latest + restart: always + volumes: + - ./ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template + - ./ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh + 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", + ] + environment: + HTTP_PORT: ${SSRF_HTTP_PORT:-3128} + COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} + networks: + # Needs to reach api and agent_backend as forward-proxy destinations. + - default + # Only agent_ssrf_proxy and local_sandbox share this internal network, so + # the local_sandbox can reach Squid without gaining a direct route to `api`. + - local_sandbox_proxy_network # ssrf_proxy server # for more information, please refer to @@ -687,6 +726,7 @@ services: restart: always volumes: - ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template - ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh entrypoint: [ @@ -1259,6 +1299,19 @@ networks: ssrf_proxy_network: driver: bridge internal: true + # Internal network shared only by agent_ssrf_proxy and local_sandbox. + local_sandbox_proxy_network: + driver: bridge + internal: true + # shellctl control channel (agent_backend -> local_sandbox:5004). + # sandbox can access agent backend through this network, this is + # a known limitation. + # + # The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution + # is still possible through the shellctl channel. + agent_sandbox_network: + driver: bridge + internal: true milvus: driver: bridge opensearch-net: diff --git a/docker/ssrf_proxy/docker-agent-entrypoint.sh b/docker/ssrf_proxy/docker-agent-entrypoint.sh new file mode 100644 index 00000000000..29dee3c48e3 --- /dev/null +++ b/docker/ssrf_proxy/docker-agent-entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +tail -F /var/log/squid/access.log 2>/dev/null & +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 & + +expand_env() { + awk '{ + while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { + var = substr($0, RSTART+2, RLENGTH-3) + val = ENVIRON[var] + $0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH) + } + print + }' "$1" +} + +echo "[ENTRYPOINT] replacing environment variables in the templates" +expand_env /etc/squid/squid.conf.template > /etc/squid/squid.conf +expand_env /etc/squid/dify_common.conf.template > /etc/squid/dify_common.conf + +/usr/sbin/squid -Nz +echo "[ENTRYPOINT] starting squid" +/usr/sbin/squid -f /etc/squid/squid.conf -NYC 1 diff --git a/docker/ssrf_proxy/docker-entrypoint.sh b/docker/ssrf_proxy/docker-entrypoint.sh index a19f9818b24..36c3a60a9f8 100755 --- a/docker/ssrf_proxy/docker-entrypoint.sh +++ b/docker/ssrf_proxy/docker-entrypoint.sh @@ -74,16 +74,22 @@ if [ -n "${SSRF_SANDBOX_PROXY_PORT:-}" ]; then } >> "$SANDBOX_PROXY_CONF" fi -# Replace environment variables in the template and output to the squid.conf -echo "[ENTRYPOINT] replacing environment variables in the template" -awk '{ - while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { - var = substr($0, RSTART+2, RLENGTH-3) - val = ENVIRON[var] - $0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH) - } - print -}' /etc/squid/squid.conf.template > /etc/squid/squid.conf +# Replace environment variables in a template file. +expand_env() { + awk '{ + while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { + var = substr($0, RSTART+2, RLENGTH-3) + val = ENVIRON[var] + $0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH) + } + print + }' "$1" +} + +# Replace environment variables in the templates and output to squid.conf +echo "[ENTRYPOINT] replacing environment variables in the templates" +expand_env /etc/squid/squid.conf.template > /etc/squid/squid.conf +expand_env /etc/squid/dify_common.conf.template > /etc/squid/dify_common.conf /usr/sbin/squid -Nz echo "[ENTRYPOINT] starting squid" diff --git a/docker/ssrf_proxy/squid-agent.conf.template b/docker/ssrf_proxy/squid-agent.conf.template new file mode 100644 index 00000000000..3a00d73dc85 --- /dev/null +++ b/docker/ssrf_proxy/squid-agent.conf.template @@ -0,0 +1,21 @@ +# Dedicated Squid config for the dify-agent local_sandbox SSRF proxy. +# All traffic on this proxy is restricted to: +# - agent_backend /agent-stub/* endpoints +# - Dify API /files/* endpoints (signed upload/download URLs) +# External internet is allowed; all other private-network destinations are denied. + +include /etc/squid/dify_common.conf + +acl dst_agent_backend dstdomain agent_backend +acl dst_dify_api dstdomain api +acl path_files urlpath_regex -i ^/files/ +acl path_agent_stub urlpath_regex -i ^/agent-stub/ + +http_port ${HTTP_PORT} + +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 +http_access deny to_private_networks +http_access allow all diff --git a/docker/ssrf_proxy/squid-common.conf.template b/docker/ssrf_proxy/squid-common.conf.template new file mode 100644 index 00000000000..d3620dd0b8b --- /dev/null +++ b/docker/ssrf_proxy/squid-common.conf.template @@ -0,0 +1,90 @@ +# Shared Squid configuration used by both ssrf_proxy and agent_ssrf_proxy. + +################################## ACL Definitions ################################ +acl client_localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) +acl client_localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) +acl client_localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) +acl client_localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines +acl client_localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) +acl client_localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) +acl client_localnet src fc00::/7 # RFC 4193 local private network range +acl client_localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines + +acl to_private_networks dst 0.0.0.0/8 +acl to_private_networks dst 10.0.0.0/8 +acl to_private_networks dst 100.64.0.0/10 +acl to_private_networks dst 127.0.0.0/8 +acl to_private_networks dst 169.254.0.0/16 +acl to_private_networks dst 172.16.0.0/12 +acl to_private_networks dst 192.168.0.0/16 +acl to_private_networks dst 224.0.0.0/4 +acl to_private_networks dst 240.0.0.0/4 +acl to_private_networks dst ::/128 +acl to_private_networks dst ::1/128 +acl to_private_networks dst ::ffff:0:0/96 # IPv4-mapped +acl to_private_networks dst ::/96 # deprecated IPv4-compatible +acl to_private_networks dst fc00::/7 +acl to_private_networks dst fe80::/10 + +acl SSL_ports port 443 +acl Safe_ports port 80 # http +acl Safe_ports port 21 # ftp +acl Safe_ports port 443 # https +acl Safe_ports port 70 # gopher +acl Safe_ports port 210 # wais +acl Safe_ports port 1025-65535 # unregistered ports +acl Safe_ports port 280 # http-mgmt +acl Safe_ports port 488 # gss-http +acl Safe_ports port 591 # filemaker +acl Safe_ports port 777 # multiling http +acl CONNECT method CONNECT + +################################## Common Parameters ################################ + +tcp_outgoing_address 0.0.0.0 + +################################## Proxy Server ################################ +coredump_dir ${COREDUMP_DIR} +refresh_pattern ^ftp: 1440 20% 10080 +refresh_pattern ^gopher: 1440 0% 1440 +refresh_pattern -i (/cgi-bin/|\?) 0 0% 0 +refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims +refresh_pattern \/InRelease$ 0 0% 0 refresh-ims +refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern . 0 20% 4320 + +################################## Request Buffer ################################ +client_request_buffer_max_size 100 MB + +################################## Performance & Concurrency ############################### +max_filedescriptors 65536 +connect_timeout 30 seconds +request_timeout 2 minutes +read_timeout 2 minutes +client_lifetime 5 minutes +shutdown_lifetime 30 seconds + +server_persistent_connections on +client_persistent_connections on +persistent_request_timeout 30 seconds +pconn_timeout 1 minute + +client_db on +server_idle_pconn_timeout 2 minutes +client_idle_pconn_timeout 2 minutes + +quick_abort_min 16 KB +quick_abort_max 16 MB +quick_abort_pct 95 + +memory_cache_mode disk +cache_mem 256 MB +maximum_object_size_in_memory 512 KB + +dns_timeout 30 seconds +dns_retransmit_interval 5 seconds + +logformat dify_log %ts.%03tu %6tr %>a %Ss/%03>Hs %a %Ss/%03>Hs %/dev/null 2>&1 || true docker rm -f "$SANDBOX_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 "$AGENT_BACKEND_CONTAINER_NAME" >/dev/null 2>&1 || true docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true } @@ -106,6 +112,7 @@ docker run \ --entrypoint sh \ --network "$NETWORK_NAME" \ --volume "$ROOT_DIR/docker/ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template:ro" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template:ro" \ --volume "$ROOT_DIR/docker/ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \ --env HTTP_PORT=3128 \ --env COREDUMP_DIR=/var/spool/squid \ @@ -141,3 +148,79 @@ if [[ "$RUN_PUBLIC_CHECK" == "true" ]]; then fi assert_sandbox_bridge_allowed "http://$CONTAINER_NAME:8194/health" + +# --------------------------------------------------------------------------- +# agent_ssrf_proxy tests +# --------------------------------------------------------------------------- + +# Mock api server: serves /files/* (200) and everything else (404). +docker run \ + --detach \ + --name "$API_CONTAINER_NAME" \ + --network "$NETWORK_NAME" \ + --network-alias api \ + "$CLIENT_IMAGE" \ + sh -c "mkdir -p /www/files && echo file-ok > /www/files/test && echo denied > /www/index.html && httpd -f -p 5001 -h /www" \ + >/dev/null + +# Mock agent_backend server: serves /agent-stub/* (200) and everything else (404). +docker run \ + --detach \ + --name "$AGENT_BACKEND_CONTAINER_NAME" \ + --network "$NETWORK_NAME" \ + --network-alias agent_backend \ + "$CLIENT_IMAGE" \ + 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 + +docker run \ + --detach \ + --name "$AGENT_PROXY_CONTAINER_NAME" \ + --entrypoint sh \ + --network "$NETWORK_NAME" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template:ro" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template:ro" \ + --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 \ + "$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 + +agent_proxy_url="http://$AGENT_PROXY_CONTAINER_NAME:3128" +for _ in {1..30}; do + agent_probe_status="$(http_code_for "$agent_proxy_url" "http://127.0.0.1:80/")" + if [[ -n "$agent_probe_status" ]]; then + break + fi + sleep 1 +done + +if [[ -z "${agent_probe_status:-}" ]]; then + echo "Agent SSRF proxy did not respond to probes." + docker logs "$AGENT_PROXY_CONTAINER_NAME" >&2 || true + exit 1 +fi + +# Private targets must be blocked. +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/" + +# agent_backend /agent-stub/* must be allowed. +assert_public_target_allowed "$agent_proxy_url" "http://agent_backend:5050/agent-stub/config" + +# agent_backend non-/agent-stub paths must be blocked (403 from Squid). +assert_private_target_blocked "$agent_proxy_url" "http://agent_backend:5050/index.html" + +# api /files/* must be allowed. +assert_public_target_allowed "$agent_proxy_url" "http://api:5001/files/test" + +# api non-/files paths must be blocked. +assert_private_target_blocked "$agent_proxy_url" "http://api:5001/index.html" + +# External internet must be allowed. +if [[ "$RUN_PUBLIC_CHECK" == "true" ]]; then + assert_public_target_allowed "$agent_proxy_url" "http://example.com/" +fi + +echo "All SSRF proxy tests passed."