- MCP server security (OWASP MCP Top 10 2026, CVE-2025-6514, the Anthropic Git MCP CVE chain CVE-2025-68143/144/145)
- Indirect prompt injection (Anthropic’s Feb 2026 system card identifies indirect injection as the dominant attack class)
- Outbound HTTP from a server-side runtime (SSRF surged 452% from 2023 to 2024)
Five-gate authorization stack
EverycallTool(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:
@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 callTools in one round-trip, and each one runs
the full five-gate path above.
Bundle signing (OPA model)
Theintegrity envelope on every bundle is a JWT-of-hashes modeled on OPA’s signed-bundle pattern.
- Re-compute sha256 of the canonicalized bundle minus the
integrityfield. - Reject if the computed digest doesn’t match
integrity.digest. - Look up the trusted public key by
integrity.keyId. Reject if unknown. - Reject if the trusted key’s algorithm doesn’t match
integrity.alg. - Verify the signature over the canonical bytes (not the digest hex) using the public key.
/healthz + structured error log + audit trail. Never partial apply.
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 thesaas 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:
audmust equal the configuredexpectedAudience(typicallybundleId)resourcemust equal the configuredexpectedResource(typically the FrontMCP server’s canonical URL)issmust equalexpectedIssuerrolesmust include at least one ofrequiredRoles(default:['frontmcp:cloud:push'])
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 inbundle.schema.ts enforces:
- URL construction: WHATWG
URLonly, 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:
additionalPropertiesconstraints applied at the Zod boundary; the executor validates input against the schema again before invoking the upstream - No
eval,Function,child_process,vm, or dynamicrequireanywhere in the executor or sync paths - No bundle data in shell commands — the plugin never shells out
SSRF defenses (layered)
A hostname allowlist alone is insufficient (DNS rebinding bypasses it). The plugin layers:- URL string check before DNS resolution: reject
file:,data:,gopher:,ftp:; requirehttps:(allowhttp:only via explicitallowHttp: true). - Hostname allowlist: only declared
services[].baseUrlhosts. - Cloud-metadata hostname blocklist:
metadata.google.internal,metadata.azure.com,metadata.aws.comblocked even if technically allowlisted. - 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 addressfd00:ec2::254, the unspecified / “this network” addresses (0/8,::), and the local-use NAT64 prefix64: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, sohttps://[::ffff:169.254.169.254]— whichnew URL()rewrites to[::ffff:a9fe:a9fe]— is refused in every spelling. An address that does not parse is refused.
- Always blocked, even with
- Redirects are never followed: requests go out with
redirect: 'manual', and any 3xx (or a browser runtime’s status-0opaqueredirect) fails the call instead of re-sending the vault-injected credential to a destination the upstream chose. - 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:
AuthoritiesEngine from @frontmcp/auth — RBAC, ABAC, ReBAC, custom evaluators, and combinators (anyOf, allOf, not) are all available. See the authorities docs 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({ 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
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
callToolreturns the action’sdatainto the sandboxed AgentScript, and only the script’s finalreturn <value>(surfaced asrun_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 viaop.maxResponseBytes).
Production checklist
Before flipping to production:-
dev: false(default) andrequireSignature: true(default) - At least one entry in
trustedKeys[] - Credentials wired via the auth vault instead of the in-memory
MemoryCredentialResolverfor any non-trivial deployment -
allowHttp: false(default) unless your upstream is onlocalhost -
allowPrivateNetworks: false(default) unless self-hosted on a private network -
outbound.maxConcurrencyPerHosttuned 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)