Skip to content

Security: bound validation errors and count request bodies before validating them - #897

Merged
irparent merged 2 commits into
mainfrom
fix/bounded-validation-errors
Oct 22, 2026
Merged

irparent merged 2 commits into
mainfrom
fix/bounded-validation-errors

Conversation

@irparent

@irparent irparent commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

This hardens how the dashboard port and the MCP tools answer input that fails validation. It does two things:

  1. Validation errors return at most 20 issues and the total.
  2. Request bodies are counted before any schema reads them: JSON bodies against the element limit tool calls already have, and OTLP protobuf against a ceiling of its own, derived from the request size limit so that exporter batches are not dropped.

1. Validation errors return at most 20 issues and the total

Every answer built from Zod issues now goes through one helper, src/utils/validation-errors.ts.

Dashboard API and MCP HTTP transport (400 responses). The body keeps its fields and gains two:

  • error and details stay as they were. details lists the first 20 issues, in Zod's order.
  • issueCount is new and always present: how many issues there were in all.
  • detailsNote is new and present only when issues were left out. It is a sentence saying how many are listed of how many.

Within one listed issue:

  • An unknown-key issue names at most 20 keys. A keyCount field gives the total when there were more.
  • A message is cut at 1,000 characters.
  • A path is cut at 32 segments.
  • A union's nested errors keep at most three issues per branch.

Where it applies:

  • every dashboard route that answered with a Zod issue list: 29 places in 13 route files, plus the shared error handler that both HTTP servers use
  • POST /v1/traces: each dropped span's reason in partialSuccess.errorMessage names three issues and how many more. Its 400 gains issueCount.
  • IRIS_INVALID_RULE_CONFIG: names 20 issues and how many more
  • the unknown-key and unknown-argument messages of the strict body, query and tool-input schemas: name 20 keys and how many more

Why 20. The largest body the API takes is a trace, with 18 top-level fields. A trace with every one of them wrong is still answered in full. A request that is wrong in more places than that is almost always wrong in one way many times, and the first twenty show the pattern. A request wrong in a few places gets exactly the answer it got before, plus issueCount.

2. Request bodies are counted before validation

What gets counted:

  • every JSON body on the dashboard port (the API's bodies, and POST /v1/traces as JSON), against the shared limit
  • a protobuf body on POST /v1/traces, as it is decoded, against its own ceiling (below). The decoder stops once it has read more nested messages than the ceiling, because every nested message decodes to at least one element.

A body over its limit gets a 413 before any schema reads it: { "error", "limit" }. The counting is the MCP SDK's own: array elements plus own object keys, stopping at the limit plus one (src/utils/element-count.ts).

One setting, renamed. The shared limit is the one tool-call arguments already have, at the same default of 100,000. Since it now covers more than tool calls, it is renamed:

New name Name on main until now
security.maxInputElements security.maxToolInputElements
IRIS_MAX_INPUT_ELEMENTS IRIS_MAX_TOOL_INPUT_ELEMENTS

The old names are still read whenever the new ones are unset, in config.json, in the environment, and in a config object built in code. A config.json that sets both names is refused at startup, naming them. The rename is documented in --help, the README, server.json (both variables listed), docs/api-reference.md and the CHANGELOG.

Legitimate bodies, measured. Each is counted the same way, at the full 1 MB request size limit:

Body Elements at 1 MB
Trace ingest bodies (POST /api/v1/traces takes the log_trace arguments; densest legitimate shape, from #896) 68,506
OpenTelemetry exports sent as JSON (tests/fixtures/otlp) 28,662 to 62,755
The same exports as protobuf, decoded: agent frameworks (17 to 26 bytes per element) 39,560 to 62,229
The same exports as protobuf, decoded: the plain Python exporter (7.5 to 10 bytes per element) 103,730 to 139,641

Protobuf gets its own ceiling. An OTLP/HTTP exporter treats a 413 as final and drops the batch, and the last row is past the shared limit. So a protobuf body is counted against max(security.maxInputElements, ceil(requestSizeLimit bytes / 5)):

  • 209,716 at the default 1 MB. That is half again above the densest real exporter body measured (one element per 7.5 bytes).
  • It is derived from the byte limit, so an operator who raises requestSizeLimit for a collector's larger batches raises it too, and one who wants less on this door lowers requestSizeLimit.
  • Its limit: a body denser than one element per 5 bytes still reaches it, for example one built by hand, or spans carrying many attributes with one-letter keys and small numbers.

OTLP sent as JSON, and every other dashboard body, keep the shared limit.

What it costs

Measured on a Windows 11 machine (i9-12900HK), Node 24.11.0, with the real dashboard server in its own process.

Refusing a 1 MB body over its limit:

Body One request 600 at once (the API's per-minute limit)
JSON trace 413, 239 B, 0.03–0.06 s all answered 413 in 12–32 s
OTLP JSON 413, 239 B, 0.04 s all answered 413 in 11 s
OTLP protobuf 413, 239 B, 0.05–0.09 s all answered 413 in 25 s

The worst JSON body under the shared limit fails validation in all of its nearly 100,000 elements. It is still answered with a reply of about 3 KB.

The protobuf ceiling:

Body One request 20 at once 600 at once
A full 1 MB batch from the plain exporter, accepted 200, 0.27 s, +72 MB — —
The costliest body found under the ceiling (about 209,700 elements, 419 KB, that all fail validation) 0.54 s, +387 MB 16 s, +1.2 GB the server is saturated: 128 answered within 112 s, the rest timed out or were reset

To spend less on that door, lower requestSizeLimit, or put a collector with its own limits in front.

Tests

tests/integration/bounded-validation-errors.test.ts, against the real dashboard server and an MCP client:

  • a trace wrong in 50,000 places gets 20 issues, issueCount: 50000 and the note, in under 5 KB
  • a trace wrong in three places gets all three, with exactly the fields error, details and issueCount
  • 1,000 unknown keys are named 20 at a time
  • a dropped span's reasons are capped
  • an MCP call with 1,000 unknown arguments names 20 of them
  • a body one element over the limit gets 413 and stores nothing, while one of exactly the limit reaches the route
  • the shared limit holds on every JSON route and on OTLP JSON
  • a protobuf body one element over its ceiling gets 413
  • a 1 MB batch built from the plain Python exporter's real body (python-plain.pb repeated with unique ids: 2,538 spans in 254 traces, about 139,600 elements) is stored whole at the defaults
  • the real protobuf fixtures go through at the default

Unit tests:

  • tests/unit/utils/validation-errors.test.ts: the helper, the error handler, and the rule-definition error
  • tests/unit/config-input-elements.test.ts: the setting, its old names, both names set at once, and the protobuf ceiling (209,716 at 1 MB, 2,097,152 at 10 MB, never below the shared limit)
  • tests/unit/otel/protobuf.test.ts: the decoder stops at the limit and decodes a request that fits

Each part's tests fail without that part. With the pre-count removed, 4 of the body tests fail. With the bounded replies reverted, 7 tests fail. With the shared limit in place of the protobuf ceiling, the 1 MB exporter batch test fails (413 where it expects 200), and so does the ceiling test.

The whole root suite passes: 400 files, 4,877 tests. No dependency or lockfile changes.

🤖 Generated with Claude Code

…idating them

Validation errors return at most 20 issues and the total. Every answer
built from Zod issues goes through one helper
(src/utils/validation-errors.ts). A 400 from the dashboard API or the MCP
HTTP transport keeps `error` and `details`, lists at most the first 20
issues, and adds `issueCount` and, when issues were left out,
`detailsNote`. Within a listed issue, unknown keys are cut to 20 (with
`keyCount`), a message to 1,000 characters, a path to 32 segments, and a
union's nested errors to a few per branch. The same bound holds for the
reasons POST /v1/traces gives for a dropped span (three issues each),
IRIS_INVALID_RULE_CONFIG, and the unknown-key and unknown-argument messages
of the strict body, query and tool-input schemas. Twenty covers a trace,
the largest body the API takes, with all 18 of its top-level fields wrong.

Request bodies are counted before any schema reads them. A JSON body on
the dashboard port, or a protobuf body on POST /v1/traces as it is
decoded, holding more array elements and object keys than the element
limit is answered 413 with a short sentence; the protobuf decoder stops
at the limit. This is the limit tool calls already have, so the setting
is renamed to cover both: security.maxInputElements and
IRIS_MAX_INPUT_ELEMENTS (default 100,000). The names it had on main,
maxToolInputElements and IRIS_MAX_TOOL_INPUT_ELEMENTS, are still read when
the new ones are unset; a config.json that sets both is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
website Ignored Ignored Preview Oct 22, 2026 6:47pm UTC

An OTLP/HTTP exporter treats a 413 as final and drops the batch, and
decoded protobuf is denser than JSON: the plain Python exporter's bodies
(tests/fixtures/otlp/python-plain.pb) hold one element per 7.5 bytes, so
a full 1 MB batch holds about 139,600 elements, past the shared limit of
100,000. Such a batch was refused.

A protobuf body on POST /v1/traces is now counted, as it decodes, against
max(security.maxInputElements, ceil(requestSizeLimit bytes / 5)):
209,716 at the default 1 MB, half again above the densest exporter body
measured. Raising requestSizeLimit for a collector's larger batches raises
the ceiling with it. OTLP sent as JSON, and every other dashboard body,
keep the shared limit.

A test sends a 1 MB batch built from the plain exporter's real body and
expects it stored whole; it fails with the shared limit in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Iris gate — 1 of 2 tripped --fail-on detector_veto

iris-eval ingest: 3 stored, 1 tripped --fail-on detector_veto (2 of 3 evaluated in dataset "release-gate")

Trace Verdict Basis Rules, classes or missing inputs Evidence
7d8a7be38cfdeb9c523ed8e0eca4be62 failed detector_veto + risk_over_loss no_pii, pii_leak, credential_leak no_pii: AWS Access Key (output 45–65)
Verdict basis Traces
detector_veto 2
clean 1

Unjudged questions: task_completed (3), tool_use_correct (3) — a trace that did not carry what a rule needs.

tests/fixtures/ci-gate/traces.ndjson · 3 evaluated · dataset release-gate: 2 in the gate · exit 1 · what the bases mean

@github-actions

Copy link
Copy Markdown

Iris gate — 1 stored, nothing tripped --fail-on any

iris-eval ingest: 1 stored, 0 tripped --fail-on any

Verdict basis Traces
clean 1

Unjudged questions: task_completed (1), tool_use_correct (1) — a trace that did not carry what a rule needs.

tests/fixtures/ci-gate/clean.ndjson · 1 evaluated · exit 0 · what the bases mean

@irparent
irparent merged commit e42e754 into main Oct 22, 2026
74 checks passed
@irparent
irparent deleted the fix/bounded-validation-errors branch October 22, 2026 19:05
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