Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 13 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,19 @@ AgentSpec JSON is validated against `schemas/agentspec.v1.0.schema.json` (JSON S

## Egress Security

Egress controls operate at both build time and runtime. Build-time controls generate allowlist artifacts and Kubernetes NetworkPolicy manifests. Runtime controls include an in-process `EgressEnforcer` (Go `http.RoundTripper`) and a local `EgressProxy` for subprocess HTTP traffic. See [Egress Security](security/egress.md) for details.
Egress controls operate at both build time and runtime. Build-time controls generate allowlist artifacts and Kubernetes NetworkPolicy manifests. Runtime controls include:

- **IP Validation** — Rejects non-standard IP formats (octal, hex, packed decimal) and IPv6 transition addresses embedding private IPs
- **SafeDialer** — Validates resolved IPs post-DNS against blocked CIDR ranges before connecting (prevents DNS rebinding)
- **EgressEnforcer** — In-process `http.RoundTripper` backed by `SafeTransport` for domain allowlist enforcement
- **EgressProxy** — Local HTTP/HTTPS forward proxy for subprocess traffic, also backed by `SafeDialer`
- **Redirect credential stripping** — `http_request` and `webhook_call` strip `Authorization`/`Cookie` headers on cross-origin redirects

The A2A server adds:
- **CORS restriction** — Origin allowlist (localhost by default), configurable via flag/env/YAML
- **Security headers** — `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Content-Security-Policy`

See [Egress Security](security/egress.md) for details.

---
← [Installation](installation.md) | [Back to README](../README.md) | [Skills](skills.md) →
5 changes: 5 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ forge run [flags]
| `--provider` | | LLM provider: `openai`, `anthropic`, or `ollama` |
| `--env` | `.env` | Path to .env file |
| `--with` | | Comma-separated channel adapters (e.g., `slack,telegram`) |
| `--cors-origins` | localhost | Comma-separated CORS allowed origins (e.g., `https://app.example.com,https://admin.example.com`). Use `*` to allow all origins |

### Examples

Expand All @@ -165,6 +166,9 @@ forge run --host 0.0.0.0 --shutdown-timeout 30s

# Run with guardrails enforced
forge run --enforce-guardrails --env .env.production

# Run with custom CORS origins (for K8s ingress)
forge run --cors-origins 'https://app.example.com,https://admin.example.com'
```

---
Expand Down Expand Up @@ -193,6 +197,7 @@ forge serve [start|stop|status|logs] [flags]
| `--port` | `8080` | HTTP server port |
| `--host` | `127.0.0.1` | Bind address (secure default) |
| `--with` | | Channel adapters |
| `--cors-origins` | localhost | Comma-separated CORS allowed origins |

### Examples

Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ egress:
- "*.github.com"
capabilities: # Capability bundles
- "slack"
allow_private_ips: false # Allow RFC 1918 IPs (auto: true in containers)

cors_origins: # CORS allowed origins for A2A server
- "https://app.example.com" # (default: localhost variants)

skills:
path: "SKILL.md"
Expand Down Expand Up @@ -91,6 +95,7 @@ schedules: # Recurring scheduled tasks (optional)
| `OPENAI_BASE_URL` | Override OpenAI base URL |
| `ANTHROPIC_BASE_URL` | Override Anthropic base URL |
| `OLLAMA_BASE_URL` | Override Ollama base URL (default: `http://localhost:11434`) |
| `FORGE_CORS_ORIGINS` | Comma-separated CORS allowed origins for A2A server |
| `FORGE_PASSPHRASE` | Passphrase for encrypted secrets file |

---
Expand Down
76 changes: 74 additions & 2 deletions docs/security/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,81 @@ Domain matching is handled by `DomainMatcher` (`forge-core/security/domain_match
- **Case insensitive**: `API.OpenAI.COM` matches `api.openai.com`
- **Localhost bypass**: `127.0.0.1`, `::1`, and `localhost` are always allowed in all modes

## IP Validation

All egress paths validate hostnames against non-standard IP formats before domain matching. The IP validator (`forge-core/security/ip_validator.go`) rejects SSRF bypass vectors:

| Blocked Format | Example | Reason |
|---------------|---------|--------|
| Octal | `0177.0.0.1` | Resolves to `127.0.0.1` in some parsers |
| Hexadecimal | `0x7f000001` | Resolves to `127.0.0.1` in some parsers |
| Packed decimal | `2130706433` | Resolves to `127.0.0.1` in some parsers |
| Leading zeros | `127.0.0.01` | Ambiguous parsing across languages |
| IPv6 transition (NAT64) | `64:ff9b::10.0.0.1` | Embeds private IPv4 in IPv6 |
| IPv6 transition (6to4) | `2002:0a00:0001::` | Embeds private IPv4 in IPv6 |
| IPv6 transition (Teredo) | `2001:0000:...` | Embeds XOR'd IPv4 in IPv6 |

The `ValidateHostIP()` function is called early in both the EgressEnforcer and EgressProxy before any domain matching occurs.

## Safe Dialer (DNS Rebinding Protection)

The `SafeDialer` (`forge-core/security/safe_dialer.go`) prevents DNS rebinding and TOCTOU attacks by validating resolved IPs before connecting:

1. Resolves hostname to IP addresses via DNS
2. Validates **all** resolved IPs against blocked CIDR ranges
3. Dials the first safe IP directly (bypasses re-resolution)

Blocked IP ranges depend on the `allowPrivateIPs` setting:

| CIDR | Always Blocked | Blocked when `allowPrivateIPs=false` |
|------|---------------|--------------------------------------|
| `169.254.169.254/32` (cloud metadata) | Yes | Yes |
| `127.0.0.0/8` (loopback) | Yes | Yes |
| `::1/128` (IPv6 loopback) | Yes | Yes |
| `0.0.0.0/8` | Yes | Yes |
| `10.0.0.0/8` (RFC 1918) | — | Yes |
| `172.16.0.0/12` (RFC 1918) | — | Yes |
| `192.168.0.0/16` (RFC 1918) | — | Yes |
| `169.254.0.0/16` (link-local) | — | Yes |
| `100.64.0.0/10` (CGNAT) | — | Yes |
| `fc00::/7` (IPv6 ULA) | — | Yes |
| `fe80::/10` (IPv6 link-local) | — | Yes |

Both the EgressEnforcer and EgressProxy use `SafeTransport` (an `http.Transport` wired to the SafeDialer) for non-localhost connections.

## Container-Aware Private IP Handling

In container and Kubernetes environments, pods communicate via service DNS names that resolve to RFC 1918 addresses (e.g., `10.96.x.x`). Blocking these would break inter-service communication.

The `allowPrivateIPs` setting is resolved with this precedence:

1. **Explicit config** — `egress.allow_private_ips` in `forge.yaml`
2. **Auto-detect** — `true` if `InContainer()` detects Docker/Kubernetes
3. **Default** — `false` (block all private IPs)

| Scenario | `allowPrivateIPs` | RFC 1918 | Cloud Metadata | Loopback |
|----------|-------------------|----------|----------------|----------|
| Local dev | `false` | Blocked | Blocked | Allowed (localhost bypass) |
| Docker Desktop | `true` (auto) | Allowed | **Blocked** | Allowed (localhost bypass) |
| Kubernetes | `true` (auto) | Allowed | **Blocked** | Allowed (localhost bypass) |

Cloud metadata (`169.254.169.254`) is **always** blocked regardless of the `allowPrivateIPs` setting.

## Runtime Egress Enforcer

The `EgressEnforcer` (`forge-core/security/egress_enforcer.go`) is an `http.RoundTripper` that wraps the default HTTP transport. Every outbound HTTP request from in-process Go code (builtins like `http_request`, `web_search`, LLM API calls) passes through it.
The `EgressEnforcer` (`forge-core/security/egress_enforcer.go`) is an `http.RoundTripper` that wraps a `SafeTransport`. Every outbound HTTP request from in-process Go code (builtins like `http_request`, `web_search`, LLM API calls) passes through it.

```go
enforcer := security.NewEgressEnforcer(nil, security.ModeAllowlist, allowedDomains)
enforcer := security.NewEgressEnforcer(nil, security.ModeAllowlist, allowedDomains, false)
client := &http.Client{Transport: enforcer}
```

Request validation order:
1. Reject non-standard IP formats (`ValidateHostIP`)
2. Allow localhost (bypass SafeTransport, use `http.DefaultTransport`)
3. Check domain against allowlist (`DomainMatcher.IsAllowed`)
4. Forward via `SafeTransport` (post-DNS IP validation)

Blocked requests return: `egress blocked: domain "X" not in allowlist (mode=allowlist)`

The enforcer fires an `OnAttempt` callback for every request, enabling audit logging with domain, mode, and allow/deny decision.
Expand Down Expand Up @@ -187,8 +253,11 @@ egress:
capabilities:
- slack
- telegram
allow_private_ips: false # default: auto-detect from container env
```

The `allow_private_ips` field controls whether RFC 1918 addresses are allowed through the SafeDialer. When omitted, it defaults to `true` inside containers (detected via `KUBERNETES_SERVICE_HOST` or `/.dockerenv`) and `false` otherwise. Cloud metadata (`169.254.169.254`) is always blocked.

## Production vs Development

| Setting | Production | Development |
Expand Down Expand Up @@ -217,9 +286,12 @@ Events without `"source"` come from the in-process enforcer; events with `"sourc
| File | Purpose |
|------|---------|
| `forge-core/security/types.go` | Profile and mode types, `EgressConfig` |
| `forge-core/security/ip_validator.go` | Strict IP parsing, CIDR blocking, IPv6 transition detection |
| `forge-core/security/safe_dialer.go` | Post-DNS-resolution IP validation, `SafeTransport` |
| `forge-core/security/domain_matcher.go` | `DomainMatcher` — shared exact/wildcard matching logic |
| `forge-core/security/egress_enforcer.go` | `EgressEnforcer` — in-process `http.RoundTripper` |
| `forge-core/security/egress_proxy.go` | `EgressProxy` — localhost HTTP/HTTPS forward proxy |
| `forge-core/security/redirect.go` | Cross-origin redirect credential stripping |
| `forge-core/security/container.go` | `InContainer()` — Docker/Kubernetes detection |
| `forge-core/security/resolver.go` | Allowlist resolution logic |
| `forge-core/security/capabilities.go` | Capability bundle definitions |
Expand Down
26 changes: 18 additions & 8 deletions docs/security/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Forge's security is organized in layers, each addressing a different threat surf
│ (content filtering, PII, jailbreak) │
├──────────────────────────────────────────────────────────────┤
│ Egress Enforcement │
(EgressEnforcer + EgressProxy + NetworkPolicy)
│ (EgressEnforcer + EgressProxy + SafeDialer + NetworkPolicy)
├──────────────────────────────────────────────────────────────┤
│ Execution Sandboxing │
│ (env isolation, binary allowlists, arg validation, │
Expand Down Expand Up @@ -55,6 +55,8 @@ Forge agents are designed to never expose inbound listeners to the public intern
- Slack: Socket Mode (outbound WebSocket via `apps.connections.open`)
- Telegram: Long-polling via `getUpdates`
- **Local-only HTTP server** — The A2A dev server binds to `localhost` by default
- **CORS restriction** — The A2A server restricts `Access-Control-Allow-Origin` to localhost by default; configurable via `--cors-origins` flag, `FORGE_CORS_ORIGINS` env var, or `cors_origins` in `forge.yaml`
- **Security response headers** — All A2A responses include `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, and `Content-Security-Policy: default-src 'none'`
- **No hidden listeners** — Every network binding is explicit and logged

This means a running Forge agent has zero inbound attack surface by default.
Expand All @@ -63,17 +65,25 @@ This means a running Forge agent has zero inbound attack surface by default.

## Egress Enforcement

Forge restricts outbound network access at three levels:
Forge restricts outbound network access at multiple levels:

### 1. In-Process Enforcer
### 1. IP Validation

The `EgressEnforcer` is a Go `http.RoundTripper` that wraps every outbound HTTP request from in-process tools (`http_request`, `web_search`, LLM API calls). It validates the destination domain against a resolved allowlist before forwarding.
All egress paths reject non-standard IP formats (octal, hex, packed decimal, leading zeros) that could bypass allowlist checks. IPv6 transition addresses (NAT64, 6to4, Teredo) embedding private IPv4 addresses are also blocked.

### 2. Subprocess Proxy
### 2. In-Process Enforcer

Skill scripts and `cli_execute` subprocesses bypass Go-level enforcement. A local `EgressProxy` on `127.0.0.1:<random-port>` validates domains for subprocess HTTP traffic via `HTTP_PROXY`/`HTTPS_PROXY` env var injection.
The `EgressEnforcer` is a Go `http.RoundTripper` backed by a `SafeTransport` that validates resolved IPs post-DNS. Every outbound HTTP request from in-process tools (`http_request`, `web_search`, LLM API calls) is checked against IP validation, domain allowlist, and post-resolution CIDR blocking.

### 3. Kubernetes NetworkPolicy
### 3. Subprocess Proxy

Skill scripts and `cli_execute` subprocesses bypass Go-level enforcement. A local `EgressProxy` on `127.0.0.1:<random-port>` validates domains and resolved IPs for subprocess HTTP traffic via `HTTP_PROXY`/`HTTPS_PROXY` env var injection.

### 4. Redirect Credential Stripping

HTTP clients used by `http_request` and `webhook_call` tools strip `Authorization`, `Cookie`, and `Proxy-Authorization` headers when a redirect crosses origin boundaries (different scheme, host, or port).

### 5. Kubernetes NetworkPolicy

In containerized deployments, generated Kubernetes `NetworkPolicy` manifests enforce egress at the pod level, restricting traffic to allowed domains on ports 80/443.

Expand Down Expand Up @@ -244,7 +254,7 @@ Production builds enforce:

| Document | Description |
|----------|-------------|
| [Egress Security](egress.md) | Deep dive into egress enforcement: profiles, modes, domain matching, proxy architecture, NetworkPolicy |
| [Egress Security](egress.md) | Deep dive into egress enforcement: IP validation, SafeDialer, profiles, modes, domain matching, proxy architecture, NetworkPolicy |
| [Secrets Management](secrets.md) | Encrypted storage, per-agent secrets, passphrase handling |
| [Build Signing & Verification](signing.md) | Key management, build signing, runtime verification |
| [Content Guardrails](guardrails.md) | PII detection, jailbreak protection, custom rules |
Expand Down
4 changes: 2 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Tools are capabilities that an LLM agent can invoke during execution. Forge prov

| Tool | Description |
|------|-------------|
| `http_request` | Make HTTP requests (GET, POST, PUT, DELETE) |
| `http_request` | Make HTTP requests (GET, POST, PUT, DELETE). Strips credentials on cross-origin redirects |
| `json_parse` | Parse and query JSON data |
| `csv_parse` | Parse CSV data into structured records |
| `datetime_now` | Get current date and time |
Expand Down Expand Up @@ -86,7 +86,7 @@ All file tools use `PathValidator` (from `pathutil.go`):
| Adapter | Description |
|---------|-------------|
| `mcp_call` | Call tools on MCP servers via JSON-RPC |
| `webhook_call` | POST JSON payloads to webhook URLs |
| `webhook_call` | POST JSON payloads to webhook URLs. Strips credentials on cross-origin redirects |
| `openapi_call` | Call OpenAPI-described endpoints |

Adapter tools bridge external services into the agent's tool set.
Expand Down
12 changes: 12 additions & 0 deletions forge-cli/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var (
runWithChannels string
runNoAuth bool
runAuthToken string
runCORSOrigins string
)

var runCmd = &cobra.Command{
Expand All @@ -51,6 +52,7 @@ func init() {
runCmd.Flags().StringVar(&runWithChannels, "with", "", "comma-separated channel adapters to start (e.g. slack,telegram)")
runCmd.Flags().BoolVar(&runNoAuth, "no-auth", false, "disable bearer token authentication (localhost only)")
runCmd.Flags().StringVar(&runAuthToken, "auth-token", "", "explicit bearer token (default: auto-generated)")
runCmd.Flags().StringVar(&runCORSOrigins, "cors-origins", "", "comma-separated CORS allowed origins (default: localhost only, use '*' for wildcard)")
}

func runRun(cmd *cobra.Command, args []string) error {
Expand All @@ -66,6 +68,15 @@ func runRun(cmd *cobra.Command, args []string) error {
enforceGuardrails = false
}

var corsOrigins []string
if runCORSOrigins != "" {
for _, o := range strings.Split(runCORSOrigins, ",") {
if o = strings.TrimSpace(o); o != "" {
corsOrigins = append(corsOrigins, o)
}
}
}

runner, err := runtime.NewRunner(runtime.RunnerConfig{
Config: cfg,
WorkDir: workDir,
Expand All @@ -81,6 +92,7 @@ func runRun(cmd *cobra.Command, args []string) error {
Channels: activeChannels,
NoAuth: runNoAuth,
AuthToken: runAuthToken,
CORSOrigins: corsOrigins,
})
if err != nil {
return fmt.Errorf("creating runner: %w", err)
Expand Down
5 changes: 5 additions & 0 deletions forge-cli/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var (
serveWithChannels string
serveNoAuth bool
serveAuthToken string
serveCORSOrigins string
)

var serveCmd = &cobra.Command{
Expand Down Expand Up @@ -96,6 +97,7 @@ func registerServeFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(&serveWithChannels, "with", "", "comma-separated channel adapters to start (e.g. slack,telegram)")
cmd.Flags().BoolVar(&serveNoAuth, "no-auth", false, "disable bearer token authentication (localhost only)")
cmd.Flags().StringVar(&serveAuthToken, "auth-token", "", "explicit bearer token (default: auto-generated)")
cmd.Flags().StringVar(&serveCORSOrigins, "cors-origins", "", "comma-separated CORS allowed origins (default: localhost only, use '*' for wildcard)")
}

func init() {
Expand Down Expand Up @@ -191,6 +193,9 @@ func serveStartRun(cmd *cobra.Command, args []string) error {
if serveAuthToken != "" {
runArgs = append(runArgs, "--auth-token", serveAuthToken)
}
if serveCORSOrigins != "" {
runArgs = append(runArgs, "--cors-origins", serveCORSOrigins)
}

// Ensure .forge directory exists
forgeDir := filepath.Dir(statePath)
Expand Down
Loading
Loading