Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5f1300f
feat: introduced an egress proxy for L7 cred injection
wylswz Jul 29, 2026
3332764
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 29, 2026
229d3aa
refactor: make injection policy extensible with type field
wylswz Jul 30, 2026
bc2de8b
session scoped cred manifest
wylswz Jul 30, 2026
1c38316
truct mitm proxy's cert
wylswz Jul 30, 2026
edc04d3
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 30, 2026
7c55e30
adjust loader
wylswz Jul 30, 2026
073aad7
gemerate docker compose
wylswz Jul 30, 2026
36aa191
unconditionally use egress proxy
wylswz Jul 30, 2026
c5de57b
grant read only access of ca.crt to agent process
wylswz Jul 30, 2026
5688663
remove garbage comments
wylswz Jul 30, 2026
64fd356
refactor: centralize env const; remove legacy env
wylswz Jul 30, 2026
783f97a
fix lint
wylswz Jul 30, 2026
a372552
remove useless comment
wylswz Jul 30, 2026
843442c
always enable egress proxy
wylswz Jul 30, 2026
e9a7d99
add upstream proxy
wylswz Jul 30, 2026
abe502d
support aws injection policy
wylswz Jul 31, 2026
f91b874
include password in proxy auth
wylswz Jul 31, 2026
2c451d2
refactor: remove unused logics
wylswz Jul 31, 2026
ae540be
remove noKeepaliveTransport
wylswz Jul 31, 2026
b26f1e4
add comment
wylswz Jul 31, 2026
8337bd8
rename file
wylswz Jul 31, 2026
67092ef
refactor: modify cred manifest
wylswz Jul 31, 2026
878e787
fix lint
wylswz Jul 31, 2026
1ab7378
refactor: rename sandbox_id to session_id
wylswz Aug 1, 2026
0ed63d2
rename sandbox_id to session_id
wylswz Aug 1, 2026
01dfd09
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
ARG dify_uid=1001
ARG NODE_MAJOR=22
ARG NODE_PACKAGE_VERSION=22.21.0-1nodesource1
ARG NODESOURCE_KEY_FPR=6F71F525282841EEDAF851B42F59B5F99B1BE0B4

Check warning on line 59 in api/Dockerfile

View workflow job for this annotation

GitHub Actions / build-docker-fork (api-amd64, {{defaultContext}}, api/Dockerfile)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NODESOURCE_KEY_FPR") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
RUN groupadd -r -g ${dify_uid} dify && \
useradd -r -u ${dify_uid} -g ${dify_uid} -s /bin/bash dify && \
chown -R dify:dify /app
Expand Down Expand Up @@ -99,10 +99,10 @@
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"

RUN mkdir -p /usr/local/share/nltk_data \
&& NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
&& chmod -R 755 /usr/local/share/nltk_data
# RUN mkdir -p /usr/local/share/nltk_data \
# && NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \
# && NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \
# && chmod -R 755 /usr/local/share/nltk_data

ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache

Expand Down
97 changes: 97 additions & 0 deletions dify-agent-runtime/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,94 @@ integration-up:
fi; \
sleep 2; \
done
@echo "Setting up egress proxy container + echo backend..."
$(eval EGRESS_NET := sandbox-rt-egress-net-$(TEST_ID))
docker network create $(EGRESS_NET) > /dev/null
$(eval ECHO_CONTAINER := echo-backend-$(TEST_ID))
docker run -d --name $(ECHO_CONTAINER) \
--network $(EGRESS_NET) --network-alias echo-backend \
mendhak/http-https-echo:31
$(eval EGRESS_CONTAINER_NAME := sandbox-rt-egress-$(TEST_ID))
$(eval EGRESS_HOST_PORT := $(shell python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'))
$(eval EGRESS_AUTH_TOKEN := test-token-egress-$(TEST_ID))
docker run -d --name $(EGRESS_CONTAINER_NAME) \
--network $(EGRESS_NET) \
-p $(EGRESS_HOST_PORT):5004 \
-e SHELLCTL_AUTH_TOKEN=$(EGRESS_AUTH_TOKEN) \
-e SHELLCTL_EGRESSPROXY_ENABLED=true \
-e HTTP_PROXY=http://127.0.0.1:18080 \
-e HTTPS_PROXY=http://127.0.0.1:18080 \
-e NO_PROXY=localhost,127.0.0.1 \
$(IMAGE_NAME)
@echo 'EGRESS_NET=$(EGRESS_NET)' >> $(STATE_FILE)
@echo 'ECHO_CONTAINER=$(ECHO_CONTAINER)' >> $(STATE_FILE)
@echo 'EGRESS_CONTAINER_NAME=$(EGRESS_CONTAINER_NAME)' >> $(STATE_FILE)
@echo 'EGRESS_HOST_PORT=$(EGRESS_HOST_PORT)' >> $(STATE_FILE)
@echo 'EGRESS_AUTH_TOKEN=$(EGRESS_AUTH_TOKEN)' >> $(STATE_FILE)
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$(EGRESS_HOST_PORT)/healthz > /dev/null 2>&1; then \
echo "Egress proxy runtime is ready on port $(EGRESS_HOST_PORT)"; \
break; \
fi; \
if [ "$$i" -eq 30 ]; then \
echo "ERROR: egress proxy runtime not ready after 60s" >&2; \
docker logs $(EGRESS_CONTAINER_NAME); \
docker rm -f $(EGRESS_CONTAINER_NAME) $(ECHO_CONTAINER) 2>/dev/null; \
docker network rm $(EGRESS_NET) 2>/dev/null; \
exit 1; \
fi; \
sleep 2; \
done
@echo "Setting up upstream squid + egress-proxy-with-upstream container..."
@echo " NOTE: an earlier iteration tried to place this runtime container on"
@echo " a network with no direct path to echo-backend (only reachable via"
@echo " squid-upstream bridging two networks), to force a true end-to-end"
@echo " regression test of hostname passthrough mirroring production's"
@echo " local_sandbox/agent_ssrf_proxy/api topology. That approach was"
@echo " reverted: multi-homing squid-upstream across two docker networks"
@echo " via 'docker network connect' hit a Docker/host networking pitfall"
@echo " (squid answered fine on its primary network but not the secondary"
@echo " one -- asymmetric routing / rp_filter in the Docker Desktop VM),"
@echo " which is an environment limitation unrelated to the proxy code."
@echo " Hostname-passthrough itself is covered reliably (no Docker network"
@echo " involved) by TestProxyUpstreamChainingPreservesHostname in"
@echo " internal/egressproxy/proxy_test.go. This test only verifies that"
@echo " upstream chaining + credential injection work end-to-end."
$(eval SQUID_CONTAINER := squid-upstream-$(TEST_ID))
docker run -d --name $(SQUID_CONTAINER) \
--network $(EGRESS_NET) --network-alias squid-upstream \
-v $(CURDIR)/tests/squid-test.conf:/etc/squid/squid.conf:ro \
ubuntu/squid:latest
$(eval EGRESS_UPSTREAM_CONTAINER_NAME := sandbox-rt-egress-upstream-$(TEST_ID))
$(eval EGRESS_UPSTREAM_HOST_PORT := $(shell python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'))
$(eval EGRESS_UPSTREAM_AUTH_TOKEN := test-token-egress-upstream-$(TEST_ID))
docker run -d --name $(EGRESS_UPSTREAM_CONTAINER_NAME) \
--network $(EGRESS_NET) \
-p $(EGRESS_UPSTREAM_HOST_PORT):5004 \
-e SHELLCTL_AUTH_TOKEN=$(EGRESS_UPSTREAM_AUTH_TOKEN) \
-e SHELLCTL_EGRESSPROXY_ENABLED=true \
-e SHELLCTL_EGRESSPROXY_UPSTREAM=http://squid-upstream:3128 \
-e HTTP_PROXY=http://127.0.0.1:18080 \
-e HTTPS_PROXY=http://127.0.0.1:18080 \
-e NO_PROXY=localhost,127.0.0.1 \
$(IMAGE_NAME)
@echo 'SQUID_CONTAINER=$(SQUID_CONTAINER)' >> $(STATE_FILE)
@echo 'EGRESS_UPSTREAM_CONTAINER_NAME=$(EGRESS_UPSTREAM_CONTAINER_NAME)' >> $(STATE_FILE)
@echo 'EGRESS_UPSTREAM_HOST_PORT=$(EGRESS_UPSTREAM_HOST_PORT)' >> $(STATE_FILE)
@echo 'EGRESS_UPSTREAM_AUTH_TOKEN=$(EGRESS_UPSTREAM_AUTH_TOKEN)' >> $(STATE_FILE)
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$(EGRESS_UPSTREAM_HOST_PORT)/healthz > /dev/null 2>&1; then \
echo "Egress-with-upstream runtime is ready on port $(EGRESS_UPSTREAM_HOST_PORT)"; \
break; \
fi; \
if [ "$$i" -eq 30 ]; then \
echo "ERROR: egress-with-upstream runtime not ready after 60s" >&2; \
docker logs $(EGRESS_UPSTREAM_CONTAINER_NAME); \
docker rm -f $(EGRESS_UPSTREAM_CONTAINER_NAME) $(SQUID_CONTAINER) 2>/dev/null; \
exit 1; \
fi; \
sleep 2; \
done

integration-test:
@test -f $(STATE_FILE) || { echo "ERROR: run 'make integration-up' first" >&2; exit 1; }
Expand All @@ -120,6 +208,10 @@ integration-test:
SHELLCTL_TEST_TOKEN=$$AUTH_TOKEN \
SHELLCTL_GO_URL_NO_ISOLATION=http://localhost:$$HOST_PORT_NOISO \
SHELLCTL_TEST_TOKEN_NO_ISOLATION=$$AUTH_TOKEN_NOISO \
SHELLCTL_EGRESS_GO_URL=http://localhost:$$EGRESS_HOST_PORT \
SHELLCTL_EGRESS_TEST_TOKEN=$$EGRESS_AUTH_TOKEN \
SHELLCTL_EGRESS_UPSTREAM_GO_URL=http://localhost:$$EGRESS_UPSTREAM_HOST_PORT \
SHELLCTL_EGRESS_UPSTREAM_TEST_TOKEN=$$EGRESS_UPSTREAM_AUTH_TOKEN \
go test -tags=integration -v -count=1 -timeout=300s ./tests/...

integration-logs:
Expand All @@ -131,6 +223,11 @@ integration-down:
. ./$(STATE_FILE); \
docker rm -f $$CONTAINER_NAME 2>/dev/null || true; \
docker rm -f $$CONTAINER_NAME_NOISO 2>/dev/null || true; \
docker rm -f $$EGRESS_CONTAINER_NAME 2>/dev/null || true; \
docker rm -f $$EGRESS_UPSTREAM_CONTAINER_NAME 2>/dev/null || true; \
docker rm -f $$SQUID_CONTAINER 2>/dev/null || true; \
docker rm -f $$ECHO_CONTAINER 2>/dev/null || true; \
docker network rm $$EGRESS_NET 2>/dev/null || true; \
rm -f $(STATE_FILE); \
fi

Expand Down
1 change: 1 addition & 0 deletions dify-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ docker build -f dify-agent-runtime/docker/Dockerfile \
```
docker run -d --name dify-agent-runtime \
-p 15004:5004 \
-e SHELLCTL_EGRESSPROXY_ENABLED=true \
dify-agent-runtime:latest
```

Expand Down
19 changes: 13 additions & 6 deletions dify-agent-runtime/cmd/runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@ func parentMode() {
env := os.Environ()
// Remove internal shellctl vars from inherited env.
env = filterEnv(env, []string{
"TMUX",
"SHELLCTL_STATE_DIR",
"SHELLCTL_RUNTIME_DIR",
"SHELLCTL_TMUX_SOCKET",
"SHELLCTL_RUNNER",
"SHELLCTL_AUTH_TOKEN",
envvar.EnvTMUX,
envvar.EnvShellctlStateDir,
envvar.EnvShellctlRuntimeDir,
envvar.EnvShellctlTmuxSocket,
envvar.EnvShellctlRunner,
envvar.EnvShellctlAuthToken,
})

envOverlay := loadEnvJSON(envPath)
Expand Down Expand Up @@ -172,6 +172,13 @@ func childMode() {
home := os.Getenv("HOME")
jobDir := filepath.Dir(scriptPath)
cfg := landlock.ConfigFromEnv(home, cwd, jobDir)

// Grant read access to the egress proxy CA cert directory, which
// lives outside the per-binding HOME that Landlock grants.
if caCert := os.Getenv(envvar.EnvSSLCertFile); caCert != "" {
cfg.ROPaths = append(cfg.ROPaths, filepath.Dir(caCert))
}

if err := landlock.Restrict(cfg); err != nil {
// the landlock is best-effort, so we just log the error whatever it is
fmt.Fprintf(os.Stderr, "shellctl-runner: WARNING: %v — running without filesystem isolation\n", err)
Expand Down
3 changes: 2 additions & 1 deletion dify-agent-runtime/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent
RUN useradd --create-home --shell /bin/sh dify \
&& mkdir -p /mnt/drive \
&& chown dify:dify /home \
&& chown -R dify:dify /home/dify /mnt/drive
&& chown -R dify:dify /home/dify /mnt/drive \
&& chown -R dify:dify /usr/local/share/ca-certificates /etc/ssl/certs /etc/ca-certificates.conf

USER dify
WORKDIR /home/dify
Expand Down
46 changes: 46 additions & 0 deletions dify-agent-runtime/docs/egress-proxy-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Egress Credential Proxy — Demo Guide

This guide demonstrates the multi-tenant egress credential proxy system for Dify Agent's local sandbox. It walks through configuring a system-level credential manifest, understanding the architecture, and verifying that credentials are injected transparently — without ever appearing as plaintext environment variables in the job process.

---

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│ local_sandbox container │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ ┌───────────────┐ │
│ │ Agent Job │ │ Egress MITM Proxy │ │ Squid (SSRF) │ │
│ │ (tmux) │───▶│ 127.0.0.1:18080 │───▶│ agent_ssrf │ │
│ │ │ │ │ │ _proxy:3128 │ │
│ │ env: │ │ ┌────────────────┐ │ └──────┬────────┘ │
│ │ HTTP_PROXY │ │ │ Resolver │ │ │ │
│ │ HTTPS_PROXY │ │ │ system tier │ │ ▼ │
│ │ TAVILY_API_ │ │ │ session tier │ │ ┌──────────┐ │
│ │ KEY=__secret│ │ │ (per sandbox) │ │ │ Internet │ │
│ │ :tavily/ │ │ └────────────────┘ │ │ (e.g. │ │
│ │ api_key__ │ │ │ │ tavily) │ │
│ └──────────────┘ │ 1. Inject headers │ └──────────┘ │
│ │ 2. Strip Proxy-Auth │ │
│ └──────────────────────┘ │
│ │
│ system-credentials.yaml ──▶ loaded at startup into system tier │
│ (mounted read-only via Docker volume) │
└─────────────────────────────────────────────────────────────────────┘
```

### Key components

- **System credential manifest** (`system-credentials.yaml`): Mounted into the container via Docker volume. Parsed at startup (YAML or JSON). Credentials enter the Resolver's **system tier** — shared across all sandbox sessions, never mutated at runtime.

- **Session credentials**: Registered per sandbox session via `PUT /v1/prepare` API (with `session_id`). Stored in the Resolver's **session tier** — isolated per sandbox, no cross-session leakage. Session credentials shadow system credentials on key conflict.

- **Egress MITM Proxy** (`127.0.0.1:18080`): Intercepts all outbound HTTP/HTTPS traffic from agent jobs. For HTTPS, it performs TLS interception using a per-container CA (generated fresh at startup, installed into the system trust store). The proxy:
1. Extracts `session_id` from the `Proxy-Authorization` header (embedded as Basic-Auth userinfo in the proxy URL).
2. **Proactively injects** credential headers based on domain-matching policies (e.g. `Authorization: Bearer <token>` for `api.tavily.com`).
3. Strips the `Proxy-Authorization` header before forwarding.

- **Squid SSRF proxy** (`agent_ssrf_proxy:3128`): Upstream of the egress proxy. Enforces network-level egress restrictions (deny private networks, allow public internet).

- **Per-container CA**: Generated at startup by `egressproxy.GenerateCA()`. Installed into the system trust store via `update-ca-certificates` (Dockerfile grants the non-root `dify` user write access to the necessary paths). This means **all** tools — including `apt-get`, `wget`, Java, etc. — trust the MITM proxy's TLS certificates without needing per-tool env vars.
12 changes: 8 additions & 4 deletions dify-agent-runtime/go.mod
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
module github.com/langgenius/dify/dify-agent-runtime

go 1.26
go 1.26.5

require (
github.com/aws/aws-sdk-go-v2 v1.43.0
github.com/elazarl/goproxy v1.8.5
github.com/landlock-lsm/go-landlock v0.9.0
github.com/spf13/cobra v1.10.2
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.37.1
)

require (
github.com/aws/smithy-go v1.27.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand All @@ -19,9 +23,9 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
modernc.org/libc v1.65.7 // indirect
Expand Down
Loading
Loading