Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion srv/graphql-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
);

Expand Down
7 changes: 7 additions & 0 deletions srv/lib/feature-flags/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
125 changes: 125 additions & 0 deletions srv/lib/graphql-guard.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
64 changes: 64 additions & 0 deletions srv/lib/input-validation/json-shape.js
Original file line number Diff line number Diff line change
@@ -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 };
}
158 changes: 158 additions & 0 deletions srv/lib/input-validation/register.js
Original file line number Diff line number Diff line change
@@ -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)`
);
}
Loading
Loading