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
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,51 @@
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] — 2026-06-21

Security hardening of the argument sanitizer and a new verify-then-attest
helper. All three packages move to 0.2.0 in lockstep.

### Security

- **ReDoS guard on `regex` argument rules.** `evalArgRule` now refuses to run a
regex pattern whose structure is prone to catastrophic backtracking (nested
unbounded quantifiers like `(a+)+`, `(.*)+`, `(.*a){15}`). The pattern comes
from the signed manifest, but the *value* is attacker-controlled — a careless
author pattern previously let a single crafted argument freeze the spawn hot
path for tens of seconds. Detection is a linear-time scan of the pattern
string (the detector itself cannot ReDoS) and fails closed. New optional
`maxLength` field on `regex` rules (default 4096) caps the input length as a
second layer. Exposed as `looksCatastrophic(pattern)`.
- **Path-traversal guard on `prefix` argument rules.** A bare `startsWith`
check was bypassable: `/safe/../../etc/passwd` satisfies `prefix: "/safe/"`
yet escapes the directory. `prefix` rules now reject `..` path components
(including the URL-encoded `%2e%2e` form, POSIX and Windows separators) by
default. New optional `denyTraversal` field (default `true`) opts out.
The secure default is enforced at evaluation time, so it also protects
manifests constructed in-memory and signed directly (where the Zod default
has not materialised). Exposed as `containsTraversal(value)`.
- **Control-character gap closed.** `shellSafeString` now also blocks VT
(U+000B), FF (U+000C) and NEL (U+0085), completing the newline/whitespace
separator default-deny set alongside the existing LF, CR, U+2028 and U+2029.
These can act as token separators or line breaks under some shells, parsers
and NFKC normalisation. CVE-2025-69256 replay fixtures extended accordingly.

### Added

- `attestSpawnVerified(signed, request, options?)` — verifies the manifest
signature *then* attests the spawn in a single fail-safe call. Closes the
footgun where an unverified or tampered manifest is handed to `attestSpawn`
(which trusts that the caller already ran `verifyManifestStrict` at startup).

### Notes

- The `prefix` rule's `denyTraversal: true` default is a behaviour change: a
pre-existing manifest that relied on `..` passing a prefix rule will now
reject it. This is intentional (a `..` in a path-prefix-guarded argument is
almost always an attack); set `denyTraversal: false` to restore the old
behaviour.

## [0.1.1] — 2026-04-28

### Added
Expand Down Expand Up @@ -45,4 +90,5 @@ Initial release.
- Per-entry validation of trust-file contents with `Object.create(null)`
containers.

[0.2.0]: https://github.com/studiomeyer-io/mcp-server-attestation/releases/tag/v0.2.0
[0.1.0]: https://github.com/studiomeyer-io/mcp-server-attestation/releases/tag/v0.1.0
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ attestSpawnStrict(signed as SignedManifest, { command, args });

That is the entire integration. Two function calls, no SaaS, no daemon.

If you would rather re-verify the signature on every spawn (defense-in-depth
against an unverified or swapped manifest reaching the gate), use the single
fail-safe call instead — it verifies *then* attests:

```ts
import { attestSpawnVerified, type SignedManifest } from "mcp-server-attestation";
attestSpawnVerified(signed as SignedManifest, { command, args });
```

## Tools (reference server `mcp-attest-demo`)

| # | Name | readOnlyHint | destructiveHint |
Expand Down Expand Up @@ -105,7 +114,10 @@ The library is transport-agnostic. The reference server is stdio-only.

- **Trust-on-First-Use** is the default. The first time you verify a server, its public key is pinned to `~/.mcp-attest/trust.json` (override: `MCP_ATTEST_TRUST_FILE`). Subsequent verifications reject any new key for the same server name with `TRUST_PIN_MISMATCH`. This catches the Cursor-style malicious-update vector.
- **No bundled trusted-keys list.** This package does not act as a gatekeeper. If you want stronger assurance, opt into `--sigstore` to cross-reference the public-key fingerprint against the Sigstore Rekor transparency log.
- **Default-deny argument sanitizer.** `shellSafeString` blocks every ASCII shell metacharacter, NUL, CR, LF, zero-width characters, BOM, RTL/LTR overrides, and Trojan-Source isolates. Allowlist behaviour requires the explicit `regex` / `enum` / `prefix` / `literal` rule kinds.
- **Default-deny argument sanitizer.** `shellSafeString` blocks every ASCII shell metacharacter, NUL, CR, LF, VT, FF, NEL, zero-width characters, BOM, RTL/LTR overrides, Trojan-Source isolates, and fullwidth-Latin confusables. Allowlist behaviour requires the explicit `regex` / `enum` / `prefix` / `literal` rule kinds.
- **ReDoS-safe `regex` rules.** A `regex` rule's pattern is signed by the author, but the argument value is attacker-controlled. The sanitizer statically detects backtracking-prone patterns (nested unbounded quantifiers like `(a+)+`) and refuses to run them, so a single crafted argument cannot freeze the spawn hot path. `regex` rules also carry a `maxLength` input cap (default 4096).
- **Traversal-safe `prefix` rules.** `prefix` rules reject `..` path components by default (`denyTraversal: true`, including the `%2e%2e` encoded form), so `/safe/../../etc/passwd` is blocked even though it satisfies `prefix: "/safe/"`.
- **Verify-then-attest.** `attestSpawnVerified` checks the manifest signature before attesting the spawn in one fail-safe call — use it instead of `attestSpawnStrict` unless you have measured the per-spawn Ed25519 verify out of a genuinely hot loop.
- **Canonical JSON** is the signed surface. Re-serialisation cannot change the signed bytes.

What this package does NOT do (out of scope):
Expand Down
21 changes: 16 additions & 5 deletions docs/MANIFEST-FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,23 @@ Tool name must match `/^[a-zA-Z_][a-zA-Z0-9_-]*$/`. Up to 64 args per tool, 256

| `kind` | Fields | Semantics |
| ------ | ------ | --------- |
| `regex` | `pattern`, `flags` | Argument must match the regex. |
| `regex` | `pattern`, `flags`, `maxLength` | Argument must match the regex. Patterns prone to catastrophic backtracking (nested unbounded quantifiers) are **refused, not run** — see ReDoS note below. `maxLength` (default 4096) caps the input length handed to the engine. |
| `enum` | `values[]` | Argument must equal one of the listed values. |
| `length` | `min`, `max` | Argument string length within `[min, max]`. |
| `prefix` | `prefix`, `maxSuffixLength` | Argument starts with `prefix`, suffix length under `maxSuffixLength`. |
| `prefix` | `prefix`, `maxSuffixLength`, `denyTraversal` | Argument starts with `prefix`, suffix length under `maxSuffixLength`. `denyTraversal` (default `true`) rejects any `..` path component, including the URL-encoded `%2e%2e` form — so `/safe/../../etc/passwd` is blocked even though it satisfies `prefix: "/safe/"`. Set `false` only when `..` is legitimately part of the value. |
| `literal` | `value` | Argument exactly equals `value`. |
| `shellSafeString` | `maxLength` | No shell metacharacters, no NUL/CR/LF, no zero-width, no BOM, no bidi-override. |
| `shellSafeString` | `maxLength` | No shell metacharacters, no NUL/CR/LF/VT/FF/NEL, no zero-width, no BOM, no bidi-override, no fullwidth-Latin confusables. |

### ReDoS note

A `regex` rule's `pattern` is part of the signed manifest, but the argument
*value* is attacker-controlled. A careless author pattern such as `(a+)+$` lets
a single crafted argument freeze the spawn hot path for tens of seconds. The
sanitizer statically detects nested-unbounded-quantifier patterns (`(a+)+`,
`(a*)*`, `(.*)+`, `(.*a){15}`, `((ab)*)*`, ...) with a linear-time scan and
**refuses to evaluate them**, failing closed. Flat patterns — `^[a-z]+$`,
`^\d{1,10}$`, `(foo|bar)`, `(ab){2,5}`, semver — are unaffected. If your
pattern is rejected, rewrite it without a nested quantifier.

`shellSafeString` is the right default for any argument that ends up in `child_process.spawn` argv — the sanitizer is implemented in `packages/lib/src/spawn.ts`.

Expand All @@ -62,13 +73,13 @@ Tool name must match `/^[a-zA-Z_][a-zA-Z0-9_-]*$/`. Up to 64 args per tool, 256
{
"command": "/usr/bin/cat",
"args": [
{ "name": "file", "kind": "prefix", "required": true, "prefix": "/safe/", "maxSuffixLength": 256 }
{ "name": "file", "kind": "prefix", "required": true, "prefix": "/safe/", "maxSuffixLength": 256, "denyTraversal": true }
],
"maxTotalArgLength": 1024
}
```

`maxTotalArgLength` is a hard cap on the sum of argv string lengths and is the primary defense against buffer-stuffing attacks.
`maxTotalArgLength` is a hard cap on the sum of argv string lengths and is the primary defense against buffer-stuffing attacks. `denyTraversal` defaults to `true`; the example shows it explicitly for clarity.

## Trust file (`~/.mcp-attest/trust.json`)

Expand Down
5 changes: 4 additions & 1 deletion docs/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
| Threat | Vector | Mitigation in this package |
| --- | --- | --- |
| Marketplace poisoning (OX Security April 2026) | Registry accepts a malicious server impersonating a known one | TOFU pin in `~/.mcp-attest/trust.json` rejects key changes for an already-trusted server name. Optional Sigstore cross-reference. |
| CVE-2025-69256 (Serverless Framework MCP RCE) | Tool argument fed to `child_process.exec()` without escaping | `shellSafeString` argument rule blocks every ASCII shell metacharacter, NUL, CR, LF, zero-width, BOM, bidi-override. Argv length capped. |
| CVE-2025-69256 (Serverless Framework MCP RCE) | Tool argument fed to `child_process.exec()` without escaping | `shellSafeString` argument rule blocks every ASCII shell metacharacter, NUL, CR, LF, VT, FF, NEL, zero-width, BOM, bidi-override, fullwidth-Latin confusables. Argv length capped. |
| CVE-2025-61591 (Cursor MCP RCE) | Malicious server spawns unrelated commands | Spawn-rule whitelist: `attestSpawn` rejects any command not in `manifest.spawnRules[]`. |
| Manifest tampering | Attacker changes a tool description or arg-rule after signing | Ed25519 signature over canonical JSON of the entire manifest. Any byte flip fails verify. |
| Fingerprint spoofing | Manifest claims one fingerprint, signed by another key | Both `signManifest` (refuses to sign) and `verifyManifest` (rejects) check that claimed fingerprint matches the embedded public key. |
| Argument-rule ReDoS (denial of service) | A `regex` arg rule with a backtracking-prone pattern; attacker sends a crafted value that hangs the spawn hot path | `evalArgRule` statically detects nested-unbounded-quantifier patterns (linear scan, cannot itself ReDoS) and refuses to run them. `regex` rules also carry a `maxLength` input cap (default 4096). |
| Path traversal past a `prefix` guard | `/safe/../../etc/passwd` satisfies a `prefix: "/safe/"` rule yet escapes the directory | `prefix` rules reject `..` path components by default (`denyTraversal: true`), including the URL-encoded `%2e%2e` form. |
| Unverified manifest reaching the spawn gate | Caller forgets to `verifyManifestStrict` at startup and hands an unverified/tampered manifest to `attestSpawn` | `attestSpawnVerified` verifies the signature before attesting in one fail-safe call. |

## What this package does NOT mitigate

Expand Down
33 changes: 27 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcp-server-attestation-monorepo",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"description": "Tool-Manifest-Attestation for Model Context Protocol. Layer-2 mitigation for marketplace-poisoning (OX Security April 2026), CVE-2025-69256 (Serverless MCP RCE), CVE-2025-61591 (Cursor MCP RCE). Ed25519 signed manifests, runtime spawn-attestation, default-deny argument sanitizer.",
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcp-attest-cli",
"version": "0.1.0",
"version": "0.2.0",
"description": "CLI for MCP tool-manifest attestation: keygen, sign, verify, inspect, fingerprint, check-pin.",
"type": "module",
"main": "./dist/bin.js",
Expand Down Expand Up @@ -30,6 +30,6 @@
"url": "https://github.com/studiomeyer-io/mcp-server-attestation/issues"
},
"dependencies": {
"mcp-server-attestation": "0.1.0"
"mcp-server-attestation": "0.2.0"
}
}
24 changes: 19 additions & 5 deletions packages/demo-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcp-attest-demo",
"version": "0.1.1",
"version": "0.2.0",
"mcpName": "io.studiomeyer/server-attestation",
"description": "Reference MCP server demonstrating mcp-server-attestation library — 5 tools for keygen, sign, verify, template-generation, and spawn-inspection. stdio transport, MCP spec 2025-06-18.",
"type": "module",
Expand All @@ -9,14 +9,26 @@
"bin": {
"mcp-attest-demo": "./dist/server.js"
},
"files": ["dist", "README.md", "LICENSE"],
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc -b && chmod +x dist/server.js",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js"
},
"keywords": ["mcp", "model-context-protocol", "attestation", "demo", "reference-server"],
"engines": { "node": ">=20.0.0" },
"keywords": [
"mcp",
"model-context-protocol",
"attestation",
"demo",
"reference-server"
],
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"author": "Matthias Meyer (StudioMeyer)",
"publishConfig": {
Expand All @@ -37,7 +49,9 @@
"zod": "^4.4.3"
},
"mcp": {
"supportedSpecVersions": ["2025-06-18"],
"supportedSpecVersions": [
"2025-06-18"
],
"transport": "stdio"
}
}
2 changes: 1 addition & 1 deletion packages/lib/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcp-server-attestation",
"version": "0.1.0",
"version": "0.2.0",
"description": "Library for Ed25519-signed MCP tool manifests, runtime spawn-attestation, default-deny argument sanitizer. Layer-2 mitigation for marketplace-poisoning, CVE-2025-69256, CVE-2025-61591.",
"type": "module",
"main": "./dist/index.js",
Expand Down
3 changes: 3 additions & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ export type { VerificationResult } from "./verify.js";
export {
attestSpawn,
attestSpawnStrict,
attestSpawnVerified,
sanitizeArgs,
evalArgRule,
looksCatastrophic,
containsTraversal,
} from "./spawn.js";
export type { SpawnRequest, SanitizationResult, AttestationResult } from "./spawn.js";

Expand Down
24 changes: 24 additions & 0 deletions packages/lib/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export const ArgRuleSchema = z.discriminatedUnion("kind", [
kind: z.literal("regex"),
pattern: z.string().min(1).max(2048),
flags: z.string().max(8).optional(),
/**
* Hard cap on the length of the value handed to the regex engine.
*
* ReDoS defense-in-depth: catastrophic backtracking is super-linear in the
* input length, so bounding the attacker-controlled value bounds the worst
* case. `evalArgRule` rejects over-long values *before* calling `.test()`.
* The primary ReDoS guard is the static pattern check (see `spawn.ts`
* `looksCatastrophic`), which refuses to run a backtracking-prone pattern
* at all; this cap is the second layer. Default 4096.
*/
maxLength: z.number().int().positive().max(65536).default(4096),
}),
baseArgRule.extend({
kind: z.literal("enum"),
Expand All @@ -39,6 +50,19 @@ export const ArgRuleSchema = z.discriminatedUnion("kind", [
kind: z.literal("prefix"),
prefix: z.string().min(1).max(512),
maxSuffixLength: z.number().int().nonnegative().max(65536).default(2048),
/**
* Reject `..` path segments anywhere in the value. The `prefix` rule's
* documented purpose is path-guarding (e.g. `prefix: "/safe/"`), but a bare
* `startsWith` check is bypassable: `/safe/../../etc/passwd` satisfies the
* prefix yet escapes the directory. With `denyTraversal` (default `true`)
* any value containing a `..` path component — `../`, `..\`, a leading or
* trailing `..`, or URL-encoded `%2e%2e` — is rejected.
*
* Default-on is the safe choice for a security package: a `..` inside a
* path-prefix-guarded argument is almost always an attack. Set to `false`
* to opt out when `..` is legitimately part of the value (rare).
*/
denyTraversal: z.boolean().default(true),
}),
baseArgRule.extend({
kind: z.literal("literal"),
Expand Down
Loading