Skip to content

Harden response-contract validation (DoS) + mock routing; adversarial QA pass#12

Merged
code-with-rashid merged 3 commits into
mainfrom
qa/adversarial-hardening
Jun 23, 2026
Merged

Harden response-contract validation (DoS) + mock routing; adversarial QA pass#12
code-with-rashid merged 3 commits into
mainfrom
qa/adversarial-hardening

Conversation

@code-with-rashid

Copy link
Copy Markdown
Owner

Summary

Adversarial QA hardening of the @truspec/core engine — a break-fix-repeat pass focused on parser/validator robustness against untrusted bytes (collection files, OpenAPI specs, imported collections, and HTTP responses under contract test) and the correctness of the spec-sync engine.

5 bugs found and fixed at the root, each with a regression test. Test suite 206 → 218 passing; typecheck 7/7; build 5/5. Two independent property-fuzz confirmation cycles across 6 seeds (120k+ iterations) came back clean.

Bugs fixed

Severity Bug Root cause Fix
High Response-schema validation exponential on recursive oneOf/anyOf (depth-18 ≈ 7.7s; depth-20+ never returns) conforms() re-validates the same subtree per branch → O(2^depth) memoize, then generalize (below)
High Same blowup via allOf/properties/items (the first memoization fix only covered oneOf/anyOf) those recurse the direct validate path route all recursion through one memoized collect()
High Validator never terminates on a primitive/cyclic value vs a self-$ref schema object-only memo can't break a (value, schema) cycle stack-based cycle detection (visiting guard)
Medium Mock server: a static route declared after a parametric one is unreachable (/users/me/users/{id}'s body) respond() returns first regex match in doc order pick the most-specific match (literal beats {param})
Medium Contract check silently passes wrong types for OpenAPI 3.1 type: ["string","null"] array type coerced to undefined → "accept anything" non-null value must satisfy ≥1 listed type

The three DoS variants are the headline: all reachable from truspec contract / run --spec, where the response being validated is exactly the thing under test. The validator is now provably linear — total work is bounded by the number of distinct (value-node, schema-node) pairs.

What was attacked and held (no change needed)

Prototype pollution (YAML / Postman+Bruno importers / jsonpath / interpolate), path traversal (web static + API + MCP writes + scaffold — raw, %2e%2e, double-encoded, null byte, backslash → no leak, no crash), JUnit XML injection, the DNS-rebinding host guard, mock/web HTTP servers vs malformed raw requests, and the live probe (GET/HEAD only).

Test plan

  • pnpm test218 passed (25 files; +12 over baseline, all new tests are regressions for the bugs above)
  • pnpm typecheck → 7/7 · pnpm build → 5/5
  • Seeded property-fuzz (validator / jsonpath / interpolate), 6 seeds, 0 crashes/hangs, slowest 2ms

Full per-cycle findings, repros, and residual-risk notes are in QA_LOG.md.

Known limitations (documented in QA_LOG.md, not changed here)

additionalProperties-as-schema is unvalidated (documented subset gap); the < 6 char secret-mask threshold; MAX_DEPTH=100 still caps very deep non-cyclic recursion (now just a backstop).

`respond()` returned the first route whose regex matched in document order, so
a literal path declared after a parametric one was unreachable — e.g. a request
to `/users/me` matched an earlier `/users/{id}` and never reached `/users/me`.

Scan all matching routes and keep the most specific via `compareSpecificity()`
(a literal segment beats a `{param}` at the same position), matching how real
routers rank. The listing order of `responder.routes` is unchanged.
…ray types

Three correctness/robustness fixes to the OpenAPI response-schema validator,
all reachable from `truspec contract` / `run --spec`, where the response under
test is effectively untrusted in a contract-testing scenario.

DoS — recursive schemas validated in exponential / non-terminating time:
- `oneOf`/`anyOf`/`allOf` whose branches `$ref` back to themselves re-validated
  the same value subtree once per branch → O(2^depth). A ~20-deep response hung
  for minutes; deeper never returned (`MAX_DEPTH` bounded depth, not the
  exponential width of re-validation).
- A primitive (or self-referential object) value against a `$ref`-cycle schema
  recursed through the schema graph with no value progress and never terminated.

Rewrote the validator so all recursion flows through a single `collect()` that
(a) memoizes results by (value-node, schema-node) identity, keeping violation
paths relative and rebasing them per caller, and (b) breaks `(value, schema)`
stack cycles via a `visiting` guard. Work is now bounded by the number of
distinct (value, schema) pairs — linear. Verified across 120k+ randomized fuzz
iterations over 6 seeds: 0 hangs, slowest 2ms.

False negative — an array `type` (`type: ["string", "null"]`, the idiomatic
OpenAPI 3.1 / JSON Schema way to express nullability) was coerced to `undefined`
and silently accepted ANY value. A non-null value must now satisfy at least one
listed type, and the null guard honors `"null"` in the array.

Adds regression tests covering each: exponential oneOf/allOf, primitive and
cyclic-value termination, deep-violation reporting, and array-type validation.
Per-cycle record of the break-fix-repeat hardening pass: each finding's repro,
root cause, fix, and regression test, plus the surfaces that held (path
traversal, prototype pollution, XML injection, malformed HTTP) and the two
clean confirmation fuzz cycles.
@code-with-rashid code-with-rashid merged commit 0036d62 into main Jun 23, 2026
1 check passed
@code-with-rashid code-with-rashid deleted the qa/adversarial-hardening branch June 23, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant