@resq-systems/security 1.0.5 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +169 -0
- package/lib/controls/query.d.mts.map +1 -0
- package/lib/controls/query.mjs +386 -0
- package/lib/controls/query.mjs.map +1 -0
- package/lib/controls/redirect.d.mts +92 -0
- package/lib/controls/redirect.d.mts.map +1 -0
- package/lib/controls/redirect.mjs +110 -0
- package/lib/controls/redirect.mjs.map +1 -0
- package/lib/controls/upload.d.mts +108 -0
- package/lib/controls/upload.d.mts.map +1 -0
- package/lib/controls/upload.mjs +374 -0
- package/lib/controls/upload.mjs.map +1 -0
- package/lib/crypto.d.mts +18 -5
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +35 -24
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +51 -6
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +51 -6
- package/lib/hash.mjs.map +1 -1
- package/lib/index.d.mts +17 -2
- package/lib/index.mjs +19 -2
- package/lib/paths.d.mts +92 -0
- package/lib/paths.d.mts.map +1 -0
- package/lib/paths.mjs +140 -0
- package/lib/paths.mjs.map +1 -0
- package/lib/sanitize.d.mts +137 -35
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs +170 -46
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +59 -0
- package/lib/threats/capec.generated.d.mts.map +1 -0
- package/lib/threats/capec.generated.mjs +644 -0
- package/lib/threats/capec.generated.mjs.map +1 -0
- package/lib/threats/engine.d.mts +94 -0
- package/lib/threats/engine.d.mts.map +1 -0
- package/lib/threats/engine.mjs +167 -0
- package/lib/threats/engine.mjs.map +1 -0
- package/lib/threats/index.d.mts +11 -0
- package/lib/threats/index.mjs +11 -0
- package/lib/threats/rules/datastore.d.mts +13 -0
- package/lib/threats/rules/datastore.d.mts.map +1 -0
- package/lib/threats/rules/datastore.mjs +366 -0
- package/lib/threats/rules/datastore.mjs.map +1 -0
- package/lib/threats/rules/index.d.mts +54 -0
- package/lib/threats/rules/index.d.mts.map +1 -0
- package/lib/threats/rules/index.mjs +121 -0
- package/lib/threats/rules/index.mjs.map +1 -0
- package/lib/threats/rules/markup.d.mts +28 -0
- package/lib/threats/rules/markup.d.mts.map +1 -0
- package/lib/threats/rules/markup.mjs +373 -0
- package/lib/threats/rules/markup.mjs.map +1 -0
- package/lib/threats/rules/protocol.d.mts +49 -0
- package/lib/threats/rules/protocol.d.mts.map +1 -0
- package/lib/threats/rules/protocol.mjs +175 -0
- package/lib/threats/rules/protocol.mjs.map +1 -0
- package/lib/threats/rules/system.d.mts +19 -0
- package/lib/threats/rules/system.d.mts.map +1 -0
- package/lib/threats/rules/system.mjs +455 -0
- package/lib/threats/rules/system.mjs.map +1 -0
- package/lib/threats/rules/web.d.mts +26 -0
- package/lib/threats/rules/web.d.mts.map +1 -0
- package/lib/threats/rules/web.mjs +412 -0
- package/lib/threats/rules/web.mjs.map +1 -0
- package/lib/threats/scoring.d.mts +59 -0
- package/lib/threats/scoring.d.mts.map +1 -0
- package/lib/threats/scoring.mjs +111 -0
- package/lib/threats/scoring.mjs.map +1 -0
- package/lib/threats/types.d.mts +245 -0
- package/lib/threats/types.d.mts.map +1 -0
- package/lib/threats/types.mjs +52 -0
- package/lib/threats/types.mjs.map +1 -0
- package/lib/threats/variants.d.mts +57 -0
- package/lib/threats/variants.d.mts.map +1 -0
- package/lib/threats/variants.mjs +144 -0
- package/lib/threats/variants.mjs.map +1 -0
- package/lib/unicode/confusables.d.mts +82 -0
- package/lib/unicode/confusables.d.mts.map +1 -0
- package/lib/unicode/confusables.mjs +954 -0
- package/lib/unicode/confusables.mjs.map +1 -0
- package/lib/unicode/index.d.mts +126 -0
- package/lib/unicode/index.d.mts.map +1 -0
- package/lib/unicode/index.mjs +288 -0
- package/lib/unicode/index.mjs.map +1 -0
- package/lib/validators.d.mts +341 -164
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +519 -338
- package/lib/validators.mjs.map +1 -1
- package/package.json +35 -8
package/README.md
CHANGED
|
@@ -120,63 +120,266 @@ The crypto surface uses nominal (branded) types so a plain `string` cannot be pa
|
|
|
120
120
|
| `isCiphertext(value)` | type guard | `true` for a well-formed envelope |
|
|
121
121
|
| `unsafeCiphertext(value)` | `Ciphertext` | Brand without checking |
|
|
122
122
|
|
|
123
|
-
### Threat Detection (`
|
|
123
|
+
### Threat Detection (`threats/`)
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
> **Detection is not the control.** Every rule carries a `primaryControl` string naming
|
|
126
|
+
> what actually prevents the weakness. Parameterized queries stop SQL injection;
|
|
127
|
+
> context-correct output encoding and DOMPurify stop XSS; `resolveContainedPath` stops
|
|
128
|
+
> traversal; spawning with an argv array stops command injection. Use findings for
|
|
129
|
+
> telemetry, risk scoring, rate limiting, and review — never as the only barrier.
|
|
126
130
|
|
|
127
|
-
|
|
131
|
+
#### `scanForThreats(input, options?): ThreatScanResult`
|
|
128
132
|
|
|
129
|
-
|
|
133
|
+
Evaluates only the rules that apply to the **sink the value is bound for**. Declaring
|
|
134
|
+
the context is the package's primary false-positive control: a biography containing
|
|
135
|
+
`C:\Windows`, a ticket containing `1=1`, and a question containing `eval(` are all
|
|
136
|
+
ordinary text, and only become evidence when the value reaches a filesystem, SQL, or
|
|
137
|
+
HTML sink.
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
```ts
|
|
140
|
+
import { scanForThreats } from "@resq-systems/security/threats";
|
|
141
|
+
|
|
142
|
+
const result = scanForThreats(req.query.file ?? "", { contexts: ["filesystem"] });
|
|
143
|
+
|
|
144
|
+
if (result.verdict === "block") {
|
|
145
|
+
// Log an allowlist, never the findings themselves: `matchedPattern` carries an
|
|
146
|
+
// excerpt of the input, which for a `credential_exposure` hit is the credential.
|
|
147
|
+
logger.warn("traversal attempt", {
|
|
148
|
+
rules: result.findings.map(({ ruleId, type, severity, cwe }) => ({
|
|
149
|
+
ruleId,
|
|
150
|
+
type,
|
|
151
|
+
severity,
|
|
152
|
+
cwe,
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
return new Response("Bad request", { status: 400 });
|
|
156
|
+
}
|
|
157
|
+
```
|
|
141
158
|
|
|
142
|
-
|
|
159
|
+
| Option | Type | Default | Description |
|
|
160
|
+
|--------|------|---------|-------------|
|
|
161
|
+
| `contexts` | `ThreatContext[]` | `["general_text"]` | Sinks the value reaches. The default enables only universal rules (bidi, invisible, control chars) |
|
|
162
|
+
| `maxLength` | `number` | `100_000` | Truncation bound |
|
|
163
|
+
| `scanVariants` | `boolean` | `true` | Also scan NFC, percent-decoded, and HTML-decoded forms |
|
|
164
|
+
| `minSeverity` | `ThreatSeverity` | `"low"` | Drop findings below this severity |
|
|
165
|
+
| `excludeRuleIds` | `string[]` | — | Silence individual rules. Prefer this to disabling a category |
|
|
166
|
+
| `policy` | `ThreatPolicy` | `{ reviewAt: 4, blockAt: 8 }` | Score thresholds |
|
|
167
|
+
|
|
168
|
+
Returns `{ isSafe, score, verdict, findings, types, truncated }`. Each finding carries
|
|
169
|
+
`ruleId`, `type`, `severity`, `confidence`, `cwe`, `primaryControl`, `variant`,
|
|
170
|
+
`matchedPattern`, `start`, and `end`.
|
|
171
|
+
|
|
172
|
+
**Contexts:** `general_text`, `html`, `sql`, `nosql`, `shell`, `filesystem`, `url`,
|
|
173
|
+
`url_parameter`, `http_header`, `jwt`, `identifier`, `object_merge`, `template`, `xml`,
|
|
174
|
+
`ldap`, `xpath`, `spreadsheet`, `log`, `llm_prompt`.
|
|
175
|
+
|
|
176
|
+
Two draw deliberately fine distinctions. `url_parameter` is a *single value* about to be
|
|
177
|
+
concatenated into a query string, where `&role=` is an injected parameter — as opposed
|
|
178
|
+
to `url`, where it is ordinary grammar. `jwt` is scoped to the token itself; to check a
|
|
179
|
+
`kid` or `jku` claim, extract it and declare *its* real sink (`filesystem`, `sql`,
|
|
180
|
+
`url`), which the existing rules already cover.
|
|
181
|
+
|
|
182
|
+
**Categories:** `xss`, `sql_injection`, `nosql_injection`, `command_injection`,
|
|
183
|
+
`path_traversal`, `prototype_pollution`, `homoglyph`, `header_injection`,
|
|
184
|
+
`ldap_injection`, `xpath_injection`, `xml_injection`, `template_injection`,
|
|
185
|
+
`file_inclusion`, `ssrf`, `formula_injection`, `log_injection`, `prompt_injection`,
|
|
186
|
+
`parameter_pollution`, `credential_exposure`, `jwt_tampering`, `resource_abuse`.
|
|
187
|
+
|
|
188
|
+
`credential_exposure` runs the opposite way to every other category: it detects the
|
|
189
|
+
application's own secret *leaving* — in a URL it is about to fetch, or a line it is
|
|
190
|
+
about to log — so a finding usually means your code is at fault, not the submitter's.
|
|
191
|
+
|
|
192
|
+
**Canonicalization.** Each scan evaluates the raw string plus whichever of `nfc`,
|
|
193
|
+
`nfkc`, `percent_decoded`, and `html_decoded` differ from it, and the finding records
|
|
194
|
+
which one matched. `nfkc` matters more than it sounds: NFC is a documented no-op on
|
|
195
|
+
compatibility characters, so before it was added, fullwidth `../../etc/passwd` and
|
|
196
|
+
`<script>` bypassed every signature in the catalog.
|
|
197
|
+
|
|
198
|
+
#### Scoring
|
|
199
|
+
|
|
200
|
+
Any individual signature produces false positives, so a single low-confidence hit
|
|
201
|
+
raises a signal rather than rejecting a submission. Score is
|
|
202
|
+
`severityWeight x confidenceMultiplier`, counted once per rule:
|
|
203
|
+
|
|
204
|
+
| Severity | Weight | Confidence | Multiplier |
|
|
205
|
+
|----------|--------|------------|------------|
|
|
206
|
+
| `low` | 1 | `low` | 0.5 |
|
|
207
|
+
| `medium` | 2 | `medium` | 1 |
|
|
208
|
+
| `high` | 4 | `high` | 1.5 |
|
|
209
|
+
| `critical` | 8 | | |
|
|
210
|
+
|
|
211
|
+
`score < 4` is `allow`, `< 8` is `review`, `>= 8` is `block`.
|
|
212
|
+
|
|
213
|
+
Helpers: `calculateThreatScore`, `verdictForScore`, `scoreForFinding`,
|
|
214
|
+
`summarizeByType`, `THREAT_RULES`, `getRulesForContexts`, `buildInputVariants`.
|
|
143
215
|
|
|
144
216
|
#### Individual Detectors
|
|
145
217
|
|
|
146
|
-
|
|
218
|
+
Thin wrappers that scan one context and return at most one `ThreatFinding`:
|
|
219
|
+
|
|
220
|
+
| Function | Context | Detects |
|
|
221
|
+
|----------|---------|---------|
|
|
222
|
+
| `containsXSSPatterns(input)` | `html` | Script tags, event handlers, `javascript:` URIs, `eval()` |
|
|
223
|
+
| `containsPrototypePollution(input)` | `object_merge` | `__proto__`, `constructor.prototype` as property paths |
|
|
224
|
+
| `containsSQLInjection(input)` | `sql` | UNION SELECT, DROP TABLE, quoted tautologies, SLEEP, stacked queries |
|
|
225
|
+
| `containsNoSQLInjection(input)` | `nosql` | MongoDB operators (`$gt`, `$where`, `$function`) |
|
|
226
|
+
| `containsCommandInjection(input)` | `shell` | Command substitution, piped shells, chained commands |
|
|
227
|
+
| `containsPathTraversal(input)` | `filesystem` | Directory traversal, NUL bytes, sensitive paths |
|
|
228
|
+
| `containsHomoglyphs(input)` | `identifier` | UTS #39 mixed-script and bidirectional spoofing |
|
|
229
|
+
|
|
230
|
+
### Unicode Identifier Security (`unicode/`)
|
|
231
|
+
|
|
232
|
+
Scope these to **protected identifiers** — usernames, domains, org names, package
|
|
233
|
+
names. UTS #39 warns that broad confusable detection flags many legitimate strings, so
|
|
234
|
+
do not run them on prose or on people's names. `containsBidiControls` is the exception
|
|
235
|
+
and is safe anywhere.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { analyzeIdentifier } from "@resq-systems/security/unicode";
|
|
239
|
+
|
|
240
|
+
const candidate = analyzeIdentifier(requestedUsername);
|
|
241
|
+
if (await skeletonIndex.has(candidate.skeleton)) {
|
|
242
|
+
return { error: "That name is too similar to an existing account" };
|
|
243
|
+
}
|
|
244
|
+
// Store `candidate.original` for display, index `candidate.skeleton`.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
| Function | Purpose |
|
|
248
|
+
|----------|---------|
|
|
249
|
+
| `analyzeIdentifier(input)` | `{ original, normalized, skeleton, scripts, isMixedScript, restrictionLevel, hasInvisibleCharacters, hasBidiControls }` |
|
|
250
|
+
| `getSkeleton(input)` | Opaque confusable comparison key. Compare it, never display it |
|
|
251
|
+
| `areConfusable(a, b)` | Whether two distinct strings share a skeleton |
|
|
252
|
+
| `getScripts(input)` | Scripts present, script-neutral characters excluded |
|
|
253
|
+
| `getRestrictionLevel(input)` | `ascii_only`, `single_script`, `highly_restrictive`, `moderately_restrictive`, `minimally_restrictive`, `unrestricted` |
|
|
254
|
+
| `isSafeIdentifier(input, max?)` | Policy check, default max `moderately_restrictive` |
|
|
255
|
+
| `containsBidiControls(input)` | Trojan Source (CVE-2021-42574). Hostile in any field |
|
|
256
|
+
| `stripInvisibleCharacters(input)` | Remove zero-width and bidi code points |
|
|
257
|
+
|
|
258
|
+
`Ольга Иванова`, `東京タワー`, and `서울-Seoul` pass. `pаypal` (with a Cyrillic `а`) and
|
|
259
|
+
a filename carrying U+202E do not.
|
|
260
|
+
|
|
261
|
+
### Path Containment (`paths.ts`) — Node only
|
|
262
|
+
|
|
263
|
+
The prevention half of CWE-22. Not exported from the package root, since it imports
|
|
264
|
+
`node:path`.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { resolveContainedPath } from "@resq-systems/security/paths";
|
|
268
|
+
|
|
269
|
+
const target = resolveContainedPath("/srv/uploads", req.body.filename);
|
|
270
|
+
if (target === null) return new Response("Bad request", { status: 400 });
|
|
271
|
+
```
|
|
147
272
|
|
|
148
|
-
| Function |
|
|
273
|
+
| Function | Purpose |
|
|
149
274
|
|----------|---------|
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `containsCommandInjection(input)` | Command substitution, piped shells |
|
|
154
|
-
| `containsPathTraversal(input)` | Directory traversal, null bytes, sensitive paths |
|
|
155
|
-
| `containsHomoglyphs(input)` | Cyrillic/Greek lookalike characters |
|
|
275
|
+
| `resolveContainedPath(base, untrusted, opts?)` | Resolved absolute path, or `null` when it escapes the base |
|
|
276
|
+
| `isPathContained(base, candidate, opts?)` | Boolean form |
|
|
277
|
+
| `sanitizeFilename(name, fallback?)` | Reduce to one safe path segment. Hygiene, not the control |
|
|
156
278
|
|
|
157
|
-
|
|
279
|
+
Performs **no** filesystem I/O, so it cannot see symlinks. When the target may exist and
|
|
280
|
+
may be a link, `realpath` both sides and re-check.
|
|
158
281
|
|
|
159
|
-
|
|
282
|
+
### Preventive Controls (`controls/`) — Node only
|
|
160
283
|
|
|
161
|
-
|
|
284
|
+
Some weaknesses cannot be detected, only prevented. A forged CSRF request is
|
|
285
|
+
byte-identical to a genuine one; `Origin: https://evil.example` is shaped exactly like a
|
|
286
|
+
legitimate origin; an upload's danger lies in three values *disagreeing*. Each of these
|
|
287
|
+
is a decision function that fails closed.
|
|
162
288
|
|
|
163
|
-
|
|
289
|
+
```ts
|
|
290
|
+
import {
|
|
291
|
+
assertUploadType,
|
|
292
|
+
isAllowedOrigin,
|
|
293
|
+
verifyCsrfToken,
|
|
294
|
+
} from "@resq-systems/security/controls";
|
|
295
|
+
|
|
296
|
+
if (!isAllowedOrigin(req.headers.origin ?? "", ALLOWED_ORIGINS)) return forbid();
|
|
297
|
+
|
|
298
|
+
const csrf = verifyCsrfToken(req.headers["x-csrf-token"], SECRET, {
|
|
299
|
+
sessionId: session.id,
|
|
300
|
+
});
|
|
301
|
+
if (!csrf.valid) return forbid();
|
|
302
|
+
```
|
|
164
303
|
|
|
165
|
-
|
|
304
|
+
| Function | Prevents |
|
|
305
|
+
|----------|----------|
|
|
306
|
+
| `isAllowedOrigin(origin, allowlist, opts?)` | CORS misconfiguration. Exact match only — no prefix, suffix, or substring path exists through it. `null` and `*` refused; subdomain matching is opt-in and label-boundary anchored |
|
|
307
|
+
| `normalizeOrigin(origin)` | Returns the canonical origin, or `null` when the value carries a path, query, or userinfo |
|
|
308
|
+
| `checkCorsResponsePolicy(policy)` | The credentialed-wildcard mistake (`ACAO: *` with `ACAC: true`) |
|
|
309
|
+
| `createCsrfToken(secret, opts?)` / `verifyCsrfToken(token, secret, opts?)` | CSRF. Signed double-submit: HMAC-SHA256 over length-prefixed fields, constant-time length-blind comparison, signed expiry, optional session binding |
|
|
310
|
+
| `assertUploadType(candidate)` | Unrestricted upload. Requires the declared `Content-Type`, the filename extension, and the magic bytes to agree on one allowlisted type |
|
|
311
|
+
| `detectFileSignature(headBytes)` | Identifies a file from its leading bytes |
|
|
312
|
+
| `validateJsonpCallback(name)` | XSSI. A JSONP callback name is concatenated into executable JavaScript, so an allowlist is the only safe validation |
|
|
313
|
+
| `analyzeQueryComplexity(query, limits?)` | Query-depth denial of service. Computed depth/alias/field bound, string- and comment-aware |
|
|
314
|
+
| `analyzeGraphQLRequest(body, limits?)` | Batched-request denial of service (API4). `analyzeQueryComplexity` measures one document; on an array batch the documented `req.body.query` call reads `undefined` and passes 250 operations. Counts top-level operations across the batch |
|
|
315
|
+
| `resolveRedirectTarget(target, opts?)` | Open redirect (CWE-601). Allowlist: a same-site path, or an absolute URL whose host you named. Tests for control characters *before* testing for an authority, because tab/LF/CR escape the origin and the authority test cannot see them |
|
|
316
|
+
| `classifyAddress(host)` / `isPubliclyRoutableAddress(host)` | Classifies an IP literal against the IANA special-purpose registries, unwrapping IPv4-mapped IPv6. Returns `null` for a name — *unknown*, not safe |
|
|
317
|
+
| `assertOutboundUrl(url, policy?)` | SSRF. Default-deny: with no allowlist and `allowPublicHosts` off, even a hostname is refused, because nothing here can know what DNS will answer. A pre-connection check — redirects need re-validation per hop and egress control remains the durable fix |
|
|
318
|
+
| `checkJsonPayloadLimits(text, limits?)` | Unrestricted resource consumption (API4). One linear pass over the JSON *text* — depth, container sizes, string lengths — before `JSON.parse` allocates the graph. Reports rather than enforces |
|
|
319
|
+
|
|
320
|
+
`sanitizeHtml` also registers a DOMPurify hook adding `rel="noopener noreferrer"` to
|
|
321
|
+
links with a non-self `target`, preventing reverse tabnabbing. It is a no-op under
|
|
322
|
+
DOMPurify's default config, which strips `target` — it matters when you opt back in with
|
|
323
|
+
`ADD_ATTR: ["target"]`.
|
|
324
|
+
|
|
325
|
+
**Each of these is one layer.** CSRF tokens need `SameSite` cookies and origin validation
|
|
326
|
+
beside them; `assertUploadType` reads only the head, so store uploads outside the webroot
|
|
327
|
+
and serve them from a separate origin with `Content-Disposition: attachment`. See
|
|
328
|
+
[WSTG-COVERAGE.md](WSTG-COVERAGE.md) §3 for what each one does *not* cover.
|
|
329
|
+
|
|
330
|
+
### Output Encoding and Field Validators (`validators.ts`)
|
|
331
|
+
|
|
332
|
+
Output encoding is context-dependent — HTML text, attributes, URLs, JS strings, and CSS
|
|
333
|
+
each have different rules, and no single function is correct for all of them.
|
|
334
|
+
|
|
335
|
+
| Function | Use for |
|
|
336
|
+
|----------|---------|
|
|
337
|
+
| `escapeHtmlText(input)` | Element text and fully quoted attribute values |
|
|
338
|
+
| `escapeHtmlAttribute(input)` | Attribute values, including unquoted ones |
|
|
339
|
+
| `sanitizeUrl(url)` | URLs (see Sanitization below) |
|
|
340
|
+
| `sanitizeHtml(html)` | Values meant to *be* markup — DOMPurify |
|
|
166
341
|
|
|
167
|
-
|
|
342
|
+
There is deliberately no JavaScript- or CSS-context escaper: hand-rolled versions are
|
|
343
|
+
reliably wrong, and the fix is to stop interpolating untrusted values into script and
|
|
344
|
+
style source.
|
|
168
345
|
|
|
169
|
-
#### `
|
|
346
|
+
#### `validatePersonName(input): boolean`
|
|
347
|
+
|
|
348
|
+
Allowlist of what a name is made of — letters in any script, combining marks,
|
|
349
|
+
apostrophes, hyphens, periods, spaces — plus a length bound and a bidi check. It does
|
|
350
|
+
**not** run SQL, path-traversal, or confusable detectors: a name is not a query, a path,
|
|
351
|
+
or a protected identifier. Encode the value at whatever sink it reaches.
|
|
352
|
+
|
|
353
|
+
#### `validateSafeText(input): boolean`
|
|
170
354
|
|
|
171
|
-
|
|
355
|
+
Schema-refinement helper over `isSafeInput` with default config.
|
|
172
356
|
|
|
173
357
|
#### `validateSafeEmail(input): boolean`
|
|
174
358
|
|
|
175
|
-
|
|
359
|
+
RFC-shaped format check plus UTS #39 analysis of the **domain**, where a mixed-script
|
|
360
|
+
host is the IDN homograph attack.
|
|
176
361
|
|
|
177
362
|
#### `getThreatErrorMessage(result): string`
|
|
178
363
|
|
|
179
|
-
|
|
364
|
+
User-facing message for the first finding only — enumerating every category that fired
|
|
365
|
+
leaks the rule set to whoever is probing it. Server-side, log an allowlist of each
|
|
366
|
+
finding's `ruleId`, `type`, `severity` and `cwe` — enough to investigate with. Do not log
|
|
367
|
+
the `ThreatFinding` itself: `matchedPattern` is an excerpt of the input, so for a
|
|
368
|
+
`credential_exposure` or `pii_exposure` hit the log line becomes the leak.
|
|
369
|
+
|
|
370
|
+
#### Deprecated
|
|
371
|
+
|
|
372
|
+
| Deprecated | Replacement |
|
|
373
|
+
|------------|-------------|
|
|
374
|
+
| `detectThreatPatterns(input, config)` | `scanForThreats(input, { contexts })` |
|
|
375
|
+
| `ThreatDetectionConfig` | `ThreatScanOptions.contexts` |
|
|
376
|
+
| `sanitizeForDisplay(input)` | `escapeHtmlText(input)` |
|
|
377
|
+
| `normalizeUnicode(input)` | `getSkeleton` / `analyzeIdentifier` |
|
|
378
|
+
| `validateSafeName(input)` | `validatePersonName(input)` |
|
|
379
|
+
|
|
380
|
+
`isSafeInput` and the legacy toggles still work; each flag maps onto a context
|
|
381
|
+
(`checkXSS` to `html`, `checkSQLInjection` to `sql`, `checkNoSQLInjection` to `nosql`,
|
|
382
|
+
`checkCommandInjection` to `shell`, `checkPathTraversal` to `filesystem`).
|
|
180
383
|
|
|
181
384
|
### Sanitization (`sanitize.ts`)
|
|
182
385
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
//#region src/controls/address.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 IP address classification and outbound-URL policy — the SSRF control
|
|
19
|
+
* the detection rules name but cannot themselves provide.
|
|
20
|
+
*
|
|
21
|
+
* The signatures in `threats/rules/system.ts` match literal addresses. A hostname whose
|
|
22
|
+
* DNS record resolves to `169.254.169.254` passes every one of them, which is why those
|
|
23
|
+
* rules point here instead.
|
|
24
|
+
*
|
|
25
|
+
* @module @resq-systems/security/controls/address
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* What an address is reserved for, per the IANA special-purpose registries.
|
|
29
|
+
*
|
|
30
|
+
* `public` means "in no special-purpose range" — routable on the internet. Every other
|
|
31
|
+
* value is a reason not to fetch it from a server.
|
|
32
|
+
*/
|
|
33
|
+
type AddressClassification = "unspecified" | "loopback" | "private" | "link_local" | "carrier_nat" | "multicast" | "broadcast" | "documentation" | "benchmarking" | "unique_local" | "teredo" | "six_to_four" | "nat64" | "reserved" | "public";
|
|
34
|
+
/** Why an outbound URL was refused. */
|
|
35
|
+
type OutboundRejectionReason =
|
|
36
|
+
/** Not parseable as a URL. */
|
|
37
|
+
"malformed" |
|
|
38
|
+
/** Scheme outside the permitted set. */
|
|
39
|
+
"protocol_not_allowed" |
|
|
40
|
+
/** Port outside the permitted set. */
|
|
41
|
+
"port_not_allowed" |
|
|
42
|
+
/** An IP literal in a range that is not publicly routable. */
|
|
43
|
+
"address_not_routable" |
|
|
44
|
+
/** A routable address, or a name, that policy does not permit. */
|
|
45
|
+
"host_not_allowed";
|
|
46
|
+
/** Outcome of {@link assertOutboundUrl}. */
|
|
47
|
+
type OutboundUrlVerdict = {
|
|
48
|
+
readonly allowed: true;
|
|
49
|
+
readonly url: URL;
|
|
50
|
+
/** `null` when the host is a name rather than an IP literal. */
|
|
51
|
+
readonly classification: AddressClassification | null;
|
|
52
|
+
} | {
|
|
53
|
+
readonly allowed: false;
|
|
54
|
+
readonly reason: OutboundRejectionReason;
|
|
55
|
+
};
|
|
56
|
+
/** Policy for {@link assertOutboundUrl}. */
|
|
57
|
+
interface OutboundUrlPolicy {
|
|
58
|
+
/**
|
|
59
|
+
* Hosts permitted regardless of classification, compared case-insensitively against
|
|
60
|
+
* the parsed host. The allowlist the OWASP cheat sheet asks for.
|
|
61
|
+
*/
|
|
62
|
+
readonly allowedHosts?: readonly string[];
|
|
63
|
+
/** Schemes permitted. Defaults to `["https:"]`. */
|
|
64
|
+
readonly allowedProtocols?: readonly string[];
|
|
65
|
+
/** Ports permitted in addition to the scheme's default. Defaults to none. */
|
|
66
|
+
readonly allowedPorts?: readonly number[];
|
|
67
|
+
/**
|
|
68
|
+
* Permit any host not in a reserved range — every public address, and every name.
|
|
69
|
+
*
|
|
70
|
+
* Defaults to `false`, and that default is the point. A name is not an address: this
|
|
71
|
+
* function cannot know whether `metadata.example.com` resolves to a public address or
|
|
72
|
+
* to `169.254.169.254`. With no allowlist and this flag off, a name is refused.
|
|
73
|
+
* Turning it on converts the control from an allowlist into a denylist over literals
|
|
74
|
+
* only, which does not stop DNS from pointing inward.
|
|
75
|
+
*/
|
|
76
|
+
readonly allowPublicHosts?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Classify a host as an IP address range, or `null` when it is not an IP literal.
|
|
80
|
+
*
|
|
81
|
+
* `null` is the answer for every domain name, and a caller must treat it as *unknown*
|
|
82
|
+
* rather than safe — conflating the two is the classic fail-open in this kind of check.
|
|
83
|
+
* {@link assertOutboundUrl} handles it explicitly.
|
|
84
|
+
*
|
|
85
|
+
* IPv4-mapped IPv6 addresses are unwrapped and classified by the address they carry, so
|
|
86
|
+
* `::ffff:169.254.169.254` is `link_local` rather than merely "some IPv6 address". That
|
|
87
|
+
* form matters in practice: `new URL("http://[::ffff:169.254.169.254]/").hostname`
|
|
88
|
+
* returns the bracketed, hex-compressed `[::ffff:a9fe:a9fe]`, which a string check misses.
|
|
89
|
+
*
|
|
90
|
+
* @param host - Hostname or IP literal, with or without IPv6 brackets.
|
|
91
|
+
* @returns The classification, or `null` when `host` is not an IP literal.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* classifyAddress("169.254.169.254"); // "link_local"
|
|
96
|
+
* classifyAddress("172.32.0.1"); // "public" — just outside 172.16/12
|
|
97
|
+
* classifyAddress("[::ffff:a9fe:a9fe]"); // "link_local"
|
|
98
|
+
* classifyAddress("metadata.example.com"); // null — a name, not an address
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
declare function classifyAddress(host: string): AddressClassification | null;
|
|
102
|
+
/**
|
|
103
|
+
* Whether a host is an IP literal in a publicly routable range.
|
|
104
|
+
*
|
|
105
|
+
* @param host - Hostname or IP literal.
|
|
106
|
+
* @returns `true` only for a routable IP literal. A domain name returns `false`, because
|
|
107
|
+
* this function cannot know what it resolves to.
|
|
108
|
+
*/
|
|
109
|
+
declare function isPubliclyRoutableAddress(host: string): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Decide whether a server may fetch a caller-supplied URL.
|
|
112
|
+
*
|
|
113
|
+
* The control the SSRF rules name. Those rules match literal addresses inside a string;
|
|
114
|
+
* this decides whether the request should be made at all.
|
|
115
|
+
*
|
|
116
|
+
* **Default deny, exhaustively.** Every path ends in an explicit allow or an explicit
|
|
117
|
+
* refusal. That is deliberate: `classifyAddress` returns `null` for every domain name, so
|
|
118
|
+
* a policy shaped "reject non-public *literals*" silently permits every name — the exact
|
|
119
|
+
* fail-open this control exists to prevent. With no `allowedHosts` and `allowPublicHosts`
|
|
120
|
+
* off, a name is refused.
|
|
121
|
+
*
|
|
122
|
+
* **A pre-connection check, and it cannot be more.** The name is resolved by the network
|
|
123
|
+
* stack after this returns, so DNS may answer differently then (rebinding); redirects
|
|
124
|
+
* need the same check applied per hop; neither is closable by a synchronous function.
|
|
125
|
+
* Network-layer egress control remains the durable fix — this narrows the window rather
|
|
126
|
+
* than shutting it.
|
|
127
|
+
*
|
|
128
|
+
* @param candidate - The URL to fetch, as text or a parsed `URL`.
|
|
129
|
+
* @param policy - See {@link OutboundUrlPolicy}. Defaults refuse everything not named.
|
|
130
|
+
* @returns A discriminated verdict. Never throws.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const verdict = assertOutboundUrl(webhookUrl, { allowedHosts: ["hooks.partner.example"] });
|
|
135
|
+
* if (!verdict.allowed) return reject(verdict.reason);
|
|
136
|
+
* await fetch(verdict.url);
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
declare function assertOutboundUrl(candidate: string | URL, policy?: OutboundUrlPolicy): OutboundUrlVerdict;
|
|
140
|
+
//#endregion
|
|
141
|
+
export { AddressClassification, OutboundRejectionReason, OutboundUrlPolicy, OutboundUrlVerdict, assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress };
|
|
142
|
+
//# sourceMappingURL=address.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"address.d.mts","names":[],"sources":["../../src/controls/address.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAmCY;;KAkBA;;;;;;;;;;;;KAaA;WAEA;WACA,KAAK;;WAEL,gBAAgB;;WAEd;WAAyB,QAAQ;;;UAG9B;;;;;WAKP;;WAEA;;WAEA;;;;;;;;;;WAUA;;;;;;;;;;;;;;;;;;;;;;;;;iBAiLM,gBAAgB,eAAe;;;;;;;;iBA4B/B,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwC1B,kBACf,oBAAoB,KACpB,SAAQ,oBACN"}
|