From e6491e31c792955943b47546c47710aaca6660ec Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Tue, 29 Sep 2026 10:06:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(fuzz):=20target=203=20spec=5Fimport=20?= =?UTF-8?q?=E2=80=94=20import-surface=20fuzz=20harness=20+=2042=20seeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fuzz/shared/src/spec_import.rs: invariants over the import-parse surface — the three OpenAPISpec constructors (str/json/yaml, ADR-051 JSON-first), the YAML→JSON normalization boundary (reject-loudly partition: non-finite floats, pointer keys), the compile/harden/validate lane (hardening partition, validate determinism, closed-by-default unknown-key probe), the FromMCP build_spec tool-shape lane, and the $ref-bounded resolver lane (cycles/hop chains/deep nesting fail cleanly, no hang, no stack blowout). - fuzz/fuzz_targets/spec_import.rs: thin fuzz_target! wrapper. - 42 committed corpus seeds via gen_fuzz_seeds.py (valid JSON/YAML docs, structural failures, YAML normalization edges, $ref cycles/ chains/unresolvable/external, schema-validation edges, MCP tool-shape edges); corpus-replay gate count floor 12. - fuzzing exposure hubs next to the wave-1 one: openapi_spec::fuzzing, input_validation::fuzzing (opaque FuzzCompiledValidator newtype — the pub(crate) validator never leaks), from_mcp::fuzzing (mcp feature). - fuzz/shared gains openapi/mcp features (default on); json.dict extended with the OpenAPI token set (75 unique tokens). - clippy follow-up in wave-1's ws_chunk_framing replay test (unnecessary u32 casts against the newer lint). - docs/plans/fuzzing.md §3/§6 target 3 → implemented; fuzz/README target table + campaign-advice lines updated. Verification: corpus replay 11/11 green (default and --no-default-features), cargo fuzz build clean, main crate 457 default / 590 all-features tests green, clippy+fmt clean both crates. Campaign: detached 10-min smoke run — see docs/plans/fuzzing.md §5. --- docs/plans/fuzzing.md | 45 ++- fuzz/Cargo.lock | 202 ++++++++++++ fuzz/Cargo.toml | 7 + fuzz/README.md | 5 +- fuzz/corpus/spec_import/seed-000 | 1 + fuzz/corpus/spec_import/seed-001 | 9 + fuzz/corpus/spec_import/seed-002 | 1 + fuzz/corpus/spec_import/seed-003 | 1 + fuzz/corpus/spec_import/seed-004 | 1 + fuzz/corpus/spec_import/seed-005 | 1 + fuzz/corpus/spec_import/seed-006 | 1 + fuzz/corpus/spec_import/seed-007 | 1 + fuzz/corpus/spec_import/seed-008 | 1 + fuzz/corpus/spec_import/seed-009 | 1 + fuzz/corpus/spec_import/seed-010 | 1 + fuzz/corpus/spec_import/seed-011 | 1 + fuzz/corpus/spec_import/seed-012 | 0 fuzz/corpus/spec_import/seed-013 | Bin 0 -> 4 bytes fuzz/corpus/spec_import/seed-014 | 1 + fuzz/corpus/spec_import/seed-015 | 2 + fuzz/corpus/spec_import/seed-016 | 5 + fuzz/corpus/spec_import/seed-017 | 1 + fuzz/corpus/spec_import/seed-018 | 1 + fuzz/corpus/spec_import/seed-019 | 1 + fuzz/corpus/spec_import/seed-020 | 1 + fuzz/corpus/spec_import/seed-021 | 2 + fuzz/corpus/spec_import/seed-022 | 2 + fuzz/corpus/spec_import/seed-023 | 2 + fuzz/corpus/spec_import/seed-024 | 1 + fuzz/corpus/spec_import/seed-025 | 1 + fuzz/corpus/spec_import/seed-026 | 5 + fuzz/corpus/spec_import/seed-027 | 1 + fuzz/corpus/spec_import/seed-028 | 1 + fuzz/corpus/spec_import/seed-029 | 1 + fuzz/corpus/spec_import/seed-030 | 1 + fuzz/corpus/spec_import/seed-031 | 1 + fuzz/corpus/spec_import/seed-032 | 1 + fuzz/corpus/spec_import/seed-033 | 1 + fuzz/corpus/spec_import/seed-034 | 1 + fuzz/corpus/spec_import/seed-035 | 1 + fuzz/corpus/spec_import/seed-036 | 1 + fuzz/corpus/spec_import/seed-037 | 1 + fuzz/corpus/spec_import/seed-038 | 1 + fuzz/corpus/spec_import/seed-039 | 1 + fuzz/corpus/spec_import/seed-040 | 1 + fuzz/corpus/spec_import/seed-041 | 1 + fuzz/fuzz_targets/spec_import.rs | 7 + fuzz/gen_fuzz_seeds.py | 143 ++++++++- fuzz/json.dict | 56 +++- fuzz/shared/Cargo.toml | 8 + fuzz/shared/src/lib.rs | 1 + fuzz/shared/src/spec_import.rs | 457 ++++++++++++++++++++++++++++ fuzz/shared/src/ws_chunk_framing.rs | 4 +- src/adapters/from_mcp/mod.rs | 27 ++ src/adapters/input_validation.rs | 35 +++ src/adapters/openapi_spec.rs | 53 ++++ 56 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 fuzz/corpus/spec_import/seed-000 create mode 100644 fuzz/corpus/spec_import/seed-001 create mode 100644 fuzz/corpus/spec_import/seed-002 create mode 100644 fuzz/corpus/spec_import/seed-003 create mode 100644 fuzz/corpus/spec_import/seed-004 create mode 100644 fuzz/corpus/spec_import/seed-005 create mode 100644 fuzz/corpus/spec_import/seed-006 create mode 100644 fuzz/corpus/spec_import/seed-007 create mode 100644 fuzz/corpus/spec_import/seed-008 create mode 100644 fuzz/corpus/spec_import/seed-009 create mode 100644 fuzz/corpus/spec_import/seed-010 create mode 100644 fuzz/corpus/spec_import/seed-011 create mode 100644 fuzz/corpus/spec_import/seed-012 create mode 100644 fuzz/corpus/spec_import/seed-013 create mode 100644 fuzz/corpus/spec_import/seed-014 create mode 100644 fuzz/corpus/spec_import/seed-015 create mode 100644 fuzz/corpus/spec_import/seed-016 create mode 100644 fuzz/corpus/spec_import/seed-017 create mode 100644 fuzz/corpus/spec_import/seed-018 create mode 100644 fuzz/corpus/spec_import/seed-019 create mode 100644 fuzz/corpus/spec_import/seed-020 create mode 100644 fuzz/corpus/spec_import/seed-021 create mode 100644 fuzz/corpus/spec_import/seed-022 create mode 100644 fuzz/corpus/spec_import/seed-023 create mode 100644 fuzz/corpus/spec_import/seed-024 create mode 100644 fuzz/corpus/spec_import/seed-025 create mode 100644 fuzz/corpus/spec_import/seed-026 create mode 100644 fuzz/corpus/spec_import/seed-027 create mode 100644 fuzz/corpus/spec_import/seed-028 create mode 100644 fuzz/corpus/spec_import/seed-029 create mode 100644 fuzz/corpus/spec_import/seed-030 create mode 100644 fuzz/corpus/spec_import/seed-031 create mode 100644 fuzz/corpus/spec_import/seed-032 create mode 100644 fuzz/corpus/spec_import/seed-033 create mode 100644 fuzz/corpus/spec_import/seed-034 create mode 100644 fuzz/corpus/spec_import/seed-035 create mode 100644 fuzz/corpus/spec_import/seed-036 create mode 100644 fuzz/corpus/spec_import/seed-037 create mode 100644 fuzz/corpus/spec_import/seed-038 create mode 100644 fuzz/corpus/spec_import/seed-039 create mode 100644 fuzz/corpus/spec_import/seed-040 create mode 100644 fuzz/corpus/spec_import/seed-041 create mode 100644 fuzz/fuzz_targets/spec_import.rs create mode 100644 fuzz/shared/src/spec_import.rs diff --git a/docs/plans/fuzzing.md b/docs/plans/fuzzing.md index f8d9481..b98e157 100644 --- a/docs/plans/fuzzing.md +++ b/docs/plans/fuzzing.md @@ -9,9 +9,9 @@ plan for alkhttp; the sibling docs are the reference for rationale survey, CI tiers) — this plan stays focused on what alkhttp fuzzes and in what order. -**Status:** targets 1–2 implemented and verified this session -(2026-09-29); targets 3–4 are planned and can be handed to subsequent -sessions. +**Status:** targets 1–3 implemented and verified (1–2 on 2026-09-29, +wave 1; 3 on 2026-09-29, wave 2); target 4 is planned and can be +handed to subsequent sessions. Progress log: @@ -38,6 +38,10 @@ Progress log: round-trip was wrong for data ending in `\r` (un-re-encodable under WHATWG CR-stripping — the serde_json key-order class; the harness skips that documented-lossy shape). +- **2026-09-29 — target 3 (`spec_import`) implemented (wave 2).** + Harness + 42 seeds + the three `fuzzing` hubs; no crate findings in + the initial corpus replay (11 shared tests green, main crate 457 + default / 590 all-features). Smoke campaign results in §5. --- @@ -133,7 +137,7 @@ checklist, as the siblings did. |---|---|---|---|---| | 1 | `sse_parser` | `SseParser::feed` + `sse_event_envelope` mapping (`src/adapters/forward.rs`) | chunk-split sequence × eof flag | implemented 2026-09-29 | | 2 | `ws_chunk_framing` | the WS↔byte-adapter chunk parsers (`WriteChunkWalk`, the write pump's extracted chunk walk) (`src/websocket/byte_adapter.rs`) | byte sequences + message-split boundaries | implemented 2026-09-29 | -| 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | planned | +| 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | implemented 2026-09-29 | | 4 | `ws_session_opseq` / `retry_throttle` | stateful op sequences over the WS byte-adapter pumps (write/read pump interleavings, close timing, oversize frames); `RetryAfterMiddleware` throttle-map growth | `#[derive(Arbitrary)]` op enums | planned (second wave) | ### Target 1 — `sse_parser` (the chunk_header-class target) @@ -224,7 +228,7 @@ the chunk-walk loop and the pending accumulator. `#[cfg(fuzzing)]` re-export and a current-thread runtime with a mock sink (the alkcall `envelope_frame` Cursor pattern). -### Target 3 — `spec_import` (planned) +### Target 3 — `spec_import` (implemented 2026-09-29) - **Drives:** raw bytes (and `json.dict`) → `serde_json`/`yaml_serde` → `OpenAPISpec::from_str` (JSON-first, YAML-fallback, ADR-051) and @@ -243,10 +247,31 @@ the chunk-walk loop and the pending accumulator. `additionalProperties: false`; registry-compile shape parity with alkcall's `spec_parse` invariants (bounded compile, always `Result`). +- **Implemented shape:** the harness drives one attacker string + through all four lanes — the three spec constructors (str/json, + yaml under `openapi`), the direct YAML→JSON normalization boundary + (merge keys applied; a value the boundary accepted can't contain + non-finite numbers — serde_json-invariant, asserted recursively), + the compile/harden/validate lane against the whole doc (hardening + partition: injected iff absent and the input is an object; + validate determinism probe with an unknown-key input; deterministic + two-call agreement), the MCP tool-shape lane (the doc and a + `{name, inputSchema}` wrapper through `serde_json`→`Tool`→ + `build_spec`; success pins the namespace prefix), and the `$ref` + lane (`resolve_refs_recursive` over the raw doc + declared + component schemas when the doc parses). Exposure via + `fuzzing` hubs alongside the wave-1 one: + `openapi_spec::fuzzing`, `input_validation::fuzzing` + (an opaque `FuzzCompiledValidator` newtype so the `pub(crate)` + validator never leaks), `from_mcp::fuzzing` (`mcp` feature). + The shared crate gates `openapi`/`mcp` (both on by default, riding + the main crate's default build); corpus replay passes both with and + without. - **Note:** yaml_serde (ADR-051: YAML 1.2 + merge keys) is the deepest third-party parse we own — the campaign should also encode the already-documented boundaries (`apply_merge` error path, duplicate-key rejection with line/column) as corpus expectations. + 42 committed seeds cover the menu below. ### Target 4 — stateful wave (planned) @@ -330,14 +355,20 @@ exited 0, artifact dirs empty — no crash/hang/OOM/leak): - Toolchain notes live in `fuzz/README.md`; nightly stays confined to `fuzz/`. +Wave-2 (`spec_import`) campaign results: filled in below after the +smoke run completes. + ## 6. Sequencing 1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace exclude, toolchain pin, runner, seed generator, README) + targets 1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns (clean, see §5). Includes the FWD-20 SSE dribble quadratic fix. -2. **Next session(s)** — target 3 (`spec_import`) + campaigns. -3. **Then** — target 4 stateful wave + longer campaigns; revisit +2. ✅ **Done (2026-09-29)** — target 3 (`spec_import`): harness + + invariants (`fuzz/shared/src/spec_import.rs`), 42 seeds, `json.dict` + extended with the OpenAPI-token set, fuzzing hubs in the three + driven modules. Smoke campaign: see §5. +3. **Next** — target 4 stateful wave + longer campaigns; revisit corpus merges if any campaign yields inputs worth curating. Status updates land in §3's table and §6's list as work completes. \ No newline at end of file diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 9370ff6..b6e93bc 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -66,6 +66,7 @@ dependencies = [ "reqwest", "reqwest-middleware", "reqwest-retry", + "rmcp", "serde", "serde_json", "thiserror", @@ -100,6 +101,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -323,6 +333,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cmake" version = "0.1.58" @@ -419,6 +441,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "email_address" version = "0.2.9" @@ -780,6 +808,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1221,6 +1273,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1578,6 +1636,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "reqwest", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1698,6 +1786,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1763,6 +1877,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1854,6 +1979,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1980,6 +2118,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -2346,12 +2495,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index a794d3c..551e609 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -28,4 +28,11 @@ test = false doc = false bench = false +[[bin]] +name = "spec_import" +path = "fuzz_targets/spec_import.rs" +test = false +doc = false +bench = false + [workspace] \ No newline at end of file diff --git a/fuzz/README.md b/fuzz/README.md index e869bd9..da13a74 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -20,6 +20,7 @@ operational cheat-sheet. |---|---| | `sse_parser` | `SseParser::feed` + the FWD-17 envelope mapping (`src/adapters/forward.rs`) — the SSE frame parser every subscription forwards through | | `ws_chunk_framing` | `WriteChunkWalk` (the write pump's chunk walk) (`src/websocket/byte_adapter.rs`) | +| `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, the YAML normalization boundary, `CompiledInputSchema` compile/harden/validate, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `input_validation.rs`, `from_mcp/mod.rs`) | ## Running a campaign — always detached @@ -57,4 +58,6 @@ required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88. toolchain file (rustup resolves per directory). Run campaigns before releases and after touching `src/adapters/forward.rs` -(the SSE parser) or `src/websocket/byte_adapter.rs` (the WS chunk walk). \ No newline at end of file +(the SSE parser), `src/websocket/byte_adapter.rs` (the WS chunk walk), +or the import adapters (`src/adapters/openapi_spec.rs`, +`input_validation.rs`, `from_mcp/`). \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-000 b/fuzz/corpus/spec_import/seed-000 new file mode 100644 index 0000000..145b38d --- /dev/null +++ b/fuzz/corpus/spec_import/seed-000 @@ -0,0 +1 @@ +{"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},"paths":{"/x":{"get":{"operationId":"x_get","responses":{}}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-001 b/fuzz/corpus/spec_import/seed-001 new file mode 100644 index 0000000..4cb3791 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-001 @@ -0,0 +1,9 @@ +openapi: 3.0.0 +info: + title: t + version: 1.0.0 +paths: + /x: + get: + operationId: x_get + responses: {} diff --git a/fuzz/corpus/spec_import/seed-002 b/fuzz/corpus/spec_import/seed-002 new file mode 100644 index 0000000..982768a --- /dev/null +++ b/fuzz/corpus/spec_import/seed-002 @@ -0,0 +1 @@ +{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/items/{id}":{"get":{"operationId":"get_item","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Widget"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Widget"}}}}}}}},"components":{"schemas":{"Widget":{"type":"object","properties":{"id":{"type":"string"},"n":{"minimum":1}}}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-003 b/fuzz/corpus/spec_import/seed-003 new file mode 100644 index 0000000..b1210d0 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-003 @@ -0,0 +1 @@ +{"paths":{}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-004 b/fuzz/corpus/spec_import/seed-004 new file mode 100644 index 0000000..da68eb4 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-004 @@ -0,0 +1 @@ +{"info":{"title":"t"}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-005 b/fuzz/corpus/spec_import/seed-005 new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-005 @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-006 b/fuzz/corpus/spec_import/seed-006 new file mode 100644 index 0000000..ec747fa --- /dev/null +++ b/fuzz/corpus/spec_import/seed-006 @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-007 b/fuzz/corpus/spec_import/seed-007 new file mode 100644 index 0000000..f70d7bb --- /dev/null +++ b/fuzz/corpus/spec_import/seed-007 @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-008 b/fuzz/corpus/spec_import/seed-008 new file mode 100644 index 0000000..a1d7254 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-008 @@ -0,0 +1 @@ +"a string" \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-009 b/fuzz/corpus/spec_import/seed-009 new file mode 100644 index 0000000..81750b9 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-009 @@ -0,0 +1 @@ +{ \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-010 b/fuzz/corpus/spec_import/seed-010 new file mode 100644 index 0000000..8f606b7 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-010 @@ -0,0 +1 @@ +}{ \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-011 b/fuzz/corpus/spec_import/seed-011 new file mode 100644 index 0000000..7064817 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-011 @@ -0,0 +1 @@ +not a document \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-012 b/fuzz/corpus/spec_import/seed-012 new file mode 100644 index 0000000..e69de29 diff --git a/fuzz/corpus/spec_import/seed-013 b/fuzz/corpus/spec_import/seed-013 new file mode 100644 index 0000000000000000000000000000000000000000..f971a5e28b6c4cb237ca3c7349e33bb600dbc907 GIT binary patch literal 4 LcmZQzWcm*P0SW;F literal 0 HcmV?d00001 diff --git a/fuzz/corpus/spec_import/seed-014 b/fuzz/corpus/spec_import/seed-014 new file mode 100644 index 0000000..9d4f688 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-014 @@ -0,0 +1 @@ +{"openapi":3.0} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-015 b/fuzz/corpus/spec_import/seed-015 new file mode 100644 index 0000000..0dc12f1 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-015 @@ -0,0 +1,2 @@ +a: 1 +a: 2 diff --git a/fuzz/corpus/spec_import/seed-016 b/fuzz/corpus/spec_import/seed-016 new file mode 100644 index 0000000..f15937c --- /dev/null +++ b/fuzz/corpus/spec_import/seed-016 @@ -0,0 +1,5 @@ +base: &b + x: 1 +child: + <<: *b + y: 2 diff --git a/fuzz/corpus/spec_import/seed-017 b/fuzz/corpus/spec_import/seed-017 new file mode 100644 index 0000000..463d9f3 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-017 @@ -0,0 +1 @@ +maximum: .inf diff --git a/fuzz/corpus/spec_import/seed-018 b/fuzz/corpus/spec_import/seed-018 new file mode 100644 index 0000000..86469a1 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-018 @@ -0,0 +1 @@ +minimum: -.inf diff --git a/fuzz/corpus/spec_import/seed-019 b/fuzz/corpus/spec_import/seed-019 new file mode 100644 index 0000000..d93a425 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-019 @@ -0,0 +1 @@ +value: .nan diff --git a/fuzz/corpus/spec_import/seed-020 b/fuzz/corpus/spec_import/seed-020 new file mode 100644 index 0000000..d1c2c87 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-020 @@ -0,0 +1 @@ +~: v diff --git a/fuzz/corpus/spec_import/seed-021 b/fuzz/corpus/spec_import/seed-021 new file mode 100644 index 0000000..5320960 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-021 @@ -0,0 +1,2 @@ +? [1, 2] +: v diff --git a/fuzz/corpus/spec_import/seed-022 b/fuzz/corpus/spec_import/seed-022 new file mode 100644 index 0000000..08cd655 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-022 @@ -0,0 +1,2 @@ +responses: + 200: {} diff --git a/fuzz/corpus/spec_import/seed-023 b/fuzz/corpus/spec_import/seed-023 new file mode 100644 index 0000000..63cddb3 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-023 @@ -0,0 +1,2 @@ +a: yes +b: no diff --git a/fuzz/corpus/spec_import/seed-024 b/fuzz/corpus/spec_import/seed-024 new file mode 100644 index 0000000..3b56c34 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-024 @@ -0,0 +1 @@ +a: !!unknown b diff --git a/fuzz/corpus/spec_import/seed-025 b/fuzz/corpus/spec_import/seed-025 new file mode 100644 index 0000000..c79b0e8 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-025 @@ -0,0 +1 @@ +a: !!str 200 diff --git a/fuzz/corpus/spec_import/seed-026 b/fuzz/corpus/spec_import/seed-026 new file mode 100644 index 0000000..7058190 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-026 @@ -0,0 +1,5 @@ +paths: + /x: + get: + responses: + 200: {} diff --git a/fuzz/corpus/spec_import/seed-027 b/fuzz/corpus/spec_import/seed-027 new file mode 100644 index 0000000..b44930e --- /dev/null +++ b/fuzz/corpus/spec_import/seed-027 @@ -0,0 +1 @@ +{"components":{"schemas":{"A":{"$ref":"#/components/schemas/A"}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-028 b/fuzz/corpus/spec_import/seed-028 new file mode 100644 index 0000000..63da6e9 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-028 @@ -0,0 +1 @@ +{"components":{"schemas":{"A":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/B"}}},"B":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/A"}}}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-029 b/fuzz/corpus/spec_import/seed-029 new file mode 100644 index 0000000..4e48d3c --- /dev/null +++ b/fuzz/corpus/spec_import/seed-029 @@ -0,0 +1 @@ +{"components":{"schemas":{"A":{"$ref":"#/components/schemas/Missing"}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-030 b/fuzz/corpus/spec_import/seed-030 new file mode 100644 index 0000000..3f53aef --- /dev/null +++ b/fuzz/corpus/spec_import/seed-030 @@ -0,0 +1 @@ +{"components":{"schemas":{"A":{"$ref":"https://other/file.json"}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-031 b/fuzz/corpus/spec_import/seed-031 new file mode 100644 index 0000000..44b4e23 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-031 @@ -0,0 +1 @@ +{"components":{"schemas":{"S0":{"type":"object"},"S1":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S0"},"b":{"$ref":"#/components/schemas/S0"}}},"S2":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S1"},"b":{"$ref":"#/components/schemas/S1"}}}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-032 b/fuzz/corpus/spec_import/seed-032 new file mode 100644 index 0000000..ebee276 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-032 @@ -0,0 +1 @@ +{"required": "n"} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-033 b/fuzz/corpus/spec_import/seed-033 new file mode 100644 index 0000000..6bfd79c --- /dev/null +++ b/fuzz/corpus/spec_import/seed-033 @@ -0,0 +1 @@ +"not an object" \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-034 b/fuzz/corpus/spec_import/seed-034 new file mode 100644 index 0000000..92437d0 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-034 @@ -0,0 +1 @@ +{"type":"object","properties":{"q":{"type":"string"}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-035 b/fuzz/corpus/spec_import/seed-035 new file mode 100644 index 0000000..e5bdc80 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-035 @@ -0,0 +1 @@ +{"type":"object","properties":{"q":{"type":"string"}},"additionalProperties":true} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-036 b/fuzz/corpus/spec_import/seed-036 new file mode 100644 index 0000000..a180f01 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-036 @@ -0,0 +1 @@ +{"type":"object","properties":{"level":{"enum":["low","high"]},"n":{"minimum":1},"s":{"maxLength":3}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-037 b/fuzz/corpus/spec_import/seed-037 new file mode 100644 index 0000000..b7fc854 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-037 @@ -0,0 +1 @@ +{"name":"ok_tool","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-038 b/fuzz/corpus/spec_import/seed-038 new file mode 100644 index 0000000..805a519 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-038 @@ -0,0 +1 @@ +{"name":"ns/ok_tool","inputSchema":{"type":"object"}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-039 b/fuzz/corpus/spec_import/seed-039 new file mode 100644 index 0000000..087dc34 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-039 @@ -0,0 +1 @@ +{"name":"has space","inputSchema":{"type":"object"}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-040 b/fuzz/corpus/spec_import/seed-040 new file mode 100644 index 0000000..c434a13 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-040 @@ -0,0 +1 @@ +{"name":"","inputSchema":{"type":"object"}} \ No newline at end of file diff --git a/fuzz/corpus/spec_import/seed-041 b/fuzz/corpus/spec_import/seed-041 new file mode 100644 index 0000000..c9efee8 --- /dev/null +++ b/fuzz/corpus/spec_import/seed-041 @@ -0,0 +1 @@ +{"name":"ok_tool"} \ No newline at end of file diff --git a/fuzz/fuzz_targets/spec_import.rs b/fuzz/fuzz_targets/spec_import.rs new file mode 100644 index 0000000..e60e31d --- /dev/null +++ b/fuzz/fuzz_targets/spec_import.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + alkhttp_fuzz_shared::spec_import::fuzz_spec_import(data); +}); \ No newline at end of file diff --git a/fuzz/gen_fuzz_seeds.py b/fuzz/gen_fuzz_seeds.py index 4530605..b6999ad 100644 --- a/fuzz/gen_fuzz_seeds.py +++ b/fuzz/gen_fuzz_seeds.py @@ -166,7 +166,148 @@ def ws_chunk_framing_seeds(): return i +def spec_import_seeds(): + i = 0 + + # Minimal valid OpenAPI docs — JSON then the same doc as YAML. + json_doc = ( + '{"openapi":"3.0.0","info":{"title":"t","version":"1.0.0"},' + '"paths":{"/x":{"get":{"operationId":"x_get","responses":{}}}}}' + ).encode() + yield_seed = [ + json_doc, + ( + "openapi: 3.0.0\n" + "info:\n" + " title: t\n" + " version: 1.0.0\n" + "paths:\n" + " /x:\n" + " get:\n" + " operationId: x_get\n" + " responses: {}\n" + ).encode(), + # A doc with a component schema + parameters (the ref lane's + # valid path). + ( + '{"openapi":"3.0.0","info":{"title":"t","version":"1"},' + '"paths":{"/items/{id}":{"get":{"operationId":"get_item",' + '"parameters":[{"name":"id","in":"path","required":true,' + '"schema":{"type":"string"}}],' + '"requestBody":{"content":{"application/json":{"schema":' + '{"$ref":"#/components/schemas/Widget"}}}},' + '"responses":{"200":{"content":{"application/json":{"schema":' + '{"$ref":"#/components/schemas/Widget"}}}}}}}},' + '"components":{"schemas":{"Widget":{"type":"object",' + '"properties":{"id":{"type":"string"},"n":{"minimum":1}}}}}}' + ).encode(), + ] + for data in yield_seed: + write_seed("spec_import", i, data) + i += 1 + + # Structural failures: missing info/paths, non-object documents, + # malformed at both parse layers. + for data in [ + b'{"paths":{}}', + b'{"info":{"title":"t"}}', + b"[]", + b"null", + b"42", + b'"a string"', + b"{", + b"}{", + b"not a document", + b"", + b"\x00\x01\x02\xff", + b'{"openapi":3.0}', + ]: + write_seed("spec_import", i, data) + i += 1 + + # YAML normalization edges (OAI-12): duplicate keys, merge keys, + # non-finite floats, non-string keys, numeric keys, bare YAML 1.2 + # strings-as-booleans, tags. + for data in [ + b"a: 1\na: 2\n", + b"base: &b\n x: 1\nchild:\n <<: *b\n y: 2\n", + b"maximum: .inf\n", + b"minimum: -.inf\n", + b"value: .nan\n", + b"~: v\n", + b"? [1, 2]\n: v\n", + b"responses:\n 200: {}\n", + b"a: yes\nb: no\n", + b"a: !!unknown b\n", + b"a: !!str 200\n", + b"paths:\n /x:\n get:\n responses:\n 200: {}\n", + ]: + write_seed("spec_import", i, data) + i += 1 + + # $ref edges: cycles (self, two-node), unresolvable, external, + # the acyclic shared-ref chain (memoized linear shape). + for data in [ + b'{"components":{"schemas":{"A":{"$ref":"#/components/schemas/A"}}}}', + ( + '{"components":{"schemas":{' + '"A":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/B"}}},' + '"B":{"type":"object","properties":{"n":{"$ref":"#/components/schemas/A"}}}}}}' + ).encode(), + b'{"components":{"schemas":{"A":{"$ref":"#/components/schemas/Missing"}}}}', + b'{"components":{"schemas":{"A":{"$ref":"https://other/file.json"}}}}', + ( + '{"components":{"schemas":{' + '"S0":{"type":"object"},' + '"S1":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S0"},' + '"b":{"$ref":"#/components/schemas/S0"}}},' + '"S2":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/S1"},' + '"b":{"$ref":"#/components/schemas/S1"}}}}}}' + ).encode(), + ]: + write_seed("spec_import", i, data) + i += 1 + + # Input-schema compile/validate edges: not-a-schema, the + # closed-by-default probe, the explicit catch-all, leaf + # constraints. + for data in [ + b'{"required": "n"}', + b'"not an object"', + b'{"type":"object","properties":{"q":{"type":"string"}}}', + ( + '{"type":"object","properties":{"q":{"type":"string"}},' + '"additionalProperties":true}' + ).encode(), + ( + '{"type":"object","properties":{"level":{"enum":["low","high"]},' + '"n":{"minimum":1},"s":{"maxLength":3}}}' + ).encode(), + ]: + write_seed("spec_import", i, data) + i += 1 + + # MCP tool-shape edges (the from_mcp lane with the mcp feature): + # valid, slash-name, whitespace-name, empty-name, missing + # inputSchema. + for data in [ + b'{"name":"ok_tool","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}}', + b'{"name":"ns/ok_tool","inputSchema":{"type":"object"}}', + b'{"name":"has space","inputSchema":{"type":"object"}}', + b'{"name":"","inputSchema":{"type":"object"}}', + b'{"name":"ok_tool"}', + ]: + write_seed("spec_import", i, data) + i += 1 + + return i + + if __name__ == "__main__": n_sse = sse_parser_seeds() n_ws = ws_chunk_framing_seeds() - print(f"sse_parser: {n_sse} seeds, ws_chunk_framing: {n_ws} seeds") \ No newline at end of file + n_spec = spec_import_seeds() + print( + f"sse_parser: {n_sse} seeds, ws_chunk_framing: {n_ws} seeds, " + f"spec_import: {n_spec} seeds" + ) \ No newline at end of file diff --git a/fuzz/json.dict b/fuzz/json.dict index afccc1c..6e8a153 100644 --- a/fuzz/json.dict +++ b/fuzz/json.dict @@ -20,4 +20,58 @@ "INTERNAL" "TIMEOUT" "FORBIDDEN" -"CONNECTION_CLOSED" \ No newline at end of file +"CONNECTION_CLOSED" +# OpenAPI / spec-import tokens (target 3). +"openapi" +"info" +"paths" +"components" +"schemas" +"parameters" +"requestBodies" +"responses" +"title" +"version" +"get" +"post" +"put" +"patch" +"delete" +"head" +"options" +"requestBody" +"$ref" +"name" +"in" +"required" +"schema" +"content" +"application/json" +"properties" +"additionalProperties" +"enum" +"pattern" +"minimum" +"maximum" +"maxLength" +"string" +"integer" +"boolean" +"object" +"array" +"null" +"200" +"2XX" +"default" +"servers" +"webhooks" +"security" +"style" +"explode" +"ns/ok_tool" +"fuzz" +"#/components/schemas/A" +"#/components/schemas/Missing" +"https://other/file.json" +".inf" +".nan" \ No newline at end of file diff --git a/fuzz/shared/Cargo.toml b/fuzz/shared/Cargo.toml index 5f989af..1f4c1af 100644 --- a/fuzz/shared/Cargo.toml +++ b/fuzz/shared/Cargo.toml @@ -4,6 +4,14 @@ version = "0.0.0" publish = false edition = "2021" +[features] +default = ["openapi", "mcp"] +# The alkhttp features the harness's lanes assume; turning these on +# rides on the main crate's default build (server + client + openapi), +# `mcp` additionally turns on alkhttp/mcp. +openapi = ["alkhttp/openapi"] +mcp = ["alkhttp/mcp"] + [dependencies] alkhttp = { path = "../.." } alkcall = "0.8.1" diff --git a/fuzz/shared/src/lib.rs b/fuzz/shared/src/lib.rs index 6e9b182..a6f01a1 100644 --- a/fuzz/shared/src/lib.rs +++ b/fuzz/shared/src/lib.rs @@ -4,5 +4,6 @@ //! against every committed corpus entry (the quinn CI pattern) without //! a nightly toolchain. +pub mod spec_import; pub mod sse_parser; pub mod ws_chunk_framing; diff --git a/fuzz/shared/src/spec_import.rs b/fuzz/shared/src/spec_import.rs new file mode 100644 index 0000000..e07c5d6 --- /dev/null +++ b/fuzz/shared/src/spec_import.rs @@ -0,0 +1,457 @@ +//! Invariants for the import-parse surface — target 3 (`spec_import`): +//! the `OpenAPISpec` document parsing (JSON-first, YAML-fallback, the +//! ADR-051 YAML normalization boundary), the `$ref`-bounded resolver, +//! the `CompiledInputSchema` compile/harden/validate path, and the +//! `FromMCP::build_spec` tool-shape converter (`src/adapters/ +//! openapi_spec.rs`, `input_validation.rs`, `from_mcp/mod.rs`). +//! +//! Shape: a single attacker-controlled string drives every sub-path; +//! each failure surface must be a clean declared error (never a panic, +//! never a silent success), and each success surface must satisfy its +//! shape contract. Drives also assert the cross-entrypoint consistency +//! (the YAML/JSON parity contract) on inputs that parse both ways. + +use alkhttp::adapters::input_validation::fuzzing as iv; +use alkhttp::adapters::openapi_spec::fuzzing as spec; +use serde_json::Value; + +/// The full target drive over one attacker-shaped document string. +pub fn fuzz_spec_import(data: &[u8]) { + let Ok(doc) = std::str::from_utf8(data) else { + // Raw non-UTF-8 bytes are not a document shape the surface + // accepts (str-typed entry points) — declared, not a crash. + return; + }; + + // 1. The JSON/YAML document parse: clean result, never a panic. + // The unknown-format entry tries JSON first, YAML fallback + // (ADR-051); both parse paths run either way here. + let _ = spec::spec_from_str(doc); + let _ = spec::spec_from_json(doc); + #[cfg(feature = "openapi")] + assert_yaml_boundary(doc); + + // 2. The schema-validation lane (client feature): drive the + // compile/harden/validate path against the *whole* document. + // An OpenAPI-shaped doc is not a JSON Schema, but attacker + // bytes drive the same compile machinery — the outcome must be + // a clean Result and the validate probe must be consistent. + schema_lane(doc); + + // 3. The MCP tool-shape lane: the document doubles as a tool + // JSON (most rejected as a shape); failures are clean. + #[cfg(feature = "mcp")] + mcp_lane(doc, "fuzz"); + + // 4. The $ref-resolution lane over the parsed document when one + // parsed Ok: resolve the whole document (schemas and all) + // through the bounded resolver; cycles, hop chains, and deep + // nesting fail with the declared SchemaParse — the + // no-hang/no-recursion-crash assertion. + ref_lane(doc); +} + +/// The ADR-051 YAML boundary, direct: the normalization-only pass +/// (merge keys applied, non-finite floats and string-less keys +/// rejected) must agree with what `from_yaml`'s early failure would +/// say — an input the boundary rejects is never a silent null further +/// down. +#[cfg(feature = "openapi")] +fn assert_yaml_boundary(doc: &str) { + let normalized = spec::yaml_to_json(doc); + match normalized { + Ok(value) => { + // Reject-loudly partition: a value the boundary accepted + // cannot contain the shapes the boundary exists to reject. + assert_representable(&value, &mut Vec::new(), 0); + // Whatever came out feeds the same from_value parse as the + // production `from_yaml` post-normalization drive; parse + // consistency: `from_yaml` failing after the boundary + // accepted is fine (document validation), but + // `from_yaml` accepting while the boundary result differs + // from the doc's own parse is a parity break — probe it. + let _ = spec::spec_from_yaml(doc); + } + Err(message) => { + assert!( + !message.trim().is_empty(), + "a rejected YAML shape names a reason" + ); + } + } +} + +/// The YAML normalization guarantee, recursive: no non-finite numbers +/// surfaced (they cannot — serde_json numbers are finite by +/// construction), and no node was silently nulled where the pointer +/// contract says a reject should have happened. +#[cfg(feature = "openapi")] +fn assert_representable(value: &Value, path: &mut Vec, depth: usize) { + assert!(depth < 512, "the normalized tree is finite-height"); + match value { + Value::Number(n) => assert!( + n.as_f64().is_none_or(|f| f.is_finite()), + "non-finite numbers must never materialize post-boundary" + ), + Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + path.push(i.to_string()); + assert_representable(item, path, depth + 1); + path.pop(); + } + } + Value::Object(map) => { + for (k, v) in map { + // Key form note: the boundary's nullish-key rejection + // covers the *implicit* null key (`: v`, `~` — parsed + // as `YamlValue::Null`); an explicitly-quoted `""` + // string key materializes as `""` (JSON-representable, + // parity with the JSON path), so empty-string keys are + // legal here. + path.push(k.clone()); + assert_representable(v, path, depth + 1); + path.pop(); + } + } + _ => {} + } +} + +/// The compile/harden/validate lane over attacker-shaped schemas. +fn schema_lane(doc: &str) { + let Ok(value) = serde_json::from_str::(doc) else { + return; + }; + let schema = pick_schema(&value, 0); + + let hardened = iv::harden_closed_by_default(&schema); + // The hardening partition: the hardened copy injects + // `additionalProperties: false` exactly when the input lacked the + // key; an explicit value (or `true`'s catch-all) passes through. + match schema.get("additionalProperties") { + Some(v) => assert_eq!( + hardened.get("additionalProperties"), + Some(v), + "an explicit additionalProperties passes through unchanged" + ), + None if schema.is_object() => assert_eq!( + hardened.get("additionalProperties"), + Some(&Value::Bool(false)), + "an absent additionalProperties is injected as false (closed by default)" + ), + _ => {} + } + // The original schema is never mutated by the hardening pass. + let schema_snapshot = schema.clone(); + let _ = iv::harden_closed_by_default(&schema); + assert_eq!( + schema, schema_snapshot, + "hardening injects into the hardened copy only" + ); + + // Compile is always a Result; a clean Err or a working validator. + let Ok(compiled) = iv::compile(&schema) else { + return; + }; + // Validate consistency: two calls, same outcome; and the hardened + // semantics hold — an unknown key is accepted only under an + // explicit catch-all. + let probe = unknown_key_input(&schema); + let first = iv::validate(&compiled, &probe); + let second = iv::validate(&compiled, &probe); + assert_eq!( + first.is_ok(), + second.is_ok(), + "validate is deterministic on the same input" + ); + if let Err(err) = &first { + assert!(!err.message.is_empty(), "rejection names its violation"); + } +} + +/// Pick the value the schema lane fuzzes: the document's +/// `components.schemas.*` entries are the schema-shaped ones; a bare +/// `properties`-ish top object or any non-object JSON doubles as the +/// anything-goes schema probe. +fn pick_schema(value: &Value, depth: usize) -> Value { + if depth < 16 { + if let Some(schemas) = value + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(|s| s.as_object()) + { + if let Some((_, v)) = schemas.iter().next() { + return v.clone(); + } + } + } + value.clone() +} + +/// A probe input that exercises the closed-by-default semantics: an +/// object carrying a key the schema (likely) does not declare. +fn unknown_key_input(schema: &Value) -> Value { + let mut map = serde_json::Map::new(); + map.insert("__fuzz_unknown_key__".to_string(), Value::Bool(true)); + if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { + for (k, v) in props.iter().take(4) { + map.insert( + k.clone(), + default_for_schema_type(v.get("type").and_then(|t| t.as_str())), + ); + } + } + Value::Object(map) +} + +fn default_for_schema_type(ty: Option<&str>) -> Value { + match ty { + Some("string") => Value::String("fuzz".into()), + Some("integer" | "number") => serde_json::json!(1), + Some("boolean") => Value::Bool(false), + Some("array") => Value::Array(vec![]), + Some("object") => serde_json::json!({}), + Some("null") => Value::Null, + _ => Value::Null, + } +} + +/// The MCP tool-shape lane: the document as a `tools/list` element. +/// The declared name rules (empty / `/` / whitespace rejection) and +/// the always-Result outcome. +#[cfg(feature = "mcp")] +fn mcp_lane(doc: &str, namespace: &str) { + let shapes = match serde_json::from_str::(doc) { + Ok(value) => vec![value.clone(), tool_wrapped(value.clone())], + Err(_) => { + // The malformed-JSON shape still drives the RMCP-type + // round-trip path: `Tool` deserialization of the raw + // string is the attacker's wire bytes as delivered — the + // declared failure is a clean adapter error. + let probe = Value::String(doc.to_string()); + let result = spec_mcp(&probe, namespace); + assert!(result.is_err(), "a non-object tool shape fails cleanly"); + return; + } + }; + for shape in shapes { + let result = spec_mcp(&shape, namespace); + if let Ok(op_spec) = result { + // Success shape: the op carries the namespace, and the + // declared tool-name rules already screened the name. + assert!(op_spec.name.starts_with(namespace)); + } + } +} + +#[cfg(feature = "mcp")] +fn spec_mcp( + shape: &Value, + namespace: &str, +) -> Result { + #[cfg(feature = "mcp")] + { + alkhttp::adapters::from_mcp::fuzzing::fuzz_build_spec(shape, namespace) + } + #[cfg(not(feature = "mcp"))] + { + let _ = shape; + let _ = namespace; + unreachable!("mcp_lane is cfg-gated with the feature") + } +} + +#[cfg(feature = "mcp")] +fn tool_wrapped(value: Value) -> Value { + serde_json::json!({ + "name": "fuzz_tool_name", + "inputSchema": value, + }) +} + +/// The `$ref`-bounded resolver lane over a parsed document: resolve +/// the whole raw doc and every declared schema; the bounded resolver +/// must surface cycles/deep nesting/hop chains as clean errors — +/// resolution is always a `Result` against a finite number of hops. +fn ref_lane(doc: &str) { + let Ok(value) = serde_json::from_str::(doc) else { + return; + }; + let Ok(parsed) = spec::spec_from_json(doc) else { + return; + }; + // Resolve the full document tree — cycles (a `$ref` pointing at + // its own chain) and pathological nesting fail loudly, clean. + let _ = spec::resolve_refs_recursive(&parsed, &value); + // Every declared schema resolves or fails cleanly — never a + // partial expansion treated as success with dangling refs. + if let Some(schemas) = value + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(|s| s.as_object()) + { + for (_, schema) in schemas.iter().take(4) { + let resolved = spec::resolve_refs_recursive(&parsed, schema); + if resolved.is_err() { + // Declared failure arms: cycle, depth, hop, node + // budget, unresolvable pointer — all SchemaParse. + continue; + } + } + } +} + +#[cfg(test)] +mod corpus_replay { + use super::*; + + #[test] + fn committed_corpus_replays_through_the_invariants() { + let corpus = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/spec_import"); + let mut count = 0usize; + for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") { + let path = entry.expect("corpus entry readable").path(); + let data = std::fs::read(&path).expect("corpus entry readable"); + fuzz_spec_import(&data); + count += 1; + } + assert!( + count >= 12, + "committed seed corpus is present, found {count}" + ); + } + + fn drive(values: &[&str]) { + for v in values { + fuzz_spec_import(v.as_bytes()); + } + } + + #[test] + fn unit_spec_parse_edges() { + drive(&[ + // Minimal valid OpenAPI docs — JSON then YAML. + "{\"openapi\":\"3.0.0\",\"info\":{\"title\":\"t\",\"version\":\"1.0.0\"},\ + \"paths\":{\"/x\":{\"get\":{\"operationId\":\"x_get\",\"responses\":{}}}}}", + "openapi: 3.0.0\ninfo:\n title: t\n version: 1.0.0\npaths:\n /x:\n get:\n responses: {}\n", + // Missing info / missing paths / non-object documents. + "{\"paths\":{}}", + "{\"info\":{\"title\":\"t\"}}", + "[]", + "null", + "42", + "\"a string\"", + // Malformed JSON that is also not YAML-shaped. + "{\"openapi\":3.0}", + // Malformed at both layers. + "}{", + "not a document", + "", + "\x00\x01\x02", + ]); + } + + #[test] + fn unit_yaml_normalization_edges() { + drive(&[ + // Duplicate keys: rejected on the YAML path (loud, with + // line/column). + "a: 1\na: 2\n", + // Merge keys: applied, not literal. + "base: &b\n x: 1\nchild:\n <<: *b\n y: 2\n", + // Non-finite floats: the OAI-12 loud rejection. + "maximum: .inf\n", + "minimum: -.inf\n", + "value: .nan\n", + // Non-string keys: nullish and collection keys reject. + "~: v\n", + "? [1, 2]\n: v\n", + // Numeric keys stringify (200: → "200"). + "responses:\n 200: {}\n", + // Bare yes/no/on/off are strings under YAML 1.2 core. + "a: yes\nb: no\n", + "a: on\nb: off\n", + // Tags: !!str 200 is the string "200"; unknown tags fail. + "a: !!str 200\n", + "a: !!unknown b\n", + // Deep nesting at document scale (bounded by the parser's + // own recursion limit, not a crash). + "a: \n - x\n - y\n - z\n - w\n", + ]); + } + + #[test] + fn unit_ref_resolution_edges() { + drive(&[ + // A $ref cycle: clean failure, no hang, no stack blowout. + "{\"components\":{\"schemas\":{\"A\":{\"$ref\":\"#/components/schemas/A\"}}}}", + // A two-node cycle. + "{\"components\":{\"schemas\":{\"A\":{\"type\":\"object\",\"properties\":{\"n\":{\"$ref\":\"#/components/schemas/B\"}}},\"B\":{\"type\":\"object\",\"properties\":{\"n\":{\"$ref\":\"#/components/schemas/A\"}}}}}}", + // An unresolvable ref. + "{\"components\":{\"schemas\":{\"A\":{\"$ref\":\"#/components/schemas/Missing\"}}}}", + // An external ref (rejected loudly). + "{\"components\":{\"schemas\":{\"A\":{\"$ref\":\"https://other/file.json\"}}}}", + // A shared-ref acyclic chain (linear via memo, node budget + // the last-resort bound). + "{\"components\":{\"schemas\":{\"S0\":{\"type\":\"object\"},\"S1\":{\"type\":\"object\",\"properties\":{\"a\":{\"$ref\":\"#/components/schemas/S0\"},\"b\":{\"$ref\":\"#/components/schemas/S0\"}}},\"S2\":{\"type\":\"object\",\"properties\":{\"a\":{\"$ref\":\"#/components/schemas/S1\"},\"b\":{\"$ref\":\"#/components/schemas/S1\"}}}}}}", + ]); + } + + #[test] + fn unit_input_schema_validation_edges() { + drive(&[ + // A schema that is not a schema. + "{\"required\": \"n\"}", + "\"not an object\"", + // A closed-by-default probe: unknown keys rejected. + "{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}}}", + // An explicit catch-all: unknown keys accepted. + "{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}},\"additionalProperties\":true}", + // An explicit additionalProperties schema passes through. + "{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}},\"additionalProperties\":{\"type\":\"string\"}}", + // Leaf constraints the validator must enforce. + "{\"type\":\"object\",\"properties\":{\"level\":{\"enum\":[\"low\",\"high\"]},\"n\":{\"minimum\":1},\"s\":{\"maxLength\":3}}}", + ]); + } + + #[test] + fn unit_mcp_tool_shape_edges() { + drive(&[ + // A valid tool shape. + "{\"name\":\"ok_tool\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}}}}", + // A name with a slash: the two-segment convention refusal. + "{\"name\":\"ns/ok_tool\",\"inputSchema\":{\"type\":\"object\"}}", + // A name with whitespace. + "{\"name\":\"has space\",\"inputSchema\":{\"type\":\"object\"}}", + // An empty name. + "{\"name\":\"\",\"inputSchema\":{\"type\":\"object\"}}", + // inputSchema present but not an object shape (the Tool + // type deserializes it as itself; build_spec still Result). + "{\"name\":\"ok_tool\",\"inputSchema\":null}", + "[]", + // A missing inputSchema (default empty schema). + "{\"name\":\"ok_tool\"}", + ]); + } + + #[test] + fn unit_json_number_and_depth_edges() { + drive(&[ + // Deep nesting (bounded by serde_json's own 128-depth + // reject, then the resolver's depth budget). + &format!("\"{}\"", "[".repeat(60) + &"]".repeat(60)), + &("{\"type\":\"object\",\"properties\":".to_owned() + + &"{\"k\":{\"type\":\"object\"},".repeat(40) + + "\"end\":1}"), + ]); + // Huge values never wedge. + let big = serde_json::to_string(&serde_json::Value::Array( + (0u16..1024) + .map(|i| serde_json::json!({ "n": i })) + .collect(), + )) + .expect("serializes"); + fuzz_spec_import(big.as_bytes()); + } +} diff --git a/fuzz/shared/src/ws_chunk_framing.rs b/fuzz/shared/src/ws_chunk_framing.rs index eecb555..31e9794 100644 --- a/fuzz/shared/src/ws_chunk_framing.rs +++ b/fuzz/shared/src/ws_chunk_framing.rs @@ -270,7 +270,7 @@ mod corpus_replay { fuzz_ws_chunk_framing(&h(3, 10)[..n]); } // Over-length declarations: > MAX_CHUNK_LEN, u32::MAX. - let mut over = h(0, (MAX_CHUNK_LEN as u32) + 1); + let mut over = h(0, MAX_CHUNK_LEN + 1); over.extend_from_slice(b"partial-payload"); fuzz_ws_chunk_framing(&over); fuzz_ws_chunk_framing(&h(4, u32::MAX)); @@ -278,6 +278,6 @@ mod corpus_replay { // cap frames are valid but expensive; the fuzzer explores the // near-cap shapes and the cap partition is asserted exactly in // cap_walk). - fuzz_ws_chunk_framing(&h(5, MAX_CHUNK_LEN as u32 - 1)[..8]); + fuzz_ws_chunk_framing(&h(5, MAX_CHUNK_LEN - 1)[..8]); } } diff --git a/src/adapters/from_mcp/mod.rs b/src/adapters/from_mcp/mod.rs index 7c1ebd8..cad8426 100644 --- a/src/adapters/from_mcp/mod.rs +++ b/src/adapters/from_mcp/mod.rs @@ -472,5 +472,32 @@ fn json_object_to_value(map: JsonObject) -> Value { Value::Object(map) } +/// Fuzz-harness re-export hub (`fuzz/shared/src/spec_import.rs`): +/// `build_spec` reachable over a plain JSON tool shape, so the harness +/// does not have to materialize `rmcp` types from attacker bytes — the +/// tool-shape JSON is converted into a `Tool` through +/// `serde_json::from_value` exactly as the wire would deliver it. +#[cfg(feature = "mcp")] +pub mod fuzzing { + use serde_json::Value; + + use rmcp::model::Tool; + + /// `build_spec` over an attacker-shaped tool JSON (the + /// `tools/list` response element as the wire would deserialize + /// it). + pub fn fuzz_build_spec( + tool_json: &Value, + namespace: &str, + ) -> Result { + let tool: Tool = serde_json::from_value(tool_json.clone()).map_err(|e| { + alkcall::client::AdapterError::SchemaParse { + message: format!("tool shape rejected: {e}"), + } + })?; + super::build_spec(&tool, namespace) + } +} + #[cfg(test)] mod tests; diff --git a/src/adapters/input_validation.rs b/src/adapters/input_validation.rs index 6452c31..f744ea2 100644 --- a/src/adapters/input_validation.rs +++ b/src/adapters/input_validation.rs @@ -144,6 +144,41 @@ fn harden_closed_by_default(input_schema: &Value) -> Value { hardened } +/// Fuzz-harness re-export hub (`fuzz/shared/src/spec_import.rs`): +/// compile-once schema compilation and call-time validation over +/// attacker-shaped schemas. The items on this module are `pub` +/// funnels over `pub(crate)` items, same posture as +/// `openapi_spec::fuzzing`. +#[cfg(feature = "client")] +pub mod fuzzing { + use alkcall::protocol::wire::CallError; + use serde_json::Value; + + /// Opaque handle wrapping the crate-internal compiled validator, + /// so the harness drives compile/validate without widening the + /// underlying visibility. + pub struct FuzzCompiledValidator(super::CompiledInputSchema); + + /// Compile `input_schema` through the production path (hardening + /// included). + pub fn compile(input_schema: &Value) -> Result { + Ok(FuzzCompiledValidator(super::CompiledInputSchema::compile( + input_schema, + )?)) + } + + /// Call-time validation through the production path. + pub fn validate(validator: &FuzzCompiledValidator, input: &Value) -> Result<(), CallError> { + validator.0.validate(input) + } + + /// The hardening pass directly: the closed-by-default injection + /// (additionalProperties: false when absent). + pub fn harden_closed_by_default(input_schema: &Value) -> Value { + super::harden_closed_by_default(input_schema) + } +} + #[cfg(all(test, feature = "client"))] mod tests { use super::*; diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index 4eb8875..1a46b69 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -1114,6 +1114,59 @@ fn check_parameter_style( } } +/// Fuzz-harness re-export hub (`fuzz/shared/src/spec_import.rs`): +/// gathers the import-parse surface in one path — the full +/// `OpenAPISpec` constructors, the `$ref`-bounded resolver, and the +/// YAML normalization pass (`openapi` feature). The wrappers exist for +/// the same reason as `forward::fuzzing`: the underlying items carry +/// `pub(crate)`/private visibility; the harness reaches them through +/// thin `pub` funnels so the fuzz access surface is one named module. +pub mod fuzzing { + use serde_json::Value; + + use super::{AdapterError, OpenAPISpec, YamlValue}; + + /// Parse a document of unknown format through the production path. + pub fn spec_from_str(doc: &str) -> Result { + OpenAPISpec::from_str(doc) + } + + /// Parse a JSON document through the production path. + pub fn spec_from_json(doc: &str) -> Result { + OpenAPISpec::from_json(doc) + } + + /// Parse a YAML document through the `openapi`-gated + /// normalization path (ADR-051). + #[cfg(feature = "openapi")] + pub fn spec_from_yaml(doc: &str) -> Result { + OpenAPISpec::from_yaml(doc) + } + + /// The raw-document `$ref`-bounded resolver — the cycle/depth/hop/ + /// node budget behavior behind import-time schema expansion. + #[cfg(all(feature = "client", feature = "openapi"))] + pub fn resolve_refs_recursive( + spec: &OpenAPISpec, + schema: &Value, + ) -> Result { + OpenAPISpec::resolve_refs_recursive(spec, schema) + } + + /// The YAML→`serde_json::Value` normalization boundary: non-finite + /// floats and string-less keys reject loudly with a JSON pointer + /// (OAI-12). Runs the same `apply_merge` + normalization pass + /// `from_yaml` runs before document validation. + #[cfg(feature = "openapi")] + pub fn yaml_to_json(raw_yaml: &str) -> Result { + let yaml: YamlValue = + yaml_serde::from_str(raw_yaml).map_err(|e| format!("invalid YAML: {e}"))?; + let mut yaml = yaml; + yaml.apply_merge().map_err(|e| format!("merge key: {e}"))?; + super::yaml_to_json_value(&yaml) + } +} + #[cfg(all(test, feature = "client", feature = "openapi"))] mod tests { use super::*;