@suveren/gateway 0.36.1 → 0.36.2
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/content/integrations/erp.json +1 -1
- package/dist/mcp-server/http.mjs +36 -6
- package/node_modules/fast-uri/.github/workflows/release.yml +35 -0
- package/node_modules/fast-uri/README.md +8 -0
- package/node_modules/fast-uri/index.js +98 -18
- package/node_modules/fast-uri/lib/utils.js +52 -0
- package/node_modules/fast-uri/package.json +1 -1
- package/node_modules/fast-uri/test/host-delimiter-security.test.js +94 -0
- package/node_modules/fast-uri/test/relative-normalization-security.test.js +156 -0
- package/node_modules/fast-uri/test/reserved-path-normalization.test.js +32 -4
- package/node_modules/fast-uri/test/scheme-validation.test.js +53 -14
- package/package.json +1 -1
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -4801,6 +4801,15 @@ async function readPreview(integrationManager2, integrationId, previewTool, args
|
|
|
4801
4801
|
};
|
|
4802
4802
|
}
|
|
4803
4803
|
}
|
|
4804
|
+
function unavailableReadMessage(integrationManager2, read, connector, tool, declaredAt) {
|
|
4805
|
+
if (read.reason === "no_connector") {
|
|
4806
|
+
const running = integrationManager2.getAllTools().some((t) => t.integrationId === connector);
|
|
4807
|
+
if (running) {
|
|
4808
|
+
return `Connector "${connector}" is running but has no tool "${tool}" (declared in ${declaredAt}) \u2014 the installed connector version does not offer it.`;
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
return read.message ?? `Connector "${connector}" could not be reached.`;
|
|
4812
|
+
}
|
|
4804
4813
|
function hashPreviewBody(body) {
|
|
4805
4814
|
if (body.structured !== void 0) {
|
|
4806
4815
|
return computeContentHash({ kind: "jcs", version: "1" }, body.structured);
|
|
@@ -5461,7 +5470,8 @@ async function resolveDocumentReference(integrationManager2, ref) {
|
|
|
5461
5470
|
return { ok: false, error: { code: "NOT_FOUND", message: read.message ?? `No document "${ref.kind}"/"${ref.id}" on "${ref.connector}".` } };
|
|
5462
5471
|
}
|
|
5463
5472
|
if (read.status !== "ok") {
|
|
5464
|
-
|
|
5473
|
+
const message = unavailableReadMessage(integrationManager2, read, ref.connector, docConfig.tool, `documents.${ref.kind}`);
|
|
5474
|
+
return { ok: false, error: { code: "UNAVAILABLE", message } };
|
|
5465
5475
|
}
|
|
5466
5476
|
const body = structuredOrJsonText(read.body);
|
|
5467
5477
|
if (!body || typeof body !== "object") {
|
|
@@ -5542,7 +5552,8 @@ async function resolveInsertValue(integrationManager2, ref) {
|
|
|
5542
5552
|
return { ok: false, error: { code: "NOT_FOUND", message: read.message ?? `No value "${ref.kind}"/"${ref.id}" on "${ref.connector}".` } };
|
|
5543
5553
|
}
|
|
5544
5554
|
if (read.status !== "ok") {
|
|
5545
|
-
|
|
5555
|
+
const message = unavailableReadMessage(integrationManager2, read, ref.connector, valueConfig.tool, `values.${ref.kind}`);
|
|
5556
|
+
return { ok: false, error: { code: "UNAVAILABLE", message } };
|
|
5546
5557
|
}
|
|
5547
5558
|
const body = structuredOrJsonText(read.body);
|
|
5548
5559
|
if (!body || typeof body !== "object") {
|
|
@@ -6829,7 +6840,9 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
|
|
|
6829
6840
|
content: [{
|
|
6830
6841
|
type: "text",
|
|
6831
6842
|
text: `Awaiting commitment from domain${auth.deferredCommitmentDomains.length > 1 ? "s" : ""} "${auth.deferredCommitmentDomains.join('", "')}" for tool ${tool.originalName}.
|
|
6832
|
-
Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposal_id: "${proposal.id}").` + await unreadableForNote(unreadableFor ?? [], state2)
|
|
6843
|
+
Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposal_id: "${proposal.id}").` + await unreadableForNote(unreadableFor ?? [], state2) + // Optional wait (2026-10-19): the AI can stay on the line instead of ending its turn.
|
|
6844
|
+
`
|
|
6845
|
+
Tell the person what you proposed and that it waits for their approval in the gateway; then you may wait for the decision: check-pending-commitments(proposal_id: "${proposal.id}", wait_seconds: 50).`
|
|
6833
6846
|
}]
|
|
6834
6847
|
};
|
|
6835
6848
|
} catch (err) {
|
|
@@ -8043,6 +8056,18 @@ Reason: ${detail}` : "";
|
|
|
8043
8056
|
isError: false
|
|
8044
8057
|
};
|
|
8045
8058
|
}
|
|
8059
|
+
var MAX_WAIT_SECONDS = 50;
|
|
8060
|
+
var WAIT_POLL_MS = 2e3;
|
|
8061
|
+
async function getProposalWaiting(get, id, waitSeconds, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now) {
|
|
8062
|
+
const waitMs = Math.min(Math.max(Number(waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1e3;
|
|
8063
|
+
const deadline = now() + waitMs;
|
|
8064
|
+
let match = await get(id);
|
|
8065
|
+
while (match && match.status === "pending" && now() < deadline) {
|
|
8066
|
+
await sleep2(Math.min(WAIT_POLL_MS, deadline - now()));
|
|
8067
|
+
match = await get(id);
|
|
8068
|
+
}
|
|
8069
|
+
return match;
|
|
8070
|
+
}
|
|
8046
8071
|
function checkPendingCommitmentsHandler(state2, integrationManager2) {
|
|
8047
8072
|
return async (args) => {
|
|
8048
8073
|
if (!state2.spClient.isUnlocked()) {
|
|
@@ -8050,7 +8075,11 @@ function checkPendingCommitmentsHandler(state2, integrationManager2) {
|
|
|
8050
8075
|
}
|
|
8051
8076
|
try {
|
|
8052
8077
|
if (args.proposal_id) {
|
|
8053
|
-
const match = await
|
|
8078
|
+
const match = await getProposalWaiting(
|
|
8079
|
+
(id) => state2.spClient.getProposalById(id),
|
|
8080
|
+
args.proposal_id,
|
|
8081
|
+
args.wait_seconds
|
|
8082
|
+
);
|
|
8054
8083
|
if (!match) {
|
|
8055
8084
|
return {
|
|
8056
8085
|
content: [{
|
|
@@ -8304,9 +8333,10 @@ function createMcpServer(state2, integrationManager2) {
|
|
|
8304
8333
|
server.registerTool(
|
|
8305
8334
|
"check-pending-commitments",
|
|
8306
8335
|
{
|
|
8307
|
-
description: "Check status of pending proposals awaiting domain owner commitment. Call with a proposal_id to check a specific proposal, or without to see all.",
|
|
8336
|
+
description: "Check status of pending proposals awaiting domain owner commitment. Call with a proposal_id to check a specific proposal, or without to see all. With a proposal_id you may also pass wait_seconds (max 50): the gateway then waits until the person decides and answers at once \u2014 first tell the person what you proposed and that it waits for their approval in the gateway.",
|
|
8308
8337
|
inputSchema: {
|
|
8309
|
-
proposal_id: z2.string().optional().describe("Specific proposal ID to check. Omit to see all.")
|
|
8338
|
+
proposal_id: z2.string().optional().describe("Specific proposal ID to check. Omit to see all."),
|
|
8339
|
+
wait_seconds: z2.number().int().min(0).max(50).optional().describe("Only with proposal_id: wait up to this many seconds (max 50) while the proposal is still pending; answers as soon as the person decides. Omit or 0 to answer at once.")
|
|
8310
8340
|
}
|
|
8311
8341
|
},
|
|
8312
8342
|
withOutcomeOutbox(checkPendingCommitmentsHandler(state2, integrationManager2))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Release the package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
semver:
|
|
7
|
+
description: 'Release bump type'
|
|
8
|
+
required: true
|
|
9
|
+
type: choice
|
|
10
|
+
options:
|
|
11
|
+
- patch
|
|
12
|
+
- minor
|
|
13
|
+
- major
|
|
14
|
+
- prepatch
|
|
15
|
+
- preminor
|
|
16
|
+
- premajor
|
|
17
|
+
- prerelease
|
|
18
|
+
preid:
|
|
19
|
+
description: 'Prerelease identifier (e.g. rc), used by the pre* bump types'
|
|
20
|
+
required: false
|
|
21
|
+
type: string
|
|
22
|
+
|
|
23
|
+
permissions: {}
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
release:
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write # required for npm provenance via OIDC
|
|
29
|
+
contents: write # required to push the release commit and the tag
|
|
30
|
+
uses: fastify/workflows/.github/workflows/reusable-release.yml@b0a2ab2d8259b6bd574b804cd6874bbd75c3577c
|
|
31
|
+
with:
|
|
32
|
+
semver: ${{ inputs.semver }}
|
|
33
|
+
preid: ${{ inputs.preid }}
|
|
34
|
+
dist-tag: ${{ startsWith(inputs.semver, 'pre') && 'three-next' || 'three' }}
|
|
35
|
+
make-latest: false
|
|
@@ -14,12 +14,18 @@ All of the above functions can accept an additional options argument that is an
|
|
|
14
14
|
|
|
15
15
|
Malformed authorities and out-of-range ports are reported through the parsed component's `error` field. `normalize()` leaves malformed string inputs unchanged, and `equal()` returns `false` when either string input is malformed.
|
|
16
16
|
|
|
17
|
+
Schemes are never percent-decoded. RFC 3986 does not allow percent-encoding in a scheme, so an input such as `%6Aavascript:alert(1)` is reported as malformed (`error: 'URI scheme is malformed.'`) instead of being parsed as `javascript:`. `normalize()` leaves it unchanged, `equal()` returns `false`, and `resolve()` and `serialize()` throw.
|
|
18
|
+
|
|
19
|
+
For schemes with domain hosts (`http`, `https`, `ws`, `wss`, or any scheme with the `domainHost` option), a host containing an ASCII character that RFC 3986 does not allow in a reg-name (for example `@`, TAB, LF, CR, space, `"`, `<`, `>`, `^`, `` ` ``, `{`, `|`, `}`) is reported as malformed (`error: 'URI host is malformed.'`). It is never passed through a WHATWG URL parser that could treat `@` as a delimiter or strip whitespace. `normalize()` leaves such input unchanged, `equal()` returns `false`, and `resolve()` throws.
|
|
20
|
+
|
|
17
21
|
* `scheme` (string)
|
|
18
22
|
Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
|
|
19
23
|
|
|
20
24
|
* `reference` (string)
|
|
21
25
|
If set to `"suffix"`, it indicates that the URI is in the suffix format and the parser will use the option's `scheme` property to determine the URI's scheme.
|
|
22
26
|
|
|
27
|
+
A suffix reference is written without its scheme and without the `//` authority marker (RFC 3986 section 4.5). Such a reference is inherently ambiguous: an authority component cannot always be distinguished from a path. When the input carries an authority of the form `host:port` (for example `//javascript:80/x`) or a userinfo containing a colon, the serialized suffix output can begin with text that reparses as a URI scheme (for example `javascript:80/x`). Do not treat suffix output as a trusted absolute URL, and do not render it or use it in a scheme allowlist without first validating it with `parse()`. If you need an unambiguous result, use the default (non-suffix) serialization.
|
|
28
|
+
|
|
23
29
|
* `tolerant` (boolean, false)
|
|
24
30
|
If set to `true`, the parser will relax URI resolving rules.
|
|
25
31
|
|
|
@@ -79,6 +85,8 @@ uri.normalize('http://example.com/a%2Fb')
|
|
|
79
85
|
|
|
80
86
|
Reserved path escapes such as `%2F` and `%2E` are preserved as path data during normalization and comparison.
|
|
81
87
|
|
|
88
|
+
Dot segments in relative references are removed without changing what the reference resolves to: leading `..` segments that cannot be collapsed are kept, a relative path is never turned into a root-relative one, and a first-segment colon exposed by the removal is kept behind a leading `./` so the reference is not reparsed as a scheme (`a/../b` → `b`, `../x` → `../x`, `x/..` → `./`, `a/../a:b` → `./a:b`).
|
|
89
|
+
|
|
82
90
|
### Equal
|
|
83
91
|
|
|
84
92
|
```js
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
|
|
3
|
+
const { normalizeIPv6, removeDotSegments, removeDotSegmentsRelative, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
|
|
4
4
|
const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
|
|
5
5
|
|
|
6
6
|
const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u
|
|
7
7
|
const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
|
|
8
|
+
const MALFORMED_HOST_ERROR = 'URI host is malformed.'
|
|
9
|
+
// ASCII characters outside the RFC 3986 reg-name set (unreserved, sub-delims
|
|
10
|
+
// and "%" for pct-encoded): C0 controls, space, DEL, and "#/:<>?@[\]^`{|}.
|
|
11
|
+
const FORBIDDEN_DOMAIN_HOST_CHARACTER = /[\0-\x20"#/:<>?@[\\\]^`{|}\x7F]/u
|
|
8
12
|
|
|
9
13
|
/**
|
|
14
|
+
* RFC 3986 section 3.1 does not allow percent-encoding in a scheme, so the
|
|
15
|
+
* scheme is validated as-is and never decoded. Decoding would let inert input
|
|
16
|
+
* such as `%6Aavascript:` become a live `javascript:` scheme.
|
|
17
|
+
*
|
|
10
18
|
* @param {string} scheme
|
|
11
19
|
* @returns {string}
|
|
12
20
|
*/
|
|
13
|
-
function
|
|
14
|
-
const
|
|
15
|
-
if (!VALID_SCHEME.test(
|
|
21
|
+
function validateScheme (scheme) {
|
|
22
|
+
const stringScheme = String(scheme)
|
|
23
|
+
if (!VALID_SCHEME.test(stringScheme)) {
|
|
16
24
|
throw new TypeError(MALFORMED_SCHEME_ERROR)
|
|
17
25
|
}
|
|
18
|
-
return
|
|
26
|
+
return stringScheme
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -76,6 +84,12 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
76
84
|
const resolvedHost = resolved.host
|
|
77
85
|
const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
|
|
78
86
|
(isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
|
|
87
|
+
// Inputs are parsed scheme-neutrally, so delimiters and controls in a domain
|
|
88
|
+
// host are only detected here, once the final scheme is known. They always
|
|
89
|
+
// fail closed, including in percent-encoded hosts.
|
|
90
|
+
if (hasForbiddenDomainHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)) {
|
|
91
|
+
throw new Error(MALFORMED_HOST_ERROR)
|
|
92
|
+
}
|
|
79
93
|
canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
|
|
80
94
|
// Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
|
|
81
95
|
// parser can reject them even though fast-uri preserves them safely as RFC
|
|
@@ -133,14 +147,17 @@ function resolveComponent (base, relative, options, skipNormalization) {
|
|
|
133
147
|
if (relative.path[0] === '/') {
|
|
134
148
|
target.path = removeDotSegments(relative.path)
|
|
135
149
|
} else {
|
|
136
|
-
|
|
150
|
+
const baseHasAuthority = base.userinfo !== undefined || base.host !== undefined || base.port !== undefined
|
|
151
|
+
if (baseHasAuthority && !base.path) {
|
|
137
152
|
target.path = '/' + relative.path
|
|
138
153
|
} else if (!base.path) {
|
|
139
154
|
target.path = relative.path
|
|
140
155
|
} else {
|
|
141
156
|
target.path = base.path.slice(0, base.path.lastIndexOf('/') + 1) + relative.path
|
|
142
157
|
}
|
|
143
|
-
target.path =
|
|
158
|
+
target.path = base.scheme === undefined && !baseHasAuthority && target.path[0] !== '/'
|
|
159
|
+
? removeDotSegmentsRelative(target.path)
|
|
160
|
+
: removeDotSegments(target.path)
|
|
144
161
|
}
|
|
145
162
|
target.query = relative.query
|
|
146
163
|
}
|
|
@@ -196,7 +213,7 @@ function serialize (cmpts, opts) {
|
|
|
196
213
|
const uriTokens = []
|
|
197
214
|
|
|
198
215
|
if (component.scheme) {
|
|
199
|
-
component.scheme =
|
|
216
|
+
component.scheme = validateScheme(component.scheme)
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
// find scheme handler
|
|
@@ -206,7 +223,11 @@ function serialize (cmpts, opts) {
|
|
|
206
223
|
if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
|
|
207
224
|
|
|
208
225
|
const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
|
|
209
|
-
|
|
226
|
+
// A reference with no emitted scheme is path-noscheme: a bare ':' in its
|
|
227
|
+
// first path segment would reparse as a scheme (RFC 3986 3.3/4.2). Enforced
|
|
228
|
+
// under skipEscape too (resolve()), so the reapply below gates on this flag.
|
|
229
|
+
const schemelessReference = (component.scheme === undefined || options.reference === 'suffix') && !hasAuthority
|
|
230
|
+
const pathNoScheme = !options.skipEscape && schemelessReference
|
|
210
231
|
|
|
211
232
|
if (component.path !== undefined) {
|
|
212
233
|
if (!options.skipEscape) {
|
|
@@ -218,7 +239,7 @@ function serialize (cmpts, opts) {
|
|
|
218
239
|
|
|
219
240
|
if (options.reference !== 'suffix' && component.scheme) {
|
|
220
241
|
// Scheme handlers may replace the scheme during serialization.
|
|
221
|
-
component.scheme =
|
|
242
|
+
component.scheme = validateScheme(component.scheme)
|
|
222
243
|
uriTokens.push(component.scheme, ':')
|
|
223
244
|
}
|
|
224
245
|
|
|
@@ -238,13 +259,18 @@ function serialize (cmpts, opts) {
|
|
|
238
259
|
let s = component.path
|
|
239
260
|
|
|
240
261
|
if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) {
|
|
241
|
-
|
|
262
|
+
// A relative-path reference keeps its unresolvable leading ".." segments
|
|
263
|
+
// and must never become root-relative (RFC 3986 section 5.2.4 applies to
|
|
264
|
+
// merged target paths, not to the reference itself).
|
|
265
|
+
s = schemelessReference && s[0] !== '/'
|
|
266
|
+
? removeDotSegmentsRelative(s)
|
|
267
|
+
: removeDotSegments(s)
|
|
242
268
|
}
|
|
243
269
|
|
|
244
270
|
// Dot-segment removal can expose a colon that was not originally in the
|
|
245
271
|
// first segment (for example, "./a:b"). Reapply path-noscheme encoding so
|
|
246
272
|
// the serialized relative reference cannot be reparsed as a URI scheme.
|
|
247
|
-
if (
|
|
273
|
+
if (schemelessReference) {
|
|
248
274
|
s = serializePathEncoding(s, true)
|
|
249
275
|
}
|
|
250
276
|
|
|
@@ -359,6 +385,11 @@ function hasMalformedComponentPercentEncoding (matches) {
|
|
|
359
385
|
* @returns {boolean} whether host conversion failed
|
|
360
386
|
*/
|
|
361
387
|
function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
388
|
+
if (hasForbiddenDomainHost(parsed, options, schemeHandler, isIP)) {
|
|
389
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
392
|
+
|
|
362
393
|
if (
|
|
363
394
|
!options.unicodeSupport &&
|
|
364
395
|
(!schemeHandler || !schemeHandler.unicodeSupport) &&
|
|
@@ -368,16 +399,62 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
|
368
399
|
isIP === false &&
|
|
369
400
|
nonSimpleDomain(parsed.host)
|
|
370
401
|
) {
|
|
402
|
+
let url
|
|
371
403
|
try {
|
|
372
|
-
|
|
404
|
+
url = new URL('http://' + parsed.host)
|
|
373
405
|
} catch (e) {
|
|
374
406
|
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
|
|
375
407
|
return true
|
|
376
408
|
}
|
|
409
|
+
// The WHATWG parser must have read the whole input as a host. Anything
|
|
410
|
+
// else means it found a delimiter fast-uri did not, so its hostname would
|
|
411
|
+
// describe a different authority.
|
|
412
|
+
if (!isHostOnlyURL(url)) {
|
|
413
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
414
|
+
return true
|
|
415
|
+
}
|
|
416
|
+
parsed.host = url.hostname
|
|
377
417
|
}
|
|
378
418
|
return false
|
|
379
419
|
}
|
|
380
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Whether a domain host contains an ASCII character that RFC 3986 does not
|
|
423
|
+
* allow in a reg-name (unreserved, sub-delims, or pct-encoded). Such a host is
|
|
424
|
+
* rejected instead of being handed to the WHATWG URL parser, which would treat
|
|
425
|
+
* "@" as a userinfo delimiter (silently dropping the data before it) and strip
|
|
426
|
+
* TAB, LF, and CR. Non-ASCII characters remain allowed for IDNs.
|
|
427
|
+
*
|
|
428
|
+
* @param {import('./types/index').URIComponent} parsed
|
|
429
|
+
* @param {import('./types/index').Options} options
|
|
430
|
+
* @param {{ domainHost?: boolean }|undefined} schemeHandler
|
|
431
|
+
* @param {boolean} isIP
|
|
432
|
+
* @returns {boolean}
|
|
433
|
+
*/
|
|
434
|
+
function hasForbiddenDomainHost (parsed, options, schemeHandler, isIP) {
|
|
435
|
+
return Boolean(
|
|
436
|
+
parsed.host &&
|
|
437
|
+
isIP === false &&
|
|
438
|
+
!isIPLiteral(parsed.host) &&
|
|
439
|
+
(options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
|
|
440
|
+
FORBIDDEN_DOMAIN_HOST_CHARACTER.test(parsed.host)
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* @param {URL} url
|
|
446
|
+
* @returns {boolean}
|
|
447
|
+
*/
|
|
448
|
+
function isHostOnlyURL (url) {
|
|
449
|
+
return url.username === '' &&
|
|
450
|
+
url.password === '' &&
|
|
451
|
+
url.port === '' &&
|
|
452
|
+
url.pathname === '/' &&
|
|
453
|
+
url.search === '' &&
|
|
454
|
+
url.hash === '' &&
|
|
455
|
+
url.hostname !== ''
|
|
456
|
+
}
|
|
457
|
+
|
|
381
458
|
/**
|
|
382
459
|
* @param {string} uri
|
|
383
460
|
* @param {import('./types/index').Options} [opts]
|
|
@@ -460,9 +537,8 @@ function parseWithStatus (uri, opts) {
|
|
|
460
537
|
parsed.fragment = matches[8]
|
|
461
538
|
|
|
462
539
|
if (parsed.scheme !== undefined) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
parsed.scheme = decodedScheme.toLowerCase()
|
|
540
|
+
if (VALID_SCHEME.test(parsed.scheme)) {
|
|
541
|
+
parsed.scheme = parsed.scheme.toLowerCase()
|
|
466
542
|
} else {
|
|
467
543
|
parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
|
|
468
544
|
malformedScheme = true
|
|
@@ -496,7 +572,7 @@ function parseWithStatus (uri, opts) {
|
|
|
496
572
|
parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
|
|
497
573
|
|
|
498
574
|
if (malformedIPLiteral) {
|
|
499
|
-
parsed.error = parsed.error ||
|
|
575
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
500
576
|
malformedAuthorityOrPort = true
|
|
501
577
|
}
|
|
502
578
|
} else {
|
|
@@ -533,7 +609,11 @@ function parseWithStatus (uri, opts) {
|
|
|
533
609
|
// pass only restores uppercase hex in escapes that remain encoded.
|
|
534
610
|
host = normalizePercentEncoding(host.toLowerCase())
|
|
535
611
|
}
|
|
536
|
-
|
|
612
|
+
// Resolution parses both inputs with the internal `null` scheme before
|
|
613
|
+
// the effective scheme is known. Keep raw host delimiters for that step
|
|
614
|
+
// so resolve() can reject them for domain-host schemes; the resolved
|
|
615
|
+
// authority is encoded when it is serialized.
|
|
616
|
+
parsed.host = isIP || options.scheme !== 'null' ? reescapeHostDelimiters(host, isIP) : host
|
|
537
617
|
}
|
|
538
618
|
|
|
539
619
|
if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
|
|
@@ -345,6 +345,57 @@ function removeDotSegments (path) {
|
|
|
345
345
|
return output.join('')
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Removes dot segments from the path of a relative reference (a path that does
|
|
350
|
+
* not start with "/") without changing what it resolves to against any base.
|
|
351
|
+
* Unlike `removeDotSegments`, leading ".." segments that cannot be collapsed
|
|
352
|
+
* are kept, the result never becomes root-relative, and a result that would be
|
|
353
|
+
* empty or start with an empty segment is prefixed with "." (an empty path
|
|
354
|
+
* means "same document" and a leading "//" would introduce an authority).
|
|
355
|
+
*
|
|
356
|
+
* @param {string} path
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
function removeDotSegmentsRelative (path) {
|
|
360
|
+
if (path === '' || path[0] === '/') {
|
|
361
|
+
return removeDotSegments(path)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const segments = path.split('/')
|
|
365
|
+
const last = segments.length - 1
|
|
366
|
+
/** @type {string[]} */
|
|
367
|
+
const output = []
|
|
368
|
+
let trailingDirectory = false
|
|
369
|
+
|
|
370
|
+
for (let i = 0; i <= last; i++) {
|
|
371
|
+
const segment = segments[i]
|
|
372
|
+
if (segment === '.' || segment === '..') {
|
|
373
|
+
if (segment === '..') {
|
|
374
|
+
if (output.length !== 0 && output[output.length - 1] !== '..') {
|
|
375
|
+
output.pop()
|
|
376
|
+
} else {
|
|
377
|
+
output.push('..')
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
trailingDirectory = i === last
|
|
381
|
+
} else {
|
|
382
|
+
output.push(segment)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (trailingDirectory && output[output.length - 1] !== '..') {
|
|
387
|
+
output.push('')
|
|
388
|
+
}
|
|
389
|
+
if (output.length === 0 || output[0] === '' || output[0].includes(':')) {
|
|
390
|
+
// An empty path, a leading empty segment ("//"), or a first segment
|
|
391
|
+
// containing ":" (which would be mistaken for a scheme, RFC 3986 section
|
|
392
|
+
// 4.2) must stay behind a "." segment so the reference keeps its meaning.
|
|
393
|
+
output.unshift('.')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return output.join('/')
|
|
397
|
+
}
|
|
398
|
+
|
|
348
399
|
/**
|
|
349
400
|
* Re-escape RFC 3986 gen-delims that must not appear literally in the host.
|
|
350
401
|
* After the URI regex parses, these characters cannot be literal in the host
|
|
@@ -740,6 +791,7 @@ module.exports = {
|
|
|
740
791
|
encodeFragment,
|
|
741
792
|
escapePreservingEscapes,
|
|
742
793
|
removeDotSegments,
|
|
794
|
+
removeDotSegmentsRelative,
|
|
743
795
|
isIPv4,
|
|
744
796
|
isUUID,
|
|
745
797
|
normalizeIPv6,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const MALFORMED_HOST_ERROR = 'URI host is malformed.'
|
|
7
|
+
|
|
8
|
+
const domainHostSchemes = ['http', 'https', 'ws', 'wss']
|
|
9
|
+
|
|
10
|
+
// Hosts containing ASCII characters that RFC 3986 does not allow in a
|
|
11
|
+
// reg-name. Handing them to the WHATWG URL parser would read "@" as a userinfo
|
|
12
|
+
// delimiter (dropping the data before it) and strip TAB, LF and CR.
|
|
13
|
+
const forbiddenHosts = [
|
|
14
|
+
'b@evil.example',
|
|
15
|
+
'ev\til.example',
|
|
16
|
+
'ev\nil.example',
|
|
17
|
+
'ev\ril.example',
|
|
18
|
+
'ev\r\nil.example',
|
|
19
|
+
'a b.example',
|
|
20
|
+
'a"b.example',
|
|
21
|
+
'a<b.example',
|
|
22
|
+
'a>b.example',
|
|
23
|
+
'a^b.example',
|
|
24
|
+
'a`b.example',
|
|
25
|
+
'a{b.example',
|
|
26
|
+
'a|b.example',
|
|
27
|
+
'a}b.example',
|
|
28
|
+
'a\u007Fb.example',
|
|
29
|
+
'a\u0000b.example'
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
test('domain hosts with forbidden ASCII characters fail closed', (t) => {
|
|
33
|
+
for (const scheme of domainHostSchemes) {
|
|
34
|
+
for (const host of forbiddenHosts) {
|
|
35
|
+
const uri = `${scheme}://user@${host}/path`
|
|
36
|
+
const label = JSON.stringify(uri)
|
|
37
|
+
|
|
38
|
+
const parsed = fastURI.parse(uri)
|
|
39
|
+
t.equal(parsed.error, MALFORMED_HOST_ERROR, `${label} reports a malformed host`)
|
|
40
|
+
t.notEqual(parsed.host, 'evil.example', `${label} does not report a truncated host`)
|
|
41
|
+
|
|
42
|
+
t.equal(fastURI.normalize(uri), uri, `${label} normalizes to itself`)
|
|
43
|
+
t.equal(fastURI.normalize(uri, { unicodeSupport: true }), uri, `${label} normalizes to itself with unicodeSupport`)
|
|
44
|
+
t.equal(fastURI.equal(uri, uri), false, `${label} is not comparable`)
|
|
45
|
+
t.equal(fastURI.equal(uri, `${scheme}://user@evil.example/path`), false, `${label} does not alias the stripped host`)
|
|
46
|
+
|
|
47
|
+
t.throws(
|
|
48
|
+
() => fastURI.resolve(`${scheme}://trusted.example/`, `//user@${host}/path`),
|
|
49
|
+
/URI host is malformed\./,
|
|
50
|
+
`${label} is rejected by resolve`
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
t.end()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a second "@" cannot move the authority to a different host', (t) => {
|
|
58
|
+
const uri = 'http://a@b@evil.example/'
|
|
59
|
+
const parsed = fastURI.parse(uri)
|
|
60
|
+
t.equal(parsed.userinfo, 'a')
|
|
61
|
+
t.notEqual(parsed.host, 'evil.example', 'host is not silently truncated')
|
|
62
|
+
t.equal(parsed.error, MALFORMED_HOST_ERROR)
|
|
63
|
+
t.equal(fastURI.normalize(uri), uri)
|
|
64
|
+
|
|
65
|
+
t.throws(
|
|
66
|
+
() => fastURI.resolve('http://trusted.example/', '//a@b%41@evil.example/p'),
|
|
67
|
+
/URI host is malformed\./,
|
|
68
|
+
'percent-encoded data does not bypass the check in resolve'
|
|
69
|
+
)
|
|
70
|
+
t.end()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('the domainHost option applies the same rules to other schemes', (t) => {
|
|
74
|
+
const parsed = fastURI.parse('foo://a@b@evil.example/', { domainHost: true })
|
|
75
|
+
t.equal(parsed.error, MALFORMED_HOST_ERROR)
|
|
76
|
+
t.end()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('valid domain hosts are unaffected', (t) => {
|
|
80
|
+
t.equal(fastURI.normalize('http://Example.COM/'), 'http://example.com/')
|
|
81
|
+
t.equal(fastURI.normalize('http://%65xample.com/'), 'http://example.com/')
|
|
82
|
+
t.equal(fastURI.normalize('http://bücher.de/'), 'http://xn--bcher-kva.de/')
|
|
83
|
+
t.equal(fastURI.normalize('http://bücher.de/', { unicodeSupport: true }), 'http://bücher.de/')
|
|
84
|
+
t.equal(fastURI.parse("http://a!$&'()*+,;=b.example/").error, undefined, 'sub-delims are allowed')
|
|
85
|
+
t.equal(fastURI.parse('http://user:pass@example.com:8080/').host, 'example.com')
|
|
86
|
+
t.equal(fastURI.resolve('http://trusted.example/', '//user@example.com/p'), 'http://user@example.com/p')
|
|
87
|
+
t.end()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('schemes without domain hosts keep encoding reg-name data', (t) => {
|
|
91
|
+
t.equal(fastURI.normalize('foo://a@b@evil.example/'), 'foo://a@b%40evil.example/')
|
|
92
|
+
t.equal(fastURI.resolve('foo://x/', '//a@b@evil.example/p'), 'foo://a@b%40evil.example/p')
|
|
93
|
+
t.end()
|
|
94
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const ABSOLUTE_BASES = [
|
|
7
|
+
'http://h/p/q/r',
|
|
8
|
+
'http://h/',
|
|
9
|
+
'foo://h/a/b/',
|
|
10
|
+
'http://h/p/q/'
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const RELATIVE_BASES = ['', 'base', 'relative/base']
|
|
14
|
+
|
|
15
|
+
test('normalize keeps the meaning of relative-path references', (t) => {
|
|
16
|
+
const cases = [
|
|
17
|
+
['a/../b', 'b'],
|
|
18
|
+
['x/..', './'],
|
|
19
|
+
['../x', '../x'],
|
|
20
|
+
['./../x', '../x'],
|
|
21
|
+
['a/b/../../..', '..'],
|
|
22
|
+
['a/./b', 'a/b'],
|
|
23
|
+
['a/b/..', 'a/'],
|
|
24
|
+
['.', './'],
|
|
25
|
+
['..', '..'],
|
|
26
|
+
['../', '../'],
|
|
27
|
+
['../../a/./b/../c', '../../a/c'],
|
|
28
|
+
['a//..', 'a/'],
|
|
29
|
+
['a/../b?q#f', 'b?q#f']
|
|
30
|
+
]
|
|
31
|
+
for (const [input, expected] of cases) {
|
|
32
|
+
t.equal(fastURI.normalize(input), expected, `${input} -> ${expected}`)
|
|
33
|
+
}
|
|
34
|
+
t.end()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('normalize never turns a relative-path reference into a root or network path', (t) => {
|
|
38
|
+
t.equal(fastURI.normalize('.//x'), './/x', 'leading empty segment is kept behind "."')
|
|
39
|
+
t.equal(fastURI.normalize('a/..//x'), './/x', 'collapsed prefix does not expose "//"')
|
|
40
|
+
t.equal(fastURI.parse(fastURI.normalize('a/..//x')).host, undefined, 'no authority is introduced')
|
|
41
|
+
t.equal(fastURI.normalize('./a:b'), './a:b', 'first-segment colon is kept behind a "." segment (RFC 3986 section 4.2)')
|
|
42
|
+
t.equal(fastURI.parse(fastURI.normalize('./a:b')).scheme, undefined, 'no scheme is introduced')
|
|
43
|
+
t.end()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('normalize keeps the existing behavior for root-relative and absolute paths', (t) => {
|
|
47
|
+
t.equal(fastURI.normalize('/a/../b'), '/b')
|
|
48
|
+
t.equal(fastURI.normalize('/../x'), '/x')
|
|
49
|
+
t.equal(fastURI.normalize('http://h/a/../../b'), 'http://h/b')
|
|
50
|
+
t.end()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('equal does not alias relative references with different targets', (t) => {
|
|
54
|
+
t.equal(fastURI.equal('../x', 'x'), false)
|
|
55
|
+
t.equal(fastURI.equal('a/../b', '/b'), false)
|
|
56
|
+
t.equal(fastURI.equal('x/..', '/'), false)
|
|
57
|
+
t.equal(fastURI.equal('a/../b', 'b'), true)
|
|
58
|
+
t.end()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('resolving a normalized relative reference matches resolving the original', (t) => {
|
|
62
|
+
for (const base of [...ABSOLUTE_BASES, ...RELATIVE_BASES]) {
|
|
63
|
+
for (const ref of ['a/../b', 'x/..', '../x', './../x', 'a/b/../../..', '.', '..', './/x']) {
|
|
64
|
+
t.equal(
|
|
65
|
+
fastURI.resolve(base, fastURI.normalize(ref)),
|
|
66
|
+
fastURI.resolve(base, ref),
|
|
67
|
+
`${base} + ${ref}`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
t.equal(fastURI.resolve('http://h/p/q/r', fastURI.normalize('a/../b')), 'http://h/p/q/b')
|
|
72
|
+
t.equal(fastURI.resolve('http://h/p/q/r', fastURI.normalize('../x')), 'http://h/p/x')
|
|
73
|
+
t.end()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('property: normalization of relative references is resolution-preserving and idempotent', (t) => {
|
|
77
|
+
const segments = ['a', 'b', '.', '..', '', 'a:b']
|
|
78
|
+
// Deterministic linear congruential generator so failures are reproducible.
|
|
79
|
+
let seed = 0x5ec02
|
|
80
|
+
const next = (n) => {
|
|
81
|
+
seed = (seed * 1103515245 + 12345) % 2147483648
|
|
82
|
+
return Math.floor((seed / 2147483648) * n)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let failures = 0
|
|
86
|
+
let checked = 0
|
|
87
|
+
for (let i = 0; i < 4000; i++) {
|
|
88
|
+
const length = 1 + next(6)
|
|
89
|
+
const parts = []
|
|
90
|
+
for (let j = 0; j < length; j++) parts.push(segments[next(segments.length)])
|
|
91
|
+
const ref = parts.join('/')
|
|
92
|
+
// Only relative-path references: empty refs, refs starting with "/", and
|
|
93
|
+
// refs whose first segment contains ":" are not affected by this change.
|
|
94
|
+
if (ref === '' || ref[0] === '/' || fastURI.parse(ref).scheme !== undefined) continue
|
|
95
|
+
|
|
96
|
+
checked++
|
|
97
|
+
const normalized = fastURI.normalize(ref)
|
|
98
|
+
if (fastURI.normalize(normalized) !== normalized) {
|
|
99
|
+
failures++
|
|
100
|
+
t.fail(`not idempotent: ${ref} -> ${normalized} -> ${fastURI.normalize(normalized)}`)
|
|
101
|
+
}
|
|
102
|
+
// 3.x's removeDotSegments (used by resolve) collapses a trailing "//" to
|
|
103
|
+
// "/" (fixed in v4 by #185), so resolve() itself is inconsistent for
|
|
104
|
+
// these paths regardless of normalization.
|
|
105
|
+
if (normalized.endsWith('//')) continue
|
|
106
|
+
for (const base of ABSOLUTE_BASES) {
|
|
107
|
+
const expected = fastURI.resolve(base, ref)
|
|
108
|
+
const actual = fastURI.resolve(base, normalized)
|
|
109
|
+
if (actual !== expected) {
|
|
110
|
+
failures++
|
|
111
|
+
t.fail(`${base} + ${ref}: normalized ${normalized} resolves to ${actual}, expected ${expected}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
t.ok(checked > 1000, `checked ${checked} relative references`)
|
|
117
|
+
t.equal(failures, 0, 'no relative reference changed meaning')
|
|
118
|
+
t.end()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('suffix normalization preserves relative path meaning', (t) => {
|
|
122
|
+
const options = { reference: 'suffix', scheme: 'x' }
|
|
123
|
+
for (const [input, expected] of [
|
|
124
|
+
['a/../b', 'b'],
|
|
125
|
+
['x/..', './'],
|
|
126
|
+
['a/../a:b', './a:b']
|
|
127
|
+
]) {
|
|
128
|
+
t.equal(fastURI.normalize(input, options), expected, `${input} -> ${expected}`)
|
|
129
|
+
}
|
|
130
|
+
t.end()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('normalize does not promote a colon-first relative reference to a scheme (skipEscape)', (t) => {
|
|
134
|
+
// RFC 3986 section 4.2: a first path segment containing ":" must stay
|
|
135
|
+
// protected by a leading "./", or it is mistaken for a scheme. Collapsing
|
|
136
|
+
// the protecting dot segment must not expose the colon. The default path
|
|
137
|
+
// escapes the colon; with skipEscape the colon is emitted raw and the
|
|
138
|
+
// reference is reparsed as absolute (scheme "a"), changing its meaning.
|
|
139
|
+
const colonRefs = ['./a:b', 'a/../a:b']
|
|
140
|
+
for (const ref of colonRefs) {
|
|
141
|
+
const normalized = fastURI.normalize(ref, { skipEscape: true })
|
|
142
|
+
t.equal(
|
|
143
|
+
fastURI.parse(normalized).scheme,
|
|
144
|
+
undefined,
|
|
145
|
+
`${ref} -> ${normalized} must not introduce a scheme`
|
|
146
|
+
)
|
|
147
|
+
for (const base of ABSOLUTE_BASES) {
|
|
148
|
+
t.equal(
|
|
149
|
+
fastURI.resolve(base, normalized, { skipEscape: true }),
|
|
150
|
+
fastURI.resolve(base, ref, { skipEscape: true }),
|
|
151
|
+
`${base} + ${ref}: normalized ${normalized} must resolve the same`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
t.end()
|
|
156
|
+
})
|
|
@@ -77,13 +77,13 @@ test('serialize uses the RFC 3986 path character set without opening escapes', (
|
|
|
77
77
|
)
|
|
78
78
|
t.equal(
|
|
79
79
|
fastURI.serialize({ path: './a:b/c:d' }),
|
|
80
|
-
'a
|
|
81
|
-
'
|
|
80
|
+
'./a:b/c:d',
|
|
81
|
+
'a first-segment colon exposed by dot-segment removal is kept behind a "." segment'
|
|
82
82
|
)
|
|
83
83
|
t.equal(
|
|
84
84
|
fastURI.normalize('./a:b'),
|
|
85
|
-
'a
|
|
86
|
-
'normalization keeps a relative path from becoming a scheme'
|
|
85
|
+
'./a:b',
|
|
86
|
+
'normalization keeps a relative path from becoming a scheme without re-encoding the colon'
|
|
87
87
|
)
|
|
88
88
|
t.equal(
|
|
89
89
|
fastURI.serialize({ path: '/Kſ' }),
|
|
@@ -107,3 +107,31 @@ test('equal distinguishes escaped reserved path data from literal syntax', (t) =
|
|
|
107
107
|
)
|
|
108
108
|
t.end()
|
|
109
109
|
})
|
|
110
|
+
|
|
111
|
+
// A suffix reference never emits its scheme token, so it is a scheme-less
|
|
112
|
+
// (path-noscheme) reference: a bare ':' in its first path segment must be
|
|
113
|
+
// escaped, or the serialized output reparses as an absolute URI with a scheme.
|
|
114
|
+
// @see https://github.com/fastify/fast-uri/security/advisories/GHSA-333x-w4r6-6crp
|
|
115
|
+
test('suffix reference escapes a first-segment colon instead of emitting a scheme', (t) => {
|
|
116
|
+
const dangerous = /^(?:javascript|data|vbscript):/i
|
|
117
|
+
const options = { reference: 'suffix', scheme: 'x' }
|
|
118
|
+
|
|
119
|
+
for (const input of ['javascript:alert(1)', '%6Aavascript:alert(1)', 'data:text/html,x', 'vbscript:msgbox(1)']) {
|
|
120
|
+
t.notOk(dangerous.test(fastURI.normalize(input, options)), `${input} normalizes to an inert reference`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
t.equal(
|
|
124
|
+
fastURI.normalize('javascript:alert(1)', options),
|
|
125
|
+
'javascript%3Aalert(1)',
|
|
126
|
+
'escapes the leading-segment colon to %3A'
|
|
127
|
+
)
|
|
128
|
+
t.notOk(
|
|
129
|
+
dangerous.test(fastURI.resolve('https://a.example/base/', '%6Aavascript:alert(1)', { reference: 'suffix' })),
|
|
130
|
+
'resolve in suffix mode does not emit a live scheme'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// legitimate suffix usage is unchanged
|
|
134
|
+
t.equal(fastURI.normalize('path/to/file', options), 'path/to/file', 'plain path unchanged')
|
|
135
|
+
t.equal(fastURI.normalize('a/b:c', options), 'a/b:c', 'colon in a later segment stays literal')
|
|
136
|
+
t.end()
|
|
137
|
+
})
|
|
@@ -15,7 +15,15 @@ const malformedSchemes = [
|
|
|
15
15
|
'foo_bar:value',
|
|
16
16
|
'éxample:value',
|
|
17
17
|
'Kttp://example.com/',
|
|
18
|
-
'ſcheme:value'
|
|
18
|
+
'ſcheme:value',
|
|
19
|
+
// RFC 3986 section 3.1 does not allow percent-encoding in a scheme.
|
|
20
|
+
'%4Aavascript:alert(1)',
|
|
21
|
+
'%6aavascript:alert(1)',
|
|
22
|
+
'foo%2Bbar:value',
|
|
23
|
+
'%u006Aavascript:1',
|
|
24
|
+
'ht%74ps://example.com/',
|
|
25
|
+
'ma%69lto:user@example.org',
|
|
26
|
+
'%64ata:text/html,x'
|
|
19
27
|
]
|
|
20
28
|
|
|
21
29
|
test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
@@ -23,10 +31,7 @@ test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
|
23
31
|
['a:value', 'a'],
|
|
24
32
|
['HTTP://example.com/', 'http'],
|
|
25
33
|
['a1+.-:value', 'a1+.-'],
|
|
26
|
-
['
|
|
27
|
-
['foo%2Bbar:value', 'foo+bar'],
|
|
28
|
-
['%u006Aavascript:1', 'javascript'],
|
|
29
|
-
['ht%74ps://example.com/', 'https']
|
|
34
|
+
['foo+bar:value', 'foo+bar']
|
|
30
35
|
]
|
|
31
36
|
|
|
32
37
|
for (const [uri, scheme] of validSchemes) {
|
|
@@ -42,17 +47,41 @@ test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
|
42
47
|
t.end()
|
|
43
48
|
})
|
|
44
49
|
|
|
45
|
-
test('
|
|
50
|
+
test('percent-encoded schemes do not select scheme handlers', (t) => {
|
|
46
51
|
t.equal(
|
|
47
52
|
fastURI.normalize('ht%74ps://example.com:443'),
|
|
48
|
-
'
|
|
49
|
-
'HTTP normalization
|
|
53
|
+
'ht%74ps://example.com:443',
|
|
54
|
+
'HTTP normalization does not run for an encoded scheme'
|
|
50
55
|
)
|
|
51
56
|
|
|
52
57
|
// 3.x has no mailto scheme handler (added in v4); use the ws handler to
|
|
53
|
-
// verify that a percent-encoded scheme
|
|
54
|
-
const ws = fastURI.parse('
|
|
55
|
-
t.equal(ws.
|
|
58
|
+
// verify that a percent-encoded scheme does not select its scheme handler
|
|
59
|
+
const ws = fastURI.parse('w%73://example.com/a?b')
|
|
60
|
+
t.equal(ws.resourceName, undefined, 'ws parsing does not run for an encoded scheme')
|
|
61
|
+
t.equal(ws.error, MALFORMED_SCHEME_ERROR)
|
|
62
|
+
t.end()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('normalization cannot synthesize a scheme from encoded input', (t) => {
|
|
66
|
+
const inputs = [
|
|
67
|
+
'%6Aavascript:alert(1)',
|
|
68
|
+
'%4A%41%56%41%53%43%52%49%50%54:alert(1)',
|
|
69
|
+
'%u006Aavascript:alert(1)',
|
|
70
|
+
'j%61vascript:alert(1)',
|
|
71
|
+
'%64ata:text/html,<script>alert(1)</script>',
|
|
72
|
+
'vb%73cript:msgbox(1)'
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
for (const uri of inputs) {
|
|
76
|
+
const parsed = fastURI.parse(uri)
|
|
77
|
+
t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri + ' is malformed')
|
|
78
|
+
t.notOk(/^(?:javascript|data|vbscript):/i.test(String(parsed.scheme) + ':'), uri + ' parsed scheme is not decoded')
|
|
79
|
+
t.equal(fastURI.normalize(uri), uri, uri + ' normalizes to itself')
|
|
80
|
+
t.equal(fastURI.equal(uri, uri), false, uri + ' is not comparable')
|
|
81
|
+
t.throws(() => fastURI.resolve('https://example.com/app/', uri), /URI scheme is malformed\./, uri + ' is rejected by resolve')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
t.equal(fastURI.equal('%68ttp://a/', 'http://a/'), false, 'encoded scheme does not alias a plain scheme')
|
|
56
85
|
t.end()
|
|
57
86
|
})
|
|
58
87
|
|
|
@@ -95,11 +124,21 @@ test('resolve rejects malformed decoded schemes in either input', (t) => {
|
|
|
95
124
|
t.end()
|
|
96
125
|
})
|
|
97
126
|
|
|
98
|
-
test('serialize validates
|
|
127
|
+
test('serialize validates component schemes without decoding them', (t) => {
|
|
99
128
|
t.equal(
|
|
100
|
-
fastURI.serialize({ scheme: 'foo
|
|
129
|
+
fastURI.serialize({ scheme: 'foo+bar', path: 'value' }),
|
|
101
130
|
'foo+bar:value',
|
|
102
|
-
'valid
|
|
131
|
+
'valid scheme is serialized'
|
|
132
|
+
)
|
|
133
|
+
t.throws(
|
|
134
|
+
() => fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
|
|
135
|
+
/URI scheme is malformed\./,
|
|
136
|
+
'percent-encoded scheme is rejected'
|
|
137
|
+
)
|
|
138
|
+
t.throws(
|
|
139
|
+
() => fastURI.serialize({ scheme: '%6Aavascript', path: 'alert(1)' }),
|
|
140
|
+
/URI scheme is malformed\./,
|
|
141
|
+
'encoded scheme cannot serialize as javascript:'
|
|
103
142
|
)
|
|
104
143
|
t.throws(
|
|
105
144
|
() => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suveren/gateway",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.2",
|
|
4
4
|
"description": "Suveren gateway — local agent gateway built in compliance with the Human Agency Protocol (HAP). Runs the UI, control plane, and MCP server in one Node process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|