@resq-systems/security 2.1.0 → 2.1.1
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 +1 -1
- package/lib/controls/address.d.mts +8 -8
- package/lib/controls/address.d.mts.map +1 -1
- package/lib/controls/address.mjs.map +1 -1
- package/lib/controls/csrf.d.mts +7 -7
- package/lib/controls/csrf.d.mts.map +1 -1
- package/lib/controls/csrf.mjs +5 -2
- package/lib/controls/csrf.mjs.map +1 -1
- package/lib/controls/origin.d.mts +6 -6
- package/lib/controls/origin.d.mts.map +1 -1
- package/lib/controls/origin.mjs +1 -0
- package/lib/controls/origin.mjs.map +1 -1
- package/lib/controls/payload.d.mts +4 -4
- package/lib/controls/payload.d.mts.map +1 -1
- package/lib/controls/payload.mjs.map +1 -1
- package/lib/controls/query.d.mts +8 -8
- package/lib/controls/query.d.mts.map +1 -1
- package/lib/controls/query.mjs +1 -0
- package/lib/controls/query.mjs.map +1 -1
- package/lib/controls/redirect.d.mts +5 -5
- package/lib/controls/redirect.d.mts.map +1 -1
- package/lib/controls/redirect.mjs.map +1 -1
- package/lib/controls/upload.d.mts +7 -7
- package/lib/controls/upload.d.mts.map +1 -1
- package/lib/controls/upload.mjs.map +1 -1
- package/lib/crypto.d.mts +20 -21
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +3 -1
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +5 -5
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +1 -0
- package/lib/hash.mjs.map +1 -1
- package/lib/paths.d.mts +5 -5
- package/lib/paths.d.mts.map +1 -1
- package/lib/paths.mjs +1 -0
- package/lib/paths.mjs.map +1 -1
- package/lib/sanitize.d.mts +36 -37
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +4 -4
- package/lib/threats/capec.generated.d.mts.map +1 -1
- package/lib/threats/capec.generated.mjs.map +1 -1
- package/lib/threats/engine.d.mts +4 -5
- package/lib/threats/engine.d.mts.map +1 -1
- package/lib/threats/engine.mjs +1 -0
- package/lib/threats/engine.mjs.map +1 -1
- package/lib/threats/rules/datastore.d.mts +4 -5
- package/lib/threats/rules/datastore.d.mts.map +1 -1
- package/lib/threats/rules/datastore.mjs.map +1 -1
- package/lib/threats/rules/index.d.mts +5 -5
- package/lib/threats/rules/index.d.mts.map +1 -1
- package/lib/threats/rules/index.mjs.map +1 -1
- package/lib/threats/rules/markup.d.mts +4 -5
- package/lib/threats/rules/markup.d.mts.map +1 -1
- package/lib/threats/rules/markup.mjs +1 -0
- package/lib/threats/rules/markup.mjs.map +1 -1
- package/lib/threats/rules/protocol.d.mts +4 -5
- package/lib/threats/rules/protocol.d.mts.map +1 -1
- package/lib/threats/rules/protocol.mjs.map +1 -1
- package/lib/threats/rules/system.d.mts +4 -5
- package/lib/threats/rules/system.d.mts.map +1 -1
- package/lib/threats/rules/system.mjs.map +1 -1
- package/lib/threats/rules/web.d.mts +5 -6
- package/lib/threats/rules/web.d.mts.map +1 -1
- package/lib/threats/rules/web.mjs +1 -1
- package/lib/threats/rules/web.mjs.map +1 -1
- package/lib/threats/scoring.d.mts +5 -6
- package/lib/threats/scoring.d.mts.map +1 -1
- package/lib/threats/scoring.mjs +1 -0
- package/lib/threats/scoring.mjs.map +1 -1
- package/lib/threats/types.d.mts +18 -18
- package/lib/threats/types.d.mts.map +1 -1
- package/lib/threats/types.mjs.map +1 -1
- package/lib/threats/variants.d.mts +3 -4
- package/lib/threats/variants.d.mts.map +1 -1
- package/lib/threats/variants.mjs.map +1 -1
- package/lib/unicode/confusables.d.mts +5 -5
- package/lib/unicode/confusables.d.mts.map +1 -1
- package/lib/unicode/confusables.mjs +1 -0
- package/lib/unicode/confusables.mjs.map +1 -1
- package/lib/unicode/index.d.mts +11 -11
- package/lib/unicode/index.d.mts.map +1 -1
- package/lib/unicode/index.mjs +1 -0
- package/lib/unicode/index.mjs.map +1 -1
- package/lib/validators.d.mts +89 -39
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +285 -21
- package/lib/validators.mjs.map +1 -1
- package/package.json +7 -7
|
@@ -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 *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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"}
|
|
1
|
+
{"version":3,"file":"query.mjs","names":[],"sources":["../../src/controls/query.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//#region src/controls/redirect.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* Copyright 2026 ResQ Systems, Inc.
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
5
|
*
|
|
5
6
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
7
|
* you may not use this file except in compliance with the License.
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
* @module @resq-systems/security/controls/redirect
|
|
23
24
|
*/
|
|
24
25
|
/** Why a redirect target was refused. */
|
|
25
|
-
type RedirectRejectionReason =
|
|
26
|
+
export type RedirectRejectionReason =
|
|
26
27
|
/** Not a string, or empty after trimming. */
|
|
27
28
|
"malformed" |
|
|
28
29
|
/** Longer than the configured bound. */
|
|
@@ -36,7 +37,7 @@ type RedirectRejectionReason =
|
|
|
36
37
|
/** Absolute URL whose host is not allowlisted. */
|
|
37
38
|
"host_not_allowed";
|
|
38
39
|
/** Outcome of {@link resolveRedirectTarget}. */
|
|
39
|
-
type RedirectVerdict = {
|
|
40
|
+
export type RedirectVerdict = {
|
|
40
41
|
readonly allowed: true;
|
|
41
42
|
readonly target: string;
|
|
42
43
|
} | {
|
|
@@ -44,7 +45,7 @@ type RedirectVerdict = {
|
|
|
44
45
|
readonly reason: RedirectRejectionReason;
|
|
45
46
|
};
|
|
46
47
|
/** Policy for {@link resolveRedirectTarget}. */
|
|
47
|
-
interface RedirectPolicyOptions {
|
|
48
|
+
export interface RedirectPolicyOptions {
|
|
48
49
|
/**
|
|
49
50
|
* Hosts an absolute target may point at, compared case-insensitively against the
|
|
50
51
|
* parsed host. Omit to refuse every absolute URL — the safer default, and the right
|
|
@@ -86,7 +87,6 @@ interface RedirectPolicyOptions {
|
|
|
86
87
|
* // { allowed: true, target: "https://partner.example/sso" }
|
|
87
88
|
* ```
|
|
88
89
|
*/
|
|
89
|
-
declare function resolveRedirectTarget(target: string, options?: RedirectPolicyOptions): RedirectVerdict;
|
|
90
|
+
export declare function resolveRedirectTarget(target: string, options?: RedirectPolicyOptions): RedirectVerdict;
|
|
90
91
|
//#endregion
|
|
91
|
-
export { RedirectPolicyOptions, RedirectRejectionReason, RedirectVerdict, resolveRedirectTarget };
|
|
92
92
|
//# sourceMappingURL=redirect.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redirect.d.mts","names":[],"sources":["../../src/controls/redirect.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"redirect.d.mts","names":[],"sources":["../../src/controls/redirect.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;YA4BY;;;;;;;;;;;;;;YAeA;WACE;WAAwB;;WACxB;WAAyB,QAAQ;;;iBAG9B;;;;;;WAMP;;;;;WAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAkEM,sBACf,gBACA,UAAS,wBACP"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redirect.mjs","names":[],"sources":["../../src/controls/redirect.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 Allowlisted resolution of redirect and forward destinations\n * (CWE-601) — decides whether a caller-supplied `next` value may be placed in a\n * `Location` header.\n *\n * @module @resq-systems/security/controls/redirect\n */\n\n//#region Types\n\n/** Why a redirect target was refused. */\nexport type RedirectRejectionReason =\n\t/** Not a string, or empty after trimming. */\n\t| \"malformed\"\n\t/** Longer than the configured bound. */\n\t| \"too_long\"\n\t/** Contains a character that lets the target escape the origin. */\n\t| \"control_character\"\n\t/** Opens a network-path reference and would leave the site. */\n\t| \"opens_authority\"\n\t/** Absolute URL using a scheme other than http or https. */\n\t| \"unsupported_scheme\"\n\t/** Absolute URL whose host is not allowlisted. */\n\t| \"host_not_allowed\";\n\n/** Outcome of {@link resolveRedirectTarget}. */\nexport type RedirectVerdict =\n\t| { readonly allowed: true; readonly target: string }\n\t| { readonly allowed: false; readonly reason: RedirectRejectionReason };\n\n/** Policy for {@link resolveRedirectTarget}. */\nexport interface RedirectPolicyOptions {\n\t/**\n\t * Hosts an absolute target may point at, compared case-insensitively against the\n\t * parsed host. Omit to refuse every absolute URL — the safer default, and the right\n\t * one for a `next=` parameter.\n\t */\n\treadonly allowedHosts?: readonly string[];\n\t/**\n\t * Longest target accepted. Defaults to 2048: comfortably above any real return path,\n\t * and below the length at which proxies begin truncating a `Location` value.\n\t */\n\treadonly maxLength?: number;\n}\n\n//#endregion\n\n//#region Implementation\n\n/** Default bound on a redirect target. */\nconst DEFAULT_MAX_LENGTH = 2048;\n\n/**\n * Characters that must never appear in a redirect target.\n *\n * C0, C1, and the two Unicode line terminators. Tab, LF and CR are the load-bearing\n * members: the URL parser strips them *before* resolving, so a tab between the leading\n * slash and a host resolves to a network-path reference and leaves the site — while the\n * authority test below, which reads only the literal leading characters, sees an\n * ordinary path.\n *\n * Sweeping U+0000-U+3000 for targets shaped `/<cp>/host` finds exactly five code points\n * that escape the origin: tab, LF, CR, and the two slashes. The authority test catches\n * the slashes and none of the first three, and all three of those are control\n * characters. That is why this test runs first; reordering the two reopens the hole\n * silently.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are the bypass\nconst UNSAFE_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]/;\n\n/**\n * A network-path reference: two leading slashes, in either direction.\n *\n * Inlined rather than imported from `sanitize.ts`, which statically imports `effect` —\n * an optional peer. Importing it here would make the entire `./controls` subpath fail to\n * load for a consumer who never installed `effect`.\n */\nconst OPENS_AUTHORITY = /^[/\\\\]{2}/;\n\n/**\n * Decide whether a caller-supplied value may be used as a redirect destination.\n *\n * The classic post-login `?next=` bug (CWE-601): a value that looks like a path but\n * resolves to another origin, so the site itself delivers the victim to the attacker\n * with its own credibility attached.\n *\n * An **allowlist**, per the OWASP Unvalidated Redirects and Forwards cheat sheet. Two\n * things are accepted: a same-site path beginning with a single slash, and — only when\n * `allowedHosts` names the host — an absolute `http`/`https` URL. Everything else is\n * refused with a reason, including the schemes that never belong in a `Location` header.\n *\n * Percent-encoded control bytes are **accepted deliberately**: `%0d%0a` stays literal in\n * a `Location` value and does not split a header, so refusing it would reject ordinary\n * URLs whose paths carry encoded data.\n *\n * @param target - Untrusted destination, typically a query parameter.\n * @param options - Policy. Absolute URLs are refused unless `allowedHosts` names the host.\n * @returns A discriminated verdict: the trimmed target, or the reason it was refused.\n *\n * @example\n * ```ts\n * resolveRedirectTarget(\"/dashboard?tab=recent\");\n * // { allowed: true, target: \"/dashboard?tab=recent\" }\n *\n * resolveRedirectTarget(\"https://partner.example/sso\", { allowedHosts: [\"partner.example\"] });\n * // { allowed: true, target: \"https://partner.example/sso\" }\n * ```\n */\nexport function resolveRedirectTarget(\n\ttarget: string,\n\toptions: RedirectPolicyOptions = {},\n): RedirectVerdict {\n\tif (typeof target !== \"string\") return { allowed: false, reason: \"malformed\" };\n\n\t// Trimmed once, at entry. Leading whitespace otherwise survives the relative-path\n\t// test and reaches the absolute parse, where `new URL` trims it anyway — so a\n\t// space-prefixed network-path reference would be judged by a different branch than\n\t// the bare one.\n\tconst trimmed = target.trim();\n\tif (trimmed.length === 0) return { allowed: false, reason: \"malformed\" };\n\n\tconst maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;\n\tif (trimmed.length > maxLength) return { allowed: false, reason: \"too_long\" };\n\n\t// Order matters — see UNSAFE_CHARS. This must precede the authority test.\n\tif (UNSAFE_CHARS.test(trimmed)) return { allowed: false, reason: \"control_character\" };\n\n\tif (OPENS_AUTHORITY.test(trimmed)) return { allowed: false, reason: \"opens_authority\" };\n\n\t// A single leading slash is a same-site path, and the two tests above have already\n\t// ruled out everything that could make it resolve elsewhere.\n\tif (trimmed.startsWith(\"/\")) return { allowed: true, target: trimmed };\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(trimmed);\n\t} catch {\n\t\t// Neither absolute nor rooted — a bare relative path such as \"dashboard\", which\n\t\t// resolves against the current directory and cannot change origin.\n\t\treturn { allowed: true, target: trimmed };\n\t}\n\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn { allowed: false, reason: \"unsupported_scheme\" };\n\t}\n\n\t// Compared against the *parsed* host, so userinfo cannot disguise the destination:\n\t// a URL whose userinfo is a trusted name still parses with the attacker's host.\n\tconst allowedHosts = options.allowedHosts ?? [];\n\tconst host = parsed.host.toLowerCase();\n\tconst permitted = allowedHosts.some((candidate) => candidate.trim().toLowerCase() === host);\n\n\treturn permitted\n\t\t? { allowed: true, target: trimmed }\n\t\t: { allowed: false, reason: \"host_not_allowed\" };\n}\n\n//#endregion\n"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"redirect.mjs","names":[],"sources":["../../src/controls/redirect.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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 Allowlisted resolution of redirect and forward destinations\n * (CWE-601) — decides whether a caller-supplied `next` value may be placed in a\n * `Location` header.\n *\n * @module @resq-systems/security/controls/redirect\n */\n\n//#region Types\n\n/** Why a redirect target was refused. */\nexport type RedirectRejectionReason =\n\t/** Not a string, or empty after trimming. */\n\t| \"malformed\"\n\t/** Longer than the configured bound. */\n\t| \"too_long\"\n\t/** Contains a character that lets the target escape the origin. */\n\t| \"control_character\"\n\t/** Opens a network-path reference and would leave the site. */\n\t| \"opens_authority\"\n\t/** Absolute URL using a scheme other than http or https. */\n\t| \"unsupported_scheme\"\n\t/** Absolute URL whose host is not allowlisted. */\n\t| \"host_not_allowed\";\n\n/** Outcome of {@link resolveRedirectTarget}. */\nexport type RedirectVerdict =\n\t| { readonly allowed: true; readonly target: string }\n\t| { readonly allowed: false; readonly reason: RedirectRejectionReason };\n\n/** Policy for {@link resolveRedirectTarget}. */\nexport interface RedirectPolicyOptions {\n\t/**\n\t * Hosts an absolute target may point at, compared case-insensitively against the\n\t * parsed host. Omit to refuse every absolute URL — the safer default, and the right\n\t * one for a `next=` parameter.\n\t */\n\treadonly allowedHosts?: readonly string[];\n\t/**\n\t * Longest target accepted. Defaults to 2048: comfortably above any real return path,\n\t * and below the length at which proxies begin truncating a `Location` value.\n\t */\n\treadonly maxLength?: number;\n}\n\n//#endregion\n\n//#region Implementation\n\n/** Default bound on a redirect target. */\nconst DEFAULT_MAX_LENGTH = 2048;\n\n/**\n * Characters that must never appear in a redirect target.\n *\n * C0, C1, and the two Unicode line terminators. Tab, LF and CR are the load-bearing\n * members: the URL parser strips them *before* resolving, so a tab between the leading\n * slash and a host resolves to a network-path reference and leaves the site — while the\n * authority test below, which reads only the literal leading characters, sees an\n * ordinary path.\n *\n * Sweeping U+0000-U+3000 for targets shaped `/<cp>/host` finds exactly five code points\n * that escape the origin: tab, LF, CR, and the two slashes. The authority test catches\n * the slashes and none of the first three, and all three of those are control\n * characters. That is why this test runs first; reordering the two reopens the hole\n * silently.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are the bypass\nconst UNSAFE_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]/;\n\n/**\n * A network-path reference: two leading slashes, in either direction.\n *\n * Inlined rather than imported from `sanitize.ts`, which statically imports `effect` —\n * an optional peer. Importing it here would make the entire `./controls` subpath fail to\n * load for a consumer who never installed `effect`.\n */\nconst OPENS_AUTHORITY = /^[/\\\\]{2}/;\n\n/**\n * Decide whether a caller-supplied value may be used as a redirect destination.\n *\n * The classic post-login `?next=` bug (CWE-601): a value that looks like a path but\n * resolves to another origin, so the site itself delivers the victim to the attacker\n * with its own credibility attached.\n *\n * An **allowlist**, per the OWASP Unvalidated Redirects and Forwards cheat sheet. Two\n * things are accepted: a same-site path beginning with a single slash, and — only when\n * `allowedHosts` names the host — an absolute `http`/`https` URL. Everything else is\n * refused with a reason, including the schemes that never belong in a `Location` header.\n *\n * Percent-encoded control bytes are **accepted deliberately**: `%0d%0a` stays literal in\n * a `Location` value and does not split a header, so refusing it would reject ordinary\n * URLs whose paths carry encoded data.\n *\n * @param target - Untrusted destination, typically a query parameter.\n * @param options - Policy. Absolute URLs are refused unless `allowedHosts` names the host.\n * @returns A discriminated verdict: the trimmed target, or the reason it was refused.\n *\n * @example\n * ```ts\n * resolveRedirectTarget(\"/dashboard?tab=recent\");\n * // { allowed: true, target: \"/dashboard?tab=recent\" }\n *\n * resolveRedirectTarget(\"https://partner.example/sso\", { allowedHosts: [\"partner.example\"] });\n * // { allowed: true, target: \"https://partner.example/sso\" }\n * ```\n */\nexport function resolveRedirectTarget(\n\ttarget: string,\n\toptions: RedirectPolicyOptions = {},\n): RedirectVerdict {\n\tif (typeof target !== \"string\") return { allowed: false, reason: \"malformed\" };\n\n\t// Trimmed once, at entry. Leading whitespace otherwise survives the relative-path\n\t// test and reaches the absolute parse, where `new URL` trims it anyway — so a\n\t// space-prefixed network-path reference would be judged by a different branch than\n\t// the bare one.\n\tconst trimmed = target.trim();\n\tif (trimmed.length === 0) return { allowed: false, reason: \"malformed\" };\n\n\tconst maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;\n\tif (trimmed.length > maxLength) return { allowed: false, reason: \"too_long\" };\n\n\t// Order matters — see UNSAFE_CHARS. This must precede the authority test.\n\tif (UNSAFE_CHARS.test(trimmed)) return { allowed: false, reason: \"control_character\" };\n\n\tif (OPENS_AUTHORITY.test(trimmed)) return { allowed: false, reason: \"opens_authority\" };\n\n\t// A single leading slash is a same-site path, and the two tests above have already\n\t// ruled out everything that could make it resolve elsewhere.\n\tif (trimmed.startsWith(\"/\")) return { allowed: true, target: trimmed };\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(trimmed);\n\t} catch {\n\t\t// Neither absolute nor rooted — a bare relative path such as \"dashboard\", which\n\t\t// resolves against the current directory and cannot change origin.\n\t\treturn { allowed: true, target: trimmed };\n\t}\n\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn { allowed: false, reason: \"unsupported_scheme\" };\n\t}\n\n\t// Compared against the *parsed* host, so userinfo cannot disguise the destination:\n\t// a URL whose userinfo is a trusted name still parses with the attacker's host.\n\tconst allowedHosts = options.allowedHosts ?? [];\n\tconst host = parsed.host.toLowerCase();\n\tconst permitted = allowedHosts.some((candidate) => candidate.trim().toLowerCase() === host);\n\n\treturn permitted\n\t\t? { allowed: true, target: trimmed }\n\t\t: { allowed: false, reason: \"host_not_allowed\" };\n}\n\n//#endregion\n"],"mappings":";;AAmEA,MAAM,qBAAqB;;;;;;;;;;;;;;;;AAkB3B,MAAM,eAAe;;;;;;;;AASrB,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BxB,SAAgB,sBACf,QACA,UAAiC,CAAC,GAChB;CAClB,IAAI,OAAO,WAAW,UAAU,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAY;CAM7E,MAAM,UAAU,OAAO,KAAK;CAC5B,IAAI,QAAQ,WAAW,GAAG,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAY;CAEvE,MAAM,YAAY,QAAQ,aAAa;CACvC,IAAI,QAAQ,SAAS,WAAW,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAW;CAG5E,IAAI,aAAa,KAAK,OAAO,GAAG,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAoB;CAErF,IAAI,gBAAgB,KAAK,OAAO,GAAG,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAkB;CAItF,IAAI,QAAQ,WAAW,GAAG,GAAG,OAAO;EAAE,SAAS;EAAM,QAAQ;CAAQ;CAErE,IAAI;CACJ,IAAI;EACH,SAAS,IAAI,IAAI,OAAO;CACzB,QAAQ;EAGP,OAAO;GAAE,SAAS;GAAM,QAAQ;EAAQ;CACzC;CAEA,IAAI,OAAO,aAAa,WAAW,OAAO,aAAa,UACtD,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAqB;CAKvD,MAAM,eAAe,QAAQ,gBAAgB,CAAC;CAC9C,MAAM,OAAO,OAAO,KAAK,YAAY;CAGrC,OAFkB,aAAa,MAAM,cAAc,UAAU,KAAK,CAAC,CAAC,YAAY,MAAM,IAEvE,IACZ;EAAE,SAAS;EAAM,QAAQ;CAAQ,IACjC;EAAE,SAAS;EAAO,QAAQ;CAAmB;AACjD"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//#region src/controls/upload.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* Copyright 2026 ResQ Systems, Inc.
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
5
|
*
|
|
5
6
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
7
|
* you may not use this file except in compliance with the License.
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
* @module @resq-systems/security/controls/upload
|
|
36
37
|
*/
|
|
37
38
|
/** File types this module recognizes from leading bytes. */
|
|
38
|
-
type FileTypeName = "png" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "ico" | "pdf" | "zip" | "gzip" | "mp4" | "svg" | "text";
|
|
39
|
+
export type FileTypeName = "png" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "ico" | "pdf" | "zip" | "gzip" | "mp4" | "svg" | "text";
|
|
39
40
|
/**
|
|
40
41
|
* Identify a file from its leading bytes.
|
|
41
42
|
*
|
|
@@ -52,11 +53,11 @@ type FileTypeName = "png" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "ico" | "
|
|
|
52
53
|
* // "png"
|
|
53
54
|
* ```
|
|
54
55
|
*/
|
|
55
|
-
declare function detectFileSignature(headBytes: Uint8Array): FileTypeName | null;
|
|
56
|
+
export declare function detectFileSignature(headBytes: Uint8Array): FileTypeName | null;
|
|
56
57
|
/** Why an upload was refused. */
|
|
57
|
-
type UploadRejectionReason = "no_bytes" | "unrecognized_signature" | "type_not_allowed" | "extension_missing" | "extension_mismatch" | "declared_type_mismatch";
|
|
58
|
+
export type UploadRejectionReason = "no_bytes" | "unrecognized_signature" | "type_not_allowed" | "extension_missing" | "extension_mismatch" | "declared_type_mismatch";
|
|
58
59
|
/** Outcome of {@link assertUploadType}. */
|
|
59
|
-
type UploadVerdict = {
|
|
60
|
+
export type UploadVerdict = {
|
|
60
61
|
readonly ok: true;
|
|
61
62
|
readonly type: FileTypeName;
|
|
62
63
|
} | {
|
|
@@ -65,7 +66,7 @@ type UploadVerdict = {
|
|
|
65
66
|
readonly detail: string;
|
|
66
67
|
};
|
|
67
68
|
/** Input for {@link assertUploadType}. */
|
|
68
|
-
interface UploadCandidate {
|
|
69
|
+
export interface UploadCandidate {
|
|
69
70
|
/** `Content-Type` the client claimed. Parameters such as `; charset=` are ignored. */
|
|
70
71
|
readonly declaredType: string;
|
|
71
72
|
/** Filename the client supplied. */
|
|
@@ -102,7 +103,6 @@ interface UploadCandidate {
|
|
|
102
103
|
* const stored = `${crypto.randomUUID()}.${verdict.type}`;
|
|
103
104
|
* ```
|
|
104
105
|
*/
|
|
105
|
-
declare function assertUploadType(candidate: UploadCandidate): UploadVerdict;
|
|
106
|
+
export declare function assertUploadType(candidate: UploadCandidate): UploadVerdict;
|
|
106
107
|
//#endregion
|
|
107
|
-
export { FileTypeName, UploadCandidate, UploadRejectionReason, UploadVerdict, assertUploadType, detectFileSignature };
|
|
108
108
|
//# sourceMappingURL=upload.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.mts","names":[],"sources":["../../src/controls/upload.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"upload.d.mts","names":[],"sources":["../../src/controls/upload.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAyCY;;;;;;;;;;;;;;;;;wBAoLI,oBAAoB,WAAW,aAAa;;YAqChD;;YASA;WACE;WAAmB,MAAM;;WACzB;WAAoB,QAAQ;WAAgC;;;iBAGzD;;WAEP;;WAEA;;WAEA,WAAW;;WAEX,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA+CV,iBAAiB,WAAW,kBAAkB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.mjs","names":[],"sources":["../../src/controls/upload.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 Upload type agreement — the control for WSTG-BUSL-08 and BUSL-09.\n *\n * This deliberately is not a rule in the threat catalog, because it cannot be one at\n * any grade: the weakness is a *disagreement between three values* — the declared\n * `Content-Type`, the filename extension, and the actual leading bytes — and a rule\n * sees one string at a time. `shell.php.jpg` only becomes interesting once you know\n * the bytes are not a JPEG.\n *\n * The check is allowlist-shaped. A denylist of dangerous extensions is bypassed by the\n * next extension nobody listed; an allowlist of permitted types fails closed.\n *\n * **Still not sufficient alone.** A polyglot can carry a valid PNG header and PHP\n * source in its trailing bytes, and this reads only the head. The controls that hold\n * are: generate the stored filename yourself, store outside the webroot, serve from a\n * separate origin with a fixed `Content-Type` and `Content-Disposition: attachment`,\n * and re-encode images through a parser rather than storing raw bytes.\n *\n * @module @resq-systems/security/controls/upload\n */\n\n//#region Signature table\n\n/** File types this module recognizes from leading bytes. */\nexport type FileTypeName =\n\t| \"png\"\n\t| \"jpeg\"\n\t| \"gif\"\n\t| \"webp\"\n\t| \"bmp\"\n\t| \"tiff\"\n\t| \"ico\"\n\t| \"pdf\"\n\t| \"zip\"\n\t| \"gzip\"\n\t| \"mp4\"\n\t| \"svg\"\n\t| \"text\";\n\n/** One magic-byte signature. `offset` is where `bytes` must appear. */\ninterface FileSignature {\n\treadonly type: FileTypeName;\n\treadonly offset: number;\n\treadonly bytes: readonly number[];\n\t/** Further bytes required at a second offset, for container formats. */\n\treadonly also?: { readonly offset: number; readonly bytes: readonly number[] };\n\t/** Further bytes at a second offset, any one of which satisfies the signature. */\n\treadonly alsoAnyOf?: {\n\t\treadonly offset: number;\n\t\treadonly options: readonly (readonly number[])[];\n\t};\n}\n\n/** ASCII bytes for an ISO base media file format brand. */\nconst brand = (code: string): readonly number[] => [...code].map((c) => c.charCodeAt(0));\n\n/**\n * ISO-BMFF major brands accepted for the `mp4` family.\n *\n * The `ftyp` box marker sits at offset 4, so a signature that checks only those four\n * bytes constrains nothing at offset 0 — `<!--ftyp--><script>alert(1)</script>` was\n * detected as `mp4` and accepted as `clip.mp4`. Every other row in the table pins\n * offset 0; this one cannot, so it pins the brand at offset 8 instead.\n *\n * The list is deliberately generous: too narrow and legitimate video is rejected,\n * trading a fail-open for a fail-closed. `qt ` is required by the `.mov` and\n * `video/quicktime` entries already present in the extension and media-type tables.\n */\nconst MP4_BRANDS: readonly (readonly number[])[] = [\n\t\"isom\",\n\t\"iso2\",\n\t\"iso4\",\n\t\"iso5\",\n\t\"iso6\",\n\t\"mp41\",\n\t\"mp42\",\n\t\"mmp4\",\n\t\"avc1\",\n\t\"dash\",\n\t\"M4V \",\n\t\"M4A \",\n\t\"M4P \",\n\t\"M4B \",\n\t\"qt \",\n\t\"3gp4\",\n\t\"3gp5\",\n\t\"3g2a\",\n\t\"MSNV\",\n].map(brand);\n\n/**\n * Magic-byte signatures, most specific first.\n *\n * `zip` covers DOCX, XLSX, PPTX, ODT, JAR, and APK — every one is a ZIP container and\n * nothing in the leading bytes distinguishes them. A caller needing that distinction\n * must open the archive and inspect its manifest.\n */\nconst SIGNATURES: readonly FileSignature[] = [\n\t{ type: \"png\", offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },\n\t{ type: \"gif\", offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] },\n\t{ type: \"pdf\", offset: 0, bytes: [0x25, 0x50, 0x44, 0x46, 0x2d] },\n\t// RIFF....WEBP — the four size bytes between the markers are skipped.\n\t{\n\t\ttype: \"webp\",\n\t\toffset: 0,\n\t\tbytes: [0x52, 0x49, 0x46, 0x46],\n\t\talso: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },\n\t},\n\t{ type: \"jpeg\", offset: 0, bytes: [0xff, 0xd8, 0xff] },\n\t{ type: \"tiff\", offset: 0, bytes: [0x49, 0x49, 0x2a, 0x00] },\n\t{ type: \"tiff\", offset: 0, bytes: [0x4d, 0x4d, 0x00, 0x2a] },\n\t{ type: \"ico\", offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] },\n\t{ type: \"zip\", offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] },\n\t{ type: \"zip\", offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] },\n\t{ type: \"gzip\", offset: 0, bytes: [0x1f, 0x8b] },\n\t{ type: \"bmp\", offset: 0, bytes: [0x42, 0x4d] },\n\t// Last, and below every offset-0 row, so it can never shadow a fixed magic number.\n\t// The brand requirement at offset 8 is what stops arbitrary content from claiming\n\t// to be video on the strength of four bytes at offset 4.\n\t{\n\t\ttype: \"mp4\",\n\t\toffset: 4,\n\t\tbytes: [0x66, 0x74, 0x79, 0x70],\n\t\talsoAnyOf: { offset: 8, options: MP4_BRANDS },\n\t},\n];\n\n/** Extensions each recognized type may legitimately carry. */\nconst EXTENSIONS_BY_TYPE: Readonly<Record<FileTypeName, readonly string[]>> = {\n\tpng: [\"png\"],\n\tjpeg: [\"jpg\", \"jpeg\", \"jpe\"],\n\tgif: [\"gif\"],\n\twebp: [\"webp\"],\n\tbmp: [\"bmp\"],\n\ttiff: [\"tif\", \"tiff\"],\n\tico: [\"ico\"],\n\tpdf: [\"pdf\"],\n\tzip: [\"zip\", \"docx\", \"xlsx\", \"pptx\", \"odt\", \"ods\", \"odp\", \"epub\"],\n\tgzip: [\"gz\", \"tgz\"],\n\tmp4: [\"mp4\", \"m4v\", \"m4a\", \"mov\"],\n\tsvg: [\"svg\"],\n\ttext: [\"txt\", \"csv\", \"md\", \"log\", \"json\"],\n};\n\n/** Media types each recognized type may legitimately be declared as. */\nconst MEDIA_TYPES_BY_TYPE: Readonly<Record<FileTypeName, readonly string[]>> = {\n\tpng: [\"image/png\"],\n\tjpeg: [\"image/jpeg\", \"image/jpg\"],\n\tgif: [\"image/gif\"],\n\twebp: [\"image/webp\"],\n\tbmp: [\"image/bmp\", \"image/x-ms-bmp\"],\n\ttiff: [\"image/tiff\"],\n\tico: [\"image/x-icon\", \"image/vnd.microsoft.icon\"],\n\tpdf: [\"application/pdf\"],\n\tzip: [\n\t\t\"application/zip\",\n\t\t\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\t\t\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\t\t\"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\t\t\"application/vnd.oasis.opendocument.text\",\n\t\t\"application/epub+zip\",\n\t],\n\tgzip: [\"application/gzip\", \"application/x-gzip\"],\n\tmp4: [\"video/mp4\", \"audio/mp4\", \"video/quicktime\"],\n\tsvg: [\"image/svg+xml\"],\n\ttext: [\"text/plain\", \"text/csv\", \"text/markdown\", \"application/json\"],\n};\n\n//#endregion\n\n//#region Detection\n\n/** Leading bytes inspected when no binary signature matches, to classify text. */\nconst TEXT_SNIFF_LENGTH = 256;\n\n/** Opening of an SVG document, with or without an XML prolog or leading comments. */\nconst SVG_OPENING =\n\t/^\\s{0,64}(?:<\\?xml[^>]{0,512}\\?>\\s{0,64})?(?:<!--[^>]{0,512}-->\\s{0,64}){0,8}<svg\\b/i;\n\n/** True when `haystack` contains `bytes` starting at `offset`. */\nfunction matchesAt(haystack: Uint8Array, offset: number, bytes: readonly number[]): boolean {\n\tif (haystack.length < offset + bytes.length) return false;\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\tif (haystack[offset + i] !== bytes[i]) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Identify a file from its leading bytes.\n *\n * Binary signatures are checked first. If none matches and the bytes decode as UTF-8\n * with no control characters, the content is classified `svg` or `text`.\n *\n * @param headBytes - The file's leading bytes. 64 covers every binary signature here;\n * 256 or more improves text and SVG classification.\n * @returns The detected type, or `null` when nothing matches.\n *\n * @example\n * ```ts\n * detectFileSignature(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));\n * // \"png\"\n * ```\n */\nexport function detectFileSignature(headBytes: Uint8Array): FileTypeName | null {\n\tif (!(headBytes instanceof Uint8Array) || headBytes.length === 0) return null;\n\n\tfor (const signature of SIGNATURES) {\n\t\tif (!matchesAt(headBytes, signature.offset, signature.bytes)) continue;\n\t\tif (signature.also && !matchesAt(headBytes, signature.also.offset, signature.also.bytes)) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (signature.alsoAnyOf) {\n\t\t\tconst { offset, options } = signature.alsoAnyOf;\n\t\t\tif (!options.some((candidate) => matchesAt(headBytes, offset, candidate))) continue;\n\t\t}\n\t\treturn signature.type;\n\t}\n\n\tconst head = headBytes.subarray(0, TEXT_SNIFF_LENGTH);\n\tlet decoded: string;\n\ttry {\n\t\tdecoded = new TextDecoder(\"utf-8\", { fatal: true }).decode(head);\n\t} catch {\n\t\treturn null;\n\t}\n\n\t// A NUL or other C0 control (tab, CR, and LF excepted) means this is not text.\n\tfor (let i = 0; i < decoded.length; i++) {\n\t\tconst code = decoded.charCodeAt(i);\n\t\tif (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) return null;\n\t}\n\n\treturn SVG_OPENING.test(decoded) ? \"svg\" : \"text\";\n}\n\n//#endregion\n\n//#region Agreement check\n\n/** Why an upload was refused. */\nexport type UploadRejectionReason =\n\t| \"no_bytes\"\n\t| \"unrecognized_signature\"\n\t| \"type_not_allowed\"\n\t| \"extension_missing\"\n\t| \"extension_mismatch\"\n\t| \"declared_type_mismatch\";\n\n/** Outcome of {@link assertUploadType}. */\nexport type UploadVerdict =\n\t| { readonly ok: true; readonly type: FileTypeName }\n\t| { readonly ok: false; readonly reason: UploadRejectionReason; readonly detail: string };\n\n/** Input for {@link assertUploadType}. */\nexport interface UploadCandidate {\n\t/** `Content-Type` the client claimed. Parameters such as `; charset=` are ignored. */\n\treadonly declaredType: string;\n\t/** Filename the client supplied. */\n\treadonly filename: string;\n\t/** The file's leading bytes — at least 64, ideally 256 or more. */\n\treadonly headBytes: Uint8Array;\n\t/** Types permitted for this endpoint. An empty list refuses everything. */\n\treadonly allow: readonly FileTypeName[];\n}\n\n/** Lowercase extension without the dot, or `null` when the name carries none. */\nfunction extensionOf(filename: string): string | null {\n\tconst lastDot = filename.lastIndexOf(\".\");\n\tif (lastDot <= 0 || lastDot === filename.length - 1) return null;\n\treturn filename\n\t\t.slice(lastDot + 1)\n\t\t.toLowerCase()\n\t\t.trim();\n}\n\n/** Media type with parameters and casing stripped. */\nfunction mediaTypeOf(declaredType: string): string {\n\tif (typeof declaredType !== \"string\") return \"\";\n\tconst [base = \"\"] = declaredType.split(\";\");\n\treturn base.trim().toLowerCase();\n}\n\n/**\n * Require the declared type, the filename extension, and the actual bytes to agree on\n * one permitted file type.\n *\n * The bytes are authoritative — they are the only one of the three a client cannot\n * simply assert. The other two must be consistent with what the bytes actually are,\n * which is what refuses `shell.php.jpg` (bytes are PHP source, extension claims JPEG)\n * and an `avatar.jpg` declared as `application/x-httpd-php`.\n *\n * @param candidate - See {@link UploadCandidate}.\n * @returns `{ ok: true, type }`, or `{ ok: false, reason, detail }`. Never throws.\n *\n * @example\n * ```ts\n * const verdict = assertUploadType({\n * declaredType: file.type,\n * filename: file.name,\n * headBytes: new Uint8Array(await file.slice(0, 256).arrayBuffer()),\n * allow: [\"png\", \"jpeg\", \"webp\"],\n * });\n *\n * if (!verdict.ok) return new Response(`Rejected: ${verdict.reason}`, { status: 400 });\n *\n * // Generate the stored name yourself — never reuse the client's.\n * const stored = `${crypto.randomUUID()}.${verdict.type}`;\n * ```\n */\nexport function assertUploadType(candidate: UploadCandidate): UploadVerdict {\n\tconst { declaredType, filename, headBytes, allow } = candidate;\n\n\tif (!(headBytes instanceof Uint8Array) || headBytes.length === 0) {\n\t\treturn { ok: false, reason: \"no_bytes\", detail: \"headBytes was empty\" };\n\t}\n\n\tconst detected = detectFileSignature(headBytes);\n\tif (detected === null) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"unrecognized_signature\",\n\t\t\tdetail: \"leading bytes match no known file type\",\n\t\t};\n\t}\n\n\tif (!Array.isArray(allow) || !allow.includes(detected)) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"type_not_allowed\",\n\t\t\tdetail: `detected ${detected}, which is not in the allowlist`,\n\t\t};\n\t}\n\n\tconst extension = extensionOf(typeof filename === \"string\" ? filename : \"\");\n\tif (extension === null) {\n\t\treturn { ok: false, reason: \"extension_missing\", detail: \"filename carries no extension\" };\n\t}\n\n\t// The *last* extension is what a webserver dispatches on, so `shell.php.jpg` is\n\t// judged here as `jpg` and caught by the byte comparison instead, while\n\t// `avatar.jpg.php` is judged as `php` and caught right here.\n\tif (!EXTENSIONS_BY_TYPE[detected].includes(extension)) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"extension_mismatch\",\n\t\t\tdetail: `bytes are ${detected} but the extension is .${extension}`,\n\t\t};\n\t}\n\n\tconst media = mediaTypeOf(declaredType);\n\tif (media.length > 0 && !MEDIA_TYPES_BY_TYPE[detected].includes(media)) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"declared_type_mismatch\",\n\t\t\tdetail: `bytes are ${detected} but Content-Type claimed ${media}`,\n\t\t};\n\t}\n\n\treturn { ok: true, type: detected };\n}\n\n//#endregion\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"upload.mjs","names":[],"sources":["../../src/controls/upload.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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 Upload type agreement — the control for WSTG-BUSL-08 and BUSL-09.\n *\n * This deliberately is not a rule in the threat catalog, because it cannot be one at\n * any grade: the weakness is a *disagreement between three values* — the declared\n * `Content-Type`, the filename extension, and the actual leading bytes — and a rule\n * sees one string at a time. `shell.php.jpg` only becomes interesting once you know\n * the bytes are not a JPEG.\n *\n * The check is allowlist-shaped. A denylist of dangerous extensions is bypassed by the\n * next extension nobody listed; an allowlist of permitted types fails closed.\n *\n * **Still not sufficient alone.** A polyglot can carry a valid PNG header and PHP\n * source in its trailing bytes, and this reads only the head. The controls that hold\n * are: generate the stored filename yourself, store outside the webroot, serve from a\n * separate origin with a fixed `Content-Type` and `Content-Disposition: attachment`,\n * and re-encode images through a parser rather than storing raw bytes.\n *\n * @module @resq-systems/security/controls/upload\n */\n\n//#region Signature table\n\n/** File types this module recognizes from leading bytes. */\nexport type FileTypeName =\n\t| \"png\"\n\t| \"jpeg\"\n\t| \"gif\"\n\t| \"webp\"\n\t| \"bmp\"\n\t| \"tiff\"\n\t| \"ico\"\n\t| \"pdf\"\n\t| \"zip\"\n\t| \"gzip\"\n\t| \"mp4\"\n\t| \"svg\"\n\t| \"text\";\n\n/** One magic-byte signature. `offset` is where `bytes` must appear. */\ninterface FileSignature {\n\treadonly type: FileTypeName;\n\treadonly offset: number;\n\treadonly bytes: readonly number[];\n\t/** Further bytes required at a second offset, for container formats. */\n\treadonly also?: { readonly offset: number; readonly bytes: readonly number[] };\n\t/** Further bytes at a second offset, any one of which satisfies the signature. */\n\treadonly alsoAnyOf?: {\n\t\treadonly offset: number;\n\t\treadonly options: readonly (readonly number[])[];\n\t};\n}\n\n/** ASCII bytes for an ISO base media file format brand. */\nconst brand = (code: string): readonly number[] => [...code].map((c) => c.charCodeAt(0));\n\n/**\n * ISO-BMFF major brands accepted for the `mp4` family.\n *\n * The `ftyp` box marker sits at offset 4, so a signature that checks only those four\n * bytes constrains nothing at offset 0 — `<!--ftyp--><script>alert(1)</script>` was\n * detected as `mp4` and accepted as `clip.mp4`. Every other row in the table pins\n * offset 0; this one cannot, so it pins the brand at offset 8 instead.\n *\n * The list is deliberately generous: too narrow and legitimate video is rejected,\n * trading a fail-open for a fail-closed. `qt ` is required by the `.mov` and\n * `video/quicktime` entries already present in the extension and media-type tables.\n */\nconst MP4_BRANDS: readonly (readonly number[])[] = [\n\t\"isom\",\n\t\"iso2\",\n\t\"iso4\",\n\t\"iso5\",\n\t\"iso6\",\n\t\"mp41\",\n\t\"mp42\",\n\t\"mmp4\",\n\t\"avc1\",\n\t\"dash\",\n\t\"M4V \",\n\t\"M4A \",\n\t\"M4P \",\n\t\"M4B \",\n\t\"qt \",\n\t\"3gp4\",\n\t\"3gp5\",\n\t\"3g2a\",\n\t\"MSNV\",\n].map(brand);\n\n/**\n * Magic-byte signatures, most specific first.\n *\n * `zip` covers DOCX, XLSX, PPTX, ODT, JAR, and APK — every one is a ZIP container and\n * nothing in the leading bytes distinguishes them. A caller needing that distinction\n * must open the archive and inspect its manifest.\n */\nconst SIGNATURES: readonly FileSignature[] = [\n\t{ type: \"png\", offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },\n\t{ type: \"gif\", offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] },\n\t{ type: \"pdf\", offset: 0, bytes: [0x25, 0x50, 0x44, 0x46, 0x2d] },\n\t// RIFF....WEBP — the four size bytes between the markers are skipped.\n\t{\n\t\ttype: \"webp\",\n\t\toffset: 0,\n\t\tbytes: [0x52, 0x49, 0x46, 0x46],\n\t\talso: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },\n\t},\n\t{ type: \"jpeg\", offset: 0, bytes: [0xff, 0xd8, 0xff] },\n\t{ type: \"tiff\", offset: 0, bytes: [0x49, 0x49, 0x2a, 0x00] },\n\t{ type: \"tiff\", offset: 0, bytes: [0x4d, 0x4d, 0x00, 0x2a] },\n\t{ type: \"ico\", offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] },\n\t{ type: \"zip\", offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] },\n\t{ type: \"zip\", offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] },\n\t{ type: \"gzip\", offset: 0, bytes: [0x1f, 0x8b] },\n\t{ type: \"bmp\", offset: 0, bytes: [0x42, 0x4d] },\n\t// Last, and below every offset-0 row, so it can never shadow a fixed magic number.\n\t// The brand requirement at offset 8 is what stops arbitrary content from claiming\n\t// to be video on the strength of four bytes at offset 4.\n\t{\n\t\ttype: \"mp4\",\n\t\toffset: 4,\n\t\tbytes: [0x66, 0x74, 0x79, 0x70],\n\t\talsoAnyOf: { offset: 8, options: MP4_BRANDS },\n\t},\n];\n\n/** Extensions each recognized type may legitimately carry. */\nconst EXTENSIONS_BY_TYPE: Readonly<Record<FileTypeName, readonly string[]>> = {\n\tpng: [\"png\"],\n\tjpeg: [\"jpg\", \"jpeg\", \"jpe\"],\n\tgif: [\"gif\"],\n\twebp: [\"webp\"],\n\tbmp: [\"bmp\"],\n\ttiff: [\"tif\", \"tiff\"],\n\tico: [\"ico\"],\n\tpdf: [\"pdf\"],\n\tzip: [\"zip\", \"docx\", \"xlsx\", \"pptx\", \"odt\", \"ods\", \"odp\", \"epub\"],\n\tgzip: [\"gz\", \"tgz\"],\n\tmp4: [\"mp4\", \"m4v\", \"m4a\", \"mov\"],\n\tsvg: [\"svg\"],\n\ttext: [\"txt\", \"csv\", \"md\", \"log\", \"json\"],\n};\n\n/** Media types each recognized type may legitimately be declared as. */\nconst MEDIA_TYPES_BY_TYPE: Readonly<Record<FileTypeName, readonly string[]>> = {\n\tpng: [\"image/png\"],\n\tjpeg: [\"image/jpeg\", \"image/jpg\"],\n\tgif: [\"image/gif\"],\n\twebp: [\"image/webp\"],\n\tbmp: [\"image/bmp\", \"image/x-ms-bmp\"],\n\ttiff: [\"image/tiff\"],\n\tico: [\"image/x-icon\", \"image/vnd.microsoft.icon\"],\n\tpdf: [\"application/pdf\"],\n\tzip: [\n\t\t\"application/zip\",\n\t\t\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\t\t\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\t\t\"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\t\t\"application/vnd.oasis.opendocument.text\",\n\t\t\"application/epub+zip\",\n\t],\n\tgzip: [\"application/gzip\", \"application/x-gzip\"],\n\tmp4: [\"video/mp4\", \"audio/mp4\", \"video/quicktime\"],\n\tsvg: [\"image/svg+xml\"],\n\ttext: [\"text/plain\", \"text/csv\", \"text/markdown\", \"application/json\"],\n};\n\n//#endregion\n\n//#region Detection\n\n/** Leading bytes inspected when no binary signature matches, to classify text. */\nconst TEXT_SNIFF_LENGTH = 256;\n\n/** Opening of an SVG document, with or without an XML prolog or leading comments. */\nconst SVG_OPENING =\n\t/^\\s{0,64}(?:<\\?xml[^>]{0,512}\\?>\\s{0,64})?(?:<!--[^>]{0,512}-->\\s{0,64}){0,8}<svg\\b/i;\n\n/** True when `haystack` contains `bytes` starting at `offset`. */\nfunction matchesAt(haystack: Uint8Array, offset: number, bytes: readonly number[]): boolean {\n\tif (haystack.length < offset + bytes.length) return false;\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\tif (haystack[offset + i] !== bytes[i]) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Identify a file from its leading bytes.\n *\n * Binary signatures are checked first. If none matches and the bytes decode as UTF-8\n * with no control characters, the content is classified `svg` or `text`.\n *\n * @param headBytes - The file's leading bytes. 64 covers every binary signature here;\n * 256 or more improves text and SVG classification.\n * @returns The detected type, or `null` when nothing matches.\n *\n * @example\n * ```ts\n * detectFileSignature(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));\n * // \"png\"\n * ```\n */\nexport function detectFileSignature(headBytes: Uint8Array): FileTypeName | null {\n\tif (!(headBytes instanceof Uint8Array) || headBytes.length === 0) return null;\n\n\tfor (const signature of SIGNATURES) {\n\t\tif (!matchesAt(headBytes, signature.offset, signature.bytes)) continue;\n\t\tif (signature.also && !matchesAt(headBytes, signature.also.offset, signature.also.bytes)) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (signature.alsoAnyOf) {\n\t\t\tconst { offset, options } = signature.alsoAnyOf;\n\t\t\tif (!options.some((candidate) => matchesAt(headBytes, offset, candidate))) continue;\n\t\t}\n\t\treturn signature.type;\n\t}\n\n\tconst head = headBytes.subarray(0, TEXT_SNIFF_LENGTH);\n\tlet decoded: string;\n\ttry {\n\t\tdecoded = new TextDecoder(\"utf-8\", { fatal: true }).decode(head);\n\t} catch {\n\t\treturn null;\n\t}\n\n\t// A NUL or other C0 control (tab, CR, and LF excepted) means this is not text.\n\tfor (let i = 0; i < decoded.length; i++) {\n\t\tconst code = decoded.charCodeAt(i);\n\t\tif (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) return null;\n\t}\n\n\treturn SVG_OPENING.test(decoded) ? \"svg\" : \"text\";\n}\n\n//#endregion\n\n//#region Agreement check\n\n/** Why an upload was refused. */\nexport type UploadRejectionReason =\n\t| \"no_bytes\"\n\t| \"unrecognized_signature\"\n\t| \"type_not_allowed\"\n\t| \"extension_missing\"\n\t| \"extension_mismatch\"\n\t| \"declared_type_mismatch\";\n\n/** Outcome of {@link assertUploadType}. */\nexport type UploadVerdict =\n\t| { readonly ok: true; readonly type: FileTypeName }\n\t| { readonly ok: false; readonly reason: UploadRejectionReason; readonly detail: string };\n\n/** Input for {@link assertUploadType}. */\nexport interface UploadCandidate {\n\t/** `Content-Type` the client claimed. Parameters such as `; charset=` are ignored. */\n\treadonly declaredType: string;\n\t/** Filename the client supplied. */\n\treadonly filename: string;\n\t/** The file's leading bytes — at least 64, ideally 256 or more. */\n\treadonly headBytes: Uint8Array;\n\t/** Types permitted for this endpoint. An empty list refuses everything. */\n\treadonly allow: readonly FileTypeName[];\n}\n\n/** Lowercase extension without the dot, or `null` when the name carries none. */\nfunction extensionOf(filename: string): string | null {\n\tconst lastDot = filename.lastIndexOf(\".\");\n\tif (lastDot <= 0 || lastDot === filename.length - 1) return null;\n\treturn filename\n\t\t.slice(lastDot + 1)\n\t\t.toLowerCase()\n\t\t.trim();\n}\n\n/** Media type with parameters and casing stripped. */\nfunction mediaTypeOf(declaredType: string): string {\n\tif (typeof declaredType !== \"string\") return \"\";\n\tconst [base = \"\"] = declaredType.split(\";\");\n\treturn base.trim().toLowerCase();\n}\n\n/**\n * Require the declared type, the filename extension, and the actual bytes to agree on\n * one permitted file type.\n *\n * The bytes are authoritative — they are the only one of the three a client cannot\n * simply assert. The other two must be consistent with what the bytes actually are,\n * which is what refuses `shell.php.jpg` (bytes are PHP source, extension claims JPEG)\n * and an `avatar.jpg` declared as `application/x-httpd-php`.\n *\n * @param candidate - See {@link UploadCandidate}.\n * @returns `{ ok: true, type }`, or `{ ok: false, reason, detail }`. Never throws.\n *\n * @example\n * ```ts\n * const verdict = assertUploadType({\n * declaredType: file.type,\n * filename: file.name,\n * headBytes: new Uint8Array(await file.slice(0, 256).arrayBuffer()),\n * allow: [\"png\", \"jpeg\", \"webp\"],\n * });\n *\n * if (!verdict.ok) return new Response(`Rejected: ${verdict.reason}`, { status: 400 });\n *\n * // Generate the stored name yourself — never reuse the client's.\n * const stored = `${crypto.randomUUID()}.${verdict.type}`;\n * ```\n */\nexport function assertUploadType(candidate: UploadCandidate): UploadVerdict {\n\tconst { declaredType, filename, headBytes, allow } = candidate;\n\n\tif (!(headBytes instanceof Uint8Array) || headBytes.length === 0) {\n\t\treturn { ok: false, reason: \"no_bytes\", detail: \"headBytes was empty\" };\n\t}\n\n\tconst detected = detectFileSignature(headBytes);\n\tif (detected === null) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"unrecognized_signature\",\n\t\t\tdetail: \"leading bytes match no known file type\",\n\t\t};\n\t}\n\n\tif (!Array.isArray(allow) || !allow.includes(detected)) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"type_not_allowed\",\n\t\t\tdetail: `detected ${detected}, which is not in the allowlist`,\n\t\t};\n\t}\n\n\tconst extension = extensionOf(typeof filename === \"string\" ? filename : \"\");\n\tif (extension === null) {\n\t\treturn { ok: false, reason: \"extension_missing\", detail: \"filename carries no extension\" };\n\t}\n\n\t// The *last* extension is what a webserver dispatches on, so `shell.php.jpg` is\n\t// judged here as `jpg` and caught by the byte comparison instead, while\n\t// `avatar.jpg.php` is judged as `php` and caught right here.\n\tif (!EXTENSIONS_BY_TYPE[detected].includes(extension)) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"extension_mismatch\",\n\t\t\tdetail: `bytes are ${detected} but the extension is .${extension}`,\n\t\t};\n\t}\n\n\tconst media = mediaTypeOf(declaredType);\n\tif (media.length > 0 && !MEDIA_TYPES_BY_TYPE[detected].includes(media)) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: \"declared_type_mismatch\",\n\t\t\tdetail: `bytes are ${detected} but Content-Type claimed ${media}`,\n\t\t};\n\t}\n\n\treturn { ok: true, type: detected };\n}\n\n//#endregion\n"],"mappings":"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"}
|
package/lib/crypto.d.mts
CHANGED
|
@@ -6,35 +6,35 @@ import { Brand, PositiveInt } from "@resq-systems/types";
|
|
|
6
6
|
* should consume a value of this type; read one back from storage through
|
|
7
7
|
* {@link toCiphertext}.
|
|
8
8
|
*/
|
|
9
|
-
type Ciphertext = Brand<string, "Ciphertext">;
|
|
9
|
+
export type Ciphertext = Brand<string, "Ciphertext">;
|
|
10
10
|
/**
|
|
11
11
|
* A secret accepted by {@link encryptData}/{@link decryptData} as the
|
|
12
12
|
* scrypt password. Mint one at the boundary where the secret enters the
|
|
13
13
|
* process (typically from `process.env`) via {@link toEncryptionKey}.
|
|
14
14
|
*/
|
|
15
|
-
type EncryptionKey = Brand<string, "EncryptionKey">;
|
|
15
|
+
export type EncryptionKey = Brand<string, "EncryptionKey">;
|
|
16
16
|
/** Cryptographically random hex token minted by {@link generateSecureToken}. */
|
|
17
|
-
type SecureToken = Brand<string, "SecureToken">;
|
|
17
|
+
export type SecureToken = Brand<string, "SecureToken">;
|
|
18
18
|
/** Lowercase 64-char SHA-256 hex digest produced by {@link hashData}. */
|
|
19
|
-
type Sha256Hex = Brand<string, "Sha256Hex">;
|
|
19
|
+
export type Sha256Hex = Brand<string, "Sha256Hex">;
|
|
20
20
|
/** A PII string masked for safe logging by {@link maskPII}/{@link maskEmail}. */
|
|
21
|
-
type Masked = Brand<string, "Masked">;
|
|
21
|
+
export type Masked = Brand<string, "Masked">;
|
|
22
22
|
/** Type guard: `true` when `value` is a usable {@link EncryptionKey}. */
|
|
23
|
-
declare const isEncryptionKey: (value: string) => value is Brand<string, "EncryptionKey">;
|
|
23
|
+
export declare const isEncryptionKey: (value: string) => value is Brand<string, "EncryptionKey">;
|
|
24
24
|
/** Assert `value` is a non-empty secret and brand it, throwing otherwise. */
|
|
25
|
-
declare const toEncryptionKey: (value: string) => Brand<string, "EncryptionKey">;
|
|
25
|
+
export declare const toEncryptionKey: (value: string) => Brand<string, "EncryptionKey">;
|
|
26
26
|
/** Return `value` branded as an {@link EncryptionKey}, or `null` when empty. */
|
|
27
|
-
declare const coerceEncryptionKey: (value: string) => Brand<string, "EncryptionKey">;
|
|
27
|
+
export declare const coerceEncryptionKey: (value: string) => Brand<string, "EncryptionKey">;
|
|
28
28
|
/** Brand `value` as an {@link EncryptionKey} without checking. */
|
|
29
|
-
declare const unsafeEncryptionKey: (value: string) => Brand<string, "EncryptionKey">;
|
|
29
|
+
export declare const unsafeEncryptionKey: (value: string) => Brand<string, "EncryptionKey">;
|
|
30
30
|
/** Type guard: `true` when `value` is a well-formed {@link Ciphertext} envelope. */
|
|
31
|
-
declare const isCiphertext: (value: string) => value is Brand<string, "Ciphertext">;
|
|
31
|
+
export declare const isCiphertext: (value: string) => value is Brand<string, "Ciphertext">;
|
|
32
32
|
/** Assert `value` is a well-formed envelope and brand it, throwing otherwise. */
|
|
33
|
-
declare const toCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
33
|
+
export declare const toCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
34
34
|
/** Return `value` branded as a {@link Ciphertext}, or `null` when malformed. */
|
|
35
|
-
declare const coerceCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
35
|
+
export declare const coerceCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
36
36
|
/** Brand `value` as a {@link Ciphertext} without checking. */
|
|
37
|
-
declare const unsafeCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
37
|
+
export declare const unsafeCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
38
38
|
/**
|
|
39
39
|
* Encrypt a UTF-8 string with AES-256-GCM authenticated encryption.
|
|
40
40
|
*
|
|
@@ -71,7 +71,7 @@ declare const unsafeCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
|
71
71
|
* await db.users.update(id, { email: ct });
|
|
72
72
|
* ```
|
|
73
73
|
*/
|
|
74
|
-
declare function encryptData(plaintext: string, encryptionKey: EncryptionKey): Promise<Ciphertext>;
|
|
74
|
+
export declare function encryptData(plaintext: string, encryptionKey: EncryptionKey): Promise<Ciphertext>;
|
|
75
75
|
/**
|
|
76
76
|
* Reverse {@link encryptData}. Verifies the GCM authentication tag
|
|
77
77
|
* before returning plaintext — tampered ciphertexts throw.
|
|
@@ -95,7 +95,7 @@ declare function encryptData(plaintext: string, encryptionKey: EncryptionKey): P
|
|
|
95
95
|
* const plaintext = await decryptData(stored, process.env.PII_KEY!);
|
|
96
96
|
* ```
|
|
97
97
|
*/
|
|
98
|
-
declare function decryptData(encryptedData: Ciphertext, encryptionKey: EncryptionKey): Promise<string>;
|
|
98
|
+
export declare function decryptData(encryptedData: Ciphertext, encryptionKey: EncryptionKey): Promise<string>;
|
|
99
99
|
/**
|
|
100
100
|
* Compute a SHA-256 digest of a UTF-8 string and return it as lowercase
|
|
101
101
|
* hex.
|
|
@@ -113,7 +113,7 @@ declare function decryptData(encryptedData: Ciphertext, encryptionKey: Encryptio
|
|
|
113
113
|
* hashData("hello"); // → "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
114
114
|
* ```
|
|
115
115
|
*/
|
|
116
|
-
declare function hashData(data: string): Sha256Hex;
|
|
116
|
+
export declare function hashData(data: string): Sha256Hex;
|
|
117
117
|
/**
|
|
118
118
|
* Generate a cryptographically random hex token suitable for session
|
|
119
119
|
* IDs, password-reset tokens, CSRF tokens, and similar single-use
|
|
@@ -132,7 +132,7 @@ declare function hashData(data: string): Sha256Hex;
|
|
|
132
132
|
* generateSecureToken(toPositiveInt(16)); // 32-char hex (128-bit entropy)
|
|
133
133
|
* ```
|
|
134
134
|
*/
|
|
135
|
-
declare function generateSecureToken(length?: PositiveInt): SecureToken;
|
|
135
|
+
export declare function generateSecureToken(length?: PositiveInt): SecureToken;
|
|
136
136
|
/**
|
|
137
137
|
* Mask an arbitrary PII string for safe logging — keeps the first two
|
|
138
138
|
* and last two characters and replaces everything in between with
|
|
@@ -147,7 +147,7 @@ declare function generateSecureToken(length?: PositiveInt): SecureToken;
|
|
|
147
147
|
* maskPII("AB12"); // → "****"
|
|
148
148
|
* ```
|
|
149
149
|
*/
|
|
150
|
-
declare function maskPII(data: string): Masked;
|
|
150
|
+
export declare function maskPII(data: string): Masked;
|
|
151
151
|
/**
|
|
152
152
|
* Mask an email address while preserving the domain — useful for
|
|
153
153
|
* deduplication and support workflows where the domain is non-PII but
|
|
@@ -164,7 +164,7 @@ declare function maskPII(data: string): Masked;
|
|
|
164
164
|
* maskEmail("not-an-email"); // → "no********il" (maskPII fallback)
|
|
165
165
|
* ```
|
|
166
166
|
*/
|
|
167
|
-
declare function maskEmail(email: string): Masked;
|
|
167
|
+
export declare function maskEmail(email: string): Masked;
|
|
168
168
|
/**
|
|
169
169
|
* Recursively shallow-copy an object, replacing any field whose key
|
|
170
170
|
* contains a sensitive substring (case-insensitive) with `[REDACTED]`,
|
|
@@ -202,7 +202,6 @@ declare function maskEmail(email: string): Masked;
|
|
|
202
202
|
* // → { id: 1, email: "u@x.com" (masked), apiKey: "[REDACTED]", nested: { token: "[REDACTED]" } }
|
|
203
203
|
* ```
|
|
204
204
|
*/
|
|
205
|
-
declare function sanitizeForLogging(obj: Record<string, unknown>, sensitiveFields?: string[]): Record<string, unknown>;
|
|
205
|
+
export declare function sanitizeForLogging(obj: Record<string, unknown>, sensitiveFields?: string[]): Record<string, unknown>;
|
|
206
206
|
//#endregion
|
|
207
|
-
export { Ciphertext, EncryptionKey, Masked, SecureToken, Sha256Hex, coerceCiphertext, coerceEncryptionKey, decryptData, encryptData, generateSecureToken, hashData, isCiphertext, isEncryptionKey, maskEmail, maskPII, sanitizeForLogging, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey };
|
|
208
207
|
//# sourceMappingURL=crypto.d.mts.map
|
package/lib/crypto.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.mts","names":[],"sources":["../src/crypto.ts"],"mappings":";;;;;;;;
|
|
1
|
+
{"version":3,"file":"crypto.d.mts","names":[],"sources":["../src/crypto.ts"],"mappings":";;;;;;;;YA4DY,aAAa;;;;;;YAOb,gBAAgB;;YAGhB,cAAc;;YAGd,YAAY;;YAGZ,SAAS;;qBAiBR,kBAAe,kBAAA,SAAA;;qBAEf,kBAAe,kBAAA;;qBAEf,sBAAmB,kBAAA;;qBAEnB,sBAAmB,kBAAA;;qBAcnB,eAAY,kBAAA,SAAA;;qBAEZ,eAAY,kBAAA;;qBAEZ,mBAAgB,kBAAA;;qBAEhB,mBAAgB,kBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA0DP,YACrB,mBACA,eAAe,gBACb,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;wBAoCW,YACrB,eAAe,YACf,eAAe,gBACb;;;;;;;;;;;;;;;;;;wBAqCa,SAAS,eAAe;;;;;;;;;;;;;;;;;;;wBAsBxB,oBAAoB,SAAQ,cAAkC;;;;;;;;;;;;;;;wBAkB9D,QAAQ,eAAe;;;;;;;;;;;;;;;;;wBAyBvB,UAAU,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAiD1B,mBACf,KAAK,yBACL,6BAQE"}
|
package/lib/crypto.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { promisify } from "node:util";
|
|
|
4
4
|
//#region src/crypto.ts
|
|
5
5
|
/**
|
|
6
6
|
* Copyright 2026 ResQ Systems, Inc.
|
|
7
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
7
8
|
*
|
|
8
9
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
9
10
|
* you may not use this file except in compliance with the License.
|
|
@@ -245,7 +246,8 @@ function maskEmail(email) {
|
|
|
245
246
|
const local = parts[0];
|
|
246
247
|
const domain = parts[1];
|
|
247
248
|
if (!domain || !local) return maskPII(email);
|
|
248
|
-
|
|
249
|
+
const maskedLocal = local.length > 2 ? `${local[0]}${"*".repeat(local.length - 2)}${local[local.length - 1]}` : "**";
|
|
250
|
+
return unsafeBrand(`${maskedLocal}@${domain}`);
|
|
249
251
|
}
|
|
250
252
|
/**
|
|
251
253
|
* Recursively shallow-copy an object, replacing any field whose key
|
package/lib/crypto.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.mjs","names":[],"sources":["../src/crypto.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 Server-side cryptographic utilities — AES-256-GCM authenticated\n * encryption, SHA-256 hashing, secure token generation, and PII masking — guarded\n * by nominal branded types for keys, ciphertext, and masked output. Supports SOC 2,\n * ISO 27001, and NIST 800-53 SC-28 (data at rest) / SC-13 (cryptographic protection)\n * controls.\n *\n * @module @resq-systems/security/crypto\n */\n\nimport {\n\ttype Brand,\n\tbrandRefiner,\n\ttype PositiveInt,\n\ttoPositiveInt,\n\tunsafeBrand,\n} from \"@resq-systems/types\";\nimport { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\n//#region Constants\nconst scryptAsync = promisify(scrypt);\n\n/** AES-256-GCM encryption algorithm. */\nconst ALGORITHM = \"aes-256-gcm\";\n/** Initialization vector length in bytes. */\nconst IV_LENGTH = 16;\n/** Authentication tag length in bytes. */\nconst AUTH_TAG_LENGTH = 16;\n/** Salt length for key derivation, in bytes. */\nconst SALT_LENGTH = 32;\n/** Derived key length in bytes (256 bits for AES-256). */\nconst KEY_LENGTH = 32;\n//#endregion\n\n//#region Branded Types\n\n/**\n * Base64 AES-256-GCM payload produced by {@link encryptData} — the\n * `salt | iv | authTag | ciphertext` envelope. Only {@link decryptData}\n * should consume a value of this type; read one back from storage through\n * {@link toCiphertext}.\n */\nexport type Ciphertext = Brand<string, \"Ciphertext\">;\n\n/**\n * A secret accepted by {@link encryptData}/{@link decryptData} as the\n * scrypt password. Mint one at the boundary where the secret enters the\n * process (typically from `process.env`) via {@link toEncryptionKey}.\n */\nexport type EncryptionKey = Brand<string, \"EncryptionKey\">;\n\n/** Cryptographically random hex token minted by {@link generateSecureToken}. */\nexport type SecureToken = Brand<string, \"SecureToken\">;\n\n/** Lowercase 64-char SHA-256 hex digest produced by {@link hashData}. */\nexport type Sha256Hex = Brand<string, \"Sha256Hex\">;\n\n/** A PII string masked for safe logging by {@link maskPII}/{@link maskEmail}. */\nexport type Masked = Brand<string, \"Masked\">;\n\n/** Minimum decoded byte length of a well-formed {@link Ciphertext} envelope. */\nconst CIPHERTEXT_MIN_BYTES = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;\n\n/**\n * Smart constructors for {@link EncryptionKey}. The runtime check is a\n * non-empty string — scrypt stretches any non-empty secret into a 256-bit\n * key, so entropy is the caller's responsibility, but an empty key is\n * always a bug.\n */\nconst EncryptionKeyBrand = brandRefiner<string, \"EncryptionKey\">(\n\t(value) => value.length > 0,\n\t\"encryption key\",\n);\n\n/** Type guard: `true` when `value` is a usable {@link EncryptionKey}. */\nexport const isEncryptionKey = EncryptionKeyBrand.is;\n/** Assert `value` is a non-empty secret and brand it, throwing otherwise. */\nexport const toEncryptionKey = EncryptionKeyBrand.from;\n/** Return `value` branded as an {@link EncryptionKey}, or `null` when empty. */\nexport const coerceEncryptionKey = EncryptionKeyBrand.coerce;\n/** Brand `value` as an {@link EncryptionKey} without checking. */\nexport const unsafeEncryptionKey = EncryptionKeyBrand.unsafe;\n\n/**\n * Smart constructors for {@link Ciphertext}. The runtime check verifies the\n * value base64-decodes to at least the fixed envelope header size — enough\n * to reject truncated or non-base64 input before it reaches\n * {@link decryptData}.\n */\nconst CiphertextBrand = brandRefiner<string, \"Ciphertext\">(\n\t(value) => value.length > 0 && Buffer.from(value, \"base64\").length >= CIPHERTEXT_MIN_BYTES,\n\t\"ciphertext\",\n);\n\n/** Type guard: `true` when `value` is a well-formed {@link Ciphertext} envelope. */\nexport const isCiphertext = CiphertextBrand.is;\n/** Assert `value` is a well-formed envelope and brand it, throwing otherwise. */\nexport const toCiphertext = CiphertextBrand.from;\n/** Return `value` branded as a {@link Ciphertext}, or `null` when malformed. */\nexport const coerceCiphertext = CiphertextBrand.coerce;\n/** Brand `value` as a {@link Ciphertext} without checking. */\nexport const unsafeCiphertext = CiphertextBrand.unsafe;\n//#endregion\n\n//#region Internal\n\n/**\n * Derive a 32-byte (AES-256) key from a password and per-record salt\n * using scrypt with Node's default cost parameters.\n *\n * Internal helper — used inside the encrypt/decrypt round-trip because\n * the salt must travel alongside the ciphertext for decryption to\n * succeed.\n *\n * @internal\n */\nasync function deriveKey(password: string, salt: Buffer): Promise<Buffer> {\n\treturn (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;\n}\n//#endregion\n\n//#region Public API\n\n/**\n * Encrypt a UTF-8 string with AES-256-GCM authenticated encryption.\n *\n * Each call generates a fresh random salt and IV — the same plaintext\n * encrypted twice with the same `encryptionKey` produces different\n * ciphertexts, which is the property you want for at-rest encryption.\n *\n * Output layout (base64-encoded): `salt(32) | iv(16) | authTag(16) | ciphertext(*)`.\n * The companion {@link decryptData} understands this layout.\n *\n * @param plaintext - UTF-8 string to encrypt.\n * @param encryptionKey - Caller-supplied secret. Treated as a password\n * and stretched into a 256-bit AES key via scrypt; can be any length,\n * though a high-entropy secret (≥ 32 bytes) is strongly preferred.\n *\n * @returns A self-contained base64 string. Store or transmit verbatim;\n * the salt/IV are recovered on decryption.\n *\n * @throws From the underlying Node crypto primitives if `encryptionKey`\n * is empty or scrypt fails. Failure surfaces as a rejected `Promise`,\n * never a resolved error value.\n *\n * Draws from the platform CSPRNG (`randomBytes`) each call, so it is not\n * a pure function and its output is non-deterministic. There is no\n * `AbortSignal` hook — once awaited the scrypt work runs to completion.\n * Independent calls share no state and are safe to run concurrently.\n *\n * @compliance NIST 800-53 SC-28 (Protection of Information at Rest),\n * SC-13 (Cryptographic Protection).\n *\n * @example\n * ```ts\n * const ct = await encryptData(\"user@example.com\", process.env.PII_KEY!);\n * await db.users.update(id, { email: ct });\n * ```\n */\nexport async function encryptData(\n\tplaintext: string,\n\tencryptionKey: EncryptionKey,\n): Promise<Ciphertext> {\n\tconst salt = randomBytes(SALT_LENGTH);\n\tconst key = await deriveKey(encryptionKey, salt);\n\tconst iv = randomBytes(IV_LENGTH);\n\n\tconst cipher = createCipheriv(ALGORITHM, key, iv);\n\tconst encrypted = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n\tconst authTag = cipher.getAuthTag();\n\n\tconst combined = Buffer.concat([salt, iv, authTag, encrypted]);\n\treturn CiphertextBrand.unsafe(combined.toString(\"base64\"));\n}\n\n/**\n * Reverse {@link encryptData}. Verifies the GCM authentication tag\n * before returning plaintext — tampered ciphertexts throw.\n *\n * @param encryptedData - Base64 string produced by {@link encryptData}.\n * @param encryptionKey - Same key/password used to encrypt. Wrong keys\n * throw an \"Unsupported state or unable to authenticate data\" error\n * from Node — the authenticated tag failure is indistinguishable from\n * tampering, by design.\n *\n * @returns The original UTF-8 plaintext.\n *\n * @throws Error if the tag does not verify (wrong key, modified\n * ciphertext, truncated payload). Catch this and treat it as a\n * security event, not a recoverable error. The rejection comes back\n * as a rejected `Promise`. No `AbortSignal` is honoured; concurrent\n * calls are independent and share no state.\n *\n * @example\n * ```ts\n * const plaintext = await decryptData(stored, process.env.PII_KEY!);\n * ```\n */\nexport async function decryptData(\n\tencryptedData: Ciphertext,\n\tencryptionKey: EncryptionKey,\n): Promise<string> {\n\tconst combined = Buffer.from(encryptedData, \"base64\");\n\n\tconst salt = combined.subarray(0, SALT_LENGTH);\n\tconst iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);\n\tconst authTag = combined.subarray(\n\t\tSALT_LENGTH + IV_LENGTH,\n\t\tSALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,\n\t);\n\tconst ciphertext = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);\n\n\tconst key = await deriveKey(encryptionKey, salt);\n\n\tconst decipher = createDecipheriv(ALGORITHM, key, iv);\n\tdecipher.setAuthTag(authTag);\n\n\tconst decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n\treturn decrypted.toString(\"utf8\");\n}\n\n/**\n * Compute a SHA-256 digest of a UTF-8 string and return it as lowercase\n * hex.\n *\n * **Not for password storage.** SHA-256 is fast by design — use a\n * deliberately slow KDF (`bcrypt`, `argon2`, or `scrypt`) for\n * password-equivalent material. This helper is intended for\n * non-reversible identifiers, content hashes, and idempotency keys.\n *\n * @param data - UTF-8 input.\n * @returns 64-character lowercase hex digest.\n *\n * @example\n * ```ts\n * hashData(\"hello\"); // → \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n * ```\n */\nexport function hashData(data: string): Sha256Hex {\n\treturn unsafeBrand<\"Sha256Hex\", string>(createHash(\"sha256\").update(data).digest(\"hex\"));\n}\n\n/**\n * Generate a cryptographically random hex token suitable for session\n * IDs, password-reset tokens, CSRF tokens, and similar single-use\n * secrets.\n *\n * @param length - Number of random *bytes* to draw as a {@link PositiveInt}\n * (the returned hex string is twice as long). Default `32` ⇒ 64-char hex\n * / 256 bits of entropy. Construct non-default lengths with `toPositiveInt`\n * so zero-byte and negative lengths are unrepresentable.\n * @returns A {@link SecureToken}: lowercase hex string of length `length * 2`.\n *\n * @example\n * ```ts\n * import { toPositiveInt } from \"@resq-systems/types\";\n * generateSecureToken(); // 64-char hex (256-bit entropy)\n * generateSecureToken(toPositiveInt(16)); // 32-char hex (128-bit entropy)\n * ```\n */\nexport function generateSecureToken(length: PositiveInt = toPositiveInt(32)): SecureToken {\n\treturn unsafeBrand<\"SecureToken\", string>(randomBytes(length).toString(\"hex\"));\n}\n\n/**\n * Mask an arbitrary PII string for safe logging — keeps the first two\n * and last two characters and replaces everything in between with\n * asterisks. Strings of length ≤ 4 are fully masked as `\"****\"`.\n *\n * @param data - Raw PII string.\n * @returns Masked representation safe for logs.\n *\n * @example\n * ```ts\n * maskPII(\"4242424242424242\"); // → \"42************42\"\n * maskPII(\"AB12\"); // → \"****\"\n * ```\n */\nexport function maskPII(data: string): Masked {\n\tif (data.length <= 4) {\n\t\treturn unsafeBrand<\"Masked\", string>(\"****\");\n\t}\n\treturn unsafeBrand<\"Masked\", string>(\n\t\t`${data.slice(0, 2)}${\"*\".repeat(data.length - 4)}${data.slice(-2)}`,\n\t);\n}\n\n/**\n * Mask an email address while preserving the domain — useful for\n * deduplication and support workflows where the domain is non-PII but\n * the local part identifies the user.\n *\n * @param email - Full email. Falls back to {@link maskPII} if the input\n * does not contain a valid `local@domain` shape.\n * @returns Masked email; e.g. `\"j*****e@example.com\"`.\n *\n * @example\n * ```ts\n * maskEmail(\"jane@example.com\"); // → \"j**e@example.com\"\n * maskEmail(\"ab@example.com\"); // → \"**@example.com\"\n * maskEmail(\"not-an-email\"); // → \"no********il\" (maskPII fallback)\n * ```\n */\nexport function maskEmail(email: string): Masked {\n\tconst parts = email.split(\"@\");\n\tconst local = parts[0];\n\tconst domain = parts[1];\n\tif (!domain || !local) return maskPII(email);\n\tconst maskedLocal =\n\t\tlocal.length > 2\n\t\t\t? `${local[0]}${\"*\".repeat(local.length - 2)}${local[local.length - 1]}`\n\t\t\t: \"**\";\n\treturn unsafeBrand<\"Masked\", string>(`${maskedLocal}@${domain}`);\n}\n\n/**\n * Recursively shallow-copy an object, replacing any field whose key\n * contains a sensitive substring (case-insensitive) with `[REDACTED]`,\n * and masking string fields whose key contains `\"email\"` via\n * {@link maskEmail}.\n *\n * Designed for log structures — preserves shape so log queries continue\n * to work, but ensures secrets and identifiers don't leak. Use as a\n * defensive layer **before** writing structured log lines.\n *\n * @param obj - Object to sanitize. Original is not mutated.\n * @param sensitiveFields - Substring allow-list. Defaults to\n * `[\"password\", \"passwordHash\", \"token\", \"secret\",\n * \"twoFactorSecret\", \"apiKey\"]`. Substrings match anywhere in the\n * key, e.g. `\"token\"` matches `\"refreshToken\"` and `\"id_token\"`.\n *\n * @returns A new object with sensitive fields redacted and emails\n * masked. Any non-null object value is recursed and comes back as a\n * plain object keyed by its enumerable own properties — so arrays\n * return as index-keyed objects (`[\"a\"]` → `{ \"0\": \"a\" }`) and class\n * instances / `Date`s lose their prototype. Only primitives, `null`,\n * and `undefined` pass through unchanged.\n *\n * @throws {RangeError} On a circular reference — recursion has no cycle\n * guard, so a self-referential object overflows the call stack.\n *\n * @example\n * ```ts\n * sanitizeForLogging({\n * id: 1,\n * email: \"u@x.com\",\n * apiKey: \"sk-...\",\n * nested: { token: \"...\" },\n * });\n * // → { id: 1, email: \"u@x.com\" (masked), apiKey: \"[REDACTED]\", nested: { token: \"[REDACTED]\" } }\n * ```\n */\nexport function sanitizeForLogging(\n\tobj: Record<string, unknown>,\n\tsensitiveFields: string[] = [\n\t\t\"password\",\n\t\t\"passwordHash\",\n\t\t\"token\",\n\t\t\"secret\",\n\t\t\"twoFactorSecret\",\n\t\t\"apiKey\",\n\t],\n): Record<string, unknown> {\n\tconst sanitized: Record<string, unknown> = {};\n\n\tfor (const [key, value] of Object.entries(obj)) {\n\t\tif (sensitiveFields.some((field) => key.toLowerCase().includes(field.toLowerCase()))) {\n\t\t\tsanitized[key] = \"[REDACTED]\";\n\t\t} else if (key.toLowerCase().includes(\"email\") && typeof value === \"string\") {\n\t\t\tsanitized[key] = maskEmail(value);\n\t\t} else if (typeof value === \"object\" && value !== null) {\n\t\t\tsanitized[key] = sanitizeForLogging(value as Record<string, unknown>, sensitiveFields);\n\t\t} else {\n\t\t\tsanitized[key] = value;\n\t\t}\n\t}\n\n\treturn sanitized;\n}\n//#endregion\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"crypto.mjs","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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 Server-side cryptographic utilities — AES-256-GCM authenticated\n * encryption, SHA-256 hashing, secure token generation, and PII masking — guarded\n * by nominal branded types for keys, ciphertext, and masked output. Supports SOC 2,\n * ISO 27001, and NIST 800-53 SC-28 (data at rest) / SC-13 (cryptographic protection)\n * controls.\n *\n * @module @resq-systems/security/crypto\n */\n\nimport {\n\ttype Brand,\n\tbrandRefiner,\n\ttype PositiveInt,\n\ttoPositiveInt,\n\tunsafeBrand,\n} from \"@resq-systems/types\";\nimport { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\n//#region Constants\nconst scryptAsync = promisify(scrypt);\n\n/** AES-256-GCM encryption algorithm. */\nconst ALGORITHM = \"aes-256-gcm\";\n/** Initialization vector length in bytes. */\nconst IV_LENGTH = 16;\n/** Authentication tag length in bytes. */\nconst AUTH_TAG_LENGTH = 16;\n/** Salt length for key derivation, in bytes. */\nconst SALT_LENGTH = 32;\n/** Derived key length in bytes (256 bits for AES-256). */\nconst KEY_LENGTH = 32;\n//#endregion\n\n//#region Branded Types\n\n/**\n * Base64 AES-256-GCM payload produced by {@link encryptData} — the\n * `salt | iv | authTag | ciphertext` envelope. Only {@link decryptData}\n * should consume a value of this type; read one back from storage through\n * {@link toCiphertext}.\n */\nexport type Ciphertext = Brand<string, \"Ciphertext\">;\n\n/**\n * A secret accepted by {@link encryptData}/{@link decryptData} as the\n * scrypt password. Mint one at the boundary where the secret enters the\n * process (typically from `process.env`) via {@link toEncryptionKey}.\n */\nexport type EncryptionKey = Brand<string, \"EncryptionKey\">;\n\n/** Cryptographically random hex token minted by {@link generateSecureToken}. */\nexport type SecureToken = Brand<string, \"SecureToken\">;\n\n/** Lowercase 64-char SHA-256 hex digest produced by {@link hashData}. */\nexport type Sha256Hex = Brand<string, \"Sha256Hex\">;\n\n/** A PII string masked for safe logging by {@link maskPII}/{@link maskEmail}. */\nexport type Masked = Brand<string, \"Masked\">;\n\n/** Minimum decoded byte length of a well-formed {@link Ciphertext} envelope. */\nconst CIPHERTEXT_MIN_BYTES = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;\n\n/**\n * Smart constructors for {@link EncryptionKey}. The runtime check is a\n * non-empty string — scrypt stretches any non-empty secret into a 256-bit\n * key, so entropy is the caller's responsibility, but an empty key is\n * always a bug.\n */\nconst EncryptionKeyBrand = brandRefiner<string, \"EncryptionKey\">(\n\t(value) => value.length > 0,\n\t\"encryption key\",\n);\n\n/** Type guard: `true` when `value` is a usable {@link EncryptionKey}. */\nexport const isEncryptionKey = EncryptionKeyBrand.is;\n/** Assert `value` is a non-empty secret and brand it, throwing otherwise. */\nexport const toEncryptionKey = EncryptionKeyBrand.from;\n/** Return `value` branded as an {@link EncryptionKey}, or `null` when empty. */\nexport const coerceEncryptionKey = EncryptionKeyBrand.coerce;\n/** Brand `value` as an {@link EncryptionKey} without checking. */\nexport const unsafeEncryptionKey = EncryptionKeyBrand.unsafe;\n\n/**\n * Smart constructors for {@link Ciphertext}. The runtime check verifies the\n * value base64-decodes to at least the fixed envelope header size — enough\n * to reject truncated or non-base64 input before it reaches\n * {@link decryptData}.\n */\nconst CiphertextBrand = brandRefiner<string, \"Ciphertext\">(\n\t(value) => value.length > 0 && Buffer.from(value, \"base64\").length >= CIPHERTEXT_MIN_BYTES,\n\t\"ciphertext\",\n);\n\n/** Type guard: `true` when `value` is a well-formed {@link Ciphertext} envelope. */\nexport const isCiphertext = CiphertextBrand.is;\n/** Assert `value` is a well-formed envelope and brand it, throwing otherwise. */\nexport const toCiphertext = CiphertextBrand.from;\n/** Return `value` branded as a {@link Ciphertext}, or `null` when malformed. */\nexport const coerceCiphertext = CiphertextBrand.coerce;\n/** Brand `value` as a {@link Ciphertext} without checking. */\nexport const unsafeCiphertext = CiphertextBrand.unsafe;\n//#endregion\n\n//#region Internal\n\n/**\n * Derive a 32-byte (AES-256) key from a password and per-record salt\n * using scrypt with Node's default cost parameters.\n *\n * Internal helper — used inside the encrypt/decrypt round-trip because\n * the salt must travel alongside the ciphertext for decryption to\n * succeed.\n *\n * @internal\n */\nasync function deriveKey(password: string, salt: Buffer): Promise<Buffer> {\n\treturn (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;\n}\n//#endregion\n\n//#region Public API\n\n/**\n * Encrypt a UTF-8 string with AES-256-GCM authenticated encryption.\n *\n * Each call generates a fresh random salt and IV — the same plaintext\n * encrypted twice with the same `encryptionKey` produces different\n * ciphertexts, which is the property you want for at-rest encryption.\n *\n * Output layout (base64-encoded): `salt(32) | iv(16) | authTag(16) | ciphertext(*)`.\n * The companion {@link decryptData} understands this layout.\n *\n * @param plaintext - UTF-8 string to encrypt.\n * @param encryptionKey - Caller-supplied secret. Treated as a password\n * and stretched into a 256-bit AES key via scrypt; can be any length,\n * though a high-entropy secret (≥ 32 bytes) is strongly preferred.\n *\n * @returns A self-contained base64 string. Store or transmit verbatim;\n * the salt/IV are recovered on decryption.\n *\n * @throws From the underlying Node crypto primitives if `encryptionKey`\n * is empty or scrypt fails. Failure surfaces as a rejected `Promise`,\n * never a resolved error value.\n *\n * Draws from the platform CSPRNG (`randomBytes`) each call, so it is not\n * a pure function and its output is non-deterministic. There is no\n * `AbortSignal` hook — once awaited the scrypt work runs to completion.\n * Independent calls share no state and are safe to run concurrently.\n *\n * @compliance NIST 800-53 SC-28 (Protection of Information at Rest),\n * SC-13 (Cryptographic Protection).\n *\n * @example\n * ```ts\n * const ct = await encryptData(\"user@example.com\", process.env.PII_KEY!);\n * await db.users.update(id, { email: ct });\n * ```\n */\nexport async function encryptData(\n\tplaintext: string,\n\tencryptionKey: EncryptionKey,\n): Promise<Ciphertext> {\n\tconst salt = randomBytes(SALT_LENGTH);\n\tconst key = await deriveKey(encryptionKey, salt);\n\tconst iv = randomBytes(IV_LENGTH);\n\n\tconst cipher = createCipheriv(ALGORITHM, key, iv);\n\tconst encrypted = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n\tconst authTag = cipher.getAuthTag();\n\n\tconst combined = Buffer.concat([salt, iv, authTag, encrypted]);\n\treturn CiphertextBrand.unsafe(combined.toString(\"base64\"));\n}\n\n/**\n * Reverse {@link encryptData}. Verifies the GCM authentication tag\n * before returning plaintext — tampered ciphertexts throw.\n *\n * @param encryptedData - Base64 string produced by {@link encryptData}.\n * @param encryptionKey - Same key/password used to encrypt. Wrong keys\n * throw an \"Unsupported state or unable to authenticate data\" error\n * from Node — the authenticated tag failure is indistinguishable from\n * tampering, by design.\n *\n * @returns The original UTF-8 plaintext.\n *\n * @throws Error if the tag does not verify (wrong key, modified\n * ciphertext, truncated payload). Catch this and treat it as a\n * security event, not a recoverable error. The rejection comes back\n * as a rejected `Promise`. No `AbortSignal` is honoured; concurrent\n * calls are independent and share no state.\n *\n * @example\n * ```ts\n * const plaintext = await decryptData(stored, process.env.PII_KEY!);\n * ```\n */\nexport async function decryptData(\n\tencryptedData: Ciphertext,\n\tencryptionKey: EncryptionKey,\n): Promise<string> {\n\tconst combined = Buffer.from(encryptedData, \"base64\");\n\n\tconst salt = combined.subarray(0, SALT_LENGTH);\n\tconst iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);\n\tconst authTag = combined.subarray(\n\t\tSALT_LENGTH + IV_LENGTH,\n\t\tSALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,\n\t);\n\tconst ciphertext = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);\n\n\tconst key = await deriveKey(encryptionKey, salt);\n\n\tconst decipher = createDecipheriv(ALGORITHM, key, iv);\n\tdecipher.setAuthTag(authTag);\n\n\tconst decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n\treturn decrypted.toString(\"utf8\");\n}\n\n/**\n * Compute a SHA-256 digest of a UTF-8 string and return it as lowercase\n * hex.\n *\n * **Not for password storage.** SHA-256 is fast by design — use a\n * deliberately slow KDF (`bcrypt`, `argon2`, or `scrypt`) for\n * password-equivalent material. This helper is intended for\n * non-reversible identifiers, content hashes, and idempotency keys.\n *\n * @param data - UTF-8 input.\n * @returns 64-character lowercase hex digest.\n *\n * @example\n * ```ts\n * hashData(\"hello\"); // → \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n * ```\n */\nexport function hashData(data: string): Sha256Hex {\n\treturn unsafeBrand<\"Sha256Hex\", string>(createHash(\"sha256\").update(data).digest(\"hex\"));\n}\n\n/**\n * Generate a cryptographically random hex token suitable for session\n * IDs, password-reset tokens, CSRF tokens, and similar single-use\n * secrets.\n *\n * @param length - Number of random *bytes* to draw as a {@link PositiveInt}\n * (the returned hex string is twice as long). Default `32` ⇒ 64-char hex\n * / 256 bits of entropy. Construct non-default lengths with `toPositiveInt`\n * so zero-byte and negative lengths are unrepresentable.\n * @returns A {@link SecureToken}: lowercase hex string of length `length * 2`.\n *\n * @example\n * ```ts\n * import { toPositiveInt } from \"@resq-systems/types\";\n * generateSecureToken(); // 64-char hex (256-bit entropy)\n * generateSecureToken(toPositiveInt(16)); // 32-char hex (128-bit entropy)\n * ```\n */\nexport function generateSecureToken(length: PositiveInt = toPositiveInt(32)): SecureToken {\n\treturn unsafeBrand<\"SecureToken\", string>(randomBytes(length).toString(\"hex\"));\n}\n\n/**\n * Mask an arbitrary PII string for safe logging — keeps the first two\n * and last two characters and replaces everything in between with\n * asterisks. Strings of length ≤ 4 are fully masked as `\"****\"`.\n *\n * @param data - Raw PII string.\n * @returns Masked representation safe for logs.\n *\n * @example\n * ```ts\n * maskPII(\"4242424242424242\"); // → \"42************42\"\n * maskPII(\"AB12\"); // → \"****\"\n * ```\n */\nexport function maskPII(data: string): Masked {\n\tif (data.length <= 4) {\n\t\treturn unsafeBrand<\"Masked\", string>(\"****\");\n\t}\n\treturn unsafeBrand<\"Masked\", string>(\n\t\t`${data.slice(0, 2)}${\"*\".repeat(data.length - 4)}${data.slice(-2)}`,\n\t);\n}\n\n/**\n * Mask an email address while preserving the domain — useful for\n * deduplication and support workflows where the domain is non-PII but\n * the local part identifies the user.\n *\n * @param email - Full email. Falls back to {@link maskPII} if the input\n * does not contain a valid `local@domain` shape.\n * @returns Masked email; e.g. `\"j*****e@example.com\"`.\n *\n * @example\n * ```ts\n * maskEmail(\"jane@example.com\"); // → \"j**e@example.com\"\n * maskEmail(\"ab@example.com\"); // → \"**@example.com\"\n * maskEmail(\"not-an-email\"); // → \"no********il\" (maskPII fallback)\n * ```\n */\nexport function maskEmail(email: string): Masked {\n\tconst parts = email.split(\"@\");\n\tconst local = parts[0];\n\tconst domain = parts[1];\n\tif (!domain || !local) return maskPII(email);\n\tconst maskedLocal =\n\t\tlocal.length > 2\n\t\t\t? `${local[0]}${\"*\".repeat(local.length - 2)}${local[local.length - 1]}`\n\t\t\t: \"**\";\n\treturn unsafeBrand<\"Masked\", string>(`${maskedLocal}@${domain}`);\n}\n\n/**\n * Recursively shallow-copy an object, replacing any field whose key\n * contains a sensitive substring (case-insensitive) with `[REDACTED]`,\n * and masking string fields whose key contains `\"email\"` via\n * {@link maskEmail}.\n *\n * Designed for log structures — preserves shape so log queries continue\n * to work, but ensures secrets and identifiers don't leak. Use as a\n * defensive layer **before** writing structured log lines.\n *\n * @param obj - Object to sanitize. Original is not mutated.\n * @param sensitiveFields - Substring allow-list. Defaults to\n * `[\"password\", \"passwordHash\", \"token\", \"secret\",\n * \"twoFactorSecret\", \"apiKey\"]`. Substrings match anywhere in the\n * key, e.g. `\"token\"` matches `\"refreshToken\"` and `\"id_token\"`.\n *\n * @returns A new object with sensitive fields redacted and emails\n * masked. Any non-null object value is recursed and comes back as a\n * plain object keyed by its enumerable own properties — so arrays\n * return as index-keyed objects (`[\"a\"]` → `{ \"0\": \"a\" }`) and class\n * instances / `Date`s lose their prototype. Only primitives, `null`,\n * and `undefined` pass through unchanged.\n *\n * @throws {RangeError} On a circular reference — recursion has no cycle\n * guard, so a self-referential object overflows the call stack.\n *\n * @example\n * ```ts\n * sanitizeForLogging({\n * id: 1,\n * email: \"u@x.com\",\n * apiKey: \"sk-...\",\n * nested: { token: \"...\" },\n * });\n * // → { id: 1, email: \"u@x.com\" (masked), apiKey: \"[REDACTED]\", nested: { token: \"[REDACTED]\" } }\n * ```\n */\nexport function sanitizeForLogging(\n\tobj: Record<string, unknown>,\n\tsensitiveFields: string[] = [\n\t\t\"password\",\n\t\t\"passwordHash\",\n\t\t\"token\",\n\t\t\"secret\",\n\t\t\"twoFactorSecret\",\n\t\t\"apiKey\",\n\t],\n): Record<string, unknown> {\n\tconst sanitized: Record<string, unknown> = {};\n\n\tfor (const [key, value] of Object.entries(obj)) {\n\t\tif (sensitiveFields.some((field) => key.toLowerCase().includes(field.toLowerCase()))) {\n\t\t\tsanitized[key] = \"[REDACTED]\";\n\t\t} else if (key.toLowerCase().includes(\"email\") && typeof value === \"string\") {\n\t\t\tsanitized[key] = maskEmail(value);\n\t\t} else if (typeof value === \"object\" && value !== null) {\n\t\t\tsanitized[key] = sanitizeForLogging(value as Record<string, unknown>, sensitiveFields);\n\t\t} else {\n\t\t\tsanitized[key] = value;\n\t\t}\n\t}\n\n\treturn sanitized;\n}\n//#endregion\n"],"mappings":"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"}
|