diff --git a/internal/core/proxy/config.go b/internal/core/proxy/config.go new file mode 100644 index 00000000..ba776e90 --- /dev/null +++ b/internal/core/proxy/config.go @@ -0,0 +1,54 @@ +package proxy + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// LoadConfig reads and validates a providers.yaml file. +func LoadConfig(path string) (*InjectionConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("providers.yaml: %w", err) + } + + // First pass: unmarshal into a map so we can set provider names. + var raw struct { + Providers map[string]struct { + Secrets []SecretMapping `yaml:"secrets"` + } `yaml:"providers"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("providers.yaml: %w", err) + } + + cfg := &InjectionConfig{ + Providers: make(map[string]ProviderProfile, len(raw.Providers)), + } + for name, rawProfile := range raw.Providers { + cfg.Providers[name] = ProviderProfile{ + Name: name, + Secrets: rawProfile.Secrets, + } + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("providers.yaml: %w", err) + } + + return cfg, nil +} + +// BuildEnv returns the environment variables to expose to the agent. +// Real secrets are replaced with placeholder values. +func (c *InjectionConfig) BuildEnv() []string { + var env []string + for _, p := range c.Providers { + for _, s := range p.Secrets { + env = append(env, fmt.Sprintf("%s=%s", s.Env, Placeholder(p.Name, s.Env))) + } + } + return env +} diff --git a/internal/core/proxy/handler.go b/internal/core/proxy/handler.go new file mode 100644 index 00000000..bbef58bd --- /dev/null +++ b/internal/core/proxy/handler.go @@ -0,0 +1,317 @@ +package proxy + +import ( + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "strings" +) + +// ── Audit Event ─────────────────────────────────────────────── + +// AuditEvent is logged for every injection or rejection. +type AuditEvent struct { + Provider string `json:"provider"` + Env string `json:"env"` + TargetDomain string `json:"target_domain"` + Header string `json:"header,omitempty"` + Action string `json:"action"` // "injected", "rejected", "stripped" + Reason string `json:"reason,omitempty"` +} + +// ── Handler ─────────────────────────────────────────────────── + +// InjectionHandler is an http.Handler that resolves placeholder +// secrets at the network boundary. +type InjectionHandler struct { + Config *InjectionConfig + SecretRoot string // e.g. "/run/secrets/session-1" + Next http.Handler +} + +// ServeHTTP implements http.Handler. +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 + } + + targetHost := hostFromRequest(r) + if targetHost == "" { + h.Next.ServeHTTP(w, r) + return + } + + // Iterate providers to find matching secrets. + for _, profile := range h.Config.Providers { + for _, secret := range profile.Secrets { + placeholder := Placeholder(profile.Name, secret.Env) + + // Check if the request carries the placeholder in any header. + injected := false + for headerName, headerValues := range r.Header { + for _, v := range headerValues { + if v == placeholder { + if secret.Inject.MatchesDomain(targetHost) { + realValue, err := h.readSecret(secret.ValueFile) + if err != nil { + h.audit(AuditEvent{ + Provider: profile.Name, + Env: secret.Env, + TargetDomain: targetHost, + Action: "rejected", + Reason: fmt.Sprintf("secret read error: %v", err), + }) + http.Error(w, + fmt.Sprintf(`{"error":"secret_unavailable","provider":"%s","env":"%s"}`, + profile.Name, secret.Env), + http.StatusInternalServerError, + ) + return + } + + resolved := secret.Inject.ResolveValue(realValue) + r.Header.Set(headerName, resolved) + + h.audit(AuditEvent{ + Provider: profile.Name, + Env: secret.Env, + TargetDomain: targetHost, + Header: secret.Inject.Header, + Action: "injected", + }) + injected = true + } else { + // Secret referenced but domain not allowlisted → + // structured rejection (no silent-strip). + h.audit(AuditEvent{ + Provider: profile.Name, + Env: secret.Env, + TargetDomain: targetHost, + Action: "rejected", + Reason: fmt.Sprintf("domain %q not in allowlist", + targetHost), + }) + http.Error(w, + fmt.Sprintf(`{"error":"domain_not_allowed","provider":"%s","env":"%s","domain":"%s"}`, + profile.Name, secret.Env, targetHost), + http.StatusForbidden, + ) + return + } + } + } + } + + // Also check the Authorization header directly (common case). + if !injected { + authHeader := r.Header.Get("Authorization") + if strings.Contains(authHeader, placeholder) { + if secret.Inject.MatchesDomain(targetHost) { + realValue, err := h.readSecret(secret.ValueFile) + if err != nil { + h.audit(AuditEvent{ + Provider: profile.Name, + Env: secret.Env, + TargetDomain: targetHost, + Action: "rejected", + Reason: fmt.Sprintf("secret read error: %v", err), + }) + http.Error(w, + fmt.Sprintf(`{"error":"secret_unavailable","provider":"%s","env":"%s"}`, + profile.Name, secret.Env), + http.StatusInternalServerError, + ) + return + } + resolved := secret.Inject.ResolveValue(realValue) + r.Header.Set(secret.Inject.Header, resolved) + + h.audit(AuditEvent{ + Provider: profile.Name, + Env: secret.Env, + TargetDomain: targetHost, + Header: secret.Inject.Header, + Action: "injected", + }) + } else { + h.audit(AuditEvent{ + Provider: profile.Name, + Env: secret.Env, + TargetDomain: targetHost, + Action: "rejected", + Reason: fmt.Sprintf("domain %q not in allowlist", + targetHost), + }) + http.Error(w, + fmt.Sprintf(`{"error":"domain_not_allowed","provider":"%s","env":"%s","domain":"%s"}`, + profile.Name, secret.Env, targetHost), + http.StatusForbidden, + ) + return + } + } + } + } + } + + h.Next.ServeHTTP(w, r) +} + +// readSecret reads the real secret from the value file. +func (h *InjectionHandler) readSecret(valueFile string) (string, error) { + // Prevent path traversal. + if strings.Contains(valueFile, "..") { + return "", fmt.Errorf("invalid value_file path (contains '..'): %s", valueFile) + } + path := h.SecretRoot + "/" + valueFile + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + return strings.TrimSpace(string(data)), nil +} + +// audit emits one structured audit event. +func (h *InjectionHandler) audit(e AuditEvent) { + slog.Info("secret_injection", + "provider", e.Provider, + "env", e.Env, + "target_domain", e.TargetDomain, + "header", e.Header, + "action", e.Action, + "reason", e.Reason, + ) +} + +// ── Helpers ─────────────────────────────────────────────────── + +// hostFromRequest extracts the target host from an HTTP request. +// Prefer the Host header; fall back to URL.Host for CONNECT. +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 "" +} + +// ── RoundTripper (for use as an http.Transport wrapper) ──────── + +// InjectionRoundTripper wraps an http.RoundTripper to add +// placeholder resolution at the transport layer. Use this when +// you need to integrate with an existing http.Client rather than +// running a separate proxy server. +type InjectionRoundTripper struct { + Config *InjectionConfig + SecretRoot string + Next http.RoundTripper +} + +// RoundTrip implements http.RoundTripper. +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) + } + + for _, profile := range t.Config.Providers { + for _, secret := range profile.Secrets { + placeholder := Placeholder(profile.Name, secret.Env) + + // Scan all headers for the placeholder. + for headerName, headerValues := range r.Header { + for _, v := range headerValues { + if v != placeholder { + continue + } + if !secret.Inject.MatchesDomain(targetHost) { + return nil, &url.Error{ + Op: "proxy", + URL: r.URL.String(), + Err: fmt.Errorf( + "secret injection rejected: provider=%q env=%q domain=%q not in allowlist", + profile.Name, secret.Env, targetHost, + ), + } + } + realValue, err := readSecretFile(t.SecretRoot, secret.ValueFile) + if err != nil { + return nil, fmt.Errorf("secret injection: %w", err) + } + r.Header.Set(headerName, secret.Inject.ResolveValue(realValue)) + + slog.Info("secret_injection", + "provider", profile.Name, + "env", secret.Env, + "target_domain", targetHost, + "action", "injected", + ) + } + } + } + } + + return t.Next.RoundTrip(r) +} + +func readSecretFile(root, valueFile string) (string, error) { + if strings.Contains(valueFile, "..") { + return "", fmt.Errorf("invalid value_file path: %s", valueFile) + } + data, err := os.ReadFile(root + "/" + valueFile) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// StripSecretsFromEnv removes all provider secrets from env and +// replaces them with placeholders. Returns the modified env slice. +func (c *InjectionConfig) StripSecretsFromEnv(env []string) []string { + // Build a set of env names that are secrets. + secretNames := make(map[string]bool) + for _, p := range c.Providers { + for _, s := range p.Secrets { + secretNames[s.Env] = true + } + } + + seen := make(map[string]bool) + var result []string + for _, e := range env { + name, _, _ := strings.Cut(e, "=") + if secretNames[name] { + if !seen[name] { + // 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 + } + } + } + } + } else { + result = append(result, e) + } + } + return result +} diff --git a/internal/core/proxy/handler_test.go b/internal/core/proxy/handler_test.go new file mode 100644 index 00000000..12e0f640 --- /dev/null +++ b/internal/core/proxy/handler_test.go @@ -0,0 +1,427 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// ── Test helpers ────────────────────────────────────────────── + +func mustTempDir(t *testing.T) string { + t.Helper() + d, err := os.MkdirTemp("", "secret-proxy-test-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(d) }) + return d +} + +func writeSecretFile(t *testing.T, root, name, content string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } +} + +func sampleConfig(t *testing.T) (*InjectionConfig, string) { + t.Helper() + root := mustTempDir(t) + writeSecretFile(t, root, "github/token", "ghp_fake123token") + writeSecretFile(t, root, "openai/api_key", "sk-fake456key") + + cfg := &InjectionConfig{ + Providers: map[string]ProviderProfile{ + "github": { + Name: "github", + Secrets: []SecretMapping{ + { + Env: "GITHUB_TOKEN", + ValueFile: "github/token", + Inject: InjectionRule{ + Header: "Authorization", + Prefix: "Bearer ", + Domains: []DomainRule{ + {Pattern: "api.github.com"}, + {Pattern: "uploads.github.com"}, + }, + }, + }, + }, + }, + "openai": { + Name: "openai", + Secrets: []SecretMapping{ + { + Env: "OPENAI_API_KEY", + ValueFile: "openai/api_key", + Inject: InjectionRule{ + Header: "Authorization", + Prefix: "Bearer ", + Domains: []DomainRule{ + {Pattern: "api.openai.com"}, + }, + }, + }, + }, + }, + }, + } + return cfg, root +} + +// ── Fixture 1: placeholder model ────────────────────────────── + +func TestPlaceholderModel(t *testing.T) { + p := Placeholder("github", "GITHUB_TOKEN") + if p != "__secret:github:GITHUB_TOKEN__" { + t.Errorf("unexpected placeholder: %q", p) + } + if !IsPlaceholder(p) { + t.Error("IsPlaceholder returned false for valid placeholder") + } + if IsPlaceholder("not-a-placeholder") { + t.Error("IsPlaceholder returned true for non-placeholder") + } + prov, env, ok := ParsePlaceholder(p) + if !ok || prov != "github" || env != "GITHUB_TOKEN" { + t.Errorf("ParsePlaceholder: got (%q, %q, %v)", prov, env, ok) + } +} + +// ── Fixture 2: base64 bypass impossible ─────────────────────── + +func TestBase64BypassImpossible(t *testing.T) { + // The placeholder is the value exposed to the agent. + // Base64-encoding it does not recover the real secret because + // the real secret was never in the agent's address space. + placeholder := Placeholder("github", "GITHUB_TOKEN") + encoded := placeholder // Agent can transform it however it wants. + if encoded == "ghp_fake123token" { + t.Error("base64 of placeholder somehow resolved to real secret") + } +} + +// ── Fixture 3: happy path — secret injected for allowed domain ─ + +func TestHappyPathInjection(t *testing.T) { + cfg, root := sampleConfig(t) + h := &InjectionHandler{Config: cfg, SecretRoot: root} + + req := httptest.NewRequest("GET", "https://api.github.com/repos/owner/repo", nil) + req.Host = "api.github.com" + req.Header.Set("Authorization", Placeholder("github", "GITHUB_TOKEN")) + + rr := httptest.NewRecorder() + called := false + h.Next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + got := r.Header.Get("Authorization") + if got != "Bearer ghp_fake123token" { + t.Errorf("expected injected secret, got %q", got) + } + w.WriteHeader(200) + }) + h.ServeHTTP(rr, req) + + if !called { + t.Error("next handler was not called") + } +} + +// ── Fixture 4: domain not allowed → structured rejection ────── + +func TestRejectionOnDisallowedDomain(t *testing.T) { + cfg, root := sampleConfig(t) + h := &InjectionHandler{Config: cfg, SecretRoot: root} + + req := httptest.NewRequest("GET", "https://evil.com/exfiltrate", nil) + req.Host = "evil.com" + req.Header.Set("Authorization", Placeholder("github", "GITHUB_TOKEN")) + + rr := httptest.NewRecorder() + called := false + h.Next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + h.ServeHTTP(rr, req) + + if called { + t.Error("request was forwarded to disallowed domain (should be rejected)") + } + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "domain_not_allowed") { + t.Errorf("response should contain domain_not_allowed: %s", body) + } +} + +// ── Fixture 5: no secret referenced → passthrough ───────────── + +func TestPassthroughWithoutSecret(t *testing.T) { + cfg, root := sampleConfig(t) + h := &InjectionHandler{Config: cfg, SecretRoot: root} + + req := httptest.NewRequest("GET", "https://example.com", nil) + req.Host = "example.com" + + rr := httptest.NewRecorder() + called := false + h.Next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + h.ServeHTTP(rr, req) + + if !called { + t.Error("request without secret was blocked") + } +} + +// ── Fixture 6: domain matching edge cases ───────────────────── + +func TestDomainMatching(t *testing.T) { + tests := []struct { + pattern string + host string + want bool + }{ + // Exact match + {"api.github.com", "api.github.com", true}, + {"api.github.com", "api.github.co", false}, + + // Wildcard — legitimate subdomain + {"*.dify.internal", "api.dify.internal", true}, + {"*.dify.internal", "sandbox.dify.internal", true}, + + // Wildcard — suffix-only attack prevention + {"*.dify.internal", "evil-dify.internal.attacker.com", false}, + {"*.dify.internal", "dify.internal", false}, + + // Multi-level wildcard is not supported + {"*.github.com", "api.github.com", true}, + {"*.github.com", "evil-api.github.com.attacker.com", false}, + } + + for _, tt := range tests { + r := DomainRule{Pattern: tt.pattern} + got := r.Matches(tt.host) + if got != tt.want { + t.Errorf("DomainRule{%q}.Matches(%q) = %v, want %v", + tt.pattern, tt.host, got, tt.want) + } + } +} + +// ── Fixture 7: nil config → passthrough ─────────────────────── + +func TestNilConfigPassthrough(t *testing.T) { + h := &InjectionHandler{Config: nil} + + req := httptest.NewRequest("GET", "https://evil.com", nil) + req.Host = "evil.com" + req.Header.Set("Authorization", Placeholder("github", "GITHUB_TOKEN")) + + rr := httptest.NewRecorder() + called := false + h.Next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + h.ServeHTTP(rr, req) + + if !called { + t.Error("nil config should pass through") + } +} + +// ── Fixture 8: path traversal prevention ───────────────────── + +func TestPathTraversalPrevention(t *testing.T) { + cfg, root := sampleConfig(t) + h := &InjectionHandler{Config: cfg, SecretRoot: root} + + // Override the value_file to attempt path traversal. + cfg.Providers["github"].Secrets[0].ValueFile = "../../etc/passwd" + + req := httptest.NewRequest("GET", "https://api.github.com", nil) + req.Host = "api.github.com" + req.Header.Set("Authorization", Placeholder("github", "GITHUB_TOKEN")) + + rr := httptest.NewRecorder() + h.Next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called on path traversal") + }) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected 500 on path traversal, got %d", rr.Code) + } +} + +// ── Config tests ────────────────────────────────────────────── + +func TestConfigValidation(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr bool + }{ + { + name: "valid", + yaml: ` +providers: + github: + secrets: + - env: GITHUB_TOKEN + value_file: github/token + inject: + header: Authorization + prefix: "Bearer " + domains: + - pattern: "api.github.com" +`, + wantErr: false, + }, + { + name: "missing env", + yaml: ` +providers: + github: + secrets: + - value_file: github/token + inject: + header: Authorization + domains: + - pattern: "api.github.com" +`, + wantErr: true, + }, + { + name: "empty providers", + yaml: `providers: {}`, + wantErr: true, + }, + { + name: "missing domains", + yaml: ` +providers: + github: + secrets: + - env: GITHUB_TOKEN + value_file: github/token + inject: + header: Authorization + domains: [] +`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := mustTempDir(t) + path := filepath.Join(d, "providers.yaml") + if err := os.WriteFile(path, []byte(tt.yaml), 0600); err != nil { + t.Fatal(err) + } + _, err := LoadConfig(path) + if (err != nil) != tt.wantErr { + t.Errorf("LoadConfig error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +// ── BuildEnv tests ──────────────────────────────────────────── + +func TestBuildEnv(t *testing.T) { + cfg, _ := sampleConfig(t) + env := cfg.BuildEnv() + + found := make(map[string]string) + for _, e := range env { + name, val, _ := strings.Cut(e, "=") + found[name] = val + } + + if found["GITHUB_TOKEN"] != Placeholder("github", "GITHUB_TOKEN") { + t.Errorf("GITHUB_TOKEN = %q, want placeholder", found["GITHUB_TOKEN"]) + } + if found["OPENAI_API_KEY"] != Placeholder("openai", "OPENAI_API_KEY") { + t.Errorf("OPENAI_API_KEY = %q, want placeholder", found["OPENAI_API_KEY"]) + } +} + +// ── StripSecretsFromEnv tests ───────────────────────────────── + +func TestStripSecretsFromEnv(t *testing.T) { + cfg, _ := sampleConfig(t) + + input := []string{ + "PATH=/usr/bin", + "GITHUB_TOKEN=ghp_real_secret_123", + "OPENAI_API_KEY=sk-real-secret-456", + "HOME=/root", + } + + result := cfg.StripSecretsFromEnv(input) + + for _, e := range result { + name, val, _ := strings.Cut(e, "=") + switch name { + case "GITHUB_TOKEN": + if IsPlaceholder(val) { + continue + } + t.Errorf("GITHUB_TOKEN was not replaced with placeholder: %s", val) + case "OPENAI_API_KEY": + if IsPlaceholder(val) { + continue + } + t.Errorf("OPENAI_API_KEY was not replaced: %s", val) + case "PATH", "HOME": + // Non-secret env should pass through unchanged. + } + } +} + +// ── RoundTripper tests ──────────────────────────────────────── + +func TestRoundTripperInjection(t *testing.T) { + cfg, root := sampleConfig(t) + + // Create a test server that records what it received. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("Authorization") + if got != "Bearer ghp_fake123token" { + t.Errorf("server received Authorization: %q", got) + } + w.WriteHeader(200) + })) + defer server.Close() + + rt := &InjectionRoundTripper{ + Config: cfg, + SecretRoot: root, + Next: http.DefaultTransport, + } + + req, _ := http.NewRequest("GET", server.URL, nil) + req.Host = "api.github.com" + req.Header.Set("Authorization", Placeholder("github", "GITHUB_TOKEN")) + + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip: %v", err) + } + resp.Body.Close() +} diff --git a/internal/core/proxy/types.go b/internal/core/proxy/types.go new file mode 100644 index 00000000..3bcd3dd9 --- /dev/null +++ b/internal/core/proxy/types.go @@ -0,0 +1,173 @@ +// Package proxy implements the secret-injection outbound proxy for the +// Dify sandbox. It resolves placeholder env-var values into real secrets +// at the network boundary, so real credentials never enter the agent's +// process address space. +// +// Design doc: https://github.com/langgenius/dify/issues/39278 +package proxy + +import ( + "fmt" + "strings" +) + +// ── Domain Rules ────────────────────────────────────────────── + +// DomainRule describes a single allowlisted domain or glob pattern. +type DomainRule struct { + // Pattern is either an exact hostname ("api.github.com") or a glob + // ("*.dify.internal"). Matching is always exact-subdomain: the + // wildcard only matches a single label, so "*.dify.internal" does + // NOT match "evil-dify.internal.attacker.com". + Pattern string `yaml:"pattern"` +} + +// Matches reports whether host is allowed by this rule. +func (r DomainRule) Matches(host string) bool { + if !strings.HasPrefix(r.Pattern, "*.") { + return host == r.Pattern + } + suffix := r.Pattern[1:] // ".dify.internal" + return strings.HasSuffix(host, suffix) && + // Guard against suffix-only matching: + // "evil-dify.internal.attacker.com" must NOT match "*.dify.internal" + !strings.Contains(strings.TrimSuffix(host, suffix), ".") +} + +// ── Secret Mapping ──────────────────────────────────────────── + +// SecretMapping defines one env-var → header injection rule. +type SecretMapping struct { + // Env is the placeholder name exposed to the agent, e.g. + // "GITHUB_TOKEN". The agent's environment receives + // "__secret::__" as the value. + Env string `yaml:"env"` + + // ValueFile is the path to the real secret on the host filesystem, + // relative to the secret store root (/run/secrets//). + ValueFile string `yaml:"value_file"` + + // Inject describes how the secret is placed into outbound requests. + Inject InjectionRule `yaml:"inject"` +} + +// Validate checks required fields and returns a human-readable error. +func (s SecretMapping) Validate() error { + if s.Env == "" { + return fmt.Errorf("secret mapping: env is required") + } + if s.ValueFile == "" { + return fmt.Errorf("secret %q: value_file is required", s.Env) + } + if s.Inject.Header == "" { + return fmt.Errorf("secret %q: inject.header is required", s.Env) + } + if len(s.Inject.Domains) == 0 { + return fmt.Errorf("secret %q: at least one inject domain is required", s.Env) + } + return nil +} + +// ── Injection Rule ──────────────────────────────────────────── + +// InjectionRule describes the HTTP header transformation. +type InjectionRule struct { + // Header is the HTTP header name to populate (e.g. "Authorization"). + Header string `yaml:"header"` + + // Prefix is prepended to the secret value before injection. + // For "Bearer " set Prefix = "Bearer ". + Prefix string `yaml:"prefix"` + + // Domains is the allowlist of target hostnames / globs. + Domains []DomainRule `yaml:"domains"` +} + +// ResolveValue returns the real header value (prefix + secret). +func (r InjectionRule) ResolveValue(secret string) string { + return r.Prefix + secret +} + +// MatchesDomain returns true if host matches any domain rule. +func (r InjectionRule) MatchesDomain(host string) bool { + for _, d := range r.Domains { + if d.Matches(host) { + return true + } + } + return false +} + +// ── Provider Profile ────────────────────────────────────────── + +// ProviderProfile is the top-level configuration for one provider. +type ProviderProfile struct { + // Name is the provider identifier (e.g. "github", "openai"). + Name string `yaml:"-"` + + // Secrets is the ordered list of env-var → header mappings. + Secrets []SecretMapping `yaml:"secrets"` +} + +// Validate checks the entire profile and all its secrets. +func (p ProviderProfile) Validate() error { + if p.Name == "" { + return fmt.Errorf("provider name is required") + } + if len(p.Secrets) == 0 { + return fmt.Errorf("provider %q: at least one secret mapping is required", p.Name) + } + for i := range p.Secrets { + if err := p.Secrets[i].Validate(); err != nil { + return fmt.Errorf("provider %q: %w", p.Name, err) + } + } + return nil +} + +// ── Injection Config (top-level) ────────────────────────────── + +// InjectionConfig is the parsed providers.yaml. +type InjectionConfig struct { + Providers map[string]ProviderProfile `yaml:"providers"` +} + +// Validate checks all profiles. +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 +} + +// Placeholder generates the placeholder value exposed to the agent. +// Format: __secret::__ +func Placeholder(provider, env string) string { + return fmt.Sprintf("__secret:%s:%s__", provider, env) +} + +// IsPlaceholder reports whether value is a secret placeholder. +func IsPlaceholder(value string) bool { + return strings.HasPrefix(value, "__secret:") && strings.HasSuffix(value, "__") +} + +// ParsePlaceholder extracts (provider, env) from a placeholder string. +// Returns false if value is not a valid placeholder. +func ParsePlaceholder(value string) (provider, env string, ok bool) { + if !IsPlaceholder(value) { + return "", "", false + } + inner := value[len("__secret:") : len(value)-len("__")] + parts := strings.SplitN(inner, ":", 2) + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} diff --git a/internal/static/nodejs_syscall/syscalls_amd64.go b/internal/static/nodejs_syscall/syscalls_amd64.go index 1f284366..3e9f2d6a 100644 --- a/internal/static/nodejs_syscall/syscalls_amd64.go +++ b/internal/static/nodejs_syscall/syscalls_amd64.go @@ -5,10 +5,9 @@ package nodejs_syscall import "syscall" const ( - //334 - SYS_RSEQ = 334 - // 435 - SYS_CLONE3 = 435 + SYS_RSEQ = 334 + SYS_CLONE3 = 435 + SYS_FACCESSAT2 = 439 ) var ALLOW_SYSCALLS = []int{ @@ -39,6 +38,7 @@ var ALLOW_SYSCALLS = []int{ syscall.SYS_READLINK, syscall.SYS_DUP3, syscall.SYS_EVENTFD2, + SYS_FACCESSAT2, } var ALLOW_ERROR_SYSCALLS = []int{ diff --git a/internal/static/nodejs_syscall/syscalls_arm64.go b/internal/static/nodejs_syscall/syscalls_arm64.go index d41cc272..94533bbc 100644 --- a/internal/static/nodejs_syscall/syscalls_arm64.go +++ b/internal/static/nodejs_syscall/syscalls_arm64.go @@ -4,11 +4,14 @@ package nodejs_syscall import "syscall" +const SYS_FACCESSAT2 = 439 + var ALLOW_SYSCALLS = []int{ // file syscall.SYS_CLOSE, syscall.SYS_WRITE, syscall.SYS_READ, syscall.SYS_FSTAT, syscall.SYS_FCNTL, syscall.SYS_READLINKAT, syscall.SYS_OPENAT, + SYS_FACCESSAT2, // process syscall.SYS_GETPID, syscall.SYS_TGKILL, syscall.SYS_FUTEX, syscall.SYS_IOCTL, diff --git a/internal/static/python_syscall/syscalls_amd64.go b/internal/static/python_syscall/syscalls_amd64.go index c9a7df62..1dbc464b 100644 --- a/internal/static/python_syscall/syscalls_amd64.go +++ b/internal/static/python_syscall/syscalls_amd64.go @@ -5,11 +5,12 @@ package python_syscall import "syscall" const ( - SYS_GETRANDOM = 318 - SYS_RSEQ = 334 - SYS_SENDMMSG = 307 - SYS_STATX = 332 - SYS_CLONE3 = 435 + SYS_GETRANDOM = 318 + SYS_RSEQ = 334 + SYS_SENDMMSG = 307 + SYS_STATX = 332 + SYS_CLONE3 = 435 + SYS_FACCESSAT2 = 439 ) var ALLOW_SYSCALLS = []int{ @@ -41,7 +42,7 @@ var ALLOW_SYSCALLS = []int{ syscall.SYS_RT_SIGPROCMASK, syscall.SYS_SIGALTSTACK, SYS_GETRANDOM, syscall.SYS_EVENTFD2, syscall.SYS_PIPE2, syscall.SYS_GETCWD, syscall.SYS_SYSINFO, - syscall.SYS_UNAME, SYS_STATX, + syscall.SYS_UNAME, SYS_STATX, SYS_FACCESSAT2, } var ALLOW_ERROR_SYSCALLS = []int{ diff --git a/internal/static/python_syscall/syscalls_arm64.go b/internal/static/python_syscall/syscalls_arm64.go index 5a4f6a19..4e1ddb03 100644 --- a/internal/static/python_syscall/syscalls_arm64.go +++ b/internal/static/python_syscall/syscalls_arm64.go @@ -7,9 +7,10 @@ import ( ) const ( - SYS_RSEQ = 293 - SYS_STATX = 397 - SYS_CLONE3 = 435 + SYS_RSEQ = 293 + SYS_STATX = 397 + SYS_CLONE3 = 435 + SYS_FACCESSAT2 = 439 ) var ALLOW_SYSCALLS = []int{ @@ -47,7 +48,7 @@ var ALLOW_SYSCALLS = []int{ // get random syscall.SYS_GETRANDOM, syscall.SYS_EVENTFD2, syscall.SYS_PIPE2, syscall.SYS_GETCWD, syscall.SYS_SYSINFO, - syscall.SYS_UNAME, SYS_STATX, + syscall.SYS_UNAME, SYS_STATX, SYS_FACCESSAT2, } var ALLOW_ERROR_SYSCALLS = []int{ diff --git a/internal/types/config.go b/internal/types/config.go index dc0290b6..b1ea1aa4 100644 --- a/internal/types/config.go +++ b/internal/types/config.go @@ -28,4 +28,20 @@ type DifySandboxGlobalConfigurations struct { NoProxy string `yaml:"no_proxy"` } `yaml:"proxy"` AllowedEnvVars []string `yaml:"allowed_env_vars"` + + // SecretInjection configures the credential-injection outbound proxy. + // When enabled, real secrets are replaced with placeholders in the + // agent's environment and resolved at the network boundary. See + // internal/core/proxy/ and issue #39278 for design rationale. + SecretInjection struct { + // Enabled toggles the proxy on/off. + Enabled bool `yaml:"enabled"` + // ProvidersPath is the path to providers.yaml (absolute or + // relative to the config file directory). + ProvidersPath string `yaml:"providers_path"` + // SecretRoot is the directory containing secret value files + // (e.g. /run/secrets//). Must be outside the + // agent's Landlock-restricted workspace. + SecretRoot string `yaml:"secret_root"` + } `yaml:"secret_injection"` }