> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> Bundle signing (OPA model), RFC 8707 audience checks, layered SSRF defenses, ABAC integration, OWASP MCP Top 10 mapping.

The plugin sits across three threat-rich domains:

1. **MCP server security** (OWASP MCP Top 10 2026, CVE-2025-6514, the Anthropic Git MCP CVE chain CVE-2025-68143/144/145)
2. **Indirect prompt injection** (Anthropic's Feb 2026 system card identifies indirect injection as the dominant attack class)
3. **Outbound HTTP from a server-side runtime** (SSRF surged 452% from 2023 to 2024)

Every security control in this page maps to one of those vectors. This is not a theoretical surface — it's a defense-in-depth stack for the production pattern of "wrap a customer's REST API and serve it to an LLM."

## Five-gate authorization stack

Every `callTool(actionId, input)` a `run_workflow` script issues traverses five gates. The AgentScript runs inside the enclave sandbox; the gates fire on each `callTool`, not once per `run_workflow`. Removing any of them requires editing the plugin source:

```
MCP client
   │
   ▼  ① Inbound auth (RFC 8707-validated JWT from the MCP transport)
SkilledOpenApi meta-tools (run_workflow → enclave sandbox)
   │
   ▼     each callTool(actionId, input) inside the script:
   ▼  ② Bundle origin (signature verified, see below)
HiddenOpRegistry
   │
   ▼  ③ Per-skill ABAC (action.requiredAuthorities via AuthoritiesEngine)
   │
   ▼  ④ Credential scoping (vaultRef must resolve via the configured CredentialResolver)
OpenApiHttpExecutor
   │
   ▼  ⑤ Outbound execution (SSRF allowlist + IP blocklist + circuit breaker)
Customer REST API
```

The enclave itself is a sixth, ambient containment layer: the dependency-free
`@enclave-vm` interpreter gives the script no host access and no network except
`callTool`, and caps a single workflow's step count, tool-call count, and wall
time. A workflow can chain many `callTool`s in one round-trip, and each one runs
the full five-gate path above.

## Bundle signing (OPA model)

The `integrity` envelope on every bundle is a JWT-of-hashes modeled on [OPA's signed-bundle pattern](https://www.openpolicyagent.org/docs/management-bundles).

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
interface BundleIntegrity {
  alg: 'RS256' | 'EdDSA';
  keyId: string;
  signature: string;   // base64url
  digest: string;      // sha256 hex of canonical bundle bytes (excluding integrity)
}
```

Verification flow:

1. Re-compute sha256 of the canonicalized bundle minus the `integrity` field.
2. Reject if the computed digest doesn't match `integrity.digest`.
3. Look up the trusted public key by `integrity.keyId`. Reject if unknown.
4. Reject if the trusted key's algorithm doesn't match `integrity.alg`.
5. Verify the signature over the canonical bytes (not the digest hex) using the public key.

Any failure → keep the existing bundle, surface failure via `/healthz` + structured error log + audit trail. Never partial apply.

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
SkilledOpenApiPlugin.init({
  // ...
  requireSignature: true,                  // default: true. Never set false in production.
  trustedKeys: [
    {
      keyId: 'frontmcp-cloud-2026-01',
      alg: 'RS256',
      publicKeyPem: process.env.FRONTMCP_BUNDLE_PUBKEY!,
    },
  ],
})
```

`dev: true` bypasses signature verification with a loud startup warning. **Never** set it in production. Setting `requireSignature: false` without `dev: true` also emits a startup warning to call out the misconfiguration.

## RFC 8707 enforcement

On the `saas` source, the pinned `authToken` is verified before every pull: it must be signed by a key in `jwksUrl`, its `iss` must equal `expectedIssuer`, its `aud` must include `expectedAudience` (and its `resource` claim too, when it has one), and it must not be expired. A rejected token stops the pull, and the cached bundle is not used in its place; an unreachable JWKS, or one with no usable signing key, is treated like any other pull failure.

Per the MCP authorization spec (draft 2026-03-15), every JWT must carry RFC 8707 Resource Indicators. The plugin enforces this on the SaaS-push channel via `BundlePushJwtVerifier`:

* **`aud`** must equal the configured `expectedAudience` (typically `bundleId`)
* **`resource`** must equal the configured `expectedResource` (typically the FrontMCP server's canonical URL)
* **`iss`** must equal `expectedIssuer`
* **`roles`** must include at least one of `requiredRoles` (default: `['frontmcp:cloud:push']`)

This blocks the confused-deputy class of attack where a token issued for one customer's FrontMCP gets replayed against another's.

For `passthrough caller token` outbound calls (an `AuthBinding` with `passthroughCallerToken: true`), the plugin verifies the caller's JWT has a `resource` claim matching the customer REST API's base URL before forwarding it.

## Input sanitization (CVE-2025-6514 lesson)

Even after signature verification, every string in a bundle is treated as adversarial input. The Zod schema in `bundle.schema.ts` enforces:

* **URL construction**: WHATWG `URL` only, never string concat
* **Path templates**: reject `..`, backticks, `$(`, `${`, whitespace, `?`, `#`
* **Header names**: must match RFC 7230 token grammar
* **Header values**: stripped of CR/LF before assignment
* **JSON Schema input**: `additionalProperties` constraints applied at the Zod boundary; the executor validates input against the schema again before invoking the upstream
* **No `eval`, `Function`, `child_process`, `vm`, or dynamic `require`** anywhere in the executor or sync paths
* **No bundle data in shell commands** — the plugin never shells out

The CVE-2025-6514 root cause was passing untrusted server response data into system handlers. The plugin doesn't shell out at all.

## SSRF defenses (layered)

A hostname allowlist alone is insufficient (DNS rebinding bypasses it). The plugin layers:

1. **URL string check** before DNS resolution: reject `file:`, `data:`, `gopher:`, `ftp:`; require `https:` (allow `http:` only via explicit `allowHttp: true`).
2. **Hostname allowlist**: only declared `services[].baseUrl` hosts.
3. **Cloud-metadata hostname blocklist**: `metadata.google.internal`, `metadata.azure.com`, `metadata.aws.com` blocked even if technically allowlisted.
4. **IP check on every address**: an IP-literal host is checked directly (no DNS needed, so it also applies on Workers); a host name is **DNS-resolved once** at request start and rejected if any resolved address falls in a deny range:
   * **Always blocked**, even with `allowPrivateNetworks: true`: link-local (169.254/16, fe80::/10) — the AWS/GCP/Azure metadata services at 169.254.169.254 — the AWS IMDSv6 address `fd00:ec2::254`, the unspecified / "this network" addresses (0/8, `::`), and the local-use NAT64 prefix `64:ff9b:1::/48`, whose IPv4 position depends on the operator's prefix length
   * **Blocked unless `allowPrivateNetworks: true`**: loopback (127/8, ::1), RFC 1918 private (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), IETF protocol assignments (192.0.0/24), benchmarking (198.18/15), multicast (224/4, ff00::/8), reserved (240/4, incl. broadcast), IPv6 ULA (fc00::/7) and site-local (fec0::/10)
   * **IPv6 forms that carry an IPv4 address** are judged by that IPv4 address: IPv4-mapped (`::ffff:0:0/96`), IPv4-translated (`::ffff:0:0:0/96`), IPv4-compatible (`::/96`), NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`). Addresses are parsed before they are checked, so `https://[::ffff:169.254.169.254]` — which `new URL()` rewrites to `[::ffff:a9fe:a9fe]` — is refused in every spelling. An address that does not parse is refused.
5. **Redirects are never followed**: requests go out with `redirect: 'manual'`, and any 3xx (or a browser runtime's status-0 `opaqueredirect`) fails the call instead of re-sending the vault-injected credential to a destination the upstream chose.
6. **Per-host concurrency cap** (`maxConcurrencyPerHost`, default 10).

`allowPrivateNetworks: true` lifts the private-network part of the IP blocklist for self-hosted scenarios where the customer's REST API legitimately lives on a private network; the metadata / link-local block stays on. Loud startup warning when set. Default is fail-closed.

## ABAC integration

`requiredAuthorities` on a skill or operation is the same `AuthoritiesPolicy` shape `@frontmcp/auth` uses everywhere:

```jsonc theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
{
  "operationId": "refundInvoice",
  "requiredAuthorities": {
    "operator": "AND",
    "permissions": { "all": ["invoices:write"] },
    "attributes": { "match": { "input.amount": { "$lte": 10000 } } }
  }
}
```

Evaluation runs through `AuthoritiesEngine` from `@frontmcp/auth` — RBAC, ABAC, ReBAC, custom evaluators, and combinators (`anyOf`, `allOf`, `not`) are all available. See the [authorities docs](/frontmcp/authentication/authorities) for the full grammar.

The check happens on each `callTool` the `run_workflow` script issues, with the caller's `authInfo` from the MCP request mapped through `AuthoritiesContextBuilder`. A denied action throws inside the script (failing that `callTool`, and the workflow unless the script catches it) and is recorded in the audit log via the `authority-fail` event.

When the server configures [authorities](/frontmcp/authentication/authorities) (`@FrontMcp({ authorities })` or the authorities plugin), bundle rules are evaluated with the server's engine and context builder, so roles and permissions come through its `claimsMapping` / `claimsResolver` and its custom evaluators apply, exactly as for `@Tool({ authorities })`. Without server authorities, roles come from the token's `roles` claim and permissions from `permissions`.

The same rules decide what a caller can discover. `search_skill`, the skill catalog in the `search_skill` description, and the SDK's skill surfaces (`skills/list`, `skills/load`, `skill://` resources) leave out a skill whose skill-level `requiredAuthorities` the caller doesn't satisfy, and `load_skill` answers `SKILL_NOT_FOUND` for it. `load_skill` also leaves out the actions the caller can never run (an operation rule that refuses the caller, or a policy-less, non-public operation under `unprotectedOps: 'deny'`); a rule that depends on the action's input (including a profile name whose rule does) can only be judged at call time, so it doesn't hide the action. `search_skill` and `load_skill` also apply `@Skill({ authorities })` and the `skills:filter` flow (feature flags) to the server's other skills.

## OWASP MCP Top 10 (2026) coverage

| OWASP MCP risk | This plugin's defense |
| - | - |
| MCP-1 Tool poisoning | Bundle signing + bundle-diff log on every swap (rug-pull detection) |
| MCP-2 Prompt injection (direct) | Inbound auth + meta-tool input schema strict validation |
| MCP-3 Indirect prompt injection | Enclave-sandboxed `run_workflow` (script reaches upstream data only via `callTool`); output schema enforcement on each action |
| MCP-4 Excessive agency | Per-skill ABAC + credential scoping + structured ABAC denial path |
| MCP-5 Sensitive info disclosure | Outbound allowlist + audit log + no credential echo in logs/traces |
| MCP-6 Insecure tool description | Signed-bundle origin + bundle-diff log surfaces description changes |
| MCP-7 Confused deputy | RFC 8707 enforcement on every inbound JWT (caller-token passthrough) |
| MCP-8 Supply chain | Bundle signing (RS256/Ed25519 JWT-of-hashes) + signed `integrity` envelope + pinned npm version |
| MCP-9 Excessive permissions | Per-bundle credential allowlist + per-skill scoping |
| MCP-10 Insufficient observability | OTel spans + audit log + bundle-diff log on every swap |

## Indirect prompt injection mitigations

The customer's REST API can return content (CRM names, ticket bodies, user-supplied fields) that contains instructions targeting the LLM. Defenses:

* **Structural separation**: upstream responses never reach the LLM directly. Each `callTool` returns the action's `data` *into the sandboxed AgentScript*, and only the script's final `return <value>` (surfaced as `run_workflow`'s `{ success, value, error, stats }`) is handed back to the LLM. Raw response bodies are opaque to the model unless the script explicitly extracts and returns them.
* **Output schema validation as a bottleneck**: every response validated against the bundle's declared `outputSchema`.
* **Response size cap** (`defaultMaxResponseBytes`, default 256KB; per-op override via `op.maxResponseBytes`).

The signed-bundle origin reduces the surface (you trust your own CI pipeline to produce instructions), but it does not eliminate it. **Treat customer REST API responses no more than user input.** Document this loudly in your team's playbook.

## Production checklist

Before flipping to production:

* [ ] `dev: false` (default) and `requireSignature: true` (default)
* [ ] At least one entry in `trustedKeys[]`
* [ ] Credentials wired via the [auth vault](/frontmcp/authentication/overview) instead of the in-memory `MemoryCredentialResolver` for any non-trivial deployment
* [ ] `allowHttp: false` (default) unless your upstream is on `localhost`
* [ ] `allowPrivateNetworks: false` (default) unless self-hosted on a private network
* [ ] `outbound.maxConcurrencyPerHost` tuned to match your upstream's rate-limit budget
* [ ] Audit log routed to a SIEM
* [ ] SaaS-side metrics monitored to detect a stalled bundle pipeline (saas source)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.