From 43f49ea9b73d0f021cbdac5c66e28159026c6182 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sat, 12 Sep 2026 14:50:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(security):=20WAF-equivalent=20input=20vali?= =?UTF-8?q?dation=20+=20GraphQL=20depth/complexity=20limit=20=E2=80=94=20P?= =?UTF-8?q?R3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- srv/graphql-config.js | 12 +- srv/lib/feature-flags/registry.js | 7 + srv/lib/graphql-guard.js | 125 +++++++ srv/lib/input-validation/json-shape.js | 64 ++++ srv/lib/input-validation/register.js | 158 +++++++++ srv/lib/mcp-channels-search.js | 3 +- srv/lib/mcp-events-search.js | 3 +- srv/lib/mcp-kg-tools.js | 2 +- .../input-validation-settings.js | 96 ++++++ srv/server.js | 8 + test/unit/input-validation.test.js | 323 ++++++++++++++++++ test/unit/mcp-channels-search.test.js | 7 +- test/unit/mcp-events-search.test.js | 7 +- 13 files changed, 807 insertions(+), 8 deletions(-) create mode 100644 srv/lib/graphql-guard.js create mode 100644 srv/lib/input-validation/json-shape.js create mode 100644 srv/lib/input-validation/register.js create mode 100644 srv/lib/runtime-config/input-validation-settings.js create mode 100644 test/unit/input-validation.test.js diff --git a/srv/graphql-config.js b/srv/graphql-config.js index f977753e6..09736985b 100644 --- a/srv/graphql-config.js +++ b/srv/graphql-config.js @@ -25,6 +25,7 @@ import cds from '@sap/cds'; import { createRequire } from 'node:module'; +import { buildPublicValidationRules } from './lib/graphql-guard.js'; const _require = createRequire(import.meta.url); const GraphQLAdapter = _require('@cap-js/graphql/lib/GraphQLAdapter'); @@ -50,10 +51,19 @@ cds.on('served', () => { // /graphql/public MUST be mounted before /graphql — Express's longest-prefix // rule does NOT apply to app.use(); it uses insertion order. Without this // ordering, a request to /graphql/public would match the /graphql handler first. + // + // validationRules is a per-request function (graphql-http evaluates it on every + // request) that adds a depth+complexity cap and, in PROD, an introspection + // block — flag-gated + config-driven, fail-open (srv/lib/graphql-guard.js). app.use( '/graphql/public', cds.middlewares.before, - GraphQLAdapter({ services: pick(PUBLIC_SERVICES), path: '/graphql/public', graphiql: false }), + GraphQLAdapter({ + services: pick(PUBLIC_SERVICES), + path: '/graphql/public', + graphiql: false, + validationRules: buildPublicValidationRules, + }), cds.middlewares.after ); diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index 956d7e8ad..495247a54 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -97,6 +97,13 @@ export const FEATURE_FLAGS = [ description: 'Cross-instance (cds-caching backed) rate limiter on the anon/expensive surface (/content, /build, /graph, /mcp*, …). Layered tiers: anon IP floor, higher tier for an already-present PAT/XSUAA token, top tier for HMAC-signed first-party agents. DB-driven config (ImsConfig key flag.ratelimit); thresholds in ImsConfig ratelimit.* via rate-limit-settings.js. No env var. Default OFF, fail-open.', howToChange: featureFlagUpsert('RATE_LIMIT_ENABLED', 'flag.ratelimit'), }, + { + key: 'INPUT_VALIDATION_ENABLED', label: 'Origin input validation', category: 'Security', + kind: 'db', imsConfigKey: 'flag.inputvalidation', + valueType: 'boolean', default: false, issue: 'origin-abuse-protection', status: 'dev-only', + description: 'WAF-equivalent input validation on the anon/agentic write surface (PR3). Body-size caps + JSON depth/shape limits on anon POST bodies before handlers run, and a depth + complexity limit (plus prod-only introspection block) on GraphQL /graphql/public. DB-driven config (ImsConfig key flag.inputvalidation); thresholds in ImsConfig inputvalidation.* via input-validation-settings.js. No env var. Default OFF, fail-open.', + howToChange: featureFlagUpsert('INPUT_VALIDATION_ENABLED', 'flag.inputvalidation'), + }, // ---- Navigator ---- { key: 'NAV_INCLUDE_NESTED_GROUPS', label: 'Navigator nested-group cards', diff --git a/srv/lib/graphql-guard.js b/srv/lib/graphql-guard.js new file mode 100644 index 000000000..e9547b747 --- /dev/null +++ b/srv/lib/graphql-guard.js @@ -0,0 +1,125 @@ +// srv/lib/graphql-guard.js +// +// Depth + complexity + introspection guard for the anonymous GraphQL endpoint +// /graphql/public (PR3 of origin-abuse-protection). Wired into graphql-config.js +// as the `validationRules` option of the /graphql/public GraphQLAdapter, which +// graphql-http evaluates PER REQUEST — so the guard is flag-gated and config- +// driven live, with no re-mount required. +// +// - Depth limit: rejects queries whose selection-set nesting exceeds gqlMaxDepth +// (a classic amplification vector on a graph schema with cyclic associations). +// - Complexity limit: rejects queries whose total field-selection count exceeds +// gqlMaxComplexity (a cheap, schema-agnostic cost proxy — no field weights, no +// new dependency). +// - Introspection: in PROD, adds graphql-js's NoSchemaIntrospectionCustomRule so +// __schema / __type probes are rejected on the public endpoint. Left ON in +// dev/local so tooling keeps working. +// +// Master kill switch: INPUT_VALIDATION_ENABLED (default OFF). When off — or on +// ANY error — the guard returns the caller's default `specifiedRules` unchanged +// (fail-open: never break the endpoint it protects). No new dependency: the +// depth/complexity rules are hand-rolled graphql-js ValidationRules. + +import cds from '@sap/cds'; +import { GraphQLError, NoSchemaIntrospectionCustomRule } from 'graphql'; +import { isFlagEnabled } from './feature-flags/db-flags.js'; +import { resolveInputValidationConfig } from './runtime-config/input-validation-settings.js'; +import { resolveDeployEnvironment } from './deploy-environment.js'; + +const LOG = cds.log('graphql-guard'); + +/** + * Walk a selection set (resolving fragment spreads, guarding cycles) and return + * the max nesting depth and total field-selection count beneath `depth`. + * @returns {{ maxDepth:number, fields:number }} + */ +function analyzeSelectionSet(selectionSet, fragments, visitedFragments, depth) { + if (!selectionSet) return { maxDepth: depth, fields: 0 }; + let maxDepth = depth; + let fields = 0; + + for (const sel of selectionSet.selections) { + if (sel.kind === 'Field') { + fields += 1; + const r = analyzeSelectionSet(sel.selectionSet, fragments, visitedFragments, depth + 1); + if (r.maxDepth > maxDepth) maxDepth = r.maxDepth; + fields += r.fields; + } else if (sel.kind === 'InlineFragment') { + // Inline fragments don't add a nesting level of their own. + const r = analyzeSelectionSet(sel.selectionSet, fragments, visitedFragments, depth); + if (r.maxDepth > maxDepth) maxDepth = r.maxDepth; + fields += r.fields; + } else if (sel.kind === 'FragmentSpread') { + const name = sel.name.value; + if (visitedFragments.has(name)) continue; // cycle guard (validate() also reports this) + const frag = fragments[name]; + if (!frag) continue; + const nextVisited = new Set(visitedFragments); + nextVisited.add(name); + const r = analyzeSelectionSet(frag.selectionSet, fragments, nextVisited, depth); + if (r.maxDepth > maxDepth) maxDepth = r.maxDepth; + fields += r.fields; + } + } + return { maxDepth, fields }; +} + +/** + * A graphql-js ValidationRule that enforces both a depth and a complexity cap. + * One rule = one AST walk per operation. + */ +export function costLimitRule(maxDepth, maxComplexity) { + return function CostLimit(context) { + const fragments = Object.create(null); + for (const def of context.getDocument().definitions) { + if (def.kind === 'FragmentDefinition') fragments[def.name.value] = def; + } + return { + OperationDefinition(node) { + const { maxDepth: d, fields: c } = analyzeSelectionSet( + node.selectionSet, + fragments, + new Set(), + 0 + ); + if (d > maxDepth) { + context.reportError( + new GraphQLError(`Query exceeds maximum depth of ${maxDepth} (depth ${d}).`, { + nodes: [node], + }) + ); + } + if (c > maxComplexity) { + context.reportError( + new GraphQLError( + `Query exceeds maximum complexity of ${maxComplexity} (cost ${c}).`, + { nodes: [node] } + ) + ); + } + return false; // analysis is done at the operation root; don't descend + }, + }; + }; +} + +/** + * Build the effective validation rules for a /graphql/public request. Shape + * matches graphql-http's `validationRules` function form: + * (req, args, specifiedRules) => rules + * FAILS OPEN: flag off / any error → return `specifiedRules` unchanged. + */ +export async function buildPublicValidationRules(_req, _args, specifiedRules) { + try { + if (!isFlagEnabled('INPUT_VALIDATION_ENABLED')) return specifiedRules; + const cfg = await resolveInputValidationConfig(); + const rules = [...specifiedRules, costLimitRule(cfg.gqlMaxDepth, cfg.gqlMaxComplexity)]; + if (resolveDeployEnvironment().id === 'prod') { + rules.push(NoSchemaIntrospectionCustomRule); + } + return rules; + } catch (err) { + LOG.warn(`validation-rule build failed (allowing default rules): ${err.message}`); + return specifiedRules; + } +} diff --git a/srv/lib/input-validation/json-shape.js b/srv/lib/input-validation/json-shape.js new file mode 100644 index 000000000..ef51d2785 --- /dev/null +++ b/srv/lib/input-validation/json-shape.js @@ -0,0 +1,64 @@ +// srv/lib/input-validation/json-shape.js +// Pure, dependency-free JSON shape validator for the origin input guard (PR3). +// Rejects payloads that are pathologically deep, wide, or long — the shapes a +// WAF would drop before they reach a handler and blow up the heap / a query. +// +// The walk is ITERATIVE (explicit stack), not recursive: a recursive walker +// would itself overflow the call stack on a deeply-nested attack payload — +// exactly the input we are trying to reject. NEVER throws; a caller-side fault +// (e.g. a getter that throws) is caught and treated as "ok" (fail-open is the +// caller's job, but we do not add a throw of our own). + +/** + * @typedef {{ maxJsonDepth:number, maxJsonKeys:number, maxArrayLen:number }} ShapeLimits + * @typedef {{ ok:true } | { ok:false, reason:'depth'|'keys'|'array', detail:string }} ShapeResult + */ + +/** + * Validate a parsed JSON value against depth / total-key / array-length limits. + * @param {*} root parsed JSON value (object/array/primitive) + * @param {ShapeLimits} limits + * @returns {ShapeResult} + */ +export function validateJsonShape(root, limits) { + const maxDepth = limits.maxJsonDepth; + const maxKeys = limits.maxJsonKeys; + const maxArrayLen = limits.maxArrayLen; + + // Primitives (and null) have no shape to violate. + if (root === null || typeof root !== 'object') return { ok: true }; + + let totalKeys = 0; + // Stack frames: { node, depth }. Depth 1 = the root container. + const stack = [{ node: root, depth: 1 }]; + + while (stack.length) { + const { node, depth } = stack.pop(); + if (depth > maxDepth) { + return { ok: false, reason: 'depth', detail: `nesting depth exceeds ${maxDepth}` }; + } + + if (Array.isArray(node)) { + if (node.length > maxArrayLen) { + return { ok: false, reason: 'array', detail: `array length ${node.length} exceeds ${maxArrayLen}` }; + } + for (let i = 0; i < node.length; i++) { + const child = node[i]; + if (child !== null && typeof child === 'object') stack.push({ node: child, depth: depth + 1 }); + } + } else { + // Plain object. + const keys = Object.keys(node); + totalKeys += keys.length; + if (totalKeys > maxKeys) { + return { ok: false, reason: 'keys', detail: `total key count exceeds ${maxKeys}` }; + } + for (let i = 0; i < keys.length; i++) { + const child = node[keys[i]]; + if (child !== null && typeof child === 'object') stack.push({ node: child, depth: depth + 1 }); + } + } + } + + return { ok: true }; +} diff --git a/srv/lib/input-validation/register.js b/srv/lib/input-validation/register.js new file mode 100644 index 000000000..73c984d43 --- /dev/null +++ b/srv/lib/input-validation/register.js @@ -0,0 +1,158 @@ +// srv/lib/input-validation/register.js +// +// WAF-equivalent input validation for the anon / agentic write surface (PR3 of +// the origin-abuse-protection series). Follows the register(app) convention of +// srv/lib/rate-limit/register.js and mounts in cds.on('bootstrap') so it sits +// AHEAD of the CAP service handlers in the express stack. +// +// Master kill switch: INPUT_VALIDATION_ENABLED (ImsConfig flag.inputvalidation), +// default OFF. When off every mounted middleware is a straight pass-through. +// Numeric thresholds come from input-validation-settings.js (~5s cache, DB- +// backed, env-free). EVERYTHING here FAILS OPEN: a fault anywhere resolves to +// next() (request allowed) — the validator must never break the surface it +// protects. +// +// Two mount modes: +// 'size' — Content-Length size cap ONLY. Never touches the request stream, so +// it can't loosen a stricter inline limit (e.g. /api/ui-event's 64kb +// express.json) and can't interfere with CAP's OData body parsing. +// Applied broadly across the anon POST surface. +// 'full' — size cap PLUS a bounded pre-parse + JSON depth/shape check. Scoped +// to the agentic JSON-RPC surface (/mcp*, /a2a, /chat/stream) which is +// anonymous AND CSRF-disabled (the sharpest exposure) and carries no +// stricter inline body limit to loosen. The pre-parse is reused +// downstream: CAP's bodyParser4 early-returns when req.body is already +// an object, and graphql-http reads req.body when a parser has run — +// so we never double-consume the stream. +// +// GraphQL /graphql/public depth/complexity/introspection limiting lives in +// srv/lib/graphql-guard.js (wired through graphql-config.js); here we only add +// the outer body-size cap for that path. + +import cds from '@sap/cds'; +import express from 'express'; +import { isFlagEnabled } from '../feature-flags/db-flags.js'; +import { resolveInputValidationConfig } from '../runtime-config/input-validation-settings.js'; +import { validateJsonShape } from './json-shape.js'; +import { counter } from '../metrics.js'; + +const LOG = cds.log('input-validation'); + +const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH']); +const JSON_CT = /application\/(json|[\w.+-]*\+json)\b/i; + +function isJsonBody(req) { + return JSON_CT.test(String(req.headers['content-type'] || '')); +} + +// Validate an already-parsed body and either 400 or continue. Fail-open: a fault +// in the walker itself logs and allows. +function shapeCheckAndContinue(req, res, next, cfg) { + try { + const body = req.body; + if (body === null || typeof body !== 'object') return next(); + const r = validateJsonShape(body, cfg); + if (!r.ok) { + counter(`inputvalidation.blocked[reason=${r.reason}]`); + return res.status(400).json({ error: 'invalid_body_shape', reason: r.reason }); + } + } catch (err) { + LOG.warn(`shape check error (allowing request): ${err.message}`); + } + return next(); +} + +/** + * Build an input-validation middleware. + * @param {{ mode?: 'size'|'full', exclude?: RegExp|null }} opts + */ +export function inputValidationMiddleware({ mode = 'size', exclude = null } = {}) { + return async function inputValidation(req, res, next) { + let cfg; + try { + if (!isFlagEnabled('INPUT_VALIDATION_ENABLED')) return next(); + if (!WRITE_METHODS.has(req.method)) return next(); + if (exclude && exclude.test(req.path)) return next(); + cfg = await resolveInputValidationConfig(); + } catch (err) { + LOG.warn(`input-validation setup error (allowing request): ${err.message}`); + return next(); + } + + try { + // 1. Body-size cap from the declared Content-Length (cheap, pre-parse). + const clen = Number(req.headers['content-length']); + if (Number.isFinite(clen) && clen > cfg.maxBodyBytes) { + counter('inputvalidation.blocked[reason=size]'); + return res.status(413).json({ error: 'payload_too_large', maxBytes: cfg.maxBodyBytes }); + } + + // 2. size-only mode, non-JSON, or a non-object body → nothing more to do. + if (mode !== 'full' || !isJsonBody(req)) return next(); + + // If a parser already ran (object body), validate what we have. + if (req.body !== undefined && req.body !== null && typeof req.body === 'object') { + return shapeCheckAndContinue(req, res, next, cfg); + } + + // Bounded pre-parse. The byte limit backstops a lying/absent Content-Length. + // strict:false keeps express.json's acceptance set (primitives allowed) so + // we don't reject payloads a handler would otherwise accept. + const parser = express.json({ limit: cfg.maxBodyBytes, strict: false }); + parser(req, res, (err) => { + if (err) { + if (err.type === 'entity.too.large' || err.status === 413 || err.statusCode === 413) { + counter('inputvalidation.blocked[reason=size]'); + return res.status(413).json({ error: 'payload_too_large', maxBytes: cfg.maxBodyBytes }); + } + // Malformed JSON: the stream is already consumed, so we cannot fall + // through to a downstream parser. CAP would itself answer 400 here + // (InvalidJSON), so a 400 is not a behaviour regression. + counter('inputvalidation.blocked[reason=malformed]'); + return res.status(400).json({ error: 'invalid_json' }); + } + return shapeCheckAndContinue(req, res, next, cfg); + }); + } catch (err) { + LOG.warn(`input-validation error (allowing request): ${err.message}`); + return next(); + } + }; +} + +// Broad anon POST surface — size cap only (stream-safe, no loosening). +const SIZE_MOUNTS = [ + // Spare the privileged publish/rollback write paths (API-key auth, low volume). + { prefix: '/content', exclude: /^\/(publish|rollback)\b/ }, + // Spare /build/repo-catalog — authenticated, intentionally large (10mb) writes. + { prefix: '/build', exclude: /^\/repo-catalog\b/ }, + { prefix: '/graph' }, + { prefix: '/feedback' }, + { prefix: '/homepage' }, + { prefix: '/api' }, + { prefix: '/graphql/public' }, +]; + +// Agentic JSON-RPC surface — size cap PLUS pre-parse depth/shape check. +const FULL_MOUNTS = [ + { prefix: '/mcp' }, + { prefix: '/mcp-pat' }, + { prefix: '/a2a' }, + { prefix: '/chat/stream' }, +]; + +/** + * Mount the input validator on the anon/agentic surface. Call from + * cds.on('bootstrap'). Idempotent-safe to call once. + */ +export function register(app) { + for (const m of SIZE_MOUNTS) { + app.use(m.prefix, inputValidationMiddleware({ mode: 'size', exclude: m.exclude })); + } + for (const m of FULL_MOUNTS) { + app.use(m.prefix, inputValidationMiddleware({ mode: 'full', exclude: m.exclude })); + } + LOG.info( + `input validator mounted (size:${SIZE_MOUNTS.length}, full:${FULL_MOUNTS.length}; flag-gated, default off)` + ); +} diff --git a/srv/lib/mcp-channels-search.js b/srv/lib/mcp-channels-search.js index e36f8a29e..2bccb5409 100644 --- a/srv/lib/mcp-channels-search.js +++ b/srv/lib/mcp-channels-search.js @@ -12,6 +12,7 @@ // contentHash, ingestBatch, audit) are never surfaced. import cds from '@sap/cds'; +import { clampLimit } from './mcp-arg-validators.js'; const LOG = cds.log('mcp-channels-search'); @@ -66,7 +67,7 @@ export async function handleSearchChannels(req) { const platform = typeof d.platform === 'string' ? d.platform.trim() : ''; let ownerScope = typeof d.ownerScope === 'string' ? d.ownerScope.trim().toLowerCase() : 'all'; if (!VALID_OWNER_SCOPES.has(ownerScope)) ownerScope = 'all'; - const limit = Math.min(Math.max(Number(d.limit) || 20, 1), 50); + const limit = clampLimit(d.limit, 20, 50); try { const { Channels } = cds.entities('com.sap.developers.ims'); diff --git a/srv/lib/mcp-events-search.js b/srv/lib/mcp-events-search.js index 9b04061de..a945e8231 100644 --- a/srv/lib/mcp-events-search.js +++ b/srv/lib/mcp-events-search.js @@ -1,4 +1,5 @@ import cds from '@sap/cds'; +import { clampLimit } from './mcp-arg-validators.js'; const LOG = cds.log('mcp-events-search'); @@ -36,7 +37,7 @@ export async function handleSearchEvents(req) { const eventType = typeof d.eventType === 'string' ? d.eventType.trim().toLowerCase() : ''; const region = typeof d.region === 'string' ? d.region.trim().toUpperCase() : 'ALL'; const upcomingOnly = d.upcomingOnly !== false; // default true - const limit = Math.min(Math.max(Number(d.limit) || 20, 1), 50); + const limit = clampLimit(d.limit, 20, 50); try { const { CommunityEvents } = cds.entities('com.sap.developers.ims.external'); diff --git a/srv/lib/mcp-kg-tools.js b/srv/lib/mcp-kg-tools.js index 9bdaa55cc..0d49b6574 100644 --- a/srv/lib/mcp-kg-tools.js +++ b/srv/lib/mcp-kg-tools.js @@ -49,7 +49,7 @@ const EMPTY_NB = { prerequisites: [], whatToLearnNext: [], sharedConcepts: [], t */ export async function handleNeighborhood(req) { const slug = (req.data.slug ?? '').toLowerCase(); - const depth = Math.min(Math.max(req.data.depth ?? 10, 1), 50); + const depth = clampLimit(req.data.depth, 10, 50); if (!slug) return { ...EMPTY_NB }; // norm handles both tutorial-arm items ({slug, weight, reason, title}) and // concept items ({slug, name, description, published}) from the teaches arm: diff --git a/srv/lib/runtime-config/input-validation-settings.js b/srv/lib/runtime-config/input-validation-settings.js new file mode 100644 index 000000000..90ed49f50 --- /dev/null +++ b/srv/lib/runtime-config/input-validation-settings.js @@ -0,0 +1,96 @@ +// srv/lib/runtime-config/input-validation-settings.js +// Numeric threshold config for the origin-side input validator (PR3). Mirrors +// rate-limit-settings.js exactly: DB-backed (ImsConfig key/value), ~5s cache, +// globalThis-pinned, never throws, NO env-var fallback (project rule: tunable +// behavior lives in the DB, toggled live in the admin UI — env vars are dropped +// by the blue-green swap). +// +// The master on/off is a separate feature flag (INPUT_VALIDATION_ENABLED, +// ImsConfig flag.inputvalidation) resolved by srv/lib/feature-flags/db-flags.js. +// THIS module only supplies the numbers once the validator is on. Any unset / +// malformed key falls back to its hardcoded default, so a partial config never +// disables protection. + +import cds from '@sap/cds'; + +const LOG = cds.log('input-validation-settings'); +const NS = 'com.sap.developers.ims'; +const TTL_MS = 5_000; + +const STATE_KEY = Symbol.for('com.sap.developers.ims:input-validation-settings-resolver'); +const _state = (globalThis[STATE_KEY] ??= { cached: null, cachedAt: 0 }); + +// Defaults chosen as WAF-equivalent safety valves: generous enough never to bite +// a legitimate GraphQL query or MCP/A2A JSON-RPC envelope, tight enough to reject +// oversized / deeply-nested flood payloads well below a HANA/heap OOM. All +// overridable per env via ImsConfig. +export const DEFAULT_CONFIG = Object.freeze({ + // Max request body size (bytes) on the anon POST surface. 1 MiB — a JSON-RPC + // or GraphQL body is kilobytes; privileged large writes (/content/publish, + // /build/repo-catalog) are excluded from this cap in register.js. + maxBodyBytes: 1_048_576, + // JSON shape limits (walked post-parse on the anon JSON surface). + maxJsonDepth: 32, // nesting depth of objects/arrays + maxJsonKeys: 10_000, // total object keys across the whole body + maxArrayLen: 50_000, // longest single array + // GraphQL /graphql/public query-document limits. + gqlMaxDepth: 12, // selection-set nesting depth + gqlMaxComplexity: 1_000, // approximate cost = total field selections +}); + +// ImsConfig key -> path into the config object (all one-level here). +const KEY_MAP = { + 'inputvalidation.maxBodyBytes': ['maxBodyBytes'], + 'inputvalidation.maxJsonDepth': ['maxJsonDepth'], + 'inputvalidation.maxJsonKeys': ['maxJsonKeys'], + 'inputvalidation.maxArrayLen': ['maxArrayLen'], + 'inputvalidation.gql.maxDepth': ['gqlMaxDepth'], + 'inputvalidation.gql.maxComplexity': ['gqlMaxComplexity'], +}; +const ALL_KEYS = Object.keys(KEY_MAP); + +function freshClone() { + return { ...DEFAULT_CONFIG }; +} + +function applyRow(cfg, key, value) { + const path = KEY_MAP[key]; + if (!path) return; + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return; // reject unset/garbage/zero → keep default + cfg[path[0]] = n; +} + +/** + * Resolve the effective numeric config. NEVER throws; on any read failure the + * hardcoded defaults are returned. Cached ~5s. + * @returns {Promise} + */ +export async function resolveInputValidationConfig() { + const now = Date.now(); + if (_state.cached && now - _state.cachedAt < TTL_MS) return _state.cached; + + const cfg = freshClone(); + try { + if (typeof cds.entities === 'function') { + const db = await cds.connect.to('db'); + const { ImsConfig } = cds.entities(NS); + const rows = await db.run( + SELECT.from(ImsConfig).columns('key', 'value').where({ key: { in: ALL_KEYS } }) + ); + for (const r of rows || []) applyRow(cfg, r.key, r.value); + } + } catch (err) { + LOG.warn(`config read failed; using defaults: ${err.message}`); + } + + _state.cached = cfg; + _state.cachedAt = now; + return cfg; +} + +/** Test-only: clear the memoised config between cases. */ +export function _resetForTest() { + _state.cached = null; + _state.cachedAt = 0; +} diff --git a/srv/server.js b/srv/server.js index 7f3dfed61..d557a7cd7 100644 --- a/srv/server.js +++ b/srv/server.js @@ -68,6 +68,7 @@ import { buildSystemPrompt } from './lib/chat-context.js'; import { createRateLimiter, RateLimitError } from './lib/chat-rate-limit.js'; import { createIpRateLimiter, ipRateLimitMiddleware } from './lib/ip-rate-limit.js'; import { register as registerRateLimit } from './lib/rate-limit/register.js'; +import { register as registerInputValidation } from './lib/input-validation/register.js'; import { streamChat } from './lib/chat-orchestrator.js'; import { buildChatInvocation } from './lib/chat-invocation.js'; import { computeEmbeddingStats } from './lib/embedding-stats.js'; @@ -1486,6 +1487,13 @@ cds.on('bootstrap', (app) => { // gated (RATE_LIMIT_ENABLED, ImsConfig flag.ratelimit), default OFF, fail-open. // Complements the always-on legacy /search limiter above. registerRateLimit(app); + + // Origin-side abuse protection: WAF-equivalent input validation on the anon / + // agentic write surface — body-size caps everywhere, plus a JSON depth/shape + // check on the agentic JSON-RPC surface (/mcp*, /a2a, /chat/stream). Flag- + // gated (INPUT_VALIDATION_ENABLED, ImsConfig flag.inputvalidation), default + // OFF, fail-open. Mounted after the rate limiter so floods are shed first. + registerInputValidation(app); }); cds.on('served', async () => { diff --git a/test/unit/input-validation.test.js b/test/unit/input-validation.test.js new file mode 100644 index 000000000..b3d27c8f1 --- /dev/null +++ b/test/unit/input-validation.test.js @@ -0,0 +1,323 @@ +// test/unit/input-validation.test.js +// Unit coverage for the origin-side input validator (PR3): +// - json-shape.js pure depth/keys/array walker +// - input-validation-settings.js config defaulting (no cds runtime → defaults) +// - the flag-gated / fail-open express middleware (register.js) +// - graphql-guard.js depth + complexity rule and the per-request rule builder +// +// db-flags + metrics are mocked so no cds runtime is needed; the settings +// resolver naturally falls back to DEFAULT_CONFIG when cds has no model. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { buildSchema, parse, validate, specifiedRules, NoSchemaIntrospectionCustomRule } from 'graphql'; + +// Mutable holder so a hoisted vi.mock factory can read per-test state. +const h = vi.hoisted(() => ({ flag: false, throwFlag: false })); +vi.mock('../../srv/lib/feature-flags/db-flags.js', () => ({ + isFlagEnabled: () => { + if (h.throwFlag) throw new Error('boom'); + return h.flag; + }, +})); +vi.mock('../../srv/lib/metrics.js', () => ({ counter: () => {} })); + +import { validateJsonShape } from '../../srv/lib/input-validation/json-shape.js'; +import { + resolveInputValidationConfig, + DEFAULT_CONFIG, + _resetForTest as _resetCfg, +} from '../../srv/lib/runtime-config/input-validation-settings.js'; +import { inputValidationMiddleware } from '../../srv/lib/input-validation/register.js'; +import { costLimitRule, buildPublicValidationRules } from '../../srv/lib/graphql-guard.js'; + +const LIMITS = { maxJsonDepth: 5, maxJsonKeys: 10, maxArrayLen: 4 }; + +describe('json-shape: validateJsonShape', () => { + it('accepts primitives and null', () => { + for (const v of [null, 1, 'x', true, undefined]) { + expect(validateJsonShape(v, LIMITS).ok).toBe(true); + } + }); + + it('accepts a shallow, small object', () => { + expect(validateJsonShape({ a: 1, b: [1, 2, 3], c: { d: 2 } }, LIMITS).ok).toBe(true); + }); + + it('rejects nesting beyond maxJsonDepth', () => { + // depth 6 object: {a:{b:{c:{d:{e:{f:1}}}}}} + const deep = { a: { b: { c: { d: { e: { f: 1 } } } } } }; + const r = validateJsonShape(deep, LIMITS); + expect(r.ok).toBe(false); + expect(r.reason).toBe('depth'); + }); + + it('accepts nesting exactly at maxJsonDepth', () => { + // depth 5: {a:{b:{c:{d:{e:1}}}}} + const atLimit = { a: { b: { c: { d: { e: 1 } } } } }; + expect(validateJsonShape(atLimit, LIMITS).ok).toBe(true); + }); + + it('rejects an array longer than maxArrayLen', () => { + const r = validateJsonShape({ list: [1, 2, 3, 4, 5] }, LIMITS); + expect(r.ok).toBe(false); + expect(r.reason).toBe('array'); + }); + + it('rejects total key count beyond maxJsonKeys', () => { + const wide = {}; + for (let i = 0; i < 11; i++) wide['k' + i] = i; + const r = validateJsonShape(wide, LIMITS); + expect(r.ok).toBe(false); + expect(r.reason).toBe('keys'); + }); + + it('does not blow its own stack on a very deep payload', () => { + let deep = {}; + let cur = deep; + for (let i = 0; i < 20000; i++) { + cur.n = {}; + cur = cur.n; + } + // Iterative walk → returns a depth rejection, never a RangeError. + expect(() => validateJsonShape(deep, LIMITS)).not.toThrow(); + expect(validateJsonShape(deep, LIMITS).reason).toBe('depth'); + }); +}); + +describe('input-validation-settings: config defaulting', () => { + beforeEach(() => _resetCfg()); + + it('returns hardcoded defaults when no cds model / ImsConfig is available', async () => { + const cfg = await resolveInputValidationConfig(); + expect(cfg.maxBodyBytes).toBe(DEFAULT_CONFIG.maxBodyBytes); + expect(cfg.maxJsonDepth).toBe(DEFAULT_CONFIG.maxJsonDepth); + expect(cfg.gqlMaxDepth).toBe(DEFAULT_CONFIG.gqlMaxDepth); + expect(cfg.gqlMaxComplexity).toBe(DEFAULT_CONFIG.gqlMaxComplexity); + }); + + it('never throws', async () => { + await expect(resolveInputValidationConfig()).resolves.toBeTypeOf('object'); + }); +}); + +// Minimal express req/res doubles. +function mkRes() { + return { + statusCode: 0, + body: null, + _headers: {}, + status(c) { + this.statusCode = c; + return this; + }, + json(o) { + this.body = o; + return this; + }, + setHeader(k, v) { + this._headers[k] = v; + }, + }; +} + +describe('input-validation middleware: flag gating + fail-open', () => { + beforeEach(() => { + h.flag = false; + h.throwFlag = false; + _resetCfg(); + }); + afterEach(() => vi.restoreAllMocks()); + + it('is a pass-through when the flag is OFF', async () => { + const mw = inputValidationMiddleware({ mode: 'full' }); + const req = { method: 'POST', path: '/x', headers: { 'content-length': '9999999999' } }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBe(0); + }); + + it('passes non-write methods through', async () => { + h.flag = true; + const mw = inputValidationMiddleware({ mode: 'full' }); + const req = { method: 'GET', path: '/x', headers: {} }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + }); + + it('rejects an oversized Content-Length with 413 (pre-parse)', async () => { + h.flag = true; + const mw = inputValidationMiddleware({ mode: 'size' }); + const tooBig = String(DEFAULT_CONFIG.maxBodyBytes + 1); + const req = { method: 'POST', path: '/x', headers: { 'content-length': tooBig } }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(413); + expect(res.body.error).toBe('payload_too_large'); + }); + + it('shape-rejects a deep already-parsed JSON body in full mode (400)', async () => { + h.flag = true; + const mw = inputValidationMiddleware({ mode: 'full' }); + // Build a body deeper than the DEFAULT maxJsonDepth (32). + let deep = {}; + let cur = deep; + for (let i = 0; i < 40; i++) { + cur.n = {}; + cur = cur.n; + } + const req = { + method: 'POST', + path: '/mcp', + headers: { 'content-type': 'application/json' }, + body: deep, + }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('invalid_body_shape'); + expect(res.body.reason).toBe('depth'); + }); + + it('allows a valid already-parsed JSON body in full mode', async () => { + h.flag = true; + const mw = inputValidationMiddleware({ mode: 'full' }); + const req = { + method: 'POST', + path: '/mcp', + headers: { 'content-type': 'application/json' }, + body: { jsonrpc: '2.0', method: 'tools/call', params: { name: 'x', arguments: { limit: 5 } } }, + }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBe(0); + }); + + it('does NOT shape-check in size mode even with a deep body', async () => { + h.flag = true; + const mw = inputValidationMiddleware({ mode: 'size' }); + let deep = {}; + let cur = deep; + for (let i = 0; i < 40; i++) { + cur.n = {}; + cur = cur.n; + } + const req = { + method: 'POST', + path: '/content', + headers: { 'content-type': 'application/json' }, + body: deep, + }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + }); + + it('honours the exclude regexp', async () => { + h.flag = true; + const mw = inputValidationMiddleware({ mode: 'size', exclude: /^\/(publish|rollback)\b/ }); + const req = { method: 'POST', path: '/publish', headers: { 'content-length': String(DEFAULT_CONFIG.maxBodyBytes + 1) } }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBe(0); + }); + + it('fails open when the flag check throws', async () => { + h.throwFlag = true; + const mw = inputValidationMiddleware({ mode: 'full' }); + const req = { method: 'POST', path: '/mcp', headers: { 'content-length': '9999999999' } }; + const res = mkRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBe(0); + }); +}); + +describe('graphql-guard: costLimitRule', () => { + const schema = buildSchema(` + type Node { name: String, child: Node, items: [Node] } + type Query { root: Node } + `); + + it('accepts a shallow, cheap query', () => { + const errs = validate(schema, parse('{ root { name } }'), [costLimitRule(3, 10)]); + expect(errs).toEqual([]); + }); + + it('rejects a query deeper than maxDepth', () => { + const q = '{ root { child { child { child { name } } } } }'; + const errs = validate(schema, parse(q), [costLimitRule(3, 100)]); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].message).toMatch(/maximum depth/); + }); + + it('rejects a query more complex than maxComplexity', () => { + const q = '{ root { name child { name items { name } } } }'; + const errs = validate(schema, parse(q), [costLimitRule(100, 3)]); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].message).toMatch(/maximum complexity/); + }); + + it('resolves fragments when measuring depth', () => { + const q = ` + { root { ...F } } + fragment F on Node { child { child { name } } } + `; + const errs = validate(schema, parse(q), [costLimitRule(3, 100)]); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].message).toMatch(/maximum depth/); + }); +}); + +describe('graphql-guard: buildPublicValidationRules', () => { + const OLD_VCAP = process.env.VCAP_APPLICATION; + beforeEach(() => { + h.flag = false; + h.throwFlag = false; + _resetCfg(); + delete process.env.VCAP_APPLICATION; + }); + afterEach(() => { + if (OLD_VCAP === undefined) delete process.env.VCAP_APPLICATION; + else process.env.VCAP_APPLICATION = OLD_VCAP; + }); + + it('returns the default rules unchanged when the flag is OFF', async () => { + const rules = await buildPublicValidationRules(null, null, specifiedRules); + expect(rules).toBe(specifiedRules); + }); + + it('adds the cost rule (but NOT the introspection block) when ON and not prod', async () => { + h.flag = true; + const rules = await buildPublicValidationRules(null, null, specifiedRules); + expect(rules.length).toBe(specifiedRules.length + 1); + expect(rules).not.toContain(NoSchemaIntrospectionCustomRule); + }); + + it('adds the introspection block in prod', async () => { + h.flag = true; + process.env.VCAP_APPLICATION = JSON.stringify({ space_name: 'prod' }); + const rules = await buildPublicValidationRules(null, null, specifiedRules); + expect(rules).toContain(NoSchemaIntrospectionCustomRule); + expect(rules.length).toBe(specifiedRules.length + 2); + }); + + it('fails open (returns default rules) when the flag check throws', async () => { + h.throwFlag = true; + const rules = await buildPublicValidationRules(null, null, specifiedRules); + expect(rules).toBe(specifiedRules); + }); +}); diff --git a/test/unit/mcp-channels-search.test.js b/test/unit/mcp-channels-search.test.js index 8cdc1a346..e95a4f01a 100644 --- a/test/unit/mcp-channels-search.test.js +++ b/test/unit/mcp-channels-search.test.js @@ -106,8 +106,11 @@ describe('search_channels', () => { it('clamps limit to [1,50]', async () => { expect(await call({ limit: 1 })).toHaveLength(1); - // limit=0 coerces to the default 20 (Number(0)||20); all 4 still fit. - expect((await call({ limit: 0 })).length).toBe(4); + // clampLimit: an explicit 0 clamps to the floor of 1 (only undefined/null + // fall back to the default). Canonical shared validator, PR3 #2270. + expect((await call({ limit: 0 })).length).toBe(1); + // omitted → default 20; all 4 rows fit. + expect((await call({})).length).toBe(4); }); it('returns the documented wire shape', async () => { diff --git a/test/unit/mcp-events-search.test.js b/test/unit/mcp-events-search.test.js index 5bcf51633..f80a1b2ba 100644 --- a/test/unit/mcp-events-search.test.js +++ b/test/unit/mcp-events-search.test.js @@ -93,8 +93,11 @@ describe('search_events', () => { it('clamps limit to [1,50]', async () => { expect(await call({ limit: 1 })).toHaveLength(1); - // limit=0 coerces to the default 20 (Number(0)||20); all upcoming still fit. - expect((await call({ limit: 0 })).length).toBe(4); + // clampLimit: an explicit 0 clamps to the floor of 1 (only undefined/null + // fall back to the default). Canonical shared validator, PR3 #2270. + expect((await call({ limit: 0 })).length).toBe(1); + // omitted → default 20; all upcoming rows fit. + expect((await call({})).length).toBe(4); }); it('returns the documented wire shape', async () => {