Skip to content
Merged
Show file tree
Hide file tree
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 Mar 11, 2026
a7e6fdf
Revert "[Core] Add on-request path traversal guard POC"
jloleysens Mar 11, 2026
585e6fd
buildPath
jloleysens Mar 11, 2026
dd42dd9
[HTTP] Add buildPath test coverage
jloleysens Mar 11, 2026
25d4db0
delete test
jloleysens Mar 11, 2026
0e5d078
two required params
jloleysens Mar 11, 2026
59f5454
typo
jloleysens Mar 11, 2026
010e8dd
Changes from node scripts/lint_ts_projects --fix
kibanamachine Mar 11, 2026
ae92d4d
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Mar 11, 2026
b3a1a5d
handle hapi path syntax
jloleysens Mar 12, 2026
834fcc5
use buildPath in delete calls
jloleysens Mar 12, 2026
c7aa871
added best-effort eslint rule to flag unsafe http.delet usages
jloleysens Mar 12, 2026
55b82c4
fix projects
jloleysens Mar 12, 2026
b551fc3
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Mar 12, 2026
cf012cb
rename eslint rule, change to suggestion and target all http method i…
jloleysens Mar 12, 2026
ea1c70e
allow constants as a safe path prefix
jloleysens Mar 12, 2026
fde26a8
Changes from node scripts/lint_ts_projects --fix
kibanamachine Mar 19, 2026
4f6dcb0
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Mar 19, 2026
097a2ae
remove extraneous import
jloleysens Mar 31, 2026
f0ab15b
Apply suggestions from code review
jloleysens Mar 31, 2026
073b1e9
increase bundle limit for lens
jloleysens Mar 31, 2026
b72770f
Merge branch 'main' into feature/http-browser-build-path-utility
jloleysens Apr 1, 2026
507132d
Add missing buildPath, await and return statements
gsoldevila Apr 1, 2026
0a17748
Protect null on `quasis[1]`
gsoldevila Apr 1, 2026
7be9e6f
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Apr 1, 2026
7238c74
address PR feedback
jloleysens Apr 2, 2026
32a79ec
use buildPath for other markdownclient calls
jloleysens Apr 2, 2026
29bf909
Merge remote-tracking branch 'upstream/main' into feature/http-browse…
jloleysens Apr 2, 2026
613f7b5
Changes from node scripts/lint_ts_projects --fix
kibanamachine Apr 2, 2026
36aae99
Changes from node scripts/regenerate_moon_projects.js --update
kibanamachine Apr 2, 2026
05f5eea
Merge remote-tracking branch 'upstream/main' into feature/http-browse…
jloleysens Apr 2, 2026
98ce5f5
remove cruft
jloleysens Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ module.exports = {
{
files: ['**/*.{js,mjs,ts,tsx}'],
rules: {
'@kbn/eslint/no_unsafe_dynamic_http_path': 'warn',
'@kbn/eslint/no_wrapped_error_in_logger': 'error',
'no-restricted-imports': ['error', ...RESTRICTED_IMPORTS],
'@kbn/eslint/no_deprecated_imports': ['warn', ...DEPRECATED_IMPORTS],
Expand Down
4 changes: 2 additions & 2 deletions examples/routing_example/public/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
ThemeServiceStart,
UserProfileService,
} from '@kbn/core/public';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { buildPath, type IHttpFetchError } from '@kbn/core-http-browser';
import {
RANDOM_NUMBER_ROUTE_PATH,
RANDOM_NUMBER_BETWEEN_ROUTE_PATH,
Expand Down Expand Up @@ -66,7 +66,7 @@ export function getServices(core: CoreStart): Services {
},
postMessage: async (message: string, id: string) => {
try {
await core.http.post(`${POST_MESSAGE_ROUTE_PATH}/${id}`, {
await core.http.post(buildPath(`${POST_MESSAGE_ROUTE_PATH}/{id}`, { id }), {
body: JSON.stringify({ message }),
});
} catch (e) {
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-eslint-plugin-eslint/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = {
no_trailing_import_slash: require('./rules/no_trailing_import_slash'),
no_constructor_args_in_property_initializers: require('./rules/no_constructor_args_in_property_initializers'),
no_this_in_property_initializers: require('./rules/no_this_in_property_initializers'),
no_unsafe_dynamic_http_path: require('./rules/no_unsafe_dynamic_http_path'),
no_unsafe_console: require('./rules/no_unsafe_console'),
no_unsafe_hash: require('./rules/no_unsafe_hash'),
require_kibana_feature_privileges_naming: require('./rules/require_kibana_feature_privileges_naming'),
Expand Down
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('/');
}
Comment thread
jloleysens marked this conversation as resolved.

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,
});
},
};
},
};
Loading
Loading