@resq-systems/security 1.0.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -33
- package/lib/controls/address.d.mts +142 -0
- package/lib/controls/address.d.mts.map +1 -0
- package/lib/controls/address.mjs +533 -0
- package/lib/controls/address.mjs.map +1 -0
- package/lib/controls/csrf.d.mts +91 -0
- package/lib/controls/csrf.d.mts.map +1 -0
- package/lib/controls/csrf.mjs +200 -0
- package/lib/controls/csrf.mjs.map +1 -0
- package/lib/controls/index.d.mts +8 -0
- package/lib/controls/index.mjs +8 -0
- package/lib/controls/origin.d.mts +95 -0
- package/lib/controls/origin.d.mts.map +1 -0
- package/lib/controls/origin.mjs +156 -0
- package/lib/controls/origin.mjs.map +1 -0
- package/lib/controls/payload.d.mts +84 -0
- package/lib/controls/payload.d.mts.map +1 -0
- package/lib/controls/payload.mjs +147 -0
- package/lib/controls/payload.mjs.map +1 -0
- package/lib/controls/query.d.mts +157 -0
- package/lib/controls/query.d.mts.map +1 -0
- package/lib/controls/query.mjs +368 -0
- package/lib/controls/query.mjs.map +1 -0
- package/lib/controls/redirect.d.mts +92 -0
- package/lib/controls/redirect.d.mts.map +1 -0
- package/lib/controls/redirect.mjs +110 -0
- package/lib/controls/redirect.mjs.map +1 -0
- package/lib/controls/upload.d.mts +108 -0
- package/lib/controls/upload.d.mts.map +1 -0
- package/lib/controls/upload.mjs +374 -0
- package/lib/controls/upload.mjs.map +1 -0
- package/lib/crypto.d.mts +18 -5
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +35 -24
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +83 -0
- package/lib/hash.d.mts.map +1 -0
- package/lib/hash.mjs +111 -0
- package/lib/hash.mjs.map +1 -0
- package/lib/index.d.mts +18 -2
- package/lib/index.mjs +20 -2
- package/lib/paths.d.mts +92 -0
- package/lib/paths.d.mts.map +1 -0
- package/lib/paths.mjs +140 -0
- package/lib/paths.mjs.map +1 -0
- package/lib/sanitize.d.mts +137 -35
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs +170 -46
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +59 -0
- package/lib/threats/capec.generated.d.mts.map +1 -0
- package/lib/threats/capec.generated.mjs +644 -0
- package/lib/threats/capec.generated.mjs.map +1 -0
- package/lib/threats/engine.d.mts +94 -0
- package/lib/threats/engine.d.mts.map +1 -0
- package/lib/threats/engine.mjs +167 -0
- package/lib/threats/engine.mjs.map +1 -0
- package/lib/threats/index.d.mts +11 -0
- package/lib/threats/index.mjs +11 -0
- package/lib/threats/rules/datastore.d.mts +13 -0
- package/lib/threats/rules/datastore.d.mts.map +1 -0
- package/lib/threats/rules/datastore.mjs +366 -0
- package/lib/threats/rules/datastore.mjs.map +1 -0
- package/lib/threats/rules/index.d.mts +54 -0
- package/lib/threats/rules/index.d.mts.map +1 -0
- package/lib/threats/rules/index.mjs +121 -0
- package/lib/threats/rules/index.mjs.map +1 -0
- package/lib/threats/rules/markup.d.mts +28 -0
- package/lib/threats/rules/markup.d.mts.map +1 -0
- package/lib/threats/rules/markup.mjs +373 -0
- package/lib/threats/rules/markup.mjs.map +1 -0
- package/lib/threats/rules/protocol.d.mts +49 -0
- package/lib/threats/rules/protocol.d.mts.map +1 -0
- package/lib/threats/rules/protocol.mjs +175 -0
- package/lib/threats/rules/protocol.mjs.map +1 -0
- package/lib/threats/rules/system.d.mts +19 -0
- package/lib/threats/rules/system.d.mts.map +1 -0
- package/lib/threats/rules/system.mjs +455 -0
- package/lib/threats/rules/system.mjs.map +1 -0
- package/lib/threats/rules/web.d.mts +26 -0
- package/lib/threats/rules/web.d.mts.map +1 -0
- package/lib/threats/rules/web.mjs +412 -0
- package/lib/threats/rules/web.mjs.map +1 -0
- package/lib/threats/scoring.d.mts +59 -0
- package/lib/threats/scoring.d.mts.map +1 -0
- package/lib/threats/scoring.mjs +111 -0
- package/lib/threats/scoring.mjs.map +1 -0
- package/lib/threats/types.d.mts +245 -0
- package/lib/threats/types.d.mts.map +1 -0
- package/lib/threats/types.mjs +52 -0
- package/lib/threats/types.mjs.map +1 -0
- package/lib/threats/variants.d.mts +57 -0
- package/lib/threats/variants.d.mts.map +1 -0
- package/lib/threats/variants.mjs +144 -0
- package/lib/threats/variants.mjs.map +1 -0
- package/lib/unicode/confusables.d.mts +82 -0
- package/lib/unicode/confusables.d.mts.map +1 -0
- package/lib/unicode/confusables.mjs +954 -0
- package/lib/unicode/confusables.mjs.map +1 -0
- package/lib/unicode/index.d.mts +126 -0
- package/lib/unicode/index.d.mts.map +1 -0
- package/lib/unicode/index.mjs +288 -0
- package/lib/unicode/index.mjs.map +1 -0
- package/lib/validators.d.mts +341 -164
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +519 -338
- package/lib/validators.mjs.map +1 -1
- package/package.json +35 -8
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
//#region src/controls/upload.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Copyright 2026 ResQ Systems, Inc.
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @fileoverview Upload type agreement — the control for WSTG-BUSL-08 and BUSL-09.
|
|
19
|
+
*
|
|
20
|
+
* This deliberately is not a rule in the threat catalog, because it cannot be one at
|
|
21
|
+
* any grade: the weakness is a *disagreement between three values* — the declared
|
|
22
|
+
* `Content-Type`, the filename extension, and the actual leading bytes — and a rule
|
|
23
|
+
* sees one string at a time. `shell.php.jpg` only becomes interesting once you know
|
|
24
|
+
* the bytes are not a JPEG.
|
|
25
|
+
*
|
|
26
|
+
* The check is allowlist-shaped. A denylist of dangerous extensions is bypassed by the
|
|
27
|
+
* next extension nobody listed; an allowlist of permitted types fails closed.
|
|
28
|
+
*
|
|
29
|
+
* **Still not sufficient alone.** A polyglot can carry a valid PNG header and PHP
|
|
30
|
+
* source in its trailing bytes, and this reads only the head. The controls that hold
|
|
31
|
+
* are: generate the stored filename yourself, store outside the webroot, serve from a
|
|
32
|
+
* separate origin with a fixed `Content-Type` and `Content-Disposition: attachment`,
|
|
33
|
+
* and re-encode images through a parser rather than storing raw bytes.
|
|
34
|
+
*
|
|
35
|
+
* @module @resq-systems/security/controls/upload
|
|
36
|
+
*/
|
|
37
|
+
/** 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
|
+
/**
|
|
40
|
+
* Identify a file from its leading bytes.
|
|
41
|
+
*
|
|
42
|
+
* Binary signatures are checked first. If none matches and the bytes decode as UTF-8
|
|
43
|
+
* with no control characters, the content is classified `svg` or `text`.
|
|
44
|
+
*
|
|
45
|
+
* @param headBytes - The file's leading bytes. 64 covers every binary signature here;
|
|
46
|
+
* 256 or more improves text and SVG classification.
|
|
47
|
+
* @returns The detected type, or `null` when nothing matches.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* detectFileSignature(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
52
|
+
* // "png"
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
declare function detectFileSignature(headBytes: Uint8Array): FileTypeName | null;
|
|
56
|
+
/** Why an upload was refused. */
|
|
57
|
+
type UploadRejectionReason = "no_bytes" | "unrecognized_signature" | "type_not_allowed" | "extension_missing" | "extension_mismatch" | "declared_type_mismatch";
|
|
58
|
+
/** Outcome of {@link assertUploadType}. */
|
|
59
|
+
type UploadVerdict = {
|
|
60
|
+
readonly ok: true;
|
|
61
|
+
readonly type: FileTypeName;
|
|
62
|
+
} | {
|
|
63
|
+
readonly ok: false;
|
|
64
|
+
readonly reason: UploadRejectionReason;
|
|
65
|
+
readonly detail: string;
|
|
66
|
+
};
|
|
67
|
+
/** Input for {@link assertUploadType}. */
|
|
68
|
+
interface UploadCandidate {
|
|
69
|
+
/** `Content-Type` the client claimed. Parameters such as `; charset=` are ignored. */
|
|
70
|
+
readonly declaredType: string;
|
|
71
|
+
/** Filename the client supplied. */
|
|
72
|
+
readonly filename: string;
|
|
73
|
+
/** The file's leading bytes — at least 64, ideally 256 or more. */
|
|
74
|
+
readonly headBytes: Uint8Array;
|
|
75
|
+
/** Types permitted for this endpoint. An empty list refuses everything. */
|
|
76
|
+
readonly allow: readonly FileTypeName[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Require the declared type, the filename extension, and the actual bytes to agree on
|
|
80
|
+
* one permitted file type.
|
|
81
|
+
*
|
|
82
|
+
* The bytes are authoritative — they are the only one of the three a client cannot
|
|
83
|
+
* simply assert. The other two must be consistent with what the bytes actually are,
|
|
84
|
+
* which is what refuses `shell.php.jpg` (bytes are PHP source, extension claims JPEG)
|
|
85
|
+
* and an `avatar.jpg` declared as `application/x-httpd-php`.
|
|
86
|
+
*
|
|
87
|
+
* @param candidate - See {@link UploadCandidate}.
|
|
88
|
+
* @returns `{ ok: true, type }`, or `{ ok: false, reason, detail }`. Never throws.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* const verdict = assertUploadType({
|
|
93
|
+
* declaredType: file.type,
|
|
94
|
+
* filename: file.name,
|
|
95
|
+
* headBytes: new Uint8Array(await file.slice(0, 256).arrayBuffer()),
|
|
96
|
+
* allow: ["png", "jpeg", "webp"],
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* if (!verdict.ok) return new Response(`Rejected: ${verdict.reason}`, { status: 400 });
|
|
100
|
+
*
|
|
101
|
+
* // Generate the stored name yourself — never reuse the client's.
|
|
102
|
+
* const stored = `${crypto.randomUUID()}.${verdict.type}`;
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
declare function assertUploadType(candidate: UploadCandidate): UploadVerdict;
|
|
106
|
+
//#endregion
|
|
107
|
+
export { FileTypeName, UploadCandidate, UploadRejectionReason, UploadVerdict, assertUploadType, detectFileSignature };
|
|
108
|
+
//# sourceMappingURL=upload.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload.d.mts","names":[],"sources":["../../src/controls/upload.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAwCY;;;;;;;;;;;;;;;;;iBAoLI,oBAAoB,WAAW,aAAa;;KAqChD;;KASA;WACE;WAAmB,MAAM;;WACzB;WAAoB,QAAQ;WAAgC;;;UAGzD;;WAEP;;WAEA;;WAEA,WAAW;;WAEX,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA+CV,iBAAiB,WAAW,kBAAkB"}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
//#region src/controls/upload.ts
|
|
2
|
+
/** ASCII bytes for an ISO base media file format brand. */
|
|
3
|
+
const brand = (code) => [...code].map((c) => c.charCodeAt(0));
|
|
4
|
+
/**
|
|
5
|
+
* Magic-byte signatures, most specific first.
|
|
6
|
+
*
|
|
7
|
+
* `zip` covers DOCX, XLSX, PPTX, ODT, JAR, and APK — every one is a ZIP container and
|
|
8
|
+
* nothing in the leading bytes distinguishes them. A caller needing that distinction
|
|
9
|
+
* must open the archive and inspect its manifest.
|
|
10
|
+
*/
|
|
11
|
+
const SIGNATURES = [
|
|
12
|
+
{
|
|
13
|
+
type: "png",
|
|
14
|
+
offset: 0,
|
|
15
|
+
bytes: [
|
|
16
|
+
137,
|
|
17
|
+
80,
|
|
18
|
+
78,
|
|
19
|
+
71,
|
|
20
|
+
13,
|
|
21
|
+
10,
|
|
22
|
+
26,
|
|
23
|
+
10
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "gif",
|
|
28
|
+
offset: 0,
|
|
29
|
+
bytes: [
|
|
30
|
+
71,
|
|
31
|
+
73,
|
|
32
|
+
70,
|
|
33
|
+
56
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "pdf",
|
|
38
|
+
offset: 0,
|
|
39
|
+
bytes: [
|
|
40
|
+
37,
|
|
41
|
+
80,
|
|
42
|
+
68,
|
|
43
|
+
70,
|
|
44
|
+
45
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: "webp",
|
|
49
|
+
offset: 0,
|
|
50
|
+
bytes: [
|
|
51
|
+
82,
|
|
52
|
+
73,
|
|
53
|
+
70,
|
|
54
|
+
70
|
|
55
|
+
],
|
|
56
|
+
also: {
|
|
57
|
+
offset: 8,
|
|
58
|
+
bytes: [
|
|
59
|
+
87,
|
|
60
|
+
69,
|
|
61
|
+
66,
|
|
62
|
+
80
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: "jpeg",
|
|
68
|
+
offset: 0,
|
|
69
|
+
bytes: [
|
|
70
|
+
255,
|
|
71
|
+
216,
|
|
72
|
+
255
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: "tiff",
|
|
77
|
+
offset: 0,
|
|
78
|
+
bytes: [
|
|
79
|
+
73,
|
|
80
|
+
73,
|
|
81
|
+
42,
|
|
82
|
+
0
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: "tiff",
|
|
87
|
+
offset: 0,
|
|
88
|
+
bytes: [
|
|
89
|
+
77,
|
|
90
|
+
77,
|
|
91
|
+
0,
|
|
92
|
+
42
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: "ico",
|
|
97
|
+
offset: 0,
|
|
98
|
+
bytes: [
|
|
99
|
+
0,
|
|
100
|
+
0,
|
|
101
|
+
1,
|
|
102
|
+
0
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "zip",
|
|
107
|
+
offset: 0,
|
|
108
|
+
bytes: [
|
|
109
|
+
80,
|
|
110
|
+
75,
|
|
111
|
+
3,
|
|
112
|
+
4
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: "zip",
|
|
117
|
+
offset: 0,
|
|
118
|
+
bytes: [
|
|
119
|
+
80,
|
|
120
|
+
75,
|
|
121
|
+
5,
|
|
122
|
+
6
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: "gzip",
|
|
127
|
+
offset: 0,
|
|
128
|
+
bytes: [31, 139]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: "bmp",
|
|
132
|
+
offset: 0,
|
|
133
|
+
bytes: [66, 77]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: "mp4",
|
|
137
|
+
offset: 4,
|
|
138
|
+
bytes: [
|
|
139
|
+
102,
|
|
140
|
+
116,
|
|
141
|
+
121,
|
|
142
|
+
112
|
|
143
|
+
],
|
|
144
|
+
alsoAnyOf: {
|
|
145
|
+
offset: 8,
|
|
146
|
+
options: [
|
|
147
|
+
"isom",
|
|
148
|
+
"iso2",
|
|
149
|
+
"iso4",
|
|
150
|
+
"iso5",
|
|
151
|
+
"iso6",
|
|
152
|
+
"mp41",
|
|
153
|
+
"mp42",
|
|
154
|
+
"mmp4",
|
|
155
|
+
"avc1",
|
|
156
|
+
"dash",
|
|
157
|
+
"M4V ",
|
|
158
|
+
"M4A ",
|
|
159
|
+
"M4P ",
|
|
160
|
+
"M4B ",
|
|
161
|
+
"qt ",
|
|
162
|
+
"3gp4",
|
|
163
|
+
"3gp5",
|
|
164
|
+
"3g2a",
|
|
165
|
+
"MSNV"
|
|
166
|
+
].map(brand)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
/** Extensions each recognized type may legitimately carry. */
|
|
171
|
+
const EXTENSIONS_BY_TYPE = {
|
|
172
|
+
png: ["png"],
|
|
173
|
+
jpeg: [
|
|
174
|
+
"jpg",
|
|
175
|
+
"jpeg",
|
|
176
|
+
"jpe"
|
|
177
|
+
],
|
|
178
|
+
gif: ["gif"],
|
|
179
|
+
webp: ["webp"],
|
|
180
|
+
bmp: ["bmp"],
|
|
181
|
+
tiff: ["tif", "tiff"],
|
|
182
|
+
ico: ["ico"],
|
|
183
|
+
pdf: ["pdf"],
|
|
184
|
+
zip: [
|
|
185
|
+
"zip",
|
|
186
|
+
"docx",
|
|
187
|
+
"xlsx",
|
|
188
|
+
"pptx",
|
|
189
|
+
"odt",
|
|
190
|
+
"ods",
|
|
191
|
+
"odp",
|
|
192
|
+
"epub"
|
|
193
|
+
],
|
|
194
|
+
gzip: ["gz", "tgz"],
|
|
195
|
+
mp4: [
|
|
196
|
+
"mp4",
|
|
197
|
+
"m4v",
|
|
198
|
+
"m4a",
|
|
199
|
+
"mov"
|
|
200
|
+
],
|
|
201
|
+
svg: ["svg"],
|
|
202
|
+
text: [
|
|
203
|
+
"txt",
|
|
204
|
+
"csv",
|
|
205
|
+
"md",
|
|
206
|
+
"log",
|
|
207
|
+
"json"
|
|
208
|
+
]
|
|
209
|
+
};
|
|
210
|
+
/** Media types each recognized type may legitimately be declared as. */
|
|
211
|
+
const MEDIA_TYPES_BY_TYPE = {
|
|
212
|
+
png: ["image/png"],
|
|
213
|
+
jpeg: ["image/jpeg", "image/jpg"],
|
|
214
|
+
gif: ["image/gif"],
|
|
215
|
+
webp: ["image/webp"],
|
|
216
|
+
bmp: ["image/bmp", "image/x-ms-bmp"],
|
|
217
|
+
tiff: ["image/tiff"],
|
|
218
|
+
ico: ["image/x-icon", "image/vnd.microsoft.icon"],
|
|
219
|
+
pdf: ["application/pdf"],
|
|
220
|
+
zip: [
|
|
221
|
+
"application/zip",
|
|
222
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
223
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
224
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
225
|
+
"application/vnd.oasis.opendocument.text",
|
|
226
|
+
"application/epub+zip"
|
|
227
|
+
],
|
|
228
|
+
gzip: ["application/gzip", "application/x-gzip"],
|
|
229
|
+
mp4: [
|
|
230
|
+
"video/mp4",
|
|
231
|
+
"audio/mp4",
|
|
232
|
+
"video/quicktime"
|
|
233
|
+
],
|
|
234
|
+
svg: ["image/svg+xml"],
|
|
235
|
+
text: [
|
|
236
|
+
"text/plain",
|
|
237
|
+
"text/csv",
|
|
238
|
+
"text/markdown",
|
|
239
|
+
"application/json"
|
|
240
|
+
]
|
|
241
|
+
};
|
|
242
|
+
/** Leading bytes inspected when no binary signature matches, to classify text. */
|
|
243
|
+
const TEXT_SNIFF_LENGTH = 256;
|
|
244
|
+
/** Opening of an SVG document, with or without an XML prolog or leading comments. */
|
|
245
|
+
const SVG_OPENING = /^\s{0,64}(?:<\?xml[^>]{0,512}\?>\s{0,64})?(?:<!--[^>]{0,512}-->\s{0,64}){0,8}<svg\b/i;
|
|
246
|
+
/** True when `haystack` contains `bytes` starting at `offset`. */
|
|
247
|
+
function matchesAt(haystack, offset, bytes) {
|
|
248
|
+
if (haystack.length < offset + bytes.length) return false;
|
|
249
|
+
for (let i = 0; i < bytes.length; i++) if (haystack[offset + i] !== bytes[i]) return false;
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Identify a file from its leading bytes.
|
|
254
|
+
*
|
|
255
|
+
* Binary signatures are checked first. If none matches and the bytes decode as UTF-8
|
|
256
|
+
* with no control characters, the content is classified `svg` or `text`.
|
|
257
|
+
*
|
|
258
|
+
* @param headBytes - The file's leading bytes. 64 covers every binary signature here;
|
|
259
|
+
* 256 or more improves text and SVG classification.
|
|
260
|
+
* @returns The detected type, or `null` when nothing matches.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```ts
|
|
264
|
+
* detectFileSignature(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
265
|
+
* // "png"
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
function detectFileSignature(headBytes) {
|
|
269
|
+
if (!(headBytes instanceof Uint8Array) || headBytes.length === 0) return null;
|
|
270
|
+
for (const signature of SIGNATURES) {
|
|
271
|
+
if (!matchesAt(headBytes, signature.offset, signature.bytes)) continue;
|
|
272
|
+
if (signature.also && !matchesAt(headBytes, signature.also.offset, signature.also.bytes)) continue;
|
|
273
|
+
if (signature.alsoAnyOf) {
|
|
274
|
+
const { offset, options } = signature.alsoAnyOf;
|
|
275
|
+
if (!options.some((candidate) => matchesAt(headBytes, offset, candidate))) continue;
|
|
276
|
+
}
|
|
277
|
+
return signature.type;
|
|
278
|
+
}
|
|
279
|
+
const head = headBytes.subarray(0, TEXT_SNIFF_LENGTH);
|
|
280
|
+
let decoded;
|
|
281
|
+
try {
|
|
282
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
287
|
+
const code = decoded.charCodeAt(i);
|
|
288
|
+
if (code < 32 && code !== 9 && code !== 10 && code !== 13) return null;
|
|
289
|
+
}
|
|
290
|
+
return SVG_OPENING.test(decoded) ? "svg" : "text";
|
|
291
|
+
}
|
|
292
|
+
/** Lowercase extension without the dot, or `null` when the name carries none. */
|
|
293
|
+
function extensionOf(filename) {
|
|
294
|
+
const lastDot = filename.lastIndexOf(".");
|
|
295
|
+
if (lastDot <= 0 || lastDot === filename.length - 1) return null;
|
|
296
|
+
return filename.slice(lastDot + 1).toLowerCase().trim();
|
|
297
|
+
}
|
|
298
|
+
/** Media type with parameters and casing stripped. */
|
|
299
|
+
function mediaTypeOf(declaredType) {
|
|
300
|
+
if (typeof declaredType !== "string") return "";
|
|
301
|
+
const [base = ""] = declaredType.split(";");
|
|
302
|
+
return base.trim().toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Require the declared type, the filename extension, and the actual bytes to agree on
|
|
306
|
+
* one permitted file type.
|
|
307
|
+
*
|
|
308
|
+
* The bytes are authoritative — they are the only one of the three a client cannot
|
|
309
|
+
* simply assert. The other two must be consistent with what the bytes actually are,
|
|
310
|
+
* which is what refuses `shell.php.jpg` (bytes are PHP source, extension claims JPEG)
|
|
311
|
+
* and an `avatar.jpg` declared as `application/x-httpd-php`.
|
|
312
|
+
*
|
|
313
|
+
* @param candidate - See {@link UploadCandidate}.
|
|
314
|
+
* @returns `{ ok: true, type }`, or `{ ok: false, reason, detail }`. Never throws.
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```ts
|
|
318
|
+
* const verdict = assertUploadType({
|
|
319
|
+
* declaredType: file.type,
|
|
320
|
+
* filename: file.name,
|
|
321
|
+
* headBytes: new Uint8Array(await file.slice(0, 256).arrayBuffer()),
|
|
322
|
+
* allow: ["png", "jpeg", "webp"],
|
|
323
|
+
* });
|
|
324
|
+
*
|
|
325
|
+
* if (!verdict.ok) return new Response(`Rejected: ${verdict.reason}`, { status: 400 });
|
|
326
|
+
*
|
|
327
|
+
* // Generate the stored name yourself — never reuse the client's.
|
|
328
|
+
* const stored = `${crypto.randomUUID()}.${verdict.type}`;
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
function assertUploadType(candidate) {
|
|
332
|
+
const { declaredType, filename, headBytes, allow } = candidate;
|
|
333
|
+
if (!(headBytes instanceof Uint8Array) || headBytes.length === 0) return {
|
|
334
|
+
ok: false,
|
|
335
|
+
reason: "no_bytes",
|
|
336
|
+
detail: "headBytes was empty"
|
|
337
|
+
};
|
|
338
|
+
const detected = detectFileSignature(headBytes);
|
|
339
|
+
if (detected === null) return {
|
|
340
|
+
ok: false,
|
|
341
|
+
reason: "unrecognized_signature",
|
|
342
|
+
detail: "leading bytes match no known file type"
|
|
343
|
+
};
|
|
344
|
+
if (!Array.isArray(allow) || !allow.includes(detected)) return {
|
|
345
|
+
ok: false,
|
|
346
|
+
reason: "type_not_allowed",
|
|
347
|
+
detail: `detected ${detected}, which is not in the allowlist`
|
|
348
|
+
};
|
|
349
|
+
const extension = extensionOf(typeof filename === "string" ? filename : "");
|
|
350
|
+
if (extension === null) return {
|
|
351
|
+
ok: false,
|
|
352
|
+
reason: "extension_missing",
|
|
353
|
+
detail: "filename carries no extension"
|
|
354
|
+
};
|
|
355
|
+
if (!EXTENSIONS_BY_TYPE[detected].includes(extension)) return {
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: "extension_mismatch",
|
|
358
|
+
detail: `bytes are ${detected} but the extension is .${extension}`
|
|
359
|
+
};
|
|
360
|
+
const media = mediaTypeOf(declaredType);
|
|
361
|
+
if (media.length > 0 && !MEDIA_TYPES_BY_TYPE[detected].includes(media)) return {
|
|
362
|
+
ok: false,
|
|
363
|
+
reason: "declared_type_mismatch",
|
|
364
|
+
detail: `bytes are ${detected} but Content-Type claimed ${media}`
|
|
365
|
+
};
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
type: detected
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
export { assertUploadType, detectFileSignature };
|
|
373
|
+
|
|
374
|
+
//# sourceMappingURL=upload.mjs.map
|
|
@@ -0,0 +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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"}
|
package/lib/crypto.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Brand, PositiveInt } from "@resq-systems/types";
|
|
2
|
-
|
|
3
2
|
//#region src/crypto.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* Base64 AES-256-GCM payload produced by {@link encryptData} — the
|
|
@@ -55,7 +54,13 @@ declare const unsafeCiphertext: (value: string) => Brand<string, "Ciphertext">;
|
|
|
55
54
|
* the salt/IV are recovered on decryption.
|
|
56
55
|
*
|
|
57
56
|
* @throws From the underlying Node crypto primitives if `encryptionKey`
|
|
58
|
-
* is empty or scrypt fails.
|
|
57
|
+
* is empty or scrypt fails. Failure surfaces as a rejected `Promise`,
|
|
58
|
+
* never a resolved error value.
|
|
59
|
+
*
|
|
60
|
+
* Draws from the platform CSPRNG (`randomBytes`) each call, so it is not
|
|
61
|
+
* a pure function and its output is non-deterministic. There is no
|
|
62
|
+
* `AbortSignal` hook — once awaited the scrypt work runs to completion.
|
|
63
|
+
* Independent calls share no state and are safe to run concurrently.
|
|
59
64
|
*
|
|
60
65
|
* @compliance NIST 800-53 SC-28 (Protection of Information at Rest),
|
|
61
66
|
* SC-13 (Cryptographic Protection).
|
|
@@ -81,7 +86,9 @@ declare function encryptData(plaintext: string, encryptionKey: EncryptionKey): P
|
|
|
81
86
|
*
|
|
82
87
|
* @throws Error if the tag does not verify (wrong key, modified
|
|
83
88
|
* ciphertext, truncated payload). Catch this and treat it as a
|
|
84
|
-
* security event, not a recoverable error.
|
|
89
|
+
* security event, not a recoverable error. The rejection comes back
|
|
90
|
+
* as a rejected `Promise`. No `AbortSignal` is honoured; concurrent
|
|
91
|
+
* calls are independent and share no state.
|
|
85
92
|
*
|
|
86
93
|
* @example
|
|
87
94
|
* ```ts
|
|
@@ -175,8 +182,14 @@ declare function maskEmail(email: string): Masked;
|
|
|
175
182
|
* key, e.g. `"token"` matches `"refreshToken"` and `"id_token"`.
|
|
176
183
|
*
|
|
177
184
|
* @returns A new object with sensitive fields redacted and emails
|
|
178
|
-
* masked.
|
|
179
|
-
*
|
|
185
|
+
* masked. Any non-null object value is recursed and comes back as a
|
|
186
|
+
* plain object keyed by its enumerable own properties — so arrays
|
|
187
|
+
* return as index-keyed objects (`["a"]` → `{ "0": "a" }`) and class
|
|
188
|
+
* instances / `Date`s lose their prototype. Only primitives, `null`,
|
|
189
|
+
* and `undefined` pass through unchanged.
|
|
190
|
+
*
|
|
191
|
+
* @throws {RangeError} On a circular reference — recursion has no cycle
|
|
192
|
+
* guard, so a self-referential object overflows the call stack.
|
|
180
193
|
*
|
|
181
194
|
* @example
|
|
182
195
|
* ```ts
|
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":";;;;;;;;KA2DY,aAAa;;;;;;KAOb,gBAAgB;;KAGhB,cAAc;;KAGd,YAAY;;KAGZ,SAAS;;cAiBR,kBAAe,kBAAA,SAAA;;cAEf,kBAAe,kBAAA;;cAEf,sBAAmB,kBAAA;;cAEnB,sBAAmB,kBAAA;;cAcnB,eAAY,kBAAA,SAAA;;cAEZ,eAAY,kBAAA;;cAEZ,mBAAgB,kBAAA;;cAEhB,mBAAgB,kBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA0DP,YACrB,mBACA,eAAe,gBACb,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;iBAoCW,YACrB,eAAe,YACf,eAAe,gBACb;;;;;;;;;;;;;;;;;;iBAqCa,SAAS,eAAe;;;;;;;;;;;;;;;;;;;iBAsBxB,oBAAoB,SAAQ,cAAkC;;;;;;;;;;;;;;;iBAkB9D,QAAQ,eAAe;;;;;;;;;;;;;;;;;iBAyBvB,UAAU,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiD1B,mBACf,KAAK,yBACL,6BAQE"}
|