-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[HTTP] Safer client calls and new browser buildPath utility
#257230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jloleysens
merged 32 commits into
elastic:main
from
jloleysens:feature/http-browser-build-path-utility
Apr 3, 2026
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
b97b1cd
[Core] Add on-request path traversal guard POC
jloleysens a7e6fdf
Revert "[Core] Add on-request path traversal guard POC"
jloleysens 585e6fd
buildPath
jloleysens dd42dd9
[HTTP] Add buildPath test coverage
jloleysens 25d4db0
delete test
jloleysens 0e5d078
two required params
jloleysens 59f5454
typo
jloleysens 010e8dd
Changes from node scripts/lint_ts_projects --fix
kibanamachine ae92d4d
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine b3a1a5d
handle hapi path syntax
jloleysens 834fcc5
use buildPath in delete calls
jloleysens c7aa871
added best-effort eslint rule to flag unsafe http.delet usages
jloleysens 55b82c4
fix projects
jloleysens b551fc3
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine cf012cb
rename eslint rule, change to suggestion and target all http method i…
jloleysens ea1c70e
allow constants as a safe path prefix
jloleysens fde26a8
Changes from node scripts/lint_ts_projects --fix
kibanamachine 4f6dcb0
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine 097a2ae
remove extraneous import
jloleysens f0ab15b
Apply suggestions from code review
jloleysens 073b1e9
increase bundle limit for lens
jloleysens b72770f
Merge branch 'main' into feature/http-browser-build-path-utility
jloleysens 507132d
Add missing buildPath, await and return statements
gsoldevila 0a17748
Protect null on `quasis[1]`
gsoldevila 7be9e6f
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine 7238c74
address PR feedback
jloleysens 32a79ec
use buildPath for other markdownclient calls
jloleysens 29bf909
Merge remote-tracking branch 'upstream/main' into feature/http-browse…
jloleysens 613f7b5
Changes from node scripts/lint_ts_projects --fix
kibanamachine 36aae99
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine 05f5eea
Merge remote-tracking branch 'upstream/main' into feature/http-browse…
jloleysens 98ce5f5
remove cruft
jloleysens File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
311 changes: 311 additions & 0 deletions
311
packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| /* | ||
| * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| * or more contributor license agreements. Licensed under the "Elastic License | ||
| * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side | ||
| * Public License v 1"; you may not use this file except in compliance with, at | ||
| * your election, the "Elastic License 2.0", the "GNU Affero General Public | ||
| * License v3.0 only", or the "Server Side Public License, v 1". | ||
| */ | ||
|
|
||
| const tsEstree = require('@typescript-eslint/typescript-estree'); | ||
| const esTypes = tsEstree.AST_NODE_TYPES; | ||
|
|
||
| /** @typedef {import("eslint").Rule.RuleModule} Rule */ | ||
| /** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.CallExpression} CallExpression */ | ||
| /** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Expression} Expression */ | ||
| /** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.MemberExpression} MemberExpression */ | ||
| /** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Node} Node */ | ||
|
|
||
| const HTTP_REQUEST_METHOD_NAMES = new Set([ | ||
| 'delete', | ||
| 'fetch', | ||
| 'get', | ||
| 'head', | ||
| 'options', | ||
| 'patch', | ||
| 'post', | ||
| 'put', | ||
| ]); | ||
| const WARN_MSG = | ||
| 'Dangerous use of dynamic http path. Use buildPath() from `@kbn/core-http-browser` or encodeURIComponent() so path params are encoded safely.'; | ||
| const ALL_CAPS_IDENTIFIER_PATTERN = /^[A-Z][A-Z0-9_]*$/; | ||
|
|
||
| /** | ||
| * Limitations: | ||
| * - This rule only inspects inline path expressions passed directly as the first argument to `http` request calls, | ||
| * or inline `path` properties in the object overload (for example `http.get({ path: ... })`). | ||
| * - It does not perform data-flow analysis, so `const path = \`/api/${id}\`; http.delete(path)` is not flagged. | ||
| * - It only targets standard HTTP verb calls and `http.fetch(...)` on `http`-like receivers | ||
| * (`http`, `this.http`, `foo.http`, etc.). | ||
| * - It treats `encodeURIComponent(...)` as a safe escape hatch, but it does not verify that callers are | ||
| * encoding the correct path segment or using the best API shape for the route. | ||
| */ | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {node is MemberExpression} | ||
| */ | ||
| const isMemberExpression = (node) => node.type === esTypes.MemberExpression; | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isHttpReference = (node) => { | ||
| if (node.type === esTypes.Identifier) { | ||
| return node.name === 'http'; | ||
| } | ||
|
|
||
| if (!isMemberExpression(node) || node.computed || node.property.type !== esTypes.Identifier) { | ||
| return false; | ||
| } | ||
|
|
||
| if (node.property.name === 'http') { | ||
| return true; | ||
| } | ||
|
|
||
| return isHttpReference(node.object); | ||
| }; | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isUsingEncodeURIComponent = (node) => { | ||
| if (node.type === esTypes.CallExpression) { | ||
| const { callee } = node; | ||
|
|
||
| if (callee.type === esTypes.Identifier) { | ||
| return callee.name === 'encodeURIComponent'; | ||
| } | ||
|
|
||
| return ( | ||
| callee.type === esTypes.MemberExpression && | ||
| !callee.computed && | ||
| callee.property.type === esTypes.Identifier && | ||
| callee.property.name === 'encodeURIComponent' | ||
| ); | ||
| } | ||
|
|
||
| if (node.type === esTypes.BinaryExpression && node.operator === '+') { | ||
| return isUsingEncodeURIComponent(node.left) && isUsingEncodeURIComponent(node.right); | ||
| } | ||
|
|
||
| if (node.type === esTypes.ConditionalExpression) { | ||
| return isUsingEncodeURIComponent(node.consequent) && isUsingEncodeURIComponent(node.alternate); | ||
| } | ||
|
|
||
| return false; | ||
| }; | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isAllCapsIdentifier = (node) => { | ||
| return node.type === esTypes.Identifier && ALL_CAPS_IDENTIFIER_PATTERN.test(node.name); | ||
| }; | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isSafePathPrefixReference = (node) => { | ||
| if (isAllCapsIdentifier(node)) { | ||
| return true; | ||
| } | ||
|
|
||
| if ( | ||
| node.type !== esTypes.MemberExpression || | ||
| node.computed || | ||
| node.property.type !== esTypes.Identifier | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| return isSafePathPrefixReference(node.object); | ||
| }; | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isPathExpressionStartingWithSlash = (node) => { | ||
| if (node.type === esTypes.Literal) { | ||
| return typeof node.value === 'string' && node.value.startsWith('/'); | ||
| } | ||
|
|
||
| if (node.type === esTypes.TemplateLiteral) { | ||
| const cooked = node.quasis[0].value.cooked; | ||
| return typeof cooked === 'string' && cooked.startsWith('/'); | ||
| } | ||
|
|
||
| if (node.type === esTypes.BinaryExpression && node.operator === '+') { | ||
| return isPathExpressionStartingWithSlash(node.left); | ||
| } | ||
|
|
||
| if (node.type === esTypes.ConditionalExpression) { | ||
| return ( | ||
| isPathExpressionStartingWithSlash(node.consequent) && | ||
| isPathExpressionStartingWithSlash(node.alternate) | ||
| ); | ||
| } | ||
|
|
||
| return false; | ||
| }; | ||
|
|
||
| /** | ||
| * @param {Node} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isSafePathSegmentExpression = (node) => { | ||
| if (node.type === esTypes.Literal) { | ||
| return true; | ||
| } | ||
|
|
||
| if (isUsingEncodeURIComponent(node)) { | ||
| return true; | ||
| } | ||
|
|
||
| if (node.type === esTypes.TemplateLiteral) { | ||
| return isSafeTemplateLiteralPath(node); | ||
| } | ||
|
|
||
| if (node.type === esTypes.BinaryExpression && node.operator === '+') { | ||
| return ( | ||
| (isSafePathSegmentExpression(node.left) && isSafePathSegmentExpression(node.right)) || | ||
| (isSafePathPrefixReference(node.left) && | ||
| isPathExpressionStartingWithSlash(node.right) && | ||
| isSafePathSegmentExpression(node.right)) | ||
| ); | ||
| } | ||
|
|
||
| if (node.type === esTypes.ConditionalExpression) { | ||
| return ( | ||
| isSafePathSegmentExpression(node.consequent) && isSafePathSegmentExpression(node.alternate) | ||
| ); | ||
| } | ||
|
|
||
| return false; | ||
| }; | ||
|
|
||
| /** | ||
| * @param {import("@typescript-eslint/typescript-estree").TSESTree.TemplateLiteral} node | ||
| * @returns {boolean} | ||
| */ | ||
| function isSafeTemplateLiteralPath(node) { | ||
| return node.expressions.every((expression, index) => { | ||
| if (isSafePathSegmentExpression(expression)) { | ||
| return true; | ||
| } | ||
|
|
||
| const afterExpr = node.quasis[1]?.value.cooked; | ||
| return ( | ||
| index === 0 && | ||
| isSafePathPrefixReference(expression) && | ||
| node.quasis[0].value.cooked === '' && | ||
| typeof afterExpr === 'string' && | ||
| afterExpr.startsWith('/') | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * @param {Expression} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isDynamicPathExpression = (node) => { | ||
| if (node.type === esTypes.TemplateLiteral) { | ||
| return !isSafePathSegmentExpression(node); | ||
| } | ||
|
|
||
| if (node.type === esTypes.BinaryExpression && node.operator === '+') { | ||
| return !isSafePathSegmentExpression(node); | ||
| } | ||
|
|
||
| if (node.type === esTypes.ConditionalExpression) { | ||
| return !isSafePathSegmentExpression(node); | ||
| } | ||
|
|
||
| return false; | ||
| }; | ||
|
|
||
| /** | ||
| * @param {CallExpression} node | ||
| * @returns {boolean} | ||
| */ | ||
| const isHttpRequestCall = (node) => { | ||
| if ( | ||
| node.callee.type !== esTypes.MemberExpression || | ||
| node.callee.computed || | ||
| node.callee.property.type !== esTypes.Identifier || | ||
| !HTTP_REQUEST_METHOD_NAMES.has(node.callee.property.name) | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| return isHttpReference(node.callee.object); | ||
| }; | ||
|
|
||
| /** | ||
| * @param {Expression | import("@typescript-eslint/typescript-estree").TSESTree.SpreadElement} node | ||
| * @returns {Expression | undefined} | ||
| */ | ||
| const getPathExpression = (node) => { | ||
| if (node.type === esTypes.SpreadElement) { | ||
| return undefined; | ||
| } | ||
|
|
||
| if (node.type !== esTypes.ObjectExpression) { | ||
| return node; | ||
| } | ||
|
|
||
| for (const property of node.properties) { | ||
| if (property.type !== esTypes.Property || property.computed || property.kind !== 'init') { | ||
| continue; | ||
| } | ||
|
|
||
| const isPathKey = | ||
| (property.key.type === esTypes.Identifier && property.key.name === 'path') || | ||
| (property.key.type === esTypes.Literal && property.key.value === 'path'); | ||
|
|
||
| if (isPathKey) { | ||
| return property.value; | ||
| } | ||
| } | ||
|
|
||
| return undefined; | ||
| }; | ||
|
|
||
| /** @type {Rule} */ | ||
| module.exports = { | ||
| meta: { | ||
| type: 'suggestion', | ||
| docs: { | ||
| description: | ||
| 'Disallow dynamically building inline paths in http request calls so callers use buildPath instead', | ||
| }, | ||
| schema: [], | ||
| }, | ||
| create(context) { | ||
| return { | ||
| CallExpression(_) { | ||
| const node = /** @type {CallExpression} */ (_); | ||
| if (!isHttpRequestCall(node) || node.arguments.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const [firstArgument] = node.arguments; | ||
| const pathExpression = getPathExpression(firstArgument); | ||
| if (!pathExpression || !isDynamicPathExpression(pathExpression)) { | ||
| return; | ||
| } | ||
|
|
||
| context.report({ | ||
| node: pathExpression, | ||
| message: WARN_MSG, | ||
| }); | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.