Skip to content

Commit 80c822b

Browse files
aksOpsclaude
andcommitted
feat(serving): config validation, integration coverage, docs refresh (PR 5/5)
Final PR of the production-readiness series. Closes the remaining audit findings around silent oversized-input clamping, missing end-to-end coverage of the serving filter chain, and stale tech-stack pins. McpTools per-call clamping: - queryNodes / queryEdges `limit` → clamped to mcp.limits.max_results - getEgoGraph `radius` → clamped to mcp.limits.max_depth - searchGraph `limit` → clamped to mcp.limits.max_results Defense-in-depth on top of the transaction-timeout cap from PR 2 — a caller asking for limit=1_000_000 or radius=999 now gets the configured ceiling instead of an unbounded Cypher walk. ConfigValidator hard ceilings + blank-string checks: - mcp.limits.max_payload_bytes — must be > 0 - mcp.limits.rate_per_minute — must be > 0 - mcp.limits.max_depth — must be 1..100 (variable-length Cypher with depth >100 is pathological — DoS sentinel) - mcp.auth.token_env / .token — blank fails validation when mode=bearer ServingChainIntegrationTest (new, 9 cases): - 401 envelope shape with X-Request-Id correlation - 200 + valid token pass-through - 401 + wrong token - security headers present on success responses - inbound X-Request-Id propagation when valid - inbound control-char rejection (log-injection defense) - 429 envelope with Retry-After + X-RateLimit-Remaining: 0 - /actuator/health bypasses auth (kubelet probes) - rate-limit bucket isolation per token Manually chains the four filters via lambda FilterChain instances — runs sub-second, no @SpringBootTest, no Neo4j. CLAUDE.md tech-stack pins: Spring Boot 4.0.5→4.0.6, Spring AI 2.0.0-M3→2.0.0-M4, Neo4j 2026.02.3→2026.04.0; added Bucket4j 8.18.0, logstash-logback-encoder 9.0, micrometer-registry-prometheus. Tests: 3689 / 0 / 0 / 32 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e8eda53 commit 80c822b

5 files changed

Lines changed: 339 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,61 @@ for that specific tag for the per-commit details.
472472
`McpToolsTest#readFileShouldHandleMissingFile` updated for new
473473
envelope contract. Full suite: 3680 tests / 0 failures / 0 errors.
474474

475+
- **Production-readiness PR 5 of 5 — config validation, integration coverage,
476+
docs refresh.** Final PR of the production-readiness series. Closes the
477+
remaining audit findings around silent oversized-input clamping, missing
478+
end-to-end coverage of the serving filter chain, and stale tech-stack
479+
pins in `CLAUDE.md`.
480+
- **MCP request-bound clamping in `McpTools`.** `queryNodes` /
481+
`queryEdges` `limit` parameters are now `Math.min(requested,
482+
mcp.limits.max_results)` so a caller asking for `LIMIT 1_000_000` no
483+
longer trips the JVM into a multi-GB allocation before the
484+
`run_cypher` row cap kicks in. `getEgoGraph` radius is clamped to
485+
`mcp.limits.max_depth` for the same reason — a `radius=999` ego
486+
walk on a hub node is a Cartesian explosion. `searchGraph` limit
487+
follows the same rule. Per-call defense-in-depth on top of the
488+
transaction-timeout cap from PR 2.
489+
- **`ConfigValidator` hard ceilings + blank-string checks.** Added
490+
explicit validations for fields previously only typed as
491+
`Integer`/`Long` with no range:
492+
- `mcp.limits.max_payload_bytes` — must be `> 0` (was silently
493+
`null` → no payload cap → infinite-row run_cypher could OOM).
494+
- `mcp.limits.rate_per_minute` — must be `> 0`.
495+
- `mcp.limits.max_depth` — must be `1..100`. The 100 ceiling is a
496+
DoS sentinel: variable-length Cypher with depth >100 is
497+
pathological in practice (a graph with 100M nodes and fan-out 5
498+
reaches every node by depth 12), so anything higher is either a
499+
misconfig or a reconnaissance probe. Catch at config-load, not
500+
at query time.
501+
- `mcp.auth.token_env` / `mcp.auth.token` — when mode=bearer,
502+
blank-string values fail validation rather than being silently
503+
coerced to null and then fail-fasting at startup with a
504+
mysterious "no token resolved" message.
505+
- **`ServingChainIntegrationTest` (new — 9 cases).** Fills the gap
506+
where each filter (`RequestIdFilter`, `SecurityHeadersFilter`,
507+
`RateLimitFilter`, `BearerAuthFilter`) had unit-test coverage in
508+
isolation but no test exercised the full chain together. Asserts
509+
the cross-filter contract: 401 envelope shape with `request_id`
510+
echoed in the `X-Request-Id` response header; 429 envelope with
511+
`Retry-After` and `X-RateLimit-Remaining: 0`; security headers
512+
present on every response (success, 401, 429); inbound
513+
`X-Request-Id` propagated end-to-end when valid; control-char
514+
inbound rejected and replaced with a generated UUID; rate-limit
515+
bucket isolation per token (one client exhausting their bucket
516+
does not affect another); health endpoint bypasses auth (kubelet
517+
probes carry no token). Manually chains the four filters via
518+
lambda `FilterChain` instances rather than spinning up a full
519+
`@SpringBootTest` so the run is sub-second and doesn't need
520+
Neo4j. Lives in `io.github.randomcodespace.iq.config.security`
521+
package to access package-private `TokenResolver.resolve()`.
522+
- **`CLAUDE.md` tech-stack pin refresh.** Stale version pins
523+
updated to current: Spring Boot 4.0.5 → 4.0.6, Spring AI 2.0.0-M3
524+
→ 2.0.0-M4, Neo4j Embedded 2026.02.3 → 2026.04.0; added
525+
Bucket4j 8.18.0, logstash-logback-encoder 9.0,
526+
micrometer-registry-prometheus to the dependency list.
527+
- **Tests:** new `ServingChainIntegrationTest` (9 cases). Full
528+
suite: 3689 tests / 0 failures / 0 errors / 32 skipped.
529+
475530
## [0.1.0] - 2026-03-28
476531

477532
First general-availability cut. See the

CLAUDE.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,18 @@
1414

1515
## Tech Stack
1616

17+
> **Source of truth:** `pom.xml` and `src/main/frontend/package.json`. Update
18+
> these pins together — when `pom.xml` bumps, this list moves with it as part
19+
> of the same PR. Pre-PR-5 the list had drifted (Spring Boot 4.0.5,
20+
> Neo4j 2026.02.3, Spring AI 2.0.0-M3); PR 5 brought it back in sync.
21+
1722
- Java 25 (virtual threads, pattern matching, records, sealed classes)
18-
- Spring Boot 4.0.5
19-
- Neo4j Embedded 2026.02.3 (Community Edition, no external server)
20-
- Spring AI 2.0.0-M3 (MCP server, `@McpTool` annotations, streamable HTTP)
23+
- Spring Boot 4.0.6 (parent POM `<version>`)
24+
- Neo4j Embedded 2026.04.0 (Community Edition, no external server)
25+
- Spring AI 2.0.0-M4 (MCP server, `@McpTool` annotations, streamable HTTP)
26+
- Bucket4j 8.18.0 (`bucket4j_jdk17-core`, in-process token-bucket rate limiter)
27+
- logstash-logback-encoder 9.0 (JSON-structured logging in `serving` profile)
28+
- micrometer-registry-prometheus (`/actuator/prometheus`, version managed by Boot BOM)
2129
- JavaParser 3.28.0 (Java AST analysis)
2230
- ANTLR 4.13.2 (TypeScript/JavaScript, Python, Go, C#, Rust, C++ grammars)
2331
- Picocli 4.7.7 (CLI framework, integrated with Spring Boot)

src/main/java/io/github/randomcodespace/iq/config/unified/ConfigValidator.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,45 @@ public List<ConfigError> validate(CodeIqUnifiedConfig c) {
6464
Integer maxRes = c.mcp().limits().maxResults();
6565
if (maxRes != null && maxRes <= 0)
6666
errs.add(new ConfigError("mcp.limits.max_results", "must be > 0", "validator"));
67+
Long maxPayload = c.mcp().limits().maxPayloadBytes();
68+
if (maxPayload != null && maxPayload <= 0)
69+
errs.add(new ConfigError("mcp.limits.max_payload_bytes", "must be > 0", "validator"));
70+
Integer ratePerMin = c.mcp().limits().ratePerMinute();
71+
if (ratePerMin != null && ratePerMin <= 0)
72+
errs.add(new ConfigError("mcp.limits.rate_per_minute", "must be > 0", "validator"));
73+
Integer maxDepth = c.mcp().limits().maxDepth();
74+
if (maxDepth != null) {
75+
if (maxDepth <= 0)
76+
errs.add(new ConfigError("mcp.limits.max_depth", "must be > 0", "validator"));
77+
// Hard ceiling on max_depth — variable-length Cypher with depth >100
78+
// is almost always either a misconfig or a reconnaissance probe.
79+
// A graph with 100M nodes and a fan-out of 5 reaches every node by
80+
// depth 12 anyway; depth >100 is pathological in practice.
81+
if (maxDepth > 100)
82+
errs.add(new ConfigError("mcp.limits.max_depth",
83+
"must be <= 100 (variable-length Cypher above this depth is "
84+
+ "pathological); got " + maxDepth, "validator"));
85+
}
86+
87+
// mcp.auth.* — blank-string checks for required fields
88+
// When mcp.auth.mode=bearer, either token_env (env var name) or token
89+
// (literal config value) must resolve to a non-blank string. The
90+
// TokenResolver also fail-fasts at startup, but catching this in
91+
// `codeiq config validate` lets operators see the issue before
92+
// launching the server.
93+
if ("bearer".equalsIgnoreCase(c.mcp().auth().mode())) {
94+
String tokenEnvName = c.mcp().auth().tokenEnv();
95+
String tokenLiteral = c.mcp().auth().token();
96+
// Blank means "set but empty" — silently coerced to null at
97+
// config-read but TokenResolver would still fail. Catch here.
98+
if (tokenEnvName != null && tokenEnvName.isBlank())
99+
errs.add(new ConfigError("mcp.auth.token_env",
100+
"must be non-blank when set (use unset for default CODEIQ_MCP_TOKEN)",
101+
"validator"));
102+
if (tokenLiteral != null && tokenLiteral.isBlank())
103+
errs.add(new ConfigError("mcp.auth.token",
104+
"must be non-blank when set", "validator"));
105+
}
67106

68107
// observability.log_format / log_level
69108
if (c.observability().logFormat() != null && !LOG_FORMATS.contains(c.observability().logFormat()))

src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,13 @@ public String queryNodes(
170170
@McpToolParam(description = "Node kind to filter by: endpoint, entity, class, method, guard, service, module, topic, queue, config_file, database_connection, component, interface, enum, etc.", required = false) String kind,
171171
@McpToolParam(description = "Maximum number of results to return (default: 50)", required = false) Integer limit) {
172172
try {
173-
return toJson(queryService.listNodes(kind, limit != null ? limit : 50, 0));
173+
// Clamp caller-supplied limit to mcp.limits.max_results (default 500).
174+
// Pre-PR-5 a caller could ask for `limit: 1_000_000` and Neo4j
175+
// would happily stream a million rows over the MCP transport,
176+
// saturating the JSON encoder and the network. Part of the
177+
// rate-limit / abuse-protection story (RAN-46 #2/#3).
178+
int safeLimit = Math.min(limit != null ? limit : 50, maxResults);
179+
return toJson(queryService.listNodes(kind, safeLimit, 0));
174180
} catch (Exception e) {
175181
return errorEnvelope("INTERNAL_ERROR", e);
176182
}
@@ -181,7 +187,8 @@ public String queryEdges(
181187
@McpToolParam(description = "Edge kind to filter by: calls, imports, depends_on, queries, produces, consumes, protects, extends, implements, contains, connects_to, maps_to, etc.", required = false) String kind,
182188
@McpToolParam(description = "Maximum number of results to return (default: 50)", required = false) Integer limit) {
183189
try {
184-
return toJson(queryService.listEdges(kind, limit != null ? limit : 50, 0));
190+
int safeLimit = Math.min(limit != null ? limit : 50, maxResults);
191+
return toJson(queryService.listEdges(kind, safeLimit, 0));
185192
} catch (Exception e) {
186193
return errorEnvelope("INTERNAL_ERROR", e);
187194
}
@@ -201,9 +208,14 @@ public String getNodeNeighbors(
201208
@McpTool(name = "get_ego_graph", description = "Get the full subgraph within N hops of a center node — all reachable nodes and edges. Use for exploring the neighborhood of a component, understanding local architecture, or visualizing a module's context. Returns nodes and edges as a graph structure.")
202209
public String getEgoGraph(
203210
@McpToolParam(description = "Center node ID") String center,
204-
@McpToolParam(description = "Number of hops from center node (default: 2, max: 10)", required = false) Integer radius) {
211+
@McpToolParam(description = "Number of hops from center node (default: 2, max: configured mcp.limits.max_depth)", required = false) Integer radius) {
205212
try {
206-
return toJson(queryService.egoGraph(center, radius != null ? radius : 2));
213+
// Clamp radius to mcp.limits.max_depth (default 10). Pre-PR-5
214+
// the description claimed "max: 10" but no code enforced it —
215+
// a caller could ask for radius=999 and Neo4j would attempt a
216+
// [*1..999] variable-length match.
217+
int safeRadius = Math.min(radius != null ? radius : 2, maxDepth);
218+
return toJson(queryService.egoGraph(center, safeRadius));
207219
} catch (Exception e) {
208220
return errorEnvelope("INTERNAL_ERROR", e);
209221
}
@@ -429,9 +441,10 @@ public String findRelatedEndpoints(
429441
@McpTool(name = "search_graph", description = "Full-text search across all node labels, IDs, file paths, and properties. Use as the starting point when the user mentions a name but you don't have the exact node ID. Returns matching nodes ranked by relevance.")
430442
public String searchGraph(
431443
@McpToolParam(description = "Search query") String query,
432-
@McpToolParam(description = "Maximum results (default: 20)", required = false) Integer limit) {
444+
@McpToolParam(description = "Maximum results (default: 20, hard cap: configured mcp.limits.max_results)", required = false) Integer limit) {
433445
try {
434-
return toJson(queryService.searchGraph(query, limit != null ? limit : 20));
446+
int safeLimit = Math.min(limit != null ? limit : 20, maxResults);
447+
return toJson(queryService.searchGraph(query, safeLimit));
435448
} catch (Exception e) {
436449
return errorEnvelope("INTERNAL_ERROR", e);
437450
}

0 commit comments

Comments
 (0)