@fjall/eslint-plugin 29.0.0 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/astHelpers.js +40 -0
- package/index.js +2 -0
- package/no-raw-request-url-in-redirect.js +854 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,4 +28,7 @@ export default [
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Every rule lives in its own file (`<rule-name>.js`); `index.js` is the barrel
|
|
31
|
-
exposing them all under the `rules` map.
|
|
31
|
+
exposing them all under the `rules` map. AST helpers that more than two rules
|
|
32
|
+
would otherwise re-derive (the scope-chain variable lookup, the TS type-wrapper
|
|
33
|
+
unwrap) live in `astHelpers.js` and are imported, never copied — a rule still
|
|
34
|
+
carrying a private copy migrates to the shared helper as it is next touched.
|
package/astHelpers.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview AST helpers shared across the plugin's rules.
|
|
3
|
+
*
|
|
4
|
+
* Rules stay one-per-file (`<rule-name>.js`); the helpers every flow-tracking
|
|
5
|
+
* rule otherwise re-derives — the scope-chain variable lookup and the
|
|
6
|
+
* type-wrapper unwrap — live here so one copy's membership cannot silently
|
|
7
|
+
* drift from another's. A sibling still carrying a private copy migrates to
|
|
8
|
+
* these as it is next touched.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Type-level wrappers with no runtime value of their own; `expression` is the value. */
|
|
12
|
+
export const TS_WRAPPER_TYPES = new Set([
|
|
13
|
+
"TSAsExpression",
|
|
14
|
+
"TSNonNullExpression",
|
|
15
|
+
"TSSatisfiesExpression",
|
|
16
|
+
"TSTypeAssertion"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The expression as written, minus type-level wrappers and the optional-chain
|
|
21
|
+
* envelope (`a?.b` parses as ChainExpression → MemberExpression).
|
|
22
|
+
*/
|
|
23
|
+
export function unwrapExpression(node) {
|
|
24
|
+
while (
|
|
25
|
+
node &&
|
|
26
|
+
(node.type === "ChainExpression" || TS_WRAPPER_TYPES.has(node.type))
|
|
27
|
+
) {
|
|
28
|
+
node = node.expression;
|
|
29
|
+
}
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The variable `name` resolves to from `scope` outward, or null when unbound. */
|
|
34
|
+
export function findVariableInScopeChain(scope, name) {
|
|
35
|
+
for (let current = scope; current; current = current.upper) {
|
|
36
|
+
const variable = current.set.get(name);
|
|
37
|
+
if (variable) return variable;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
package/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import requireAbortPrecheckInSdkLoop from "./require-abort-precheck-in-sdk-loop.
|
|
|
26
26
|
import requireAbortCompositionOnSdkSend from "./require-abort-composition-on-sdk-send.js";
|
|
27
27
|
import noClassicConnectedAccountAssume from "./no-classic-connected-account-assume.js";
|
|
28
28
|
import noRawDbTransaction from "./no-raw-db-transaction.js";
|
|
29
|
+
import noRawRequestUrlInRedirect from "./no-raw-request-url-in-redirect.js";
|
|
29
30
|
import noRawExitCode from "./no-raw-exit-code.js";
|
|
30
31
|
import noReplacementStringExpansion from "./no-replacement-string-expansion.js";
|
|
31
32
|
import noSilentResultDiscard from "./no-silent-result-discard.js";
|
|
@@ -66,6 +67,7 @@ export default {
|
|
|
66
67
|
"require-abort-composition-on-sdk-send": requireAbortCompositionOnSdkSend,
|
|
67
68
|
"no-classic-connected-account-assume": noClassicConnectedAccountAssume,
|
|
68
69
|
"no-raw-db-transaction": noRawDbTransaction,
|
|
70
|
+
"no-raw-request-url-in-redirect": noRawRequestUrlInRedirect,
|
|
69
71
|
"no-raw-exit-code": noRawExitCode,
|
|
70
72
|
"no-replacement-string-expansion": noReplacementStringExpansion,
|
|
71
73
|
"no-silent-result-discard": noSilentResultDiscard,
|
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview A redirect Location must not echo the request's own URL raw.
|
|
3
|
+
*
|
|
4
|
+
* React Router serves a client-side navigation as a single-fetch data request
|
|
5
|
+
* — `/{path}.data?_routes=…` (`/_.data` at the root) — and hands middleware
|
|
6
|
+
* and loaders that request UNMODIFIED: only route matching sees the normalised
|
|
7
|
+
* path (`getNormalizedPath` in react-router/lib/server-runtime/urls.ts, not
|
|
8
|
+
* exported). A redirect whose Location is built from `request.url` therefore
|
|
9
|
+
* lands a live tab on `/acme/overview.data?_routes=…` — the client follows the
|
|
10
|
+
* Location it copies out of the data response verbatim. The first, document
|
|
11
|
+
* navigation works; every in-app navigation after it breaks.
|
|
12
|
+
*
|
|
13
|
+
* Flagged — an ECHO of the request's own URL reaching a redirect sink:
|
|
14
|
+
* - `request.url` itself (binding names `request` / `req`, any
|
|
15
|
+
* `<expr>.request.url`, or `url` destructured from the request), except
|
|
16
|
+
* as an argument of `new URL(…)` / `URL.parse(…)`, where the URL object
|
|
17
|
+
* is tracked instead
|
|
18
|
+
* - the URL object built from it — `new URL(request.url)` /
|
|
19
|
+
* `URL.parse(request.url)` inline, a binding initialised or assigned from
|
|
20
|
+
* it, an alias of that binding, or a destructure (`const { pathname,
|
|
21
|
+
* search } = new URL(request.url)`) — read as `.pathname` / `.search` /
|
|
22
|
+
* `.href` / `.toString()` / `.toJSON()` / `String(url)` / `${url}` /
|
|
23
|
+
* `url + ""` / `url.searchParams.toString()` / `${url.searchParams}`, or
|
|
24
|
+
* passed whole to `Response.redirect(url)` (the one sink that accepts the
|
|
25
|
+
* object)
|
|
26
|
+
* - a URL resolved AGAINST the request with a static relative reference —
|
|
27
|
+
* `new URL("?page=2", request.url)`, `new URL("", request.url)`,
|
|
28
|
+
* `new URL("#top", request.url)` — which keeps the `.data` path
|
|
29
|
+
* - a `URLSearchParams` built from one of those
|
|
30
|
+
* (`new URLSearchParams(url.search)`), coerced the same ways
|
|
31
|
+
* …flowing directly, through a same-function binding, destructure or
|
|
32
|
+
* assignment (`=`, `+=`, `||=`, `??=`, `&&=`), through string transforms
|
|
33
|
+
* (`.replace`, `.slice`, `.split(…)[i]`, `.match(…)?.[i]`, `.join`, …),
|
|
34
|
+
* template literals, `+` concatenation, `??` / `||` / ternary branches, a
|
|
35
|
+
* second `new URL(echo, base)`, and the transparent wrappers `String` /
|
|
36
|
+
* `encodeURIComponent` / `encodeURI` / `decodeURIComponent` / `decodeURI`,
|
|
37
|
+
* into:
|
|
38
|
+
* - the first argument of `redirect(…)` / `redirectDocument(…)` (by name,
|
|
39
|
+
* or under any alias imported from react-router) and of `replace(…)`
|
|
40
|
+
* (only under an import binding from react-router — the bare name
|
|
41
|
+
* collides with string helpers)
|
|
42
|
+
* - the first argument of `Response.redirect(…)`
|
|
43
|
+
* - a `Location` header value (any case): a property of the `headers`
|
|
44
|
+
* object literal, of a `new Headers(…)` argument, or of a literal bound
|
|
45
|
+
* to a `*headers` name; a `["Location", …]` tuple in such an array; or
|
|
46
|
+
* `headers.set("Location", …)` / `.append(…)` on a receiver named
|
|
47
|
+
* `*headers` or bound to `new Headers(…)`
|
|
48
|
+
*
|
|
49
|
+
* NOT flagged — VALUE reads, structural checks, and the caller's own path:
|
|
50
|
+
* - `url.searchParams.get(…)` / `.has(…)` / `.getAll(…)` (a value, not the
|
|
51
|
+
* URL), and `url.searchParams` handed to a helper
|
|
52
|
+
* - `url.pathname.startsWith(…)` / `.includes(…)` / `=== …` (boolean; the
|
|
53
|
+
* Location comes from elsewhere)
|
|
54
|
+
* - `new URL("/path", request.url)` / `new URL("https://…", request.url)` —
|
|
55
|
+
* a static path- or scheme-absolute reference takes only the origin from
|
|
56
|
+
* the base — and `new URL(dynamic, request.url)`, where the reference is
|
|
57
|
+
* the caller's and must be path-absolute (cf. `safeRedirectSuffix`)
|
|
58
|
+
* - `requestPath(request)` destructures (the cure)
|
|
59
|
+
* - `url.origin` / `.host` / `.protocol` (no path or query in them)
|
|
60
|
+
* - a `location` key on a non-header object (a log payload, a Prisma
|
|
61
|
+
* `data`), or `.set("location", …)` on a non-header receiver (a Map)
|
|
62
|
+
*
|
|
63
|
+
* Known limitations:
|
|
64
|
+
* - Interprocedural flow: the rule is AST-local within a function scope,
|
|
65
|
+
* like `mask-error-message-at-boundary`. An echo returned from a helper
|
|
66
|
+
* and redirected by its caller, or passed through a user-defined helper
|
|
67
|
+
* (`redirect(normalise(url.pathname))`), is not connected. Manual
|
|
68
|
+
* discipline applies there: the only sanctioned normaliser is
|
|
69
|
+
* `requestPath(request)` at `webapp/app/.server/utils/http/requestPath.ts`,
|
|
70
|
+
* which takes the request, not its URL.
|
|
71
|
+
* - Flow-insensitive within the scope: every read of a binding the echo is
|
|
72
|
+
* assigned to counts, whatever the statement order.
|
|
73
|
+
* - A binding chain deeper than MAX_TRACE_DEPTH hops is abandoned, not
|
|
74
|
+
* walked — never seen in real code; it keeps the lint run alive.
|
|
75
|
+
*
|
|
76
|
+
* Escape hatch:
|
|
77
|
+
* `// eslint-disable-next-line fjall/no-raw-request-url-in-redirect -- <why>`
|
|
78
|
+
* above the ECHO line (the report anchors on the leaf, not the sink) for a
|
|
79
|
+
* proven document-only path. Expect zero uses.
|
|
80
|
+
*
|
|
81
|
+
* Why this rule exists: webapp-standards.md § "Redirect Locations echoed from
|
|
82
|
+
* `request.url` must normalise the single-fetch URL" landed on 2026-09-02 and
|
|
83
|
+
* recurred in new code ~90 minutes later (the legacy `/assets` 301 built its
|
|
84
|
+
* Location from a destructured `search`), with a pre-existing sibling one file
|
|
85
|
+
* over (the pre-split table shim composing `url.pathname.replace(…)` and
|
|
86
|
+
* `url.search`). Both were fixed in webapp `45beb2b3`; documented-not-
|
|
87
|
+
* mechanised rules recur, and this shape is lintable.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
import {
|
|
91
|
+
TS_WRAPPER_TYPES,
|
|
92
|
+
findVariableInScopeChain,
|
|
93
|
+
unwrapExpression
|
|
94
|
+
} from "./astHelpers.js";
|
|
95
|
+
|
|
96
|
+
const REQUEST_BINDING_NAMES = new Set(["request", "req"]);
|
|
97
|
+
const URL_ECHO_PROPERTIES = new Set(["pathname", "search", "href"]);
|
|
98
|
+
const URL_SERIALISERS = new Set(["toString", "toJSON"]);
|
|
99
|
+
// A method on the flowing string whose RESULT carries none of it — the walk
|
|
100
|
+
// ends. `match` is absent on purpose: a capture is a substring of the echo.
|
|
101
|
+
const TERMINATOR_METHODS = new Set([
|
|
102
|
+
"includes",
|
|
103
|
+
"startsWith",
|
|
104
|
+
"endsWith",
|
|
105
|
+
"test",
|
|
106
|
+
"indexOf",
|
|
107
|
+
"lastIndexOf",
|
|
108
|
+
"localeCompare",
|
|
109
|
+
"search",
|
|
110
|
+
"some",
|
|
111
|
+
"every",
|
|
112
|
+
"has"
|
|
113
|
+
]);
|
|
114
|
+
const TRANSPARENT_CALLEES = new Set([
|
|
115
|
+
"String",
|
|
116
|
+
"encodeURIComponent",
|
|
117
|
+
"encodeURI",
|
|
118
|
+
"decodeURIComponent",
|
|
119
|
+
"decodeURI"
|
|
120
|
+
]);
|
|
121
|
+
const REACT_ROUTER_REDIRECTORS = new Set([
|
|
122
|
+
"redirect",
|
|
123
|
+
"redirectDocument",
|
|
124
|
+
"replace"
|
|
125
|
+
]);
|
|
126
|
+
// Matched by bare name whatever the import; `replace` needs the import binding.
|
|
127
|
+
const NAME_MATCHED_REDIRECTORS = ["redirect", "redirectDocument"];
|
|
128
|
+
const FLOW_ASSIGNMENT_OPERATORS = new Set(["=", "+=", "||=", "??=", "&&="]);
|
|
129
|
+
const HEADER_WRITE_METHODS = new Set(["set", "append"]);
|
|
130
|
+
const LOCATION_HEADER_RE = /^location$/i;
|
|
131
|
+
const HEADERS_NAME_RE = /headers$/i;
|
|
132
|
+
// A static reference that takes only the origin from its base.
|
|
133
|
+
const ABSOLUTE_REFERENCE_RE = /^(?:\/|[a-z][a-z0-9+.-]*:)/i;
|
|
134
|
+
const MAX_TRACE_DEPTH = 200;
|
|
135
|
+
|
|
136
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
137
|
+
const noRawRequestUrlInRedirect = {
|
|
138
|
+
meta: {
|
|
139
|
+
type: "problem",
|
|
140
|
+
docs: {
|
|
141
|
+
description:
|
|
142
|
+
"A redirect Location must derive an echoed path from requestPath(request), never from request.url / new URL(request.url) — a single-fetch data request would be echoed back as /{path}.data?_routes=…"
|
|
143
|
+
},
|
|
144
|
+
messages: {
|
|
145
|
+
rawRequestUrl:
|
|
146
|
+
'`{{leaf}}` echoes the request\'s own URL into `{{sink}}`. A client-side navigation arrives as `/{path}.data?_routes=…` and the client follows the Location verbatim, so the tab lands on the data URL. Derive the echoed path from `requestPath(request)` (`~/.server/utils/http/requestPath`) instead; value reads (`url.searchParams.get(…)`) are fine. See webapp-standards.md § "Redirect Locations echoed from `request.url` must normalise the single-fetch URL".'
|
|
147
|
+
},
|
|
148
|
+
schema: []
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
create(context) {
|
|
152
|
+
const sourceCode = context.sourceCode;
|
|
153
|
+
const redirectorNames = new Set(NAME_MATCHED_REDIRECTORS);
|
|
154
|
+
|
|
155
|
+
function report(leafNode, leafLabel, sinkLabel) {
|
|
156
|
+
context.report({
|
|
157
|
+
node: leafNode,
|
|
158
|
+
messageId: "rawRequestUrl",
|
|
159
|
+
data: { leaf: leafLabel, sink: sinkLabel }
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function check(leafNode, leafLabel) {
|
|
164
|
+
const sink = findSink(leafNode, new Set(), 0);
|
|
165
|
+
if (sink !== null) report(leafNode, leafLabel, sink);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Binding resolution ────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function findVariable(identifier) {
|
|
171
|
+
return findVariableInScopeChain(
|
|
172
|
+
sourceCode.getScope(identifier),
|
|
173
|
+
identifier.name
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Every `{ id, init }` that gives the binding a value: its declarator
|
|
179
|
+
* (when initialised) and each plain `=` assignment to it.
|
|
180
|
+
*/
|
|
181
|
+
function initialisersOf(identifier, visited) {
|
|
182
|
+
const variable = findVariable(identifier);
|
|
183
|
+
if (!variable || visited.has(variable)) return [];
|
|
184
|
+
visited.add(variable);
|
|
185
|
+
const entries = [];
|
|
186
|
+
for (const def of variable.defs) {
|
|
187
|
+
if (
|
|
188
|
+
def.type === "Variable" &&
|
|
189
|
+
def.node.type === "VariableDeclarator" &&
|
|
190
|
+
def.node.init !== null
|
|
191
|
+
) {
|
|
192
|
+
entries.push({ id: def.node.id, init: def.node.init });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const ref of variable.references) {
|
|
196
|
+
const parent = ref.identifier.parent;
|
|
197
|
+
if (
|
|
198
|
+
ref.isWrite() &&
|
|
199
|
+
parent &&
|
|
200
|
+
parent.type === "AssignmentExpression" &&
|
|
201
|
+
parent.left === ref.identifier &&
|
|
202
|
+
parent.operator === "="
|
|
203
|
+
) {
|
|
204
|
+
entries.push({ id: ref.identifier, init: parent.right });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return entries;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `request` / `req` / `<expr>.request` — the request object itself. */
|
|
211
|
+
function isRequestBinding(node) {
|
|
212
|
+
node = unwrapExpression(node);
|
|
213
|
+
if (!node) return false;
|
|
214
|
+
if (node.type === "Identifier")
|
|
215
|
+
return REQUEST_BINDING_NAMES.has(node.name);
|
|
216
|
+
return (
|
|
217
|
+
node.type === "MemberExpression" &&
|
|
218
|
+
!node.computed &&
|
|
219
|
+
node.property.type === "Identifier" &&
|
|
220
|
+
REQUEST_BINDING_NAMES.has(node.property.name)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `request.url` / `req.url` / `<expr>.request.url` (chains unwrapped). */
|
|
225
|
+
function isRequestUrlMember(node) {
|
|
226
|
+
node = unwrapExpression(node);
|
|
227
|
+
return (
|
|
228
|
+
node.type === "MemberExpression" &&
|
|
229
|
+
!node.computed &&
|
|
230
|
+
node.property.type === "Identifier" &&
|
|
231
|
+
node.property.name === "url" &&
|
|
232
|
+
isRequestBinding(node.object)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `request.url` directly, a binding initialised or assigned from it, or
|
|
238
|
+
* `url` destructured from the request.
|
|
239
|
+
*/
|
|
240
|
+
function isRequestUrlValue(node, visited) {
|
|
241
|
+
node = unwrapExpression(node);
|
|
242
|
+
if (!node) return false;
|
|
243
|
+
if (isRequestUrlMember(node)) return true;
|
|
244
|
+
if (node.type !== "Identifier") return false;
|
|
245
|
+
return initialisersOf(node, visited).some((entry) => {
|
|
246
|
+
if (entry.id.type === "Identifier") {
|
|
247
|
+
return isRequestUrlValue(entry.init, visited);
|
|
248
|
+
}
|
|
249
|
+
return (
|
|
250
|
+
entry.id.type === "ObjectPattern" &&
|
|
251
|
+
isRequestBinding(entry.init) &&
|
|
252
|
+
patternPropertyKey(entry.id, node.name) === "url"
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `new URL(…)` or `URL.parse(…)` — a URL object under construction. */
|
|
258
|
+
function isUrlConstruction(node) {
|
|
259
|
+
node = unwrapExpression(node);
|
|
260
|
+
if (!node) return false;
|
|
261
|
+
if (node.type === "NewExpression") {
|
|
262
|
+
return calleeIdentifierName(node.callee) === "URL";
|
|
263
|
+
}
|
|
264
|
+
return (
|
|
265
|
+
node.type === "CallExpression" && urlStaticMethod(node) === "parse"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* "url" when the construction parses the request's URL, or resolves a
|
|
271
|
+
* static RELATIVE reference against it (the `.data` path survives). A
|
|
272
|
+
* static absolute reference takes only the origin; a dynamic one is the
|
|
273
|
+
* caller's.
|
|
274
|
+
*/
|
|
275
|
+
function urlConstructionKind(node, visited) {
|
|
276
|
+
const first = node.arguments[0];
|
|
277
|
+
const base = node.arguments[1];
|
|
278
|
+
if (first === undefined) return null;
|
|
279
|
+
if (isRequestUrlValue(first, visited)) return "url";
|
|
280
|
+
return base !== undefined &&
|
|
281
|
+
isRequestUrlValue(base, visited) &&
|
|
282
|
+
isStaticRelativeReference(first)
|
|
283
|
+
? "url"
|
|
284
|
+
: null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* "url" — a URL object built from the request's own URL
|
|
289
|
+
* "params" — a URLSearchParams carrying that URL's query
|
|
290
|
+
* null — anything else (a fixed URL, a value read, unknown)
|
|
291
|
+
*/
|
|
292
|
+
function resolveUrlKind(node, visited, depth) {
|
|
293
|
+
if (!node || depth > MAX_TRACE_DEPTH) return null;
|
|
294
|
+
node = unwrapExpression(node);
|
|
295
|
+
if (isUrlConstruction(node)) return urlConstructionKind(node, visited);
|
|
296
|
+
if (node.type === "NewExpression") {
|
|
297
|
+
const first = node.arguments[0];
|
|
298
|
+
if (
|
|
299
|
+
calleeIdentifierName(node.callee) === "URLSearchParams" &&
|
|
300
|
+
node.arguments.length === 1
|
|
301
|
+
) {
|
|
302
|
+
if (resolveUrlKind(first, visited, depth + 1) === "params") {
|
|
303
|
+
return "params";
|
|
304
|
+
}
|
|
305
|
+
const member = unwrapExpression(first);
|
|
306
|
+
if (
|
|
307
|
+
member.type === "MemberExpression" &&
|
|
308
|
+
!member.computed &&
|
|
309
|
+
member.property.type === "Identifier" &&
|
|
310
|
+
(member.property.name === "search" ||
|
|
311
|
+
member.property.name === "searchParams") &&
|
|
312
|
+
resolveUrlKind(member.object, visited, depth + 1) === "url"
|
|
313
|
+
) {
|
|
314
|
+
return "params";
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
if (
|
|
320
|
+
node.type === "MemberExpression" &&
|
|
321
|
+
!node.computed &&
|
|
322
|
+
node.property.type === "Identifier" &&
|
|
323
|
+
node.property.name === "searchParams"
|
|
324
|
+
) {
|
|
325
|
+
return resolveUrlKind(node.object, visited, depth + 1) === "url"
|
|
326
|
+
? "params"
|
|
327
|
+
: null;
|
|
328
|
+
}
|
|
329
|
+
if (node.type !== "Identifier") return null;
|
|
330
|
+
for (const entry of initialisersOf(node, visited)) {
|
|
331
|
+
if (entry.id.type === "Identifier") {
|
|
332
|
+
const kind = resolveUrlKind(entry.init, visited, depth + 1);
|
|
333
|
+
if (kind !== null) return kind;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (
|
|
337
|
+
entry.id.type === "ObjectPattern" &&
|
|
338
|
+
resolveUrlKind(entry.init, visited, depth + 1) === "url" &&
|
|
339
|
+
patternPropertyKey(entry.id, node.name) === "searchParams"
|
|
340
|
+
) {
|
|
341
|
+
return "params";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Flow: leaf → sink ─────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Walks up from an echo leaf. Returns a sink label when the string
|
|
351
|
+
* reaches a redirect / Location sink in the same function scope, or null
|
|
352
|
+
* when it terminates first (boolean check, comparison, unknown helper,
|
|
353
|
+
* statement boundary).
|
|
354
|
+
*/
|
|
355
|
+
function findSink(start, visited, depth) {
|
|
356
|
+
if (depth > MAX_TRACE_DEPTH) return null;
|
|
357
|
+
let previous = start;
|
|
358
|
+
let current = start.parent;
|
|
359
|
+
while (current) {
|
|
360
|
+
switch (current.type) {
|
|
361
|
+
case "TemplateLiteral":
|
|
362
|
+
case "ChainExpression":
|
|
363
|
+
case "AwaitExpression":
|
|
364
|
+
case "SpreadElement":
|
|
365
|
+
case "LogicalExpression":
|
|
366
|
+
break;
|
|
367
|
+
case "ArrayExpression":
|
|
368
|
+
if (isLocationTuple(current, previous)) {
|
|
369
|
+
return isHeadersLiteral(current) ? "Location header" : null;
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
case "BinaryExpression":
|
|
373
|
+
if (current.operator !== "+") return null;
|
|
374
|
+
break;
|
|
375
|
+
case "ConditionalExpression":
|
|
376
|
+
if (current.test === previous) return null;
|
|
377
|
+
break;
|
|
378
|
+
case "SequenceExpression":
|
|
379
|
+
if (
|
|
380
|
+
current.expressions[current.expressions.length - 1] !== previous
|
|
381
|
+
) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
case "MemberExpression": {
|
|
386
|
+
if (current.object !== previous) return null;
|
|
387
|
+
if (current.computed) break;
|
|
388
|
+
if (current.property.type !== "Identifier") return null;
|
|
389
|
+
const call = current.parent;
|
|
390
|
+
if (
|
|
391
|
+
call &&
|
|
392
|
+
call.type === "CallExpression" &&
|
|
393
|
+
call.callee === current
|
|
394
|
+
) {
|
|
395
|
+
if (TERMINATOR_METHODS.has(current.property.name)) return null;
|
|
396
|
+
previous = call;
|
|
397
|
+
current = call.parent;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
// A URL laundered through `new URL(echo, base)` read back out.
|
|
401
|
+
if (URL_ECHO_PROPERTIES.has(current.property.name)) break;
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
case "CallExpression": {
|
|
405
|
+
if (current.callee === previous) return null;
|
|
406
|
+
const sink = classifySinkCall(current, previous);
|
|
407
|
+
if (sink !== null) return sink;
|
|
408
|
+
if (isTransparentCall(current)) break;
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
case "NewExpression": {
|
|
412
|
+
const ctor = calleeIdentifierName(current.callee);
|
|
413
|
+
if (
|
|
414
|
+
(ctor !== "URLSearchParams" && ctor !== "URL") ||
|
|
415
|
+
current.arguments[0] !== previous
|
|
416
|
+
) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
// A construction the leaf visitors already track (its own reads
|
|
420
|
+
// are the leaves) ends this walk; an untracked one — a URL
|
|
421
|
+
// laundered from echo fragments — carries the echo onward.
|
|
422
|
+
if (resolveUrlKind(current, new Set(), depth) !== null) return null;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
case "Property": {
|
|
426
|
+
if (current.value !== previous) return null;
|
|
427
|
+
return propertyKeyMatches(current, LOCATION_HEADER_RE) &&
|
|
428
|
+
isHeadersLiteral(current.parent)
|
|
429
|
+
? "Location header"
|
|
430
|
+
: null;
|
|
431
|
+
}
|
|
432
|
+
case "VariableDeclarator": {
|
|
433
|
+
if (current.init !== previous) return null;
|
|
434
|
+
return tracePattern(current.id, visited, depth);
|
|
435
|
+
}
|
|
436
|
+
case "AssignmentExpression": {
|
|
437
|
+
if (
|
|
438
|
+
current.right !== previous ||
|
|
439
|
+
!FLOW_ASSIGNMENT_OPERATORS.has(current.operator) ||
|
|
440
|
+
current.left.type !== "Identifier"
|
|
441
|
+
) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
return traceBinding(current.left, visited, depth);
|
|
445
|
+
}
|
|
446
|
+
default:
|
|
447
|
+
if (TS_WRAPPER_TYPES.has(current.type)) break;
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
previous = current;
|
|
451
|
+
current = current.parent;
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function tracePattern(pattern, visited, depth) {
|
|
457
|
+
const identifiers = [];
|
|
458
|
+
collectPatternIdentifiers(pattern, identifiers);
|
|
459
|
+
for (const identifier of identifiers) {
|
|
460
|
+
const sink = traceBinding(identifier, visited, depth);
|
|
461
|
+
if (sink !== null) return sink;
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function traceBinding(identifier, visited, depth) {
|
|
467
|
+
const variable = findVariable(identifier);
|
|
468
|
+
if (!variable || visited.has(variable)) return null;
|
|
469
|
+
visited.add(variable);
|
|
470
|
+
for (const ref of variable.references) {
|
|
471
|
+
if (!ref.isRead() || ref.identifier === identifier) continue;
|
|
472
|
+
const sink = findSink(ref.identifier, visited, depth + 1);
|
|
473
|
+
if (sink !== null) return sink;
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function classifySinkCall(call, argument) {
|
|
479
|
+
const callee = unwrapExpression(call.callee);
|
|
480
|
+
if (callee.type === "Identifier") {
|
|
481
|
+
return redirectorNames.has(callee.name) &&
|
|
482
|
+
call.arguments[0] === argument
|
|
483
|
+
? `${callee.name}(…)`
|
|
484
|
+
: null;
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
callee.type !== "MemberExpression" ||
|
|
488
|
+
callee.computed ||
|
|
489
|
+
callee.property.type !== "Identifier"
|
|
490
|
+
) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
const method = callee.property.name;
|
|
494
|
+
if (
|
|
495
|
+
method === "redirect" &&
|
|
496
|
+
callee.object.type === "Identifier" &&
|
|
497
|
+
callee.object.name === "Response"
|
|
498
|
+
) {
|
|
499
|
+
return call.arguments[0] === argument ? "Response.redirect(…)" : null;
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
HEADER_WRITE_METHODS.has(method) &&
|
|
503
|
+
call.arguments[1] === argument &&
|
|
504
|
+
isStringLiteralMatching(call.arguments[0], LOCATION_HEADER_RE) &&
|
|
505
|
+
isHeadersReceiver(callee.object)
|
|
506
|
+
) {
|
|
507
|
+
return `${method}("Location", …)`;
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isTransparentCall(call) {
|
|
513
|
+
const name = calleeIdentifierName(call.callee);
|
|
514
|
+
return name !== null && TRANSPARENT_CALLEES.has(name);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The object or array literal is a headers value: the `headers` property,
|
|
519
|
+
* a `new Headers(…)` argument, bound to a `*headers` name, or nested in
|
|
520
|
+
* such an array.
|
|
521
|
+
*/
|
|
522
|
+
function isHeadersLiteral(literal) {
|
|
523
|
+
let child = literal;
|
|
524
|
+
let parent = literal.parent;
|
|
525
|
+
while (parent && TS_WRAPPER_TYPES.has(parent.type)) {
|
|
526
|
+
child = parent;
|
|
527
|
+
parent = parent.parent;
|
|
528
|
+
}
|
|
529
|
+
if (!parent) return false;
|
|
530
|
+
switch (parent.type) {
|
|
531
|
+
case "Property":
|
|
532
|
+
return (
|
|
533
|
+
parent.value === child && propertyKeyName(parent.key) === "headers"
|
|
534
|
+
);
|
|
535
|
+
case "NewExpression":
|
|
536
|
+
return (
|
|
537
|
+
calleeIdentifierName(parent.callee) === "Headers" &&
|
|
538
|
+
parent.arguments[0] === child
|
|
539
|
+
);
|
|
540
|
+
case "VariableDeclarator":
|
|
541
|
+
return (
|
|
542
|
+
parent.init === child &&
|
|
543
|
+
parent.id.type === "Identifier" &&
|
|
544
|
+
HEADERS_NAME_RE.test(parent.id.name)
|
|
545
|
+
);
|
|
546
|
+
case "AssignmentExpression":
|
|
547
|
+
return parent.right === child && isHeadersReceiver(parent.left);
|
|
548
|
+
case "ArrayExpression":
|
|
549
|
+
return isHeadersLiteral(parent);
|
|
550
|
+
default:
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** `headers`, `response.headers`, `new Headers()` or a binding to one. */
|
|
556
|
+
function isHeadersReceiver(node, visited = new Set()) {
|
|
557
|
+
node = unwrapExpression(node);
|
|
558
|
+
if (!node) return false;
|
|
559
|
+
if (node.type === "NewExpression") {
|
|
560
|
+
return calleeIdentifierName(node.callee) === "Headers";
|
|
561
|
+
}
|
|
562
|
+
if (node.type === "MemberExpression") {
|
|
563
|
+
return (
|
|
564
|
+
!node.computed &&
|
|
565
|
+
node.property.type === "Identifier" &&
|
|
566
|
+
HEADERS_NAME_RE.test(node.property.name)
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (node.type !== "Identifier") return false;
|
|
570
|
+
if (HEADERS_NAME_RE.test(node.name)) return true;
|
|
571
|
+
return initialisersOf(node, visited).some(
|
|
572
|
+
(entry) =>
|
|
573
|
+
entry.id.type === "Identifier" &&
|
|
574
|
+
isHeadersReceiver(entry.init, visited)
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── Leaves ────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
/** `${x}` / `x + ""` / `"" + x` — the URL object coerced to a string. */
|
|
581
|
+
function isStringCoercionContext(node) {
|
|
582
|
+
const parent = node.parent;
|
|
583
|
+
if (!parent) return false;
|
|
584
|
+
if (parent.type === "TemplateLiteral") return true;
|
|
585
|
+
return parent.type === "BinaryExpression" && parent.operator === "+";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function objectLabel(object) {
|
|
589
|
+
object = unwrapExpression(object);
|
|
590
|
+
if (object.type === "Identifier") return object.name;
|
|
591
|
+
if (isUrlConstruction(object)) return constructionLabel(object);
|
|
592
|
+
if (
|
|
593
|
+
object.type === "MemberExpression" &&
|
|
594
|
+
!object.computed &&
|
|
595
|
+
object.property.type === "Identifier"
|
|
596
|
+
) {
|
|
597
|
+
return `${objectLabel(object.object)}.${object.property.name}`;
|
|
598
|
+
}
|
|
599
|
+
return "url";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function constructionLabel(node) {
|
|
603
|
+
const callee = node.type === "NewExpression" ? "new URL" : "URL.parse";
|
|
604
|
+
const viaBase =
|
|
605
|
+
node.arguments[1] !== undefined &&
|
|
606
|
+
!isRequestUrlValue(node.arguments[0], new Set());
|
|
607
|
+
return viaBase ? `${callee}(…, request.url)` : `${callee}(request.url)`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** A bare URL / URLSearchParams object (a binding or an inline construction). */
|
|
611
|
+
function urlObjectKind(node) {
|
|
612
|
+
const inner = unwrapExpression(node);
|
|
613
|
+
if (inner.type !== "Identifier" && !isUrlConstruction(inner)) return null;
|
|
614
|
+
return resolveUrlKind(inner, new Set(), 0);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
ImportDeclaration(node) {
|
|
619
|
+
if (node.source.value !== "react-router") return;
|
|
620
|
+
for (const specifier of node.specifiers) {
|
|
621
|
+
if (
|
|
622
|
+
specifier.type === "ImportSpecifier" &&
|
|
623
|
+
specifier.imported.type === "Identifier" &&
|
|
624
|
+
REACT_ROUTER_REDIRECTORS.has(specifier.imported.name)
|
|
625
|
+
) {
|
|
626
|
+
redirectorNames.add(specifier.local.name);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
MemberExpression(node) {
|
|
632
|
+
if (isRequestUrlMember(node)) {
|
|
633
|
+
check(node, "request.url");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (node.computed || node.property.type !== "Identifier") return;
|
|
637
|
+
const property = node.property.name;
|
|
638
|
+
|
|
639
|
+
if (URL_ECHO_PROPERTIES.has(property)) {
|
|
640
|
+
if (resolveUrlKind(node.object, new Set(), 0) === "url") {
|
|
641
|
+
check(node, `${objectLabel(node.object)}.${property}`);
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (URL_SERIALISERS.has(property)) {
|
|
647
|
+
const call = node.parent;
|
|
648
|
+
if (
|
|
649
|
+
call &&
|
|
650
|
+
call.type === "CallExpression" &&
|
|
651
|
+
call.callee === node &&
|
|
652
|
+
call.arguments.length === 0 &&
|
|
653
|
+
resolveUrlKind(node.object, new Set(), 0) !== null
|
|
654
|
+
) {
|
|
655
|
+
check(call, `${objectLabel(node.object)}.${property}()`);
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (
|
|
661
|
+
property === "searchParams" &&
|
|
662
|
+
isStringCoercionContext(node) &&
|
|
663
|
+
resolveUrlKind(node.object, new Set(), 0) === "url"
|
|
664
|
+
) {
|
|
665
|
+
check(node, `${objectLabel(node.object)}.searchParams`);
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
CallExpression(node) {
|
|
670
|
+
// `String(url)` / `String(url.searchParams)`
|
|
671
|
+
if (
|
|
672
|
+
calleeIdentifierName(node.callee) === "String" &&
|
|
673
|
+
node.arguments.length === 1 &&
|
|
674
|
+
resolveUrlKind(node.arguments[0], new Set(), 0) !== null
|
|
675
|
+
) {
|
|
676
|
+
check(node, `String(${objectLabel(node.arguments[0])})`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// The object itself handed to a sink: `Response.redirect(url)`.
|
|
680
|
+
for (const argument of node.arguments.slice(0, 2)) {
|
|
681
|
+
const sink = classifySinkCall(node, argument);
|
|
682
|
+
if (sink === null || urlObjectKind(argument) === null) continue;
|
|
683
|
+
const inner = unwrapExpression(argument);
|
|
684
|
+
report(inner, objectLabel(inner), sink);
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
TemplateLiteral(node) {
|
|
689
|
+
for (const expression of node.expressions) {
|
|
690
|
+
if (urlObjectKind(expression) === null) continue;
|
|
691
|
+
const inner = unwrapExpression(expression);
|
|
692
|
+
check(inner, `\${${objectLabel(inner)}}`);
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
BinaryExpression(node) {
|
|
697
|
+
if (node.operator !== "+") return;
|
|
698
|
+
for (const side of [node.left, node.right]) {
|
|
699
|
+
if (urlObjectKind(side) === null) continue;
|
|
700
|
+
const inner = unwrapExpression(side);
|
|
701
|
+
check(inner, `${objectLabel(inner)} + ""`);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
VariableDeclarator(node) {
|
|
706
|
+
if (node.id.type !== "ObjectPattern" || node.init === null) return;
|
|
707
|
+
// `const { url } = request` — the request's URL bound to a local name.
|
|
708
|
+
if (isRequestBinding(node.init)) {
|
|
709
|
+
const property = patternProperty(node.id, "url");
|
|
710
|
+
if (property === null) return;
|
|
711
|
+
const sink = tracePattern(property.value, new Set(), 0);
|
|
712
|
+
if (sink !== null) report(property, "{ url } of request", sink);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// `const { pathname, search } = new URL(request.url)` — each
|
|
716
|
+
// destructured echo property is a leaf bound to a local name.
|
|
717
|
+
if (resolveUrlKind(node.init, new Set(), 0) !== "url") return;
|
|
718
|
+
for (const property of node.id.properties) {
|
|
719
|
+
if (property.type !== "Property") continue;
|
|
720
|
+
const key = propertyKeyName(property.key);
|
|
721
|
+
if (key === null || !URL_ECHO_PROPERTIES.has(key)) continue;
|
|
722
|
+
const sink = tracePattern(property.value, new Set(), 0);
|
|
723
|
+
if (sink !== null) {
|
|
724
|
+
report(property, `{ ${key} } of new URL(request.url)`, sink);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
function calleeIdentifierName(callee) {
|
|
733
|
+
callee = unwrapExpression(callee);
|
|
734
|
+
return callee && callee.type === "Identifier" ? callee.name : null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** The method name of a `URL.<method>(…)` call, or null. */
|
|
738
|
+
function urlStaticMethod(call) {
|
|
739
|
+
const callee = unwrapExpression(call.callee);
|
|
740
|
+
return callee &&
|
|
741
|
+
callee.type === "MemberExpression" &&
|
|
742
|
+
!callee.computed &&
|
|
743
|
+
callee.object.type === "Identifier" &&
|
|
744
|
+
callee.object.name === "URL" &&
|
|
745
|
+
callee.property.type === "Identifier"
|
|
746
|
+
? callee.property.name
|
|
747
|
+
: null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* A string literal (or expressionless template) that is a relative reference
|
|
752
|
+
* — no leading `/`, no scheme. A dynamic value is not static and returns
|
|
753
|
+
* false: the caller owns it.
|
|
754
|
+
*/
|
|
755
|
+
function isStaticRelativeReference(node) {
|
|
756
|
+
node = unwrapExpression(node);
|
|
757
|
+
let text = null;
|
|
758
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
759
|
+
text = node.value;
|
|
760
|
+
} else if (node.type === "TemplateLiteral") {
|
|
761
|
+
const head = node.quasis[0].value.cooked ?? "";
|
|
762
|
+
if (head === "" && node.expressions.length > 0) return false;
|
|
763
|
+
text = head;
|
|
764
|
+
}
|
|
765
|
+
return text !== null && !ABSOLUTE_REFERENCE_RE.test(text);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function isLocationTuple(array, valueNode) {
|
|
769
|
+
return (
|
|
770
|
+
array.elements.length === 2 &&
|
|
771
|
+
array.elements[1] === valueNode &&
|
|
772
|
+
isStringLiteralMatching(array.elements[0], LOCATION_HEADER_RE)
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function propertyKeyName(key) {
|
|
777
|
+
if (key.type === "Identifier") return key.name;
|
|
778
|
+
if (key.type === "Literal" && typeof key.value === "string") {
|
|
779
|
+
return key.value;
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function propertyKeyMatches(property, pattern) {
|
|
785
|
+
if (property.computed && property.key.type !== "Literal") return false;
|
|
786
|
+
const name = propertyKeyName(property.key);
|
|
787
|
+
return name !== null && pattern.test(name);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The pattern's property whose (possibly renamed) value binds `localName`. */
|
|
791
|
+
function patternPropertyKey(pattern, localName) {
|
|
792
|
+
const property = pattern.properties.find(
|
|
793
|
+
(p) =>
|
|
794
|
+
p.type === "Property" &&
|
|
795
|
+
p.value.type === "Identifier" &&
|
|
796
|
+
p.value.name === localName
|
|
797
|
+
);
|
|
798
|
+
return property ? propertyKeyName(property.key) : null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** The pattern's property destructuring `key`, or null. */
|
|
802
|
+
function patternProperty(pattern, key) {
|
|
803
|
+
return (
|
|
804
|
+
pattern.properties.find(
|
|
805
|
+
(p) => p.type === "Property" && propertyKeyName(p.key) === key
|
|
806
|
+
) ?? null
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function isStringLiteralMatching(node, pattern) {
|
|
811
|
+
if (!node) return false;
|
|
812
|
+
node = unwrapExpression(node);
|
|
813
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
814
|
+
return pattern.test(node.value);
|
|
815
|
+
}
|
|
816
|
+
return (
|
|
817
|
+
node.type === "TemplateLiteral" &&
|
|
818
|
+
node.expressions.length === 0 &&
|
|
819
|
+
node.quasis.length === 1 &&
|
|
820
|
+
pattern.test(node.quasis[0].value.cooked ?? "")
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function collectPatternIdentifiers(pattern, out) {
|
|
825
|
+
if (!pattern) return;
|
|
826
|
+
switch (pattern.type) {
|
|
827
|
+
case "Identifier":
|
|
828
|
+
out.push(pattern);
|
|
829
|
+
return;
|
|
830
|
+
case "ObjectPattern":
|
|
831
|
+
for (const property of pattern.properties) {
|
|
832
|
+
collectPatternIdentifiers(
|
|
833
|
+
property.type === "Property" ? property.value : property.argument,
|
|
834
|
+
out
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
case "ArrayPattern":
|
|
839
|
+
for (const element of pattern.elements) {
|
|
840
|
+
collectPatternIdentifiers(element, out);
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
case "AssignmentPattern":
|
|
844
|
+
collectPatternIdentifiers(pattern.left, out);
|
|
845
|
+
return;
|
|
846
|
+
case "RestElement":
|
|
847
|
+
collectPatternIdentifiers(pattern.argument, out);
|
|
848
|
+
return;
|
|
849
|
+
default:
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export default noRawRequestUrlInRedirect;
|