@resq-systems/security 1.0.5 → 2.0.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/README.md +236 -33
- package/lib/controls/address.d.mts +142 -0
- package/lib/controls/address.d.mts.map +1 -0
- package/lib/controls/address.mjs +533 -0
- package/lib/controls/address.mjs.map +1 -0
- package/lib/controls/csrf.d.mts +91 -0
- package/lib/controls/csrf.d.mts.map +1 -0
- package/lib/controls/csrf.mjs +200 -0
- package/lib/controls/csrf.mjs.map +1 -0
- package/lib/controls/index.d.mts +8 -0
- package/lib/controls/index.mjs +8 -0
- package/lib/controls/origin.d.mts +95 -0
- package/lib/controls/origin.d.mts.map +1 -0
- package/lib/controls/origin.mjs +156 -0
- package/lib/controls/origin.mjs.map +1 -0
- package/lib/controls/payload.d.mts +84 -0
- package/lib/controls/payload.d.mts.map +1 -0
- package/lib/controls/payload.mjs +147 -0
- package/lib/controls/payload.mjs.map +1 -0
- package/lib/controls/query.d.mts +157 -0
- package/lib/controls/query.d.mts.map +1 -0
- package/lib/controls/query.mjs +368 -0
- package/lib/controls/query.mjs.map +1 -0
- package/lib/controls/redirect.d.mts +92 -0
- package/lib/controls/redirect.d.mts.map +1 -0
- package/lib/controls/redirect.mjs +110 -0
- package/lib/controls/redirect.mjs.map +1 -0
- package/lib/controls/upload.d.mts +108 -0
- package/lib/controls/upload.d.mts.map +1 -0
- package/lib/controls/upload.mjs +374 -0
- package/lib/controls/upload.mjs.map +1 -0
- package/lib/crypto.d.mts +18 -5
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +35 -24
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +51 -6
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +51 -6
- package/lib/hash.mjs.map +1 -1
- package/lib/index.d.mts +17 -2
- package/lib/index.mjs +19 -2
- package/lib/paths.d.mts +92 -0
- package/lib/paths.d.mts.map +1 -0
- package/lib/paths.mjs +140 -0
- package/lib/paths.mjs.map +1 -0
- package/lib/sanitize.d.mts +137 -35
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs +170 -46
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +59 -0
- package/lib/threats/capec.generated.d.mts.map +1 -0
- package/lib/threats/capec.generated.mjs +644 -0
- package/lib/threats/capec.generated.mjs.map +1 -0
- package/lib/threats/engine.d.mts +94 -0
- package/lib/threats/engine.d.mts.map +1 -0
- package/lib/threats/engine.mjs +167 -0
- package/lib/threats/engine.mjs.map +1 -0
- package/lib/threats/index.d.mts +11 -0
- package/lib/threats/index.mjs +11 -0
- package/lib/threats/rules/datastore.d.mts +13 -0
- package/lib/threats/rules/datastore.d.mts.map +1 -0
- package/lib/threats/rules/datastore.mjs +366 -0
- package/lib/threats/rules/datastore.mjs.map +1 -0
- package/lib/threats/rules/index.d.mts +54 -0
- package/lib/threats/rules/index.d.mts.map +1 -0
- package/lib/threats/rules/index.mjs +121 -0
- package/lib/threats/rules/index.mjs.map +1 -0
- package/lib/threats/rules/markup.d.mts +28 -0
- package/lib/threats/rules/markup.d.mts.map +1 -0
- package/lib/threats/rules/markup.mjs +373 -0
- package/lib/threats/rules/markup.mjs.map +1 -0
- package/lib/threats/rules/protocol.d.mts +49 -0
- package/lib/threats/rules/protocol.d.mts.map +1 -0
- package/lib/threats/rules/protocol.mjs +175 -0
- package/lib/threats/rules/protocol.mjs.map +1 -0
- package/lib/threats/rules/system.d.mts +19 -0
- package/lib/threats/rules/system.d.mts.map +1 -0
- package/lib/threats/rules/system.mjs +455 -0
- package/lib/threats/rules/system.mjs.map +1 -0
- package/lib/threats/rules/web.d.mts +26 -0
- package/lib/threats/rules/web.d.mts.map +1 -0
- package/lib/threats/rules/web.mjs +412 -0
- package/lib/threats/rules/web.mjs.map +1 -0
- package/lib/threats/scoring.d.mts +59 -0
- package/lib/threats/scoring.d.mts.map +1 -0
- package/lib/threats/scoring.mjs +111 -0
- package/lib/threats/scoring.mjs.map +1 -0
- package/lib/threats/types.d.mts +245 -0
- package/lib/threats/types.d.mts.map +1 -0
- package/lib/threats/types.mjs +52 -0
- package/lib/threats/types.mjs.map +1 -0
- package/lib/threats/variants.d.mts +57 -0
- package/lib/threats/variants.d.mts.map +1 -0
- package/lib/threats/variants.mjs +144 -0
- package/lib/threats/variants.mjs.map +1 -0
- package/lib/unicode/confusables.d.mts +82 -0
- package/lib/unicode/confusables.d.mts.map +1 -0
- package/lib/unicode/confusables.mjs +954 -0
- package/lib/unicode/confusables.mjs.map +1 -0
- package/lib/unicode/index.d.mts +126 -0
- package/lib/unicode/index.d.mts.map +1 -0
- package/lib/unicode/index.mjs +288 -0
- package/lib/unicode/index.mjs.map +1 -0
- package/lib/validators.d.mts +341 -164
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +519 -338
- package/lib/validators.mjs.map +1 -1
- package/package.json +35 -8
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//#region src/controls/payload.ts
|
|
2
|
+
/**
|
|
3
|
+
* Defaults sized from real payloads rather than from what looks tidy.
|
|
4
|
+
*
|
|
5
|
+
* An earlier draft used 20/1000/200/10k/1M and rejected four of five ordinary bodies: a
|
|
6
|
+
* 250-key dependency manifest, a 25-deep config tree, a 40 KB data-URI avatar and a
|
|
7
|
+
* 2000-row page. These are backstops against a payload built to exhaust memory, not a
|
|
8
|
+
* schema — a body that trips one of them is pathological rather than merely large.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_LIMITS = {
|
|
11
|
+
maxDepth: 100,
|
|
12
|
+
maxArrayLength: 1e4,
|
|
13
|
+
maxObjectKeys: 2e3,
|
|
14
|
+
maxStringLength: 1e6,
|
|
15
|
+
maxLength: 5e6
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Cap on the per-container counter stack.
|
|
19
|
+
*
|
|
20
|
+
* Without it, a payload nested 200 000 deep grows 200 000 entries of scanner state — the
|
|
21
|
+
* same unbounded allocation this function exists to prevent, moved one layer down. Depth
|
|
22
|
+
* is still counted past the cap; only per-container entry counts stop being tracked, and
|
|
23
|
+
* a payload that deep has already exceeded `maxDepth` by a wide margin.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_COUNTER_STACK = 512;
|
|
26
|
+
/**
|
|
27
|
+
* Measure a JSON payload's structure without parsing it.
|
|
28
|
+
*
|
|
29
|
+
* `JSON.parse` allocates the whole object graph before a caller can inspect anything, so
|
|
30
|
+
* a body designed to exhaust memory has already succeeded by the time validation runs.
|
|
31
|
+
* This is one linear pass over the *text*: it counts nesting, container sizes and string
|
|
32
|
+
* lengths, and never builds a value.
|
|
33
|
+
*
|
|
34
|
+
* Reporting rather than enforcing, deliberately. It returns what it measured and which
|
|
35
|
+
* bounds were exceeded; the caller decides. Schema validation remains the real control
|
|
36
|
+
* for shape — this only bounds the cost of getting there.
|
|
37
|
+
*
|
|
38
|
+
* Malformed JSON is not diagnosed. The scanner is a bracket counter, so an invalid or
|
|
39
|
+
* truncated payload yields whatever it measured before running out; use `JSON.parse` for
|
|
40
|
+
* validity, once this has bounded the cost.
|
|
41
|
+
*
|
|
42
|
+
* @param text - The raw JSON text, before parsing.
|
|
43
|
+
* @param limits - See {@link JsonPayloadLimits}.
|
|
44
|
+
* @returns The measured {@link JsonPayloadReport}. Never throws.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* const report = checkJsonPayloadLimits(await request.text());
|
|
49
|
+
* if (!report.withinLimits) {
|
|
50
|
+
* return new Response(`Payload rejected: ${report.exceeded.join(", ")}`, { status: 413 });
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
function checkJsonPayloadLimits(text, limits = {}) {
|
|
55
|
+
const { maxDepth, maxArrayLength, maxObjectKeys, maxStringLength, maxLength } = {
|
|
56
|
+
...DEFAULT_LIMITS,
|
|
57
|
+
...limits
|
|
58
|
+
};
|
|
59
|
+
if (typeof text !== "string") return {
|
|
60
|
+
depth: 0,
|
|
61
|
+
arrayLength: 0,
|
|
62
|
+
objectKeys: 0,
|
|
63
|
+
stringLength: 0,
|
|
64
|
+
length: 0,
|
|
65
|
+
withinLimits: true,
|
|
66
|
+
exceeded: []
|
|
67
|
+
};
|
|
68
|
+
let depth = 0;
|
|
69
|
+
let deepest = 0;
|
|
70
|
+
let arrayLength = 0;
|
|
71
|
+
let objectKeys = 0;
|
|
72
|
+
let stringLength = 0;
|
|
73
|
+
/** Entry counts for the containers currently open, innermost last. */
|
|
74
|
+
const counters = [];
|
|
75
|
+
let inString = false;
|
|
76
|
+
let escaped = false;
|
|
77
|
+
let stringStart = 0;
|
|
78
|
+
/** Whether the innermost container has seen content since the last comma. */
|
|
79
|
+
let sawContent = false;
|
|
80
|
+
for (let index = 0; index < text.length; index++) {
|
|
81
|
+
const char = text[index];
|
|
82
|
+
if (inString) {
|
|
83
|
+
if (escaped) escaped = false;
|
|
84
|
+
else if (char === "\\") escaped = true;
|
|
85
|
+
else if (char === "\"") {
|
|
86
|
+
inString = false;
|
|
87
|
+
const measured = index - stringStart;
|
|
88
|
+
if (measured > stringLength) stringLength = measured;
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === "\"") {
|
|
93
|
+
inString = true;
|
|
94
|
+
stringStart = index + 1;
|
|
95
|
+
sawContent = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (char === "{" || char === "[") {
|
|
99
|
+
depth++;
|
|
100
|
+
if (depth > deepest) deepest = depth;
|
|
101
|
+
if (counters.length < MAX_COUNTER_STACK) counters.push({
|
|
102
|
+
isArray: char === "[",
|
|
103
|
+
count: 0
|
|
104
|
+
});
|
|
105
|
+
sawContent = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (char === "}" || char === "]") {
|
|
109
|
+
const container = counters.pop();
|
|
110
|
+
if (container !== void 0) {
|
|
111
|
+
const entries = sawContent || container.count > 0 ? container.count + 1 : 0;
|
|
112
|
+
if (container.isArray) {
|
|
113
|
+
if (entries > arrayLength) arrayLength = entries;
|
|
114
|
+
} else if (entries > objectKeys) objectKeys = entries;
|
|
115
|
+
}
|
|
116
|
+
depth = Math.max(0, depth - 1);
|
|
117
|
+
sawContent = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (char === ",") {
|
|
121
|
+
const container = counters[counters.length - 1];
|
|
122
|
+
if (container !== void 0) container.count++;
|
|
123
|
+
sawContent = false;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (char !== void 0 && char !== ":" && char.trim().length > 0) sawContent = true;
|
|
127
|
+
}
|
|
128
|
+
const exceeded = [];
|
|
129
|
+
if (deepest > maxDepth) exceeded.push("depth");
|
|
130
|
+
if (arrayLength > maxArrayLength) exceeded.push("arrayLength");
|
|
131
|
+
if (objectKeys > maxObjectKeys) exceeded.push("objectKeys");
|
|
132
|
+
if (stringLength > maxStringLength) exceeded.push("stringLength");
|
|
133
|
+
if (text.length > maxLength) exceeded.push("length");
|
|
134
|
+
return {
|
|
135
|
+
depth: deepest,
|
|
136
|
+
arrayLength,
|
|
137
|
+
objectKeys,
|
|
138
|
+
stringLength,
|
|
139
|
+
length: text.length,
|
|
140
|
+
withinLimits: exceeded.length === 0,
|
|
141
|
+
exceeded
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
export { checkJsonPayloadLimits };
|
|
146
|
+
|
|
147
|
+
//# sourceMappingURL=payload.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"payload.mjs","names":[],"sources":["../../src/controls/payload.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 Structural bounds on a JSON payload, measured from the text before\n * anything parses it (OWASP API Security API4 — unrestricted resource consumption).\n *\n * @module @resq-systems/security/controls/payload\n */\n\n//#region Types\n\n/** Bounds for {@link checkJsonPayloadLimits}. */\nexport interface JsonPayloadLimits {\n\t/** Deepest container nesting. Defaults to 100. */\n\treadonly maxDepth?: number;\n\t/** Most entries in any single array. Defaults to 10 000. */\n\treadonly maxArrayLength?: number;\n\t/** Most keys in any single object. Defaults to 2 000. */\n\treadonly maxObjectKeys?: number;\n\t/** Longest single string value, in characters. Defaults to 1 000 000. */\n\treadonly maxStringLength?: number;\n\t/** Longest payload overall, in characters. Defaults to 5 000 000. */\n\treadonly maxLength?: number;\n}\n\n/** Result of {@link checkJsonPayloadLimits}. */\nexport interface JsonPayloadReport {\n\t/** Deepest container nesting reached. */\n\treadonly depth: number;\n\t/** Largest array seen, by entry count. */\n\treadonly arrayLength: number;\n\t/** Largest object seen, by key count. */\n\treadonly objectKeys: number;\n\t/** Longest string value seen. */\n\treadonly stringLength: number;\n\t/** Character length of the payload. */\n\treadonly length: number;\n\t/** `true` when every bound is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the bounds exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n//#endregion\n\n//#region Implementation\n\n/**\n * Defaults sized from real payloads rather than from what looks tidy.\n *\n * An earlier draft used 20/1000/200/10k/1M and rejected four of five ordinary bodies: a\n * 250-key dependency manifest, a 25-deep config tree, a 40 KB data-URI avatar and a\n * 2000-row page. These are backstops against a payload built to exhaust memory, not a\n * schema — a body that trips one of them is pathological rather than merely large.\n */\nconst DEFAULT_LIMITS = {\n\tmaxDepth: 100,\n\tmaxArrayLength: 10_000,\n\tmaxObjectKeys: 2_000,\n\tmaxStringLength: 1_000_000,\n\tmaxLength: 5_000_000,\n} as const satisfies Required<JsonPayloadLimits>;\n\n/**\n * Cap on the per-container counter stack.\n *\n * Without it, a payload nested 200 000 deep grows 200 000 entries of scanner state — the\n * same unbounded allocation this function exists to prevent, moved one layer down. Depth\n * is still counted past the cap; only per-container entry counts stop being tracked, and\n * a payload that deep has already exceeded `maxDepth` by a wide margin.\n */\nconst MAX_COUNTER_STACK = 512;\n\n/**\n * Measure a JSON payload's structure without parsing it.\n *\n * `JSON.parse` allocates the whole object graph before a caller can inspect anything, so\n * a body designed to exhaust memory has already succeeded by the time validation runs.\n * This is one linear pass over the *text*: it counts nesting, container sizes and string\n * lengths, and never builds a value.\n *\n * Reporting rather than enforcing, deliberately. It returns what it measured and which\n * bounds were exceeded; the caller decides. Schema validation remains the real control\n * for shape — this only bounds the cost of getting there.\n *\n * Malformed JSON is not diagnosed. The scanner is a bracket counter, so an invalid or\n * truncated payload yields whatever it measured before running out; use `JSON.parse` for\n * validity, once this has bounded the cost.\n *\n * @param text - The raw JSON text, before parsing.\n * @param limits - See {@link JsonPayloadLimits}.\n * @returns The measured {@link JsonPayloadReport}. Never throws.\n *\n * @example\n * ```ts\n * const report = checkJsonPayloadLimits(await request.text());\n * if (!report.withinLimits) {\n * return new Response(`Payload rejected: ${report.exceeded.join(\", \")}`, { status: 413 });\n * }\n * ```\n */\nexport function checkJsonPayloadLimits(\n\ttext: string,\n\tlimits: JsonPayloadLimits = {},\n): JsonPayloadReport {\n\tconst { maxDepth, maxArrayLength, maxObjectKeys, maxStringLength, maxLength } = {\n\t\t...DEFAULT_LIMITS,\n\t\t...limits,\n\t};\n\n\tif (typeof text !== \"string\") {\n\t\treturn {\n\t\t\tdepth: 0,\n\t\t\tarrayLength: 0,\n\t\t\tobjectKeys: 0,\n\t\t\tstringLength: 0,\n\t\t\tlength: 0,\n\t\t\twithinLimits: true,\n\t\t\texceeded: [],\n\t\t};\n\t}\n\n\tlet depth = 0;\n\tlet deepest = 0;\n\tlet arrayLength = 0;\n\tlet objectKeys = 0;\n\tlet stringLength = 0;\n\n\t/** Entry counts for the containers currently open, innermost last. */\n\tconst counters: { isArray: boolean; count: number }[] = [];\n\tlet inString = false;\n\tlet escaped = false;\n\tlet stringStart = 0;\n\t/** Whether the innermost container has seen content since the last comma. */\n\tlet sawContent = false;\n\n\tfor (let index = 0; index < text.length; index++) {\n\t\tconst char = text[index];\n\n\t\tif (inString) {\n\t\t\tif (escaped) {\n\t\t\t\tescaped = false;\n\t\t\t} else if (char === \"\\\\\") {\n\t\t\t\tescaped = true;\n\t\t\t} else if (char === '\"') {\n\t\t\t\tinString = false;\n\t\t\t\tconst measured = index - stringStart;\n\t\t\t\tif (measured > stringLength) stringLength = measured;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tinString = true;\n\t\t\tstringStart = index + 1;\n\t\t\tsawContent = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"{\" || char === \"[\") {\n\t\t\tdepth++;\n\t\t\tif (depth > deepest) deepest = depth;\n\t\t\tif (counters.length < MAX_COUNTER_STACK) {\n\t\t\t\tcounters.push({ isArray: char === \"[\", count: 0 });\n\t\t\t}\n\t\t\tsawContent = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"}\" || char === \"]\") {\n\t\t\tconst container = counters.pop();\n\t\t\tif (container !== undefined) {\n\t\t\t\t// A container holding any content has one more entry than it has commas.\n\t\t\t\tconst entries = sawContent || container.count > 0 ? container.count + 1 : 0;\n\t\t\t\tif (container.isArray) {\n\t\t\t\t\tif (entries > arrayLength) arrayLength = entries;\n\t\t\t\t} else if (entries > objectKeys) {\n\t\t\t\t\tobjectKeys = entries;\n\t\t\t\t}\n\t\t\t}\n\t\t\tdepth = Math.max(0, depth - 1);\n\t\t\tsawContent = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \",\") {\n\t\t\tconst container = counters[counters.length - 1];\n\t\t\tif (container !== undefined) container.count++;\n\t\t\tsawContent = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char !== undefined && char !== \":\" && char.trim().length > 0) sawContent = true;\n\t}\n\n\tconst exceeded: string[] = [];\n\tif (deepest > maxDepth) exceeded.push(\"depth\");\n\tif (arrayLength > maxArrayLength) exceeded.push(\"arrayLength\");\n\tif (objectKeys > maxObjectKeys) exceeded.push(\"objectKeys\");\n\tif (stringLength > maxStringLength) exceeded.push(\"stringLength\");\n\tif (text.length > maxLength) exceeded.push(\"length\");\n\n\treturn {\n\t\tdepth: deepest,\n\t\tarrayLength,\n\t\tobjectKeys,\n\t\tstringLength,\n\t\tlength: text.length,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;AAqEA,MAAM,iBAAiB;CACtB,UAAU;CACV,gBAAgB;CAChB,eAAe;CACf,iBAAiB;CACjB,WAAW;AACZ;;;;;;;;;AAUA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8B1B,SAAgB,uBACf,MACA,SAA4B,CAAC,GACT;CACpB,MAAM,EAAE,UAAU,gBAAgB,eAAe,iBAAiB,cAAc;EAC/E,GAAG;EACH,GAAG;CACJ;CAEA,IAAI,OAAO,SAAS,UACnB,OAAO;EACN,OAAO;EACP,aAAa;EACb,YAAY;EACZ,cAAc;EACd,QAAQ;EACR,cAAc;EACd,UAAU,CAAC;CACZ;CAGD,IAAI,QAAQ;CACZ,IAAI,UAAU;CACd,IAAI,cAAc;CAClB,IAAI,aAAa;CACjB,IAAI,eAAe;;CAGnB,MAAM,WAAkD,CAAC;CACzD,IAAI,WAAW;CACf,IAAI,UAAU;CACd,IAAI,cAAc;;CAElB,IAAI,aAAa;CAEjB,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EACjD,MAAM,OAAO,KAAK;EAElB,IAAI,UAAU;GACb,IAAI,SACH,UAAU;QACJ,IAAI,SAAS,MACnB,UAAU;QACJ,IAAI,SAAS,MAAK;IACxB,WAAW;IACX,MAAM,WAAW,QAAQ;IACzB,IAAI,WAAW,cAAc,eAAe;GAC7C;GACA;EACD;EAEA,IAAI,SAAS,MAAK;GACjB,WAAW;GACX,cAAc,QAAQ;GACtB,aAAa;GACb;EACD;EAEA,IAAI,SAAS,OAAO,SAAS,KAAK;GACjC;GACA,IAAI,QAAQ,SAAS,UAAU;GAC/B,IAAI,SAAS,SAAS,mBACrB,SAAS,KAAK;IAAE,SAAS,SAAS;IAAK,OAAO;GAAE,CAAC;GAElD,aAAa;GACb;EACD;EAEA,IAAI,SAAS,OAAO,SAAS,KAAK;GACjC,MAAM,YAAY,SAAS,IAAI;GAC/B,IAAI,cAAc,KAAA,GAAW;IAE5B,MAAM,UAAU,cAAc,UAAU,QAAQ,IAAI,UAAU,QAAQ,IAAI;IAC1E,IAAI,UAAU,SACT;SAAA,UAAU,aAAa,cAAc;IAAA,OACnC,IAAI,UAAU,YACpB,aAAa;GAEf;GACA,QAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC;GAC7B,aAAa;GACb;EACD;EAEA,IAAI,SAAS,KAAK;GACjB,MAAM,YAAY,SAAS,SAAS,SAAS;GAC7C,IAAI,cAAc,KAAA,GAAW,UAAU;GACvC,aAAa;GACb;EACD;EAEA,IAAI,SAAS,KAAA,KAAa,SAAS,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,GAAG,aAAa;CAChF;CAEA,MAAM,WAAqB,CAAC;CAC5B,IAAI,UAAU,UAAU,SAAS,KAAK,OAAO;CAC7C,IAAI,cAAc,gBAAgB,SAAS,KAAK,aAAa;CAC7D,IAAI,aAAa,eAAe,SAAS,KAAK,YAAY;CAC1D,IAAI,eAAe,iBAAiB,SAAS,KAAK,cAAc;CAChE,IAAI,KAAK,SAAS,WAAW,SAAS,KAAK,QAAQ;CAEnD,OAAO;EACN,OAAO;EACP;EACA;EACA;EACA,QAAQ,KAAK;EACb,cAAc,SAAS,WAAW;EAClC;CACD;AACD"}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//#region src/controls/query.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Copyright 2026 ResQ Systems, Inc.
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Validate a JSONP callback name.
|
|
19
|
+
*
|
|
20
|
+
* A JSONP response body is `<callback>(<json>)`, so the callback name is *concatenated
|
|
21
|
+
* into executable JavaScript* — an allowlist is the only safe validation. Escaping is
|
|
22
|
+
* not an option: there is no encoding of `alert(1);//` that is both harmless and still
|
|
23
|
+
* callable.
|
|
24
|
+
*
|
|
25
|
+
* Prefer not shipping JSONP at all. It predates CORS, requires an endpoint that answers
|
|
26
|
+
* `<script src>` with credentials attached, and is the mechanism behind cross-site
|
|
27
|
+
* script inclusion.
|
|
28
|
+
*
|
|
29
|
+
* @param callback - The requested callback name.
|
|
30
|
+
* @returns `true` when the name is a plain identifier or dotted namespace path and is
|
|
31
|
+
* not reserved.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* validateJsonpCallback("app.render"); // true
|
|
36
|
+
* validateJsonpCallback("alert(1);//"); // false
|
|
37
|
+
* validateJsonpCallback("window.eval"); // false — every segment is checked
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare function validateJsonpCallback(callback: string): boolean;
|
|
41
|
+
/** Limits applied by {@link analyzeQueryComplexity}. */
|
|
42
|
+
interface QueryComplexityLimits {
|
|
43
|
+
/** Maximum nesting depth. Defaults to 10. */
|
|
44
|
+
readonly maxDepth?: number;
|
|
45
|
+
/** Maximum aliased fields. Defaults to 50. */
|
|
46
|
+
readonly maxAliases?: number;
|
|
47
|
+
/** Maximum selected fields overall. Defaults to 500. */
|
|
48
|
+
readonly maxFields?: number;
|
|
49
|
+
/** Maximum characters. Defaults to 20 000. */
|
|
50
|
+
readonly maxLength?: number;
|
|
51
|
+
}
|
|
52
|
+
/** Measured shape of a query. */
|
|
53
|
+
interface QueryComplexity {
|
|
54
|
+
/** Deepest brace nesting reached. */
|
|
55
|
+
readonly depth: number;
|
|
56
|
+
/** Count of `alias: field` constructs. Approximate — see the function note. */
|
|
57
|
+
readonly aliases: number;
|
|
58
|
+
/** Approximate count of selected fields. */
|
|
59
|
+
readonly fields: number;
|
|
60
|
+
/** Character length. */
|
|
61
|
+
readonly length: number;
|
|
62
|
+
/** `true` when every limit is satisfied. */
|
|
63
|
+
readonly withinLimits: boolean;
|
|
64
|
+
/** Names of the limits exceeded; empty when `withinLimits`. */
|
|
65
|
+
readonly exceeded: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Measure a GraphQL-shaped query against structural limits.
|
|
69
|
+
*
|
|
70
|
+
* One linear pass counting brace depth, aliases, and field tokens. This is a *bound*,
|
|
71
|
+
* not a parser: it does not validate the document, resolve fragments, or account for
|
|
72
|
+
* list multipliers, so a production GraphQL server should still run a cost-analysis
|
|
73
|
+
* plugin. What it catches is the cheap denial-of-service shape — deeply nested
|
|
74
|
+
* recursive selections and mass aliasing — before the query reaches a resolver.
|
|
75
|
+
*
|
|
76
|
+
* String literals and `#` comments are skipped, so a brace inside `"{ }"` cannot
|
|
77
|
+
* inflate the depth. The alias count is approximate: argument colons inside parens are
|
|
78
|
+
* also preceded by a word and are counted too.
|
|
79
|
+
*
|
|
80
|
+
* @param query - The query document.
|
|
81
|
+
* @param limits - See {@link QueryComplexityLimits}.
|
|
82
|
+
* @returns The measured {@link QueryComplexity}. Never throws.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* const complexity = analyzeQueryComplexity(req.body.query, { maxDepth: 8 });
|
|
87
|
+
* if (!complexity.withinLimits) {
|
|
88
|
+
* return new Response(`Query too complex: ${complexity.exceeded.join(", ")}`, {
|
|
89
|
+
* status: 400,
|
|
90
|
+
* });
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
declare function analyzeQueryComplexity(query: string, limits?: QueryComplexityLimits): QueryComplexity;
|
|
95
|
+
/** Limits for {@link analyzeGraphQLRequest}. */
|
|
96
|
+
interface GraphQLRequestLimits extends QueryComplexityLimits {
|
|
97
|
+
/**
|
|
98
|
+
* Top-level operations permitted across the whole request. Defaults to 10.
|
|
99
|
+
*
|
|
100
|
+
* The bound {@link analyzeQueryComplexity} cannot express, because it measures one
|
|
101
|
+
* document and a batch is many.
|
|
102
|
+
*/
|
|
103
|
+
readonly maxOperations?: number;
|
|
104
|
+
/** Documents permitted in one batch. Defaults to 10. */
|
|
105
|
+
readonly maxDocuments?: number;
|
|
106
|
+
}
|
|
107
|
+
/** Result of {@link analyzeGraphQLRequest}. */
|
|
108
|
+
interface GraphQLRequestAnalysis {
|
|
109
|
+
/** Documents found in the request. `1` for an ordinary single query. */
|
|
110
|
+
readonly documents: number;
|
|
111
|
+
/** Top-level operations summed across every document. */
|
|
112
|
+
readonly operations: number;
|
|
113
|
+
/** The highest per-document complexity in the batch. */
|
|
114
|
+
readonly worst: QueryComplexity;
|
|
115
|
+
/** `true` when every limit is satisfied. */
|
|
116
|
+
readonly withinLimits: boolean;
|
|
117
|
+
/** Names of the limits exceeded; empty when `withinLimits`. */
|
|
118
|
+
readonly exceeded: readonly string[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Measure a whole GraphQL *request*, including batches.
|
|
122
|
+
*
|
|
123
|
+
* {@link analyzeQueryComplexity} measures one document, which leaves two ways past it,
|
|
124
|
+
* both measured against this package:
|
|
125
|
+
*
|
|
126
|
+
* - The documented call, `analyzeQueryComplexity(req.body.query, …)`, reads `undefined`
|
|
127
|
+
* when a client posts an **array** batch — so a 250-operation request measured
|
|
128
|
+
* `{ depth: 0, fields: 0, withinLimits: true }`. A total pass that does not even trip
|
|
129
|
+
* the length bound.
|
|
130
|
+
* - Passing the raw body instead does not help: the scanner skips everything between
|
|
131
|
+
* JSON quotes, so the same batch measured `fields: 0`.
|
|
132
|
+
*
|
|
133
|
+
* Alias batching *inside* one document is already bounded and needs nothing here — 300
|
|
134
|
+
* aliased selections report `exceeded: ["aliases", "fields"]`.
|
|
135
|
+
*
|
|
136
|
+
* Accepts a parsed body (`{ query }`, or an array of them) or the raw JSON text, and
|
|
137
|
+
* delegates per-document measurement to {@link analyzeQueryComplexity}, so existing
|
|
138
|
+
* callers and limits keep their meaning.
|
|
139
|
+
*
|
|
140
|
+
* Still a bound rather than a parser: run a cost-analysis plugin in the server too.
|
|
141
|
+
*
|
|
142
|
+
* @param body - The request body, parsed or raw.
|
|
143
|
+
* @param limits - Per-document limits, plus `maxOperations` and `maxDocuments`.
|
|
144
|
+
* @returns The measured {@link GraphQLRequestAnalysis}. Never throws.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const analysis = analyzeGraphQLRequest(req.body);
|
|
149
|
+
* if (!analysis.withinLimits) {
|
|
150
|
+
* return new Response("Request rejected: " + analysis.exceeded.join(", "), { status: 400 });
|
|
151
|
+
* }
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
declare function analyzeGraphQLRequest(body: unknown, limits?: GraphQLRequestLimits): GraphQLRequestAnalysis;
|
|
155
|
+
//#endregion
|
|
156
|
+
export { GraphQLRequestAnalysis, GraphQLRequestLimits, QueryComplexity, QueryComplexityLimits, analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback };
|
|
157
|
+
//# sourceMappingURL=query.d.mts.map
|
|
@@ -0,0 +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;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyKM,sBACf,eACA,SAAQ,uBACN"}
|