Skip to content

feat(security): WAF-equivalent input validation + GraphQL depth/complexity limit — PR3 (#2270) - #2273

Merged
jung-thomas merged 1 commit into
DEVfrom
feat/2270-origin-abuse-pr3
Sep 12, 2026
Merged

jung-thomas merged 1 commit into
DEVfrom
feat/2270-origin-abuse-pr3

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

What & why

PR3 of the origin-abuse-protection series (after #2263 shared rate limiter, #2264 edge-cache Fast-Purge). Closes #2270.

Adds a WAF-equivalent input-validation layer on the anon / agentic write surface, plus depth + complexity limiting (and prod-only introspection block) on the public GraphQL endpoint. Consistent with the series: DB-flag gated, DB-driven config, env-free, fail-open, default OFF.

Layers

  • Anon POST bodies — srv/lib/input-validation/register.js mounts a two-mode express middleware in cds.on('bootstrap'), right after the rate limiter (floods shed first):
    • size mode — Content-Length body-size cap only. Stream-safe, so it can't loosen a stricter inline limit (e.g. /api/ui-event's 64kb) or interfere with OData parsing. Applied broadly (/content, /build, /graph, /feedback, /homepage, /api, /graphql/public).
    • full mode — size cap + bounded express.json pre-parse + JSON depth/keys/array-length shape check. Scoped to the anon + CSRF-off agentic JSON-RPC surface (/mcp*, /a2a, /chat/stream). Pre-parse is reused downstream (CAP bodyParser4 early-returns on object body; graphql-http reads req.body) → no double-consume.
    • Responses: 413 payload_too_large / 400 invalid_json / 400 invalid_body_shape. srv/lib/input-validation/json-shape.js is a pure, iterative (explicit-stack, never recursive) walker.
  • GraphQL /graphql/public — srv/lib/graphql-guard.js provides a hand-rolled graphql-js costLimitRule(maxDepth, maxComplexity) (fragment-resolving, cycle-guarded) and a per-request buildPublicValidationRules fn wired via validationRules in graphql-config.js. Appends the cost rule and, in PROD only, NoSchemaIntrospectionCustomRule. No new dependency (keeps srv-qa boot + "no new BTP entitlements" intact).
  • MCP — mcp-channels-search.js, mcp-events-search.js, mcp-kg-tools.js now normalize caller-controlled limit/depth via the canonical clampLimit() shared validator.

Config & flag

  • Flag INPUT_VALIDATION_ENABLED (kind:'db', ImsConfig flag.inputvalidation, default false, dev-only) registered in feature-flags/registry.js — surfaces in the Feature Flags UI and auto-registers with db-flags for isFlagEnabled().
  • Thresholds in srv/lib/runtime-config/input-validation-settings.js (ImsConfig-backed, ~5s cache, globalThis-pinned, never-throws, no env fallback — blue-green safe). Defaults: 1 MiB body, depth 32, 10k keys, 50k array, gql depth 12 / complexity 1000.
  • Fail-open everywhere: flag-off / config-error / walker-fault all resolve to next() (or specifiedRules for GraphQL).

Existing SELECT-only validator untouched

srv/lib/analytics-sql-validator.cjs stays as its own layer — this PR does not replace it.

Tests & verification

  • test/unit/input-validation.test.js (new, 25 tests): json-shape depth/keys/array + primitives + deep-payload stack-safety; config defaulting; middleware flag-gating / 413 / 400-shape / size-vs-full / exclude / fail-open; costLimitRule depth + complexity + fragment rejection; buildPublicValidationRules flag-off/on / prod-introspection / fail-open.
  • Updated test/unit/mcp-{channels,events}-search.test.js limit=0 assertions to the canonical clampLimit semantics (explicit 0 → floor 1; only omitted → default). These two were the only real test failures introduced; the rest of the unit suite is unchanged (remaining failing files are the pre-existing island/imgly vite transform issues).
  • Registry drift test (feature-flags-registry.test.js) passes.
  • srv-qa cp-list: unaffected — reverse-dependency audit confirms none of the new modules are reachable from content-store.js; their only importers (server.js, graphql-config.js) are not in srv-qa's curated tree.

Rollout

Default OFF. Enable in DEV via ImsConfig flag.inputvalidation, observe, then PROD.

…exity limit — PR3

Third PR of the origin-abuse-protection series (after PR1 shared rate limiter,
PR2 edge-cache Fast-Purge). Adds a WAF-equivalent input-validation layer on the
anon / agentic write surface plus depth+complexity limiting on the public
GraphQL endpoint. Flag-OFF-first, DB-driven config, env-free, fail-open
everywhere — the validator must never break the surface it protects.

- srv/lib/input-validation/json-shape.js (new): pure, iterative (explicit-stack,
  never recursive) depth/keys/array-length walker -> {ok} | {ok:false,reason}.
- srv/lib/input-validation/register.js (new): two-mode express middleware wired
  in cds.on('bootstrap') after the rate limiter. 'size' mode = Content-Length cap
  only (stream-safe, broad anon POST surface); 'full' mode = size cap + bounded
  express.json pre-parse + JSON shape check, scoped to the anon+CSRF-off agentic
  JSON-RPC surface (/mcp*, /a2a, /chat/stream). Pre-parse reused by CAP bodyParser4
  and graphql-http (no double-consume). 413 payload_too_large / 400 invalid_json /
  400 invalid_body_shape. Flag-gated, fail-open, counter metrics.
- srv/lib/graphql-guard.js (new): hand-rolled graphql-js ValidationRule
  costLimitRule(maxDepth,maxComplexity) (fragment-resolving, cycle-guarded) +
  buildPublicValidationRules — a per-request graphql-http validationRules fn that
  appends the cost rule and, in PROD only, NoSchemaIntrospectionCustomRule.
  Flag-gated + config-driven, fail-open (returns specifiedRules on off/error).
  No new dependency (keeps srv-qa boot + "no new BTP entitlements" safe).
- srv/graphql-config.js: pass validationRules:buildPublicValidationRules to the
  /graphql/public GraphQLAdapter.
- srv/lib/runtime-config/input-validation-settings.js (new): ImsConfig-backed
  thresholds (~5s cache, globalThis-pinned, never-throws, no env fallback),
  mirroring rate-limit-settings.js. DEFAULT_CONFIG: 1MiB body, depth 32, 10k keys,
  50k array, gql depth 12 / complexity 1000.
- srv/lib/feature-flags/registry.js: INPUT_VALIDATION_ENABLED db flag
  (flag.inputvalidation, default false, dev-only) -> surfaces in Feature Flags UI
  and auto-registers with db-flags for isFlagEnabled().
- srv/server.js: registerInputValidation(app) after registerRateLimit(app).
- srv/lib/mcp-{channels,events}-search.js, mcp-kg-tools.js: normalize
  caller-controlled limit/depth via the canonical clampLimit() shared validator.
- test/unit/input-validation.test.js (new): json-shape depth/keys/array + ok +
  stack-safety, config defaulting, middleware flag-gating/413/400-shape/
  size-vs-full/exclude/fail-open, costLimitRule depth+complexity+fragment
  rejection, buildPublicValidationRules flag-off/on/prod-introspection/fail-open.
- test/unit/mcp-{channels,events}-search.test.js: updated the limit=0 assertion to
  the canonical clampLimit semantics (explicit 0 -> floor 1; only omitted -> default).

srv-qa cp-list: unaffected — none of the new modules are reachable from
content-store.js (verified by reverse-dependency audit); their only importers are
server.js and graphql-config.js, which are not in srv-qa's curated tree.
@jung-thomas
jung-thomas merged commit 97a33c4 into DEV Sep 12, 2026
5 checks passed
@jung-thomas
jung-thomas deleted the feat/2270-origin-abuse-pr3 branch September 12, 2026 21:53
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