@resq-systems/security 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/controls/query.d.mts
CHANGED
|
@@ -112,9 +112,21 @@ interface GraphQLRequestAnalysis {
|
|
|
112
112
|
readonly operations: number;
|
|
113
113
|
/** The highest per-document complexity in the batch. */
|
|
114
114
|
readonly worst: QueryComplexity;
|
|
115
|
-
/** `true` when every limit is satisfied. */
|
|
115
|
+
/** `true` when every limit is satisfied *and* the body was fully readable. */
|
|
116
116
|
readonly withinLimits: boolean;
|
|
117
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Names of the limits exceeded; empty when `withinLimits`.
|
|
119
|
+
*
|
|
120
|
+
* Per-document names come from {@link QueryComplexity}: `depth`, `aliases`, `fields`,
|
|
121
|
+
* `length`. Batch-level names are `documents` and `operations`. Two more report that
|
|
122
|
+
* the body could not be read rather than that a bound was passed, and both mean the
|
|
123
|
+
* other counts are lower bounds rather than measurements:
|
|
124
|
+
*
|
|
125
|
+
* - `bodyDepth` — nesting exceeded the internal walk limit, so documents past it were
|
|
126
|
+
* never seen.
|
|
127
|
+
* - `malformedBody` — a `[`-prefixed string was not valid JSON, so a batch could not
|
|
128
|
+
* be read at all.
|
|
129
|
+
*/
|
|
118
130
|
readonly exceeded: readonly string[];
|
|
119
131
|
}
|
|
120
132
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.mts","names":[],"sources":["../../src/controls/query.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6EgB,sBAAsB;;UAgBrB;;WAEP;;WAEA;;WAEA;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyCM,uBACf,eACA,SAAQ,wBACN;;UAoFc,6BAA6B;;;;;;;WAOpC;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA,OAAO;;WAEP
|
|
1
|
+
{"version":3,"file":"query.d.mts","names":[],"sources":["../../src/controls/query.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6EgB,sBAAsB;;UAgBrB;;WAEP;;WAEA;;WAEA;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyCM,uBACf,eACA,SAAQ,wBACN;;UAoFc,6BAA6B;;;;;;;WAOpC;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA,OAAO;;WAEP;;;;;;;;;;;;;;WAcA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiNM,sBACf,eACA,SAAQ,uBACN"}
|
package/lib/controls/query.mjs
CHANGED
|
@@ -278,28 +278,44 @@ function countOperations(document) {
|
|
|
278
278
|
* `MAX_COUNTER_STACK`.
|
|
279
279
|
*/
|
|
280
280
|
const MAX_BODY_DEPTH = 8;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
281
|
+
function documentsFrom(body) {
|
|
282
|
+
const documents = [];
|
|
283
|
+
let tooDeep = false;
|
|
284
|
+
let malformed = false;
|
|
285
|
+
const walk = (value, depth) => {
|
|
286
|
+
if (depth > MAX_BODY_DEPTH) {
|
|
287
|
+
tooDeep = true;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (typeof value === "string") {
|
|
291
|
+
const trimmed = value.trim();
|
|
292
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
|
|
293
|
+
walk(JSON.parse(trimmed), depth + 1);
|
|
294
|
+
return;
|
|
295
|
+
} catch {
|
|
296
|
+
if (trimmed.startsWith("[")) {
|
|
297
|
+
malformed = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
documents.push(value);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(value)) {
|
|
305
|
+
for (const entry of value) walk(entry, depth + 1);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (typeof value === "object" && value !== null) {
|
|
309
|
+
const query = value.query;
|
|
310
|
+
if (typeof query === "string") documents.push(query);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
walk(body, 0);
|
|
314
|
+
return {
|
|
315
|
+
documents,
|
|
316
|
+
tooDeep,
|
|
317
|
+
malformed
|
|
318
|
+
};
|
|
303
319
|
}
|
|
304
320
|
/**
|
|
305
321
|
* Measure a whole GraphQL *request*, including batches.
|
|
@@ -340,7 +356,7 @@ function analyzeGraphQLRequest(body, limits = {}) {
|
|
|
340
356
|
...DEFAULT_REQUEST_LIMITS,
|
|
341
357
|
...limits
|
|
342
358
|
};
|
|
343
|
-
const documents = documentsFrom(body);
|
|
359
|
+
const { documents, tooDeep, malformed } = documentsFrom(body);
|
|
344
360
|
const measured = documents.map((document) => analyzeQueryComplexity(document, limits));
|
|
345
361
|
const operations = documents.reduce((total, document) => total + countOperations(document), 0);
|
|
346
362
|
const worst = measured.reduce((currentWorst, complexity) => complexity.exceeded.length > currentWorst.exceeded.length || complexity.fields > currentWorst.fields ? complexity : currentWorst, {
|
|
@@ -354,6 +370,8 @@ function analyzeGraphQLRequest(body, limits = {}) {
|
|
|
354
370
|
const exceeded = [...worst.exceeded];
|
|
355
371
|
if (documents.length > maxDocuments) exceeded.push("documents");
|
|
356
372
|
if (operations > maxOperations) exceeded.push("operations");
|
|
373
|
+
if (tooDeep) exceeded.push("bodyDepth");
|
|
374
|
+
if (malformed) exceeded.push("malformedBody");
|
|
357
375
|
return {
|
|
358
376
|
documents: documents.length,
|
|
359
377
|
operations,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.mjs","names":[],"sources":["../../src/controls/query.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Two query-surface controls that are decisions rather than signatures:\n * JSONP callback validation (WSTG-CLNT-13) and a computed query-complexity bound\n * (WSTG-APIT-01).\n *\n * Both are *computed*, not matched. Depth and alias count are structural properties and\n * a regex cannot count nesting — the same reason the engine's repeated-run check is a\n * linear scan rather than a backreference.\n *\n * @module @resq-systems/security/controls/query\n */\n\n//#region JSONP\n\n/**\n * A JavaScript identifier, optionally dotted into a namespace: `cb`, `app.render`,\n * `window.jsonp.handlers.onLoad`.\n *\n * Bounded at five segments of 64 characters. Nothing outside this shape can carry a\n * payload, because the response body is `<callback>(<json>)` and every metacharacter\n * needed to break out — quotes, parens, semicolons, angle brackets — is excluded.\n */\nconst JSONP_CALLBACK = /^[A-Za-z_$][\\w$]{0,63}(?:\\.[A-Za-z_$][\\w$]{0,63}){0,4}$/;\n\n/** Identifiers refused regardless of shape, because reflecting them assists an attacker. */\nconst JSONP_RESERVED = new Set([\n\t\"eval\",\n\t\"Function\",\n\t\"constructor\",\n\t\"__proto__\",\n\t\"prototype\",\n\t\"alert\",\n\t\"setTimeout\",\n\t\"setInterval\",\n\t\"import\",\n\t\"require\",\n]);\n\n/**\n * Validate a JSONP callback name.\n *\n * A JSONP response body is `<callback>(<json>)`, so the callback name is *concatenated\n * into executable JavaScript* — an allowlist is the only safe validation. Escaping is\n * not an option: there is no encoding of `alert(1);//` that is both harmless and still\n * callable.\n *\n * Prefer not shipping JSONP at all. It predates CORS, requires an endpoint that answers\n * `<script src>` with credentials attached, and is the mechanism behind cross-site\n * script inclusion.\n *\n * @param callback - The requested callback name.\n * @returns `true` when the name is a plain identifier or dotted namespace path and is\n * not reserved.\n *\n * @example\n * ```ts\n * validateJsonpCallback(\"app.render\"); // true\n * validateJsonpCallback(\"alert(1);//\"); // false\n * validateJsonpCallback(\"window.eval\"); // false — every segment is checked\n * ```\n */\nexport function validateJsonpCallback(callback: string): boolean {\n\tif (typeof callback !== \"string\" || callback.length === 0) return false;\n\tif (!JSONP_CALLBACK.test(callback)) return false;\n\n\t// Every segment is checked, so `window.eval` is refused as readily as `eval`.\n\tfor (const segment of callback.split(\".\")) {\n\t\tif (JSONP_RESERVED.has(segment)) return false;\n\t}\n\treturn true;\n}\n\n//#endregion\n\n//#region Query complexity\n\n/** Limits applied by {@link analyzeQueryComplexity}. */\nexport interface QueryComplexityLimits {\n\t/** Maximum nesting depth. Defaults to 10. */\n\treadonly maxDepth?: number;\n\t/** Maximum aliased fields. Defaults to 50. */\n\treadonly maxAliases?: number;\n\t/** Maximum selected fields overall. Defaults to 500. */\n\treadonly maxFields?: number;\n\t/** Maximum characters. Defaults to 20 000. */\n\treadonly maxLength?: number;\n}\n\n/** Measured shape of a query. */\nexport interface QueryComplexity {\n\t/** Deepest brace nesting reached. */\n\treadonly depth: number;\n\t/** Count of `alias: field` constructs. Approximate — see the function note. */\n\treadonly aliases: number;\n\t/** Approximate count of selected fields. */\n\treadonly fields: number;\n\t/** Character length. */\n\treadonly length: number;\n\t/** `true` when every limit is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the limits exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n/** Defaults chosen to sit well above ordinary application queries. */\nconst DEFAULT_LIMITS = {\n\tmaxDepth: 10,\n\tmaxAliases: 50,\n\tmaxFields: 500,\n\tmaxLength: 20_000,\n} as const satisfies Required<QueryComplexityLimits>;\n\n/** Word characters, for the field-token counter. */\nconst WORD_CHARACTER = /[A-Za-z0-9_]/;\n\n/**\n * Measure a GraphQL-shaped query against structural limits.\n *\n * One linear pass counting brace depth, aliases, and field tokens. This is a *bound*,\n * not a parser: it does not validate the document, resolve fragments, or account for\n * list multipliers, so a production GraphQL server should still run a cost-analysis\n * plugin. What it catches is the cheap denial-of-service shape — deeply nested\n * recursive selections and mass aliasing — before the query reaches a resolver.\n *\n * String literals and `#` comments are skipped, so a brace inside `\"{ }\"` cannot\n * inflate the depth. The alias count is approximate: argument colons inside parens are\n * also preceded by a word and are counted too.\n *\n * @param query - The query document.\n * @param limits - See {@link QueryComplexityLimits}.\n * @returns The measured {@link QueryComplexity}. Never throws.\n *\n * @example\n * ```ts\n * const complexity = analyzeQueryComplexity(req.body.query, { maxDepth: 8 });\n * if (!complexity.withinLimits) {\n * return new Response(`Query too complex: ${complexity.exceeded.join(\", \")}`, {\n * status: 400,\n * });\n * }\n * ```\n */\nexport function analyzeQueryComplexity(\n\tquery: string,\n\tlimits: QueryComplexityLimits = {},\n): QueryComplexity {\n\tconst { maxDepth, maxAliases, maxFields, maxLength } = { ...DEFAULT_LIMITS, ...limits };\n\n\tif (typeof query !== \"string\") {\n\t\treturn { depth: 0, aliases: 0, fields: 0, length: 0, withinLimits: true, exceeded: [] };\n\t}\n\n\tlet depth = 0;\n\tlet deepest = 0;\n\tlet aliases = 0;\n\tlet fields = 0;\n\tlet inString = false;\n\tlet inComment = false;\n\tlet previousWasWord = false;\n\n\tfor (let i = 0; i < query.length; i++) {\n\t\tconst char = query[i] as string;\n\n\t\tif (inComment) {\n\t\t\tif (char === \"\\n\") inComment = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (inString) {\n\t\t\tif (char === \"\\\\\") i++;\n\t\t\telse if (char === '\"') inString = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tinString = true;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"#\") {\n\t\t\tinComment = true;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"{\") {\n\t\t\tdepth++;\n\t\t\tif (depth > deepest) deepest = depth;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"}\") {\n\t\t\tif (depth > 0) depth--;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \":\") {\n\t\t\tif (previousWasWord) aliases++;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (WORD_CHARACTER.test(char)) {\n\t\t\tif (!previousWasWord) fields++;\n\t\t\tpreviousWasWord = true;\n\t\t\tcontinue;\n\t\t}\n\t\tpreviousWasWord = false;\n\t}\n\n\tconst exceeded: string[] = [];\n\tif (deepest > maxDepth) exceeded.push(\"depth\");\n\tif (aliases > maxAliases) exceeded.push(\"aliases\");\n\tif (fields > maxFields) exceeded.push(\"fields\");\n\tif (query.length > maxLength) exceeded.push(\"length\");\n\n\treturn {\n\t\tdepth: deepest,\n\t\taliases,\n\t\tfields,\n\t\tlength: query.length,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n\n//#region GraphQL requests\n\n/** Limits for {@link analyzeGraphQLRequest}. */\nexport interface GraphQLRequestLimits extends QueryComplexityLimits {\n\t/**\n\t * Top-level operations permitted across the whole request. Defaults to 10.\n\t *\n\t * The bound {@link analyzeQueryComplexity} cannot express, because it measures one\n\t * document and a batch is many.\n\t */\n\treadonly maxOperations?: number;\n\t/** Documents permitted in one batch. Defaults to 10. */\n\treadonly maxDocuments?: number;\n}\n\n/** Result of {@link analyzeGraphQLRequest}. */\nexport interface GraphQLRequestAnalysis {\n\t/** Documents found in the request. `1` for an ordinary single query. */\n\treadonly documents: number;\n\t/** Top-level operations summed across every document. */\n\treadonly operations: number;\n\t/** The highest per-document complexity in the batch. */\n\treadonly worst: QueryComplexity;\n\t/** `true` when every limit is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the limits exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n/** Default batch bounds, generous compared with real client behaviour. */\nconst DEFAULT_REQUEST_LIMITS = {\n\tmaxOperations: 10,\n\tmaxDocuments: 10,\n} as const satisfies Required<Pick<GraphQLRequestLimits, \"maxOperations\" | \"maxDocuments\">>;\n\n/** An operation keyword at the start of a definition. */\nconst OPERATION_KEYWORD = /\\b(?:query|mutation|subscription)\\b/g;\n\n/**\n * Reduce a document to the text outside braces, strings and comments.\n *\n * Operation keywords only mean anything at depth 0 — `query` inside a selection set is a\n * field name, not a second operation. Strings and `#` comments are dropped so neither can\n * contribute a brace or a keyword.\n *\n * Block strings are handled explicitly: treating `\"\"\"` as three single quotes flips the\n * in-string state an odd number of times and desynchronises the rest of the scan.\n *\n * @param document - A GraphQL document.\n * @returns The depth-0 text, and whether a selection set was opened at depth 0.\n */\nfunction topLevelText(document: string): { text: string; hasSelection: boolean } {\n\tlet text = \"\";\n\tlet depth = 0;\n\tlet hasSelection = false;\n\tlet index = 0;\n\n\twhile (index < document.length) {\n\t\tconst char = document[index];\n\n\t\tif (char === \"#\") {\n\t\t\twhile (index < document.length && document[index] !== \"\\n\") index++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (document.startsWith('\"\"\"', index)) {\n\t\t\tindex += 3;\n\t\t\twhile (index < document.length && !document.startsWith('\"\"\"', index)) index++;\n\t\t\tindex += 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tindex++;\n\t\t\twhile (index < document.length && document[index] !== '\"') {\n\t\t\t\tindex += document[index] === \"\\\\\" ? 2 : 1;\n\t\t\t}\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"{\") {\n\t\t\tif (depth === 0) hasSelection = true;\n\t\t\tdepth++;\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"}\") {\n\t\t\tdepth = Math.max(0, depth - 1);\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (depth === 0 && char !== undefined) text += char;\n\t\tindex++;\n\t}\n\n\treturn { text, hasSelection };\n}\n\n/**\n * Count the top-level operations in one document.\n *\n * @param document - A GraphQL document.\n * @returns The operation count. A document with no keyword but a depth-0 selection set\n * counts as one, because anonymous shorthand is an operation.\n */\nfunction countOperations(document: string): number {\n\tconst { text, hasSelection } = topLevelText(document);\n\tconst matches = text.match(OPERATION_KEYWORD)?.length ?? 0;\n\tif (matches > 0) return matches;\n\treturn hasSelection ? 1 : 0;\n}\n\n/**\n * Deepest body nesting traversed. A real batch is one array of operation objects, so\n * anything past this is a client sending nesting for its own sake.\n *\n * The bound is what keeps {@link analyzeGraphQLRequest}'s \"never throws\" contract honest:\n * a caller handing over `req.body` straight from a JSON body parser can pass `[[[[…]]]]`\n * nested tens of thousands deep, and an unbounded descent raises `RangeError: Maximum\n * call stack size exceeded` inside a function documented not to throw. The raw-text path\n * never had this exposure — its `JSON.parse` failure is caught below — so only the\n * already-parsed path was affected. Same unbounded-cost shape `payload.ts` caps with\n * `MAX_COUNTER_STACK`.\n */\nconst MAX_BODY_DEPTH = 8;\n\n/**\n * Extract the GraphQL documents from a request body of any accepted shape.\n *\n * @param body - Parsed body, or the raw JSON text.\n * @param depth - Current nesting level; callers use the default.\n * @returns Every `query` string found, in order.\n */\nfunction documentsFrom(body: unknown, depth = 0): string[] {\n\tif (depth > MAX_BODY_DEPTH) return [];\n\n\tif (typeof body === \"string\") {\n\t\tconst trimmed = body.trim();\n\t\tif (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n\t\t\ttry {\n\t\t\t\treturn documentsFrom(JSON.parse(trimmed) as unknown, depth + 1);\n\t\t\t} catch {\n\t\t\t\t// Not JSON after all — fall through and treat it as a bare document.\n\t\t\t}\n\t\t}\n\t\treturn [body];\n\t}\n\n\tif (Array.isArray(body)) return body.flatMap((entry) => documentsFrom(entry, depth + 1));\n\n\tif (typeof body === \"object\" && body !== null) {\n\t\tconst query = (body as { query?: unknown }).query;\n\t\treturn typeof query === \"string\" ? [query] : [];\n\t}\n\n\treturn [];\n}\n\n/**\n * Measure a whole GraphQL *request*, including batches.\n *\n * {@link analyzeQueryComplexity} measures one document, which leaves two ways past it,\n * both measured against this package:\n *\n * - The documented call, `analyzeQueryComplexity(req.body.query, …)`, reads `undefined`\n * when a client posts an **array** batch — so a 250-operation request measured\n * `{ depth: 0, fields: 0, withinLimits: true }`. A total pass that does not even trip\n * the length bound.\n * - Passing the raw body instead does not help: the scanner skips everything between\n * JSON quotes, so the same batch measured `fields: 0`.\n *\n * Alias batching *inside* one document is already bounded and needs nothing here — 300\n * aliased selections report `exceeded: [\"aliases\", \"fields\"]`.\n *\n * Accepts a parsed body (`{ query }`, or an array of them) or the raw JSON text, and\n * delegates per-document measurement to {@link analyzeQueryComplexity}, so existing\n * callers and limits keep their meaning.\n *\n * Still a bound rather than a parser: run a cost-analysis plugin in the server too.\n *\n * @param body - The request body, parsed or raw.\n * @param limits - Per-document limits, plus `maxOperations` and `maxDocuments`.\n * @returns The measured {@link GraphQLRequestAnalysis}. Never throws.\n *\n * @example\n * ```ts\n * const analysis = analyzeGraphQLRequest(req.body);\n * if (!analysis.withinLimits) {\n * return new Response(\"Request rejected: \" + analysis.exceeded.join(\", \"), { status: 400 });\n * }\n * ```\n */\nexport function analyzeGraphQLRequest(\n\tbody: unknown,\n\tlimits: GraphQLRequestLimits = {},\n): GraphQLRequestAnalysis {\n\tconst { maxOperations, maxDocuments } = { ...DEFAULT_REQUEST_LIMITS, ...limits };\n\n\tconst documents = documentsFrom(body);\n\tconst measured = documents.map((document) => analyzeQueryComplexity(document, limits));\n\tconst operations = documents.reduce((total, document) => total + countOperations(document), 0);\n\n\tconst empty: QueryComplexity = {\n\t\tdepth: 0,\n\t\taliases: 0,\n\t\tfields: 0,\n\t\tlength: 0,\n\t\twithinLimits: true,\n\t\texceeded: [],\n\t};\n\n\t// The worst document decides, so one oversized member of a batch cannot hide behind\n\t// the rest.\n\tconst worst = measured.reduce<QueryComplexity>(\n\t\t(currentWorst, complexity) =>\n\t\t\tcomplexity.exceeded.length > currentWorst.exceeded.length ||\n\t\t\tcomplexity.fields > currentWorst.fields\n\t\t\t\t? complexity\n\t\t\t\t: currentWorst,\n\t\tempty,\n\t);\n\n\tconst exceeded = [...worst.exceeded];\n\tif (documents.length > maxDocuments) exceeded.push(\"documents\");\n\tif (operations > maxOperations) exceeded.push(\"operations\");\n\n\treturn {\n\t\tdocuments: documents.length,\n\t\toperations,\n\t\tworst,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"query.mjs","names":[],"sources":["../../src/controls/query.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Two query-surface controls that are decisions rather than signatures:\n * JSONP callback validation (WSTG-CLNT-13) and a computed query-complexity bound\n * (WSTG-APIT-01).\n *\n * Both are *computed*, not matched. Depth and alias count are structural properties and\n * a regex cannot count nesting — the same reason the engine's repeated-run check is a\n * linear scan rather than a backreference.\n *\n * @module @resq-systems/security/controls/query\n */\n\n//#region JSONP\n\n/**\n * A JavaScript identifier, optionally dotted into a namespace: `cb`, `app.render`,\n * `window.jsonp.handlers.onLoad`.\n *\n * Bounded at five segments of 64 characters. Nothing outside this shape can carry a\n * payload, because the response body is `<callback>(<json>)` and every metacharacter\n * needed to break out — quotes, parens, semicolons, angle brackets — is excluded.\n */\nconst JSONP_CALLBACK = /^[A-Za-z_$][\\w$]{0,63}(?:\\.[A-Za-z_$][\\w$]{0,63}){0,4}$/;\n\n/** Identifiers refused regardless of shape, because reflecting them assists an attacker. */\nconst JSONP_RESERVED = new Set([\n\t\"eval\",\n\t\"Function\",\n\t\"constructor\",\n\t\"__proto__\",\n\t\"prototype\",\n\t\"alert\",\n\t\"setTimeout\",\n\t\"setInterval\",\n\t\"import\",\n\t\"require\",\n]);\n\n/**\n * Validate a JSONP callback name.\n *\n * A JSONP response body is `<callback>(<json>)`, so the callback name is *concatenated\n * into executable JavaScript* — an allowlist is the only safe validation. Escaping is\n * not an option: there is no encoding of `alert(1);//` that is both harmless and still\n * callable.\n *\n * Prefer not shipping JSONP at all. It predates CORS, requires an endpoint that answers\n * `<script src>` with credentials attached, and is the mechanism behind cross-site\n * script inclusion.\n *\n * @param callback - The requested callback name.\n * @returns `true` when the name is a plain identifier or dotted namespace path and is\n * not reserved.\n *\n * @example\n * ```ts\n * validateJsonpCallback(\"app.render\"); // true\n * validateJsonpCallback(\"alert(1);//\"); // false\n * validateJsonpCallback(\"window.eval\"); // false — every segment is checked\n * ```\n */\nexport function validateJsonpCallback(callback: string): boolean {\n\tif (typeof callback !== \"string\" || callback.length === 0) return false;\n\tif (!JSONP_CALLBACK.test(callback)) return false;\n\n\t// Every segment is checked, so `window.eval` is refused as readily as `eval`.\n\tfor (const segment of callback.split(\".\")) {\n\t\tif (JSONP_RESERVED.has(segment)) return false;\n\t}\n\treturn true;\n}\n\n//#endregion\n\n//#region Query complexity\n\n/** Limits applied by {@link analyzeQueryComplexity}. */\nexport interface QueryComplexityLimits {\n\t/** Maximum nesting depth. Defaults to 10. */\n\treadonly maxDepth?: number;\n\t/** Maximum aliased fields. Defaults to 50. */\n\treadonly maxAliases?: number;\n\t/** Maximum selected fields overall. Defaults to 500. */\n\treadonly maxFields?: number;\n\t/** Maximum characters. Defaults to 20 000. */\n\treadonly maxLength?: number;\n}\n\n/** Measured shape of a query. */\nexport interface QueryComplexity {\n\t/** Deepest brace nesting reached. */\n\treadonly depth: number;\n\t/** Count of `alias: field` constructs. Approximate — see the function note. */\n\treadonly aliases: number;\n\t/** Approximate count of selected fields. */\n\treadonly fields: number;\n\t/** Character length. */\n\treadonly length: number;\n\t/** `true` when every limit is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the limits exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n/** Defaults chosen to sit well above ordinary application queries. */\nconst DEFAULT_LIMITS = {\n\tmaxDepth: 10,\n\tmaxAliases: 50,\n\tmaxFields: 500,\n\tmaxLength: 20_000,\n} as const satisfies Required<QueryComplexityLimits>;\n\n/** Word characters, for the field-token counter. */\nconst WORD_CHARACTER = /[A-Za-z0-9_]/;\n\n/**\n * Measure a GraphQL-shaped query against structural limits.\n *\n * One linear pass counting brace depth, aliases, and field tokens. This is a *bound*,\n * not a parser: it does not validate the document, resolve fragments, or account for\n * list multipliers, so a production GraphQL server should still run a cost-analysis\n * plugin. What it catches is the cheap denial-of-service shape — deeply nested\n * recursive selections and mass aliasing — before the query reaches a resolver.\n *\n * String literals and `#` comments are skipped, so a brace inside `\"{ }\"` cannot\n * inflate the depth. The alias count is approximate: argument colons inside parens are\n * also preceded by a word and are counted too.\n *\n * @param query - The query document.\n * @param limits - See {@link QueryComplexityLimits}.\n * @returns The measured {@link QueryComplexity}. Never throws.\n *\n * @example\n * ```ts\n * const complexity = analyzeQueryComplexity(req.body.query, { maxDepth: 8 });\n * if (!complexity.withinLimits) {\n * return new Response(`Query too complex: ${complexity.exceeded.join(\", \")}`, {\n * status: 400,\n * });\n * }\n * ```\n */\nexport function analyzeQueryComplexity(\n\tquery: string,\n\tlimits: QueryComplexityLimits = {},\n): QueryComplexity {\n\tconst { maxDepth, maxAliases, maxFields, maxLength } = { ...DEFAULT_LIMITS, ...limits };\n\n\tif (typeof query !== \"string\") {\n\t\treturn { depth: 0, aliases: 0, fields: 0, length: 0, withinLimits: true, exceeded: [] };\n\t}\n\n\tlet depth = 0;\n\tlet deepest = 0;\n\tlet aliases = 0;\n\tlet fields = 0;\n\tlet inString = false;\n\tlet inComment = false;\n\tlet previousWasWord = false;\n\n\tfor (let i = 0; i < query.length; i++) {\n\t\tconst char = query[i] as string;\n\n\t\tif (inComment) {\n\t\t\tif (char === \"\\n\") inComment = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (inString) {\n\t\t\tif (char === \"\\\\\") i++;\n\t\t\telse if (char === '\"') inString = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tinString = true;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"#\") {\n\t\t\tinComment = true;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"{\") {\n\t\t\tdepth++;\n\t\t\tif (depth > deepest) deepest = depth;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"}\") {\n\t\t\tif (depth > 0) depth--;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \":\") {\n\t\t\tif (previousWasWord) aliases++;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (WORD_CHARACTER.test(char)) {\n\t\t\tif (!previousWasWord) fields++;\n\t\t\tpreviousWasWord = true;\n\t\t\tcontinue;\n\t\t}\n\t\tpreviousWasWord = false;\n\t}\n\n\tconst exceeded: string[] = [];\n\tif (deepest > maxDepth) exceeded.push(\"depth\");\n\tif (aliases > maxAliases) exceeded.push(\"aliases\");\n\tif (fields > maxFields) exceeded.push(\"fields\");\n\tif (query.length > maxLength) exceeded.push(\"length\");\n\n\treturn {\n\t\tdepth: deepest,\n\t\taliases,\n\t\tfields,\n\t\tlength: query.length,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n\n//#region GraphQL requests\n\n/** Limits for {@link analyzeGraphQLRequest}. */\nexport interface GraphQLRequestLimits extends QueryComplexityLimits {\n\t/**\n\t * Top-level operations permitted across the whole request. Defaults to 10.\n\t *\n\t * The bound {@link analyzeQueryComplexity} cannot express, because it measures one\n\t * document and a batch is many.\n\t */\n\treadonly maxOperations?: number;\n\t/** Documents permitted in one batch. Defaults to 10. */\n\treadonly maxDocuments?: number;\n}\n\n/** Result of {@link analyzeGraphQLRequest}. */\nexport interface GraphQLRequestAnalysis {\n\t/** Documents found in the request. `1` for an ordinary single query. */\n\treadonly documents: number;\n\t/** Top-level operations summed across every document. */\n\treadonly operations: number;\n\t/** The highest per-document complexity in the batch. */\n\treadonly worst: QueryComplexity;\n\t/** `true` when every limit is satisfied *and* the body was fully readable. */\n\treadonly withinLimits: boolean;\n\t/**\n\t * Names of the limits exceeded; empty when `withinLimits`.\n\t *\n\t * Per-document names come from {@link QueryComplexity}: `depth`, `aliases`, `fields`,\n\t * `length`. Batch-level names are `documents` and `operations`. Two more report that\n\t * the body could not be read rather than that a bound was passed, and both mean the\n\t * other counts are lower bounds rather than measurements:\n\t *\n\t * - `bodyDepth` — nesting exceeded the internal walk limit, so documents past it were\n\t * never seen.\n\t * - `malformedBody` — a `[`-prefixed string was not valid JSON, so a batch could not\n\t * be read at all.\n\t */\n\treadonly exceeded: readonly string[];\n}\n\n/** Default batch bounds, generous compared with real client behaviour. */\nconst DEFAULT_REQUEST_LIMITS = {\n\tmaxOperations: 10,\n\tmaxDocuments: 10,\n} as const satisfies Required<Pick<GraphQLRequestLimits, \"maxOperations\" | \"maxDocuments\">>;\n\n/** An operation keyword at the start of a definition. */\nconst OPERATION_KEYWORD = /\\b(?:query|mutation|subscription)\\b/g;\n\n/**\n * Reduce a document to the text outside braces, strings and comments.\n *\n * Operation keywords only mean anything at depth 0 — `query` inside a selection set is a\n * field name, not a second operation. Strings and `#` comments are dropped so neither can\n * contribute a brace or a keyword.\n *\n * Block strings are handled explicitly: treating `\"\"\"` as three single quotes flips the\n * in-string state an odd number of times and desynchronises the rest of the scan.\n *\n * @param document - A GraphQL document.\n * @returns The depth-0 text, and whether a selection set was opened at depth 0.\n */\nfunction topLevelText(document: string): { text: string; hasSelection: boolean } {\n\tlet text = \"\";\n\tlet depth = 0;\n\tlet hasSelection = false;\n\tlet index = 0;\n\n\twhile (index < document.length) {\n\t\tconst char = document[index];\n\n\t\tif (char === \"#\") {\n\t\t\twhile (index < document.length && document[index] !== \"\\n\") index++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (document.startsWith('\"\"\"', index)) {\n\t\t\tindex += 3;\n\t\t\twhile (index < document.length && !document.startsWith('\"\"\"', index)) index++;\n\t\t\tindex += 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tindex++;\n\t\t\twhile (index < document.length && document[index] !== '\"') {\n\t\t\t\tindex += document[index] === \"\\\\\" ? 2 : 1;\n\t\t\t}\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"{\") {\n\t\t\tif (depth === 0) hasSelection = true;\n\t\t\tdepth++;\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"}\") {\n\t\t\tdepth = Math.max(0, depth - 1);\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (depth === 0 && char !== undefined) text += char;\n\t\tindex++;\n\t}\n\n\treturn { text, hasSelection };\n}\n\n/**\n * Count the top-level operations in one document.\n *\n * @param document - A GraphQL document.\n * @returns The operation count. A document with no keyword but a depth-0 selection set\n * counts as one, because anonymous shorthand is an operation.\n */\nfunction countOperations(document: string): number {\n\tconst { text, hasSelection } = topLevelText(document);\n\tconst matches = text.match(OPERATION_KEYWORD)?.length ?? 0;\n\tif (matches > 0) return matches;\n\treturn hasSelection ? 1 : 0;\n}\n\n/**\n * Deepest body nesting traversed. A real batch is one array of operation objects, so\n * anything past this is a client sending nesting for its own sake.\n *\n * The bound is what keeps {@link analyzeGraphQLRequest}'s \"never throws\" contract honest:\n * a caller handing over `req.body` straight from a JSON body parser can pass `[[[[…]]]]`\n * nested tens of thousands deep, and an unbounded descent raises `RangeError: Maximum\n * call stack size exceeded` inside a function documented not to throw. The raw-text path\n * never had this exposure — its `JSON.parse` failure is caught below — so only the\n * already-parsed path was affected. Same unbounded-cost shape `payload.ts` caps with\n * `MAX_COUNTER_STACK`.\n */\nconst MAX_BODY_DEPTH = 8;\n\n/**\n * Extract the GraphQL documents from a request body of any accepted shape.\n *\n * Reports whether the walk completed, because both ways of stopping early —\n * {@link MAX_BODY_DEPTH}, and batch text that will not parse — otherwise return an\n * empty or short list that reads as an innocent request. Measured against this\n * package before the flags existed: 150 operations wrapped ten arrays deep extracted\n * `0` documents and satisfied every limit, and `\"[\".repeat(2500)` prefixed to a real\n * 150-operation batch extracted `1`. Both now surface as `exceeded` entries.\n *\n * @param body - Parsed body, or the raw JSON text.\n * @returns The documents found, plus the two early-stop flags.\n */\ninterface Extraction {\n\t/** Every `query` string found, in order. */\n\treadonly documents: readonly string[];\n\t/** Set when the walk hit {@link MAX_BODY_DEPTH} and stopped descending. */\n\treadonly tooDeep: boolean;\n\t/** Set when a `[`-prefixed string was not valid JSON, so a batch could not be read. */\n\treadonly malformed: boolean;\n}\n\nfunction documentsFrom(body: unknown): Extraction {\n\tconst documents: string[] = [];\n\tlet tooDeep = false;\n\tlet malformed = false;\n\n\t// A local walk rather than a recursive return, so accumulating a large batch stays\n\t// linear instead of re-copying the array at every level.\n\tconst walk = (value: unknown, depth: number): void => {\n\t\tif (depth > MAX_BODY_DEPTH) {\n\t\t\ttooDeep = true;\n\t\t\treturn;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\tconst trimmed = value.trim();\n\t\t\tif (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n\t\t\t\ttry {\n\t\t\t\t\twalk(JSON.parse(trimmed) as unknown, depth + 1);\n\t\t\t\t\treturn;\n\t\t\t\t} catch {\n\t\t\t\t\t// A `{`-prefixed string that is not JSON is ordinary anonymous-shorthand\n\t\t\t\t\t// GraphQL — `{ user { id } }` — so it falls through as a document. A\n\t\t\t\t\t// `[`-prefixed one cannot be: no GraphQL document starts with `[`, so this\n\t\t\t\t\t// is a batch we failed to read, and calling it \"one document\" undercounts\n\t\t\t\t\t// it to exactly the degree an attacker chooses.\n\t\t\t\t\tif (trimmed.startsWith(\"[\")) {\n\t\t\t\t\t\tmalformed = true;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tdocuments.push(value);\n\t\t\treturn;\n\t\t}\n\n\t\tif (Array.isArray(value)) {\n\t\t\tfor (const entry of value) walk(entry, depth + 1);\n\t\t\treturn;\n\t\t}\n\n\t\tif (typeof value === \"object\" && value !== null) {\n\t\t\tconst query = (value as { query?: unknown }).query;\n\t\t\tif (typeof query === \"string\") documents.push(query);\n\t\t}\n\t};\n\n\twalk(body, 0);\n\treturn { documents, tooDeep, malformed };\n}\n\n/**\n * Measure a whole GraphQL *request*, including batches.\n *\n * {@link analyzeQueryComplexity} measures one document, which leaves two ways past it,\n * both measured against this package:\n *\n * - The documented call, `analyzeQueryComplexity(req.body.query, …)`, reads `undefined`\n * when a client posts an **array** batch — so a 250-operation request measured\n * `{ depth: 0, fields: 0, withinLimits: true }`. A total pass that does not even trip\n * the length bound.\n * - Passing the raw body instead does not help: the scanner skips everything between\n * JSON quotes, so the same batch measured `fields: 0`.\n *\n * Alias batching *inside* one document is already bounded and needs nothing here — 300\n * aliased selections report `exceeded: [\"aliases\", \"fields\"]`.\n *\n * Accepts a parsed body (`{ query }`, or an array of them) or the raw JSON text, and\n * delegates per-document measurement to {@link analyzeQueryComplexity}, so existing\n * callers and limits keep their meaning.\n *\n * Still a bound rather than a parser: run a cost-analysis plugin in the server too.\n *\n * @param body - The request body, parsed or raw.\n * @param limits - Per-document limits, plus `maxOperations` and `maxDocuments`.\n * @returns The measured {@link GraphQLRequestAnalysis}. Never throws.\n *\n * @example\n * ```ts\n * const analysis = analyzeGraphQLRequest(req.body);\n * if (!analysis.withinLimits) {\n * return new Response(\"Request rejected: \" + analysis.exceeded.join(\", \"), { status: 400 });\n * }\n * ```\n */\nexport function analyzeGraphQLRequest(\n\tbody: unknown,\n\tlimits: GraphQLRequestLimits = {},\n): GraphQLRequestAnalysis {\n\tconst { maxOperations, maxDocuments } = { ...DEFAULT_REQUEST_LIMITS, ...limits };\n\n\tconst { documents, tooDeep, malformed } = documentsFrom(body);\n\tconst measured = documents.map((document) => analyzeQueryComplexity(document, limits));\n\tconst operations = documents.reduce((total, document) => total + countOperations(document), 0);\n\n\tconst empty: QueryComplexity = {\n\t\tdepth: 0,\n\t\taliases: 0,\n\t\tfields: 0,\n\t\tlength: 0,\n\t\twithinLimits: true,\n\t\texceeded: [],\n\t};\n\n\t// The worst document decides, so one oversized member of a batch cannot hide behind\n\t// the rest.\n\tconst worst = measured.reduce<QueryComplexity>(\n\t\t(currentWorst, complexity) =>\n\t\t\tcomplexity.exceeded.length > currentWorst.exceeded.length ||\n\t\t\tcomplexity.fields > currentWorst.fields\n\t\t\t\t? complexity\n\t\t\t\t: currentWorst,\n\t\tempty,\n\t);\n\n\tconst exceeded = [...worst.exceeded];\n\tif (documents.length > maxDocuments) exceeded.push(\"documents\");\n\tif (operations > maxOperations) exceeded.push(\"operations\");\n\t// Fail closed. Everything above measures what was extracted, and these two say the\n\t// extraction was incomplete — so the counts are lower bounds, not measurements, and\n\t// reporting `withinLimits: true` off them would be asserting something never checked.\n\tif (tooDeep) exceeded.push(\"bodyDepth\");\n\tif (malformed) exceeded.push(\"malformedBody\");\n\n\treturn {\n\t\tdocuments: documents.length,\n\t\toperations,\n\t\tworst,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@resq-systems/security",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Security utilities: encryption, input validation, schemas, and PII sanitization",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@total-typescript/ts-reset": "^0.6.1",
|
|
74
|
-
"@types/node": "^26.
|
|
75
|
-
"effect": "4.0.0-beta.
|
|
74
|
+
"@types/node": "^26.2.0",
|
|
75
|
+
"effect": "4.0.0-beta.107",
|
|
76
76
|
"jsdom": "^30.0.1",
|
|
77
77
|
"tsdown": "^0.22.14",
|
|
78
78
|
"typescript": "7.0.2",
|