Skip to content

Commit 8a3a303

Browse files
authored
Merge pull request #34 from initializ/security/phase1-critical-fixes
security: Phase 1 critical fixes (C-1 through C-7)
2 parents 3b109e6 + 29544ee commit 8a3a303

28 files changed

Lines changed: 1441 additions & 80 deletions

docs/architecture.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,19 @@ AgentSpec JSON is validated against `schemas/agentspec.v1.0.schema.json` (JSON S
344344

345345
## Egress Security
346346

347-
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.
347+
Egress controls operate at both build time and runtime. Build-time controls generate allowlist artifacts and Kubernetes NetworkPolicy manifests. Runtime controls include:
348+
349+
- **IP Validation** — Rejects non-standard IP formats (octal, hex, packed decimal) and IPv6 transition addresses embedding private IPs
350+
- **SafeDialer** — Validates resolved IPs post-DNS against blocked CIDR ranges before connecting (prevents DNS rebinding)
351+
- **EgressEnforcer** — In-process `http.RoundTripper` backed by `SafeTransport` for domain allowlist enforcement
352+
- **EgressProxy** — Local HTTP/HTTPS forward proxy for subprocess traffic, also backed by `SafeDialer`
353+
- **Redirect credential stripping**`http_request` and `webhook_call` strip `Authorization`/`Cookie` headers on cross-origin redirects
354+
355+
The A2A server adds:
356+
- **CORS restriction** — Origin allowlist (localhost by default), configurable via flag/env/YAML
357+
- **Security headers**`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Content-Security-Policy`
358+
359+
See [Egress Security](security/egress.md) for details.
348360

349361
---
350362
[Installation](installation.md) | [Back to README](../README.md) | [Skills](skills.md)

docs/commands.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ forge run [flags]
147147
| `--provider` | | LLM provider: `openai`, `anthropic`, or `ollama` |
148148
| `--env` | `.env` | Path to .env file |
149149
| `--with` | | Comma-separated channel adapters (e.g., `slack,telegram`) |
150+
| `--cors-origins` | localhost | Comma-separated CORS allowed origins (e.g., `https://app.example.com,https://admin.example.com`). Use `*` to allow all origins |
150151

151152
### Examples
152153

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

166167
# Run with guardrails enforced
167168
forge run --enforce-guardrails --env .env.production
169+
170+
# Run with custom CORS origins (for K8s ingress)
171+
forge run --cors-origins 'https://app.example.com,https://admin.example.com'
168172
```
169173

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

197202
### Examples
198203

docs/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ egress:
4141
- "*.github.com"
4242
capabilities: # Capability bundles
4343
- "slack"
44+
allow_private_ips: false # Allow RFC 1918 IPs (auto: true in containers)
45+
46+
cors_origins: # CORS allowed origins for A2A server
47+
- "https://app.example.com" # (default: localhost variants)
4448

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

96101
---

docs/security/egress.md

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,81 @@ Domain matching is handled by `DomainMatcher` (`forge-core/security/domain_match
4545
- **Case insensitive**: `API.OpenAI.COM` matches `api.openai.com`
4646
- **Localhost bypass**: `127.0.0.1`, `::1`, and `localhost` are always allowed in all modes
4747

48+
## IP Validation
49+
50+
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:
51+
52+
| Blocked Format | Example | Reason |
53+
|---------------|---------|--------|
54+
| Octal | `0177.0.0.1` | Resolves to `127.0.0.1` in some parsers |
55+
| Hexadecimal | `0x7f000001` | Resolves to `127.0.0.1` in some parsers |
56+
| Packed decimal | `2130706433` | Resolves to `127.0.0.1` in some parsers |
57+
| Leading zeros | `127.0.0.01` | Ambiguous parsing across languages |
58+
| IPv6 transition (NAT64) | `64:ff9b::10.0.0.1` | Embeds private IPv4 in IPv6 |
59+
| IPv6 transition (6to4) | `2002:0a00:0001::` | Embeds private IPv4 in IPv6 |
60+
| IPv6 transition (Teredo) | `2001:0000:...` | Embeds XOR'd IPv4 in IPv6 |
61+
62+
The `ValidateHostIP()` function is called early in both the EgressEnforcer and EgressProxy before any domain matching occurs.
63+
64+
## Safe Dialer (DNS Rebinding Protection)
65+
66+
The `SafeDialer` (`forge-core/security/safe_dialer.go`) prevents DNS rebinding and TOCTOU attacks by validating resolved IPs before connecting:
67+
68+
1. Resolves hostname to IP addresses via DNS
69+
2. Validates **all** resolved IPs against blocked CIDR ranges
70+
3. Dials the first safe IP directly (bypasses re-resolution)
71+
72+
Blocked IP ranges depend on the `allowPrivateIPs` setting:
73+
74+
| CIDR | Always Blocked | Blocked when `allowPrivateIPs=false` |
75+
|------|---------------|--------------------------------------|
76+
| `169.254.169.254/32` (cloud metadata) | Yes | Yes |
77+
| `127.0.0.0/8` (loopback) | Yes | Yes |
78+
| `::1/128` (IPv6 loopback) | Yes | Yes |
79+
| `0.0.0.0/8` | Yes | Yes |
80+
| `10.0.0.0/8` (RFC 1918) || Yes |
81+
| `172.16.0.0/12` (RFC 1918) || Yes |
82+
| `192.168.0.0/16` (RFC 1918) || Yes |
83+
| `169.254.0.0/16` (link-local) || Yes |
84+
| `100.64.0.0/10` (CGNAT) || Yes |
85+
| `fc00::/7` (IPv6 ULA) || Yes |
86+
| `fe80::/10` (IPv6 link-local) || Yes |
87+
88+
Both the EgressEnforcer and EgressProxy use `SafeTransport` (an `http.Transport` wired to the SafeDialer) for non-localhost connections.
89+
90+
## Container-Aware Private IP Handling
91+
92+
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.
93+
94+
The `allowPrivateIPs` setting is resolved with this precedence:
95+
96+
1. **Explicit config**`egress.allow_private_ips` in `forge.yaml`
97+
2. **Auto-detect**`true` if `InContainer()` detects Docker/Kubernetes
98+
3. **Default**`false` (block all private IPs)
99+
100+
| Scenario | `allowPrivateIPs` | RFC 1918 | Cloud Metadata | Loopback |
101+
|----------|-------------------|----------|----------------|----------|
102+
| Local dev | `false` | Blocked | Blocked | Allowed (localhost bypass) |
103+
| Docker Desktop | `true` (auto) | Allowed | **Blocked** | Allowed (localhost bypass) |
104+
| Kubernetes | `true` (auto) | Allowed | **Blocked** | Allowed (localhost bypass) |
105+
106+
Cloud metadata (`169.254.169.254`) is **always** blocked regardless of the `allowPrivateIPs` setting.
107+
48108
## Runtime Egress Enforcer
49109

50-
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.
110+
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.
51111

52112
```go
53-
enforcer := security.NewEgressEnforcer(nil, security.ModeAllowlist, allowedDomains)
113+
enforcer := security.NewEgressEnforcer(nil, security.ModeAllowlist, allowedDomains, false)
54114
client := &http.Client{Transport: enforcer}
55115
```
56116

117+
Request validation order:
118+
1. Reject non-standard IP formats (`ValidateHostIP`)
119+
2. Allow localhost (bypass SafeTransport, use `http.DefaultTransport`)
120+
3. Check domain against allowlist (`DomainMatcher.IsAllowed`)
121+
4. Forward via `SafeTransport` (post-DNS IP validation)
122+
57123
Blocked requests return: `egress blocked: domain "X" not in allowlist (mode=allowlist)`
58124

59125
The enforcer fires an `OnAttempt` callback for every request, enabling audit logging with domain, mode, and allow/deny decision.
@@ -187,8 +253,11 @@ egress:
187253
capabilities:
188254
- slack
189255
- telegram
256+
allow_private_ips: false # default: auto-detect from container env
190257
```
191258
259+
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.
260+
192261
## Production vs Development
193262

194263
| Setting | Production | Development |
@@ -217,9 +286,12 @@ Events without `"source"` come from the in-process enforcer; events with `"sourc
217286
| File | Purpose |
218287
|------|---------|
219288
| `forge-core/security/types.go` | Profile and mode types, `EgressConfig` |
289+
| `forge-core/security/ip_validator.go` | Strict IP parsing, CIDR blocking, IPv6 transition detection |
290+
| `forge-core/security/safe_dialer.go` | Post-DNS-resolution IP validation, `SafeTransport` |
220291
| `forge-core/security/domain_matcher.go` | `DomainMatcher` — shared exact/wildcard matching logic |
221292
| `forge-core/security/egress_enforcer.go` | `EgressEnforcer` — in-process `http.RoundTripper` |
222293
| `forge-core/security/egress_proxy.go` | `EgressProxy` — localhost HTTP/HTTPS forward proxy |
294+
| `forge-core/security/redirect.go` | Cross-origin redirect credential stripping |
223295
| `forge-core/security/container.go` | `InContainer()` — Docker/Kubernetes detection |
224296
| `forge-core/security/resolver.go` | Allowlist resolution logic |
225297
| `forge-core/security/capabilities.go` | Capability bundle definitions |

docs/security/overview.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Forge's security is organized in layers, each addressing a different threat surf
1515
│ (content filtering, PII, jailbreak) │
1616
├──────────────────────────────────────────────────────────────┤
1717
│ Egress Enforcement │
18-
(EgressEnforcer + EgressProxy + NetworkPolicy)
18+
│ (EgressEnforcer + EgressProxy + SafeDialer + NetworkPolicy)
1919
├──────────────────────────────────────────────────────────────┤
2020
│ Execution Sandboxing │
2121
│ (env isolation, binary allowlists, arg validation, │
@@ -55,6 +55,8 @@ Forge agents are designed to never expose inbound listeners to the public intern
5555
- Slack: Socket Mode (outbound WebSocket via `apps.connections.open`)
5656
- Telegram: Long-polling via `getUpdates`
5757
- **Local-only HTTP server** — The A2A dev server binds to `localhost` by default
58+
- **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`
59+
- **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'`
5860
- **No hidden listeners** — Every network binding is explicit and logged
5961

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

6466
## Egress Enforcement
6567

66-
Forge restricts outbound network access at three levels:
68+
Forge restricts outbound network access at multiple levels:
6769

68-
### 1. In-Process Enforcer
70+
### 1. IP Validation
6971

70-
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.
72+
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.
7173

72-
### 2. Subprocess Proxy
74+
### 2. In-Process Enforcer
7375

74-
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.
76+
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.
7577

76-
### 3. Kubernetes NetworkPolicy
78+
### 3. Subprocess Proxy
79+
80+
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.
81+
82+
### 4. Redirect Credential Stripping
83+
84+
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).
85+
86+
### 5. Kubernetes NetworkPolicy
7787

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

@@ -244,7 +254,7 @@ Production builds enforce:
244254

245255
| Document | Description |
246256
|----------|-------------|
247-
| [Egress Security](egress.md) | Deep dive into egress enforcement: profiles, modes, domain matching, proxy architecture, NetworkPolicy |
257+
| [Egress Security](egress.md) | Deep dive into egress enforcement: IP validation, SafeDialer, profiles, modes, domain matching, proxy architecture, NetworkPolicy |
248258
| [Secrets Management](secrets.md) | Encrypted storage, per-agent secrets, passphrase handling |
249259
| [Build Signing & Verification](signing.md) | Key management, build signing, runtime verification |
250260
| [Content Guardrails](guardrails.md) | PII detection, jailbreak protection, custom rules |

docs/tools.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Tools are capabilities that an LLM agent can invoke during execution. Forge prov
1717

1818
| Tool | Description |
1919
|------|-------------|
20-
| `http_request` | Make HTTP requests (GET, POST, PUT, DELETE) |
20+
| `http_request` | Make HTTP requests (GET, POST, PUT, DELETE). Strips credentials on cross-origin redirects |
2121
| `json_parse` | Parse and query JSON data |
2222
| `csv_parse` | Parse CSV data into structured records |
2323
| `datetime_now` | Get current date and time |
@@ -76,7 +76,7 @@ All file tools use `PathValidator` (from `pathutil.go`):
7676
| Adapter | Description |
7777
|---------|-------------|
7878
| `mcp_call` | Call tools on MCP servers via JSON-RPC |
79-
| `webhook_call` | POST JSON payloads to webhook URLs |
79+
| `webhook_call` | POST JSON payloads to webhook URLs. Strips credentials on cross-origin redirects |
8080
| `openapi_call` | Call OpenAPI-described endpoints |
8181

8282
Adapter tools bridge external services into the agent's tool set.

forge-cli/cmd/run.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ var (
3030
runWithChannels string
3131
runNoAuth bool
3232
runAuthToken string
33+
runCORSOrigins string
3334
)
3435

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

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

71+
var corsOrigins []string
72+
if runCORSOrigins != "" {
73+
for _, o := range strings.Split(runCORSOrigins, ",") {
74+
if o = strings.TrimSpace(o); o != "" {
75+
corsOrigins = append(corsOrigins, o)
76+
}
77+
}
78+
}
79+
6980
runner, err := runtime.NewRunner(runtime.RunnerConfig{
7081
Config: cfg,
7182
WorkDir: workDir,
@@ -81,6 +92,7 @@ func runRun(cmd *cobra.Command, args []string) error {
8192
Channels: activeChannels,
8293
NoAuth: runNoAuth,
8394
AuthToken: runAuthToken,
95+
CORSOrigins: corsOrigins,
8496
})
8597
if err != nil {
8698
return fmt.Errorf("creating runner: %w", err)

forge-cli/cmd/serve.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var (
3636
serveWithChannels string
3737
serveNoAuth bool
3838
serveAuthToken string
39+
serveCORSOrigins string
3940
)
4041

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

101103
func init() {
@@ -191,6 +193,9 @@ func serveStartRun(cmd *cobra.Command, args []string) error {
191193
if serveAuthToken != "" {
192194
runArgs = append(runArgs, "--auth-token", serveAuthToken)
193195
}
196+
if serveCORSOrigins != "" {
197+
runArgs = append(runArgs, "--cors-origins", serveCORSOrigins)
198+
}
194199

195200
// Ensure .forge directory exists
196201
forgeDir := filepath.Dir(statePath)

0 commit comments

Comments
 (0)