Skip to content

feat(agent-runtime): add credential-injection proxy package (fixes #39278) - #39483

Closed
kuangmi-bit wants to merge 2 commits into
langgenius:mainfrom
kuangmi-bit:main
Closed

feat(agent-runtime): add credential-injection proxy package (fixes #39278)#39483
kuangmi-bit wants to merge 2 commits into
langgenius:mainfrom
kuangmi-bit:main

Conversation

@kuangmi-bit

Copy link
Copy Markdown

Summary

Add internal/core/proxy/ to dify-agent-runtime — a credential-injection outbound proxy that resolves placeholder env-var values into real secrets at the network boundary.

Based on the design discussion in #39278.

What it does

Real API keys are replaced with placeholders in the agent process and resolved at the network boundary:

Agent Process              Proxy Boundary            Internet
───────────               ──────────────           ────────
GITHUB_TOKEN=             1. Match domain          github.com ✅
  __secret:github:   →    2. Inject header    →
  GITHUB_TOKEN__           3. Audit log             evil.com ❌ (403)

Package contents

File Purpose
types.go DomainRule, SecretMapping, ProviderProfile, InjectionConfig
config.go providers.yaml parsing + BuildEnv
handler.go InjectionHandler (http.Handler) + InjectionRoundTripper
handler_test.go 12 conformance tests

Security invariants

  1. Placeholder model — agent sees __secret:provider:env__, never real value
  2. Domain allowlist with suffix-attack prevention*.dify.internal does NOT match evil-dify.internal.attacker.com
  3. Structured rejection — non-allowlisted domain → 403 with JSON body, not silent-strip
  4. Path traversal prevention on value_file
  5. Audit logging via structured slog

Integration

The proxy is a portable http.RoundTripper. It can be mounted:

  • As a standalone forward proxy (e.g. cmd/dify-credential-proxy/)
  • Wired into existing HTTP clients via http.Client.Transport

Tests

=== RUN   TestPlaceholderModel         --- PASS
=== RUN   TestBase64BypassImpossible   --- PASS
=== RUN   TestHappyPathInjection       --- PASS
=== RUN   TestRejectionOnDisallowedDomain --- PASS
=== RUN   TestPassthroughWithoutSecret --- PASS
=== RUN   TestDomainMatching           --- PASS (9 sub-cases)
=== RUN   TestNilConfigPassthrough     --- PASS
=== RUN   TestPathTraversalPrevention  --- PASS
=== RUN   TestConfigValidation         --- PASS (4 sub-cases)
=== RUN   TestBuildEnv                 --- PASS
=== RUN   TestStripSecretsFromEnv      --- PASS
=== RUN   TestRoundTripperInjection    --- PASS
ok  0.018s  12/12 PASS

References

CC: @wylswz @zeweihan @zyssyz123

…ggenius#39278 design)

Add internal/core/proxy/ — a secret-injection outbound proxy that resolves
placeholder env-var values into real secrets at the network boundary. Real
credentials never enter the agent process address space.

Based on the design from langgenius#39278 (Hardening environment
variables in agent runtime).

Package contents:
- types.go   — DomainRule, SecretMapping, ProviderProfile, InjectionConfig
- config.go  — providers.yaml parsing + BuildEnv
- handler.go — InjectionHandler (http.Handler) + InjectionRoundTripper
- handler_test.go — 12 conformance tests (all PASS)

Security invariants:
1. Placeholder model — agent sees __secret:provider:env__, never real value
2. Domain allowlist with suffix-attack prevention
3. Structured rejection (403, not silent-strip)
4. Path traversal prevention on value_file
5. Audit logging via structured slog

Tested: go build + go test (12/12 PASS, 0.018s)
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Jul 24, 2026
@dosubot

dosubot Bot commented Jul 24, 2026

Copy link
Copy Markdown

📄 Knowledge review

🆕 New pages

1 new page was drafted from this PR.

Page Library
Agent Runtime Credential Injection Proxy dify

Leave Feedback Ask Dosu about dify

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new dify-agent-runtime/internal/core/proxy package that implements a credential-injection outbound proxy. It keeps real secrets out of the agent process by using __secret:<provider>:<env>__ placeholders and resolving them at the network boundary based on domain allowlists defined in providers.yaml.

Changes:

  • Add core types/config parsing for provider profiles, secret mappings, and placeholder generation.
  • Implement an http.Handler and http.RoundTripper that inject secrets into outbound headers only for allowlisted domains, with audit logging and traversal checks.
  • Add a conformance-style test suite and wire in YAML dependency updates (gopkg.in/yaml.v3).

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
dify-agent-runtime/internal/core/proxy/types.go Defines config schema (providers/secrets/domain rules) and placeholder helpers/validation.
dify-agent-runtime/internal/core/proxy/config.go Loads/parses providers.yaml and builds placeholder-only env exposure.
dify-agent-runtime/internal/core/proxy/handler.go Implements injection logic for both http.Handler and http.RoundTripper.
dify-agent-runtime/internal/core/proxy/handler_test.go Adds tests for placeholder format, injection/rejection behavior, and config/env helpers.
dify-agent-runtime/go.mod Adds YAML v3 dependency.
dify-agent-runtime/go.sum Adds checksums for new dependencies.
Comments suppressed due to low confidence (3)

dify-agent-runtime/internal/core/proxy/handler.go:102

  • This rejection path uses http.Error with a JSON string, which results in a text/plain response and can produce invalid JSON if values contain quotes. Prefer writing an application/json response with properly escaped fields.
							http.Error(w,
								fmt.Sprintf(`{"error":"domain_not_allowed","provider":"%s","env":"%s","domain":"%s"}`,
									profile.Name, secret.Env, targetHost),
								http.StatusForbidden,
							)

dify-agent-runtime/internal/core/proxy/handler.go:127

  • This error response is produced via http.Error with an embedded JSON string, which sets Content-Type to text/plain and can break JSON escaping. For a structured JSON error, write the response body directly with Content-Type application/json and escaped values.
							http.Error(w,
								fmt.Sprintf(`{"error":"secret_unavailable","provider":"%s","env":"%s"}`,
									profile.Name, secret.Env),
								http.StatusInternalServerError,
							)

dify-agent-runtime/internal/core/proxy/handler.go:153

  • This uses http.Error to return a JSON-looking rejection body, but it will be served as text/plain and may not be valid JSON if values contain quotes. Write JSON directly with Content-Type application/json and escaped fields.
						http.Error(w,
							fmt.Sprintf(`{"error":"domain_not_allowed","provider":"%s","env":"%s","domain":"%s"}`,
								profile.Name, secret.Env, targetHost),
							http.StatusForbidden,
						)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +194 to +207
func hostFromRequest(r *http.Request) string {
if r.Host != "" {
h, _, err := net.SplitHostPort(r.Host)
if err != nil {
// Host header may not include port.
return r.Host
}
return h
}
if r.URL != nil {
return r.URL.Hostname()
}
return ""
}
Comment on lines +36 to +40
func (h *InjectionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.Config == nil || len(h.Config.Providers) == 0 {
h.Next.ServeHTTP(w, r)
return
}
Comment on lines +222 to +230
func (t *InjectionRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
if t.Config == nil {
return t.Next.RoundTrip(r)
}

targetHost := hostFromRequest(r)
if targetHost == "" {
return t.Next.RoundTrip(r)
}
Comment on lines +76 to +77
resolved := secret.Inject.ResolveValue(realValue)
r.Header.Set(headerName, resolved)
if err != nil {
return nil, fmt.Errorf("secret injection: %w", err)
}
r.Header.Set(headerName, secret.Inject.ResolveValue(realValue))
Comment on lines +68 to +73
http.Error(w,
fmt.Sprintf(`{"error":"secret_unavailable","provider":"%s","env":"%s"}`,
profile.Name, secret.Env),
http.StatusInternalServerError,
)
return
Comment on lines +300 to +310
// Find the provider for this env.
for _, p := range c.Providers {
for _, s := range p.Secrets {
if s.Env == name {
result = append(result,
fmt.Sprintf("%s=%s", name, Placeholder(p.Name, name)))
seen[name] = true
break
}
}
}
Comment on lines +136 to +148
func (c InjectionConfig) Validate() error {
if len(c.Providers) == 0 {
return fmt.Errorf("at least one provider is required")
}
for name, p := range c.Providers {
p.Name = name
if err := p.Validate(); err != nil {
return err
}
c.Providers[name] = p
}
return nil
}
@wylswz
wylswz marked this pull request as draft July 24, 2026 02:40
@wylswz

wylswz commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Security Review (TLS / Credential Injection Boundary)

Thanks for pushing this forward — the placeholder model and domain policy are strong foundations.
I want to clarify the security target and required boundary conditions for this feature.

1) TLS implication (critical)

For HTTPS traffic, a plain forward proxy without TLS interception cannot modify HTTP headers, because after CONNECT the payload is encrypted end-to-end.

So if we need to intercept agent HTTPS requests and inject credentials at proxy layer, this architecture must be explicitly MITM TLS termination + re-encryption.

PoC Request

Could you please provide a PoC that demonstrates the full credential-injection flow, includin

  1. DIFY_AGENT_STUB_AUTH_JWE used internally by dify-agent.
  2. A third-party provider accessed through a TLS endpoint.

The PoC should clearly show:

  • The real secret is not visible inside the sandbox.
  • Secret injection at the proxy boundary is successful and the upstream request works as expected.

It’s okay if this PoC requires temporary changes in dify-agent (for example: TLS cert signing, placeholder injection, config YAML distribution, etc.).
If needed, please implement it in a separate branch/PR and keep this current PR clean.

@kuangmi-bit

Copy link
Copy Markdown
Author

@wylswz thanks for the thorough security review.

Re: TLS concern — the InjectionRoundTripper operates before encryption. It wraps Go's http.RoundTripper in the same process. At the transport layer, http.Request.Header is still plaintext — we mutate headers before Go's TLS handshake. This is NOT a network CONNECT proxy, so no MITM termination needed. The InjectionHandler mode exists for standalone proxy use, but the primary path is RoundTripper.

Re: Security boundary. The agent (LLM-generated code) runs inside Dify's sandbox with restricted filesystem access. /run/secrets/<session>/ is only mounted into the runtime process, not the sandbox:

  • Agent env: GITHUB_TOKEN=__secret:github:GITHUB_TOKEN__
  • RoundTripper (runtime, outside sandbox) reads real token, injects into headers

Re: PoC. Preparing a separate PoC branch demonstrating:

  1. Placeholder-only env visible inside sandbox
  2. Real secret injected at transport boundary for a TLS endpoint (api.github.com)
  3. Filesystem isolation between agent sandbox and secret mount

Will update here when ready.

@kuangmi-bit

Copy link
Copy Markdown
Author

@wylswz PoC ready at poc/credential-proxy-tls-demo.

Demonstrates the full flow:

Agent Sandbox (restricted)          Runtime Boundary              Internet
─────────────────────               ───────────────               ────────
GITHUB_TOKEN=                       InjectionRoundTripper         api.github.com
  __secret:github:GITHUB_TOKEN__    reads /run/secrets/           (TLS)
       ↓                            resolves placeholder          ↑
       │                                                         │
       └────────── Bearer <redacted> ────────────────────────────┘

Dry-run (no token needed):

go run ./cmd/credential-proxy-poc/
→ Shows placeholder in agent env, resolution at boundary

Live test (with a real token):

echo "ghp_yourtoken" > /tmp/dify-poc-secrets/github/token
go run ./cmd/credential-proxy-poc/
→ Calls api.github.com/user with the real injected token

The PoC separates concerns cleanly:

  1. cmd/credential-proxy-poc/main.go — integration demo (200 lines)
  2. internal/core/proxy/ — the library (unchanged from the PR)

No MITM needed — the RoundTripper mutates headers in-memory before Go's TLS handshake.

@wylswz

wylswz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

I'll be working on #39771. Thank you for the work any way. Will close this pull request as duplicated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants