crawlforge-mcp-server 4.9.0 → 5.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/CLAUDE.md +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- package/src/utils/urlNormalizer.js +2 -2
package/src/utils/ssrfGuard.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This wires the (previously unused) SSRF protections into the actual scraping
|
|
5
5
|
* fetch helpers. Enforcement happens at TCP connect time via a custom undici
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* connector, so it covers the initial request, every redirect hop (including
|
|
7
|
+
* hops that land on a bare IP literal, which Node's `net.connect` never hands
|
|
8
|
+
* to a `lookup` callback), and closes the DNS-rebinding window (the validated
|
|
9
|
+
* IP is the one connected to — there is no second, unchecked resolution).
|
|
9
10
|
*
|
|
10
11
|
* Two levels:
|
|
11
12
|
* - Stage 1 (default): blocks connections to loopback, link-local /
|
|
@@ -14,14 +15,24 @@
|
|
|
14
15
|
* - Stage 2 (SSRF_STRICT=true): full private-range enforcement (RFC1918, ULA,
|
|
15
16
|
* multicast, CGNAT, etc.) via the existing SSRFProtection range logic.
|
|
16
17
|
*
|
|
18
|
+
* IP-literal hostnames (loopback/metadata expressed as dotted-quad, decimal,
|
|
19
|
+
* hex, or IPv4-mapped/compatible IPv6 such as `::ffff:127.0.0.1`) are checked
|
|
20
|
+
* directly against the same rules — DNS resolution is not the only path in.
|
|
21
|
+
*
|
|
17
22
|
* Controls (backwards-compatible defaults):
|
|
18
23
|
* - SSRF_PROTECTION_ENABLED=false -> disable the guard entirely (kill switch).
|
|
19
24
|
* - ALLOWED_DOMAINS=a.com,b.com -> bypass the guard for trusted hosts
|
|
20
25
|
* (e.g. a local dev server at localhost). Matches host or any subdomain.
|
|
26
|
+
* Checked fresh for every hop (initial request and each redirect), so an
|
|
27
|
+
* allowlisted first hop does not unguard a subsequent hop to a different,
|
|
28
|
+
* non-allowlisted host.
|
|
29
|
+
* - BLOCKED_DOMAINS=a.com,b.com -> extra hostname denylist (host or
|
|
30
|
+
* subdomain match), checked at pre-flight alongside the IP-literal checks.
|
|
21
31
|
* - SSRF_STRICT=true -> Stage 2 full enforcement.
|
|
22
32
|
*/
|
|
23
33
|
import dns from 'node:dns';
|
|
24
|
-
import
|
|
34
|
+
import net from 'node:net';
|
|
35
|
+
import { Agent, buildConnector } from 'undici';
|
|
25
36
|
import { config } from '../constants/config.js';
|
|
26
37
|
import { SSRFProtection } from './ssrfProtection.js';
|
|
27
38
|
|
|
@@ -38,36 +49,144 @@ function strictMode() {
|
|
|
38
49
|
return process.env.SSRF_STRICT === 'true';
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
function stripBrackets(host) {
|
|
53
|
+
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extracts the embedded IPv4 address from an IPv4-mapped/compatible IPv6
|
|
58
|
+
* literal (e.g. `::ffff:127.0.0.1` or its fully-expanded hex-group form
|
|
59
|
+
* `0:0:0:0:0:ffff:7f00:1`), returning null if `ip` isn't one of those forms.
|
|
60
|
+
* @param {string} ip
|
|
61
|
+
* @returns {string|null}
|
|
62
|
+
*/
|
|
63
|
+
function extractMappedIPv4(ip) {
|
|
64
|
+
let s = ip.toLowerCase().split('%')[0]; // drop a zone id, if present
|
|
65
|
+
|
|
66
|
+
// A dotted-quad tail (e.g. "::ffff:127.0.0.1") -> two hex groups, so the
|
|
67
|
+
// rest of this function only has to deal with one address shape.
|
|
68
|
+
const dottedTail = s.match(/^(.*:)(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
69
|
+
if (dottedTail) {
|
|
70
|
+
const quad = dottedTail[2].split('.').map(Number);
|
|
71
|
+
if (quad.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
|
|
72
|
+
const hi = ((quad[0] << 8) | quad[1]).toString(16);
|
|
73
|
+
const lo = ((quad[2] << 8) | quad[3]).toString(16);
|
|
74
|
+
s = `${dottedTail[1]}${hi}:${lo}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let groups;
|
|
78
|
+
if (s.includes('::')) {
|
|
79
|
+
const [left, right] = s.split('::');
|
|
80
|
+
const leftParts = left ? left.split(':').filter(Boolean) : [];
|
|
81
|
+
const rightParts = right ? right.split(':').filter(Boolean) : [];
|
|
82
|
+
const missing = 8 - leftParts.length - rightParts.length;
|
|
83
|
+
if (missing < 0) return null;
|
|
84
|
+
groups = [...leftParts, ...Array(missing).fill('0'), ...rightParts];
|
|
85
|
+
} else {
|
|
86
|
+
groups = s.split(':');
|
|
87
|
+
}
|
|
88
|
+
if (groups.length !== 8) return null;
|
|
89
|
+
|
|
90
|
+
const first5AreZero = groups.slice(0, 5).every((g) => parseInt(g || '0', 16) === 0);
|
|
91
|
+
if (!first5AreZero || groups[5] !== 'ffff') return null;
|
|
92
|
+
|
|
93
|
+
const hi = parseInt(groups[6], 16);
|
|
94
|
+
const lo = parseInt(groups[7], 16);
|
|
95
|
+
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
|
|
96
|
+
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
41
99
|
/**
|
|
42
|
-
* Whether a resolved IP must be blocked for the current mode.
|
|
100
|
+
* Whether a resolved (or literal) IP must be blocked for the current mode.
|
|
101
|
+
* IPv4-mapped/compatible IPv6 literals are normalized to their embedded IPv4
|
|
102
|
+
* address first, so they can't slip past the same checks a bare IPv4 gets.
|
|
43
103
|
* @param {string} ip
|
|
44
104
|
* @returns {boolean}
|
|
45
105
|
*/
|
|
46
106
|
export function ipBlocked(ip) {
|
|
107
|
+
const mapped = net.isIPv6(ip) ? extractMappedIPv4(ip) : null;
|
|
108
|
+
const effectiveIp = mapped || ip;
|
|
47
109
|
if (strictMode()) {
|
|
48
110
|
// Full enforcement: anything not explicitly allowed by SSRFProtection.
|
|
49
|
-
return !_ssrf.isIPAllowed(
|
|
111
|
+
return !_ssrf.isIPAllowed(effectiveIp);
|
|
112
|
+
}
|
|
113
|
+
if (effectiveIp === '127.0.0.1' || effectiveIp === '::1' || effectiveIp === '0.0.0.0') return true;
|
|
114
|
+
return STAGE1_RANGES.some((range) => _ssrf.isIPInRange(effectiveIp, range));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ssrfBlockedError(message) {
|
|
118
|
+
return Object.assign(new Error(`SSRF Protection: ${message}`), { code: 'SSRF_BLOCKED' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function throwBlocked(message) {
|
|
122
|
+
throw ssrfBlockedError(message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hostMatchesList(host, list) {
|
|
126
|
+
return (list || []).some((d) => {
|
|
127
|
+
const dd = String(d).trim().toLowerCase();
|
|
128
|
+
return dd && (host === dd || host.endsWith('.' + dd));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isAllowlisted(host, allowed) {
|
|
133
|
+
return hostMatchesList(host, allowed);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isBlockedDomain(host, blocked) {
|
|
137
|
+
return hostMatchesList(host, blocked);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Shared, synchronous pre-flight: protocol, allowlist, metadata hosts,
|
|
142
|
+
* BLOCKED_DOMAINS, and (for IP-literal hosts) ipBlocked(). Throws
|
|
143
|
+
* (code SSRF_BLOCKED) on any violation. Used by both `ssrfGuard()` and
|
|
144
|
+
* `assertUrlAllowed()` so the two never drift.
|
|
145
|
+
* @param {URL} u
|
|
146
|
+
* @param {object} sec config.security.ssrfProtection
|
|
147
|
+
* @returns {{ host: string, allowlisted: boolean, ipLiteral: number }}
|
|
148
|
+
*/
|
|
149
|
+
function preflightHostCheck(u, sec) {
|
|
150
|
+
if (!['http:', 'https:'].includes(u.protocol)) {
|
|
151
|
+
throwBlocked(`protocol '${u.protocol}' is not allowed`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const host = stripBrackets(u.hostname.toLowerCase());
|
|
155
|
+
if (isAllowlisted(host, sec.allowedDomains)) {
|
|
156
|
+
return { host, allowlisted: true, ipLiteral: net.isIP(host) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (METADATA_HOSTS.has(host)) {
|
|
160
|
+
throwBlocked(`blocked metadata host '${host}'`);
|
|
50
161
|
}
|
|
51
|
-
if (
|
|
52
|
-
|
|
162
|
+
if (isBlockedDomain(host, sec.blockedDomains)) {
|
|
163
|
+
throwBlocked(`blocked hostname '${host}'`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ipLiteral = net.isIP(host);
|
|
167
|
+
if (ipLiteral && ipBlocked(host)) {
|
|
168
|
+
throwBlocked(`blocked IP literal '${host}'`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { host, allowlisted: false, ipLiteral };
|
|
53
172
|
}
|
|
54
173
|
|
|
55
174
|
/**
|
|
56
175
|
* undici connect-time lookup: resolves the host, rejects if ANY resolved address
|
|
57
176
|
* is blocked, otherwise hands undici the validated address(es) — so the socket
|
|
58
|
-
* connects to exactly what we checked (rebinding-safe).
|
|
177
|
+
* connects to exactly what we checked (rebinding-safe). Allowlisted hostnames
|
|
178
|
+
* are resolved normally, with no address filtering.
|
|
59
179
|
*/
|
|
60
180
|
function ssrfLookup(hostname, opts, callback) {
|
|
181
|
+
const sec = config.security?.ssrfProtection;
|
|
182
|
+
if (isAllowlisted(hostname.toLowerCase(), sec?.allowedDomains)) {
|
|
183
|
+
return dns.lookup(hostname, opts, callback);
|
|
184
|
+
}
|
|
61
185
|
dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
|
|
62
186
|
if (err) return callback(err);
|
|
63
187
|
for (const { address } of addresses) {
|
|
64
188
|
if (ipBlocked(address)) {
|
|
65
|
-
return callback(
|
|
66
|
-
Object.assign(
|
|
67
|
-
new Error(`SSRF Protection: ${hostname} resolves to blocked address ${address}`),
|
|
68
|
-
{ code: 'SSRF_BLOCKED' }
|
|
69
|
-
)
|
|
70
|
-
);
|
|
189
|
+
return callback(ssrfBlockedError(`${hostname} resolves to blocked address ${address}`));
|
|
71
190
|
}
|
|
72
191
|
}
|
|
73
192
|
if (opts && opts.all) return callback(null, addresses);
|
|
@@ -76,26 +195,43 @@ function ssrfLookup(hostname, opts, callback) {
|
|
|
76
195
|
});
|
|
77
196
|
}
|
|
78
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Wraps undici's base connector to also cover IP-literal hosts, which
|
|
200
|
+
* `net.connect` never routes through `lookup` — so hostname-based DNS
|
|
201
|
+
* rebinding checks (ssrfLookup, above) alone can't catch a redirect straight
|
|
202
|
+
* to e.g. `http://127.0.0.1/`. Checked per-connect, so every hop (initial
|
|
203
|
+
* request and each redirect) is validated independently.
|
|
204
|
+
*/
|
|
205
|
+
function guardedConnect(baseConnect) {
|
|
206
|
+
return function connect(opts, callback) {
|
|
207
|
+
const hostname = stripBrackets(String(opts.hostname || opts.host || '').toLowerCase());
|
|
208
|
+
if (net.isIP(hostname)) {
|
|
209
|
+
const sec = config.security?.ssrfProtection;
|
|
210
|
+
if (!isAllowlisted(hostname, sec?.allowedDomains) && ipBlocked(hostname)) {
|
|
211
|
+
return callback(ssrfBlockedError(`blocked IP literal '${hostname}'`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return baseConnect(opts, callback);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
79
218
|
let _agent = null;
|
|
80
219
|
function guardedDispatcher() {
|
|
81
220
|
if (!_agent) {
|
|
82
|
-
|
|
221
|
+
const baseConnect = buildConnector({ lookup: ssrfLookup });
|
|
222
|
+
_agent = new Agent({ connect: guardedConnect(baseConnect) });
|
|
83
223
|
}
|
|
84
224
|
return _agent;
|
|
85
225
|
}
|
|
86
226
|
|
|
87
|
-
function isAllowlisted(host, allowed) {
|
|
88
|
-
return (allowed || []).some((d) => {
|
|
89
|
-
const dd = String(d).trim().toLowerCase();
|
|
90
|
-
return dd && (host === dd || host.endsWith('.' + dd));
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
227
|
/**
|
|
95
228
|
* Pre-flight check + dispatcher selection for an outbound scrape target.
|
|
96
|
-
* Returns `{ dispatcher }` to spread into fetch options
|
|
97
|
-
*
|
|
98
|
-
*
|
|
229
|
+
* Returns `{ dispatcher }` to spread into fetch options; the dispatcher is
|
|
230
|
+
* always the guarded one (it enforces allowlist/blocklist/IP checks per-hop
|
|
231
|
+
* internally) so redirects can never escape it. `{}` is returned only when
|
|
232
|
+
* the guard is disabled outright (kill switch). Throws (code SSRF_BLOCKED)
|
|
233
|
+
* for pre-flight violations (protocol, metadata host, BLOCKED_DOMAINS, or a
|
|
234
|
+
* blocked IP-literal host) on the initial URL.
|
|
99
235
|
*
|
|
100
236
|
* @param {string} url
|
|
101
237
|
* @returns {{ dispatcher?: import('undici').Agent }}
|
|
@@ -111,22 +247,52 @@ export function ssrfGuard(url) {
|
|
|
111
247
|
return {}; // let fetch surface its own invalid-URL error
|
|
112
248
|
}
|
|
113
249
|
|
|
114
|
-
|
|
115
|
-
throw Object.assign(new Error(`SSRF Protection: protocol '${u.protocol}' is not allowed`), {
|
|
116
|
-
code: 'SSRF_BLOCKED',
|
|
117
|
-
});
|
|
118
|
-
}
|
|
250
|
+
preflightHostCheck(u, sec); // throws on violation
|
|
119
251
|
|
|
120
|
-
|
|
121
|
-
|
|
252
|
+
return { dispatcher: guardedDispatcher() };
|
|
253
|
+
}
|
|
122
254
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Shared pre-flight helper for subsystems that don't go through `safeFetch`
|
|
257
|
+
* (e.g. Playwright navigation). Resolves (returns undefined) when the URL is
|
|
258
|
+
* allowed; throws an Error with `code: 'SSRF_BLOCKED'` (message starting
|
|
259
|
+
* `SSRF Protection:`) when blocked.
|
|
260
|
+
*
|
|
261
|
+
* Kill switch and allowlist behave exactly as in `ssrfGuard()`. When
|
|
262
|
+
* `resolveDns` is true and the host is a (non-allowlisted, non-IP-literal)
|
|
263
|
+
* hostname, it is resolved via `dns.promises.lookup` and every returned
|
|
264
|
+
* address is checked with `ipBlocked()`; a DNS failure here is not itself
|
|
265
|
+
* treated as a block (the caller's own fetch/connect will surface it).
|
|
266
|
+
*
|
|
267
|
+
* @param {string} urlString
|
|
268
|
+
* @param {{ resolveDns?: boolean }} [opts]
|
|
269
|
+
* @returns {Promise<void>}
|
|
270
|
+
*/
|
|
271
|
+
export async function assertUrlAllowed(urlString, { resolveDns = false } = {}) {
|
|
272
|
+
const sec = config.security?.ssrfProtection;
|
|
273
|
+
if (!sec || sec.enabled === false) return; // kill switch
|
|
274
|
+
|
|
275
|
+
let u;
|
|
276
|
+
try {
|
|
277
|
+
u = new URL(urlString);
|
|
278
|
+
} catch {
|
|
279
|
+
return; // let the caller's own URL parsing surface the error
|
|
127
280
|
}
|
|
128
281
|
|
|
129
|
-
|
|
282
|
+
const { host, allowlisted, ipLiteral } = preflightHostCheck(u, sec); // throws on violation
|
|
283
|
+
if (allowlisted || ipLiteral || !resolveDns) return;
|
|
284
|
+
|
|
285
|
+
let addresses;
|
|
286
|
+
try {
|
|
287
|
+
addresses = await dns.promises.lookup(host, { all: true, verbatim: true });
|
|
288
|
+
} catch {
|
|
289
|
+
return; // DNS failures are not ours to enforce; let the caller's own fetch surface them
|
|
290
|
+
}
|
|
291
|
+
for (const { address } of addresses) {
|
|
292
|
+
if (ipBlocked(address)) {
|
|
293
|
+
throwBlocked(`${host} resolves to blocked address ${address}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
130
296
|
}
|
|
131
297
|
|
|
132
298
|
/** True if an error (or its fetch `cause`) came from the SSRF guard. */
|
|
@@ -146,7 +312,7 @@ export function isSsrfError(err) {
|
|
|
146
312
|
* @returns {Promise<Response>}
|
|
147
313
|
*/
|
|
148
314
|
export async function safeFetch(url, options = {}) {
|
|
149
|
-
const guard = ssrfGuard(url); // throws on protocol / metadata-host violations
|
|
315
|
+
const guard = ssrfGuard(url); // throws on protocol / metadata-host / blocklist / IP-literal violations
|
|
150
316
|
try {
|
|
151
317
|
return await fetch(url, { ...options, ...guard });
|
|
152
318
|
} catch (err) {
|
|
@@ -158,4 +324,10 @@ export async function safeFetch(url, options = {}) {
|
|
|
158
324
|
}
|
|
159
325
|
|
|
160
326
|
// Exposed for unit tests.
|
|
161
|
-
export const __ssrfInternals = {
|
|
327
|
+
export const __ssrfInternals = {
|
|
328
|
+
ssrfLookup,
|
|
329
|
+
isAllowlisted,
|
|
330
|
+
isBlockedDomain,
|
|
331
|
+
extractMappedIPv4,
|
|
332
|
+
STAGE1_RANGES,
|
|
333
|
+
};
|
|
@@ -20,8 +20,8 @@ export function normalizeUrl(url) {
|
|
|
20
20
|
if (urlObj.search) {
|
|
21
21
|
const params = new URLSearchParams(urlObj.search);
|
|
22
22
|
const sortedParams = new URLSearchParams();
|
|
23
|
-
[...params.
|
|
24
|
-
sortedParams.append(key,
|
|
23
|
+
[...params.entries()].sort(([a], [b]) => a.localeCompare(b)).forEach(([key, value]) => {
|
|
24
|
+
sortedParams.append(key, value);
|
|
25
25
|
});
|
|
26
26
|
urlObj.search = sortedParams.toString();
|
|
27
27
|
}
|