eslint-plugin-zk/src/rules/noMixedHtml.ts
/* noMixedHtml.ts
Purpose:
Description:
History:
11:15 AM 2023/12/25, Created by jumperchen
Copyright (C) 2023 Potix Corporation. All Rights Reserved.
*/
/**
* @fileoverview Checks for missing encoding when concatenating HTML strings
* @author Mikko Rantanen
*/
import { TSESTree } from '@typescript-eslint/utils';
import {tree} from '../tree';
import {re} from '../re';
import {RulesJs, PassThroughRule, FunctionRule} from '../RulesJs';
import {type Rule} from 'eslint';
// -----------------------------------------------------------------------------
// Rule Definition
// -----------------------------------------------------------------------------
export const noMixedHtml = function (context: Rule.RuleContext) {
// Default options.
let htmlVariableRules = ['html/i'] as (RegExp| string | {object: string, property: string | RegExp})[];
let htmlFunctionRules = ['AsHtml'] as (RegExp| string)[];
let sanitizedVariableRules = [] as (RegExp| string | {object: string, property: string | RegExp})[];
let functionRules = {
'.join': { passthrough: { obj: true, args: true } },
'.toString': { passthrough: { obj: true } },
'.substr': { passthrough: { obj: true } },
'.substring': { passthrough: { obj: true } },
} as Record<string, FunctionRule>;
// Read the user specified options.
if (context.options.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const opts = context.options[0] as {
htmlVariableRules?: (RegExp | string)[], htmlFunctionRules?: (RegExp| string)[],
sanitizedVariableRules?: (RegExp | string)[], functions?: Record<string, FunctionRule>};
htmlVariableRules = opts.htmlVariableRules || htmlVariableRules;
htmlFunctionRules = opts.htmlFunctionRules || htmlFunctionRules;
sanitizedVariableRules = opts.sanitizedVariableRules || sanitizedVariableRules;
functionRules = opts.functions || functionRules;
}
// Turn the name rules from string/string array to regexp.
// htmlVariableRules = htmlVariableRules.map(re.toRegexp);
htmlVariableRules = htmlVariableRules.map((rule, _, __) => {
if (typeof rule === 'string') {
return re.toRegexp(rule);
} else if (!(rule instanceof RegExp) && rule.property) {
return {
object: rule.object, // Assuming object is always a string
property: re.toRegexp(rule.property as never)
};
}
return rule;
}) as never;
htmlFunctionRules = htmlFunctionRules.map(re.toRegexp as never);
sanitizedVariableRules = sanitizedVariableRules.map((rule, _, __) => {
if (typeof rule === 'string') {
return re.toRegexp(rule);
} else if (!(rule instanceof RegExp) && rule.property) {
return {
object: rule.object, // Assuming object is always a string
property: re.toRegexp(rule.property as never)
};
}
return rule;
}) as never;
const allRules = new RulesJs({
functionRules: functionRules as never
});
// Expression stack for tracking the topmost expression that is marked
// XSS-candidate when we find '<html>' strings.
const exprStack = [] as {node: TSESTree.Node, xss?: boolean, sanitized?: boolean | undefined}[];
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Checks whether the node represents a passthrough function.
*
* @param {Node} node - Node to check.
*
* @returns {bool} - True, if the node is an array join.
*/
const getPassthrough = function (node: TSESTree.Node): PassThroughRule | false {
if (node.type !== 'CallExpression')
return false;
const rules = allRules.getFunctionRules(node);
return rules.passthrough as PassThroughRule;
};
/**
* Gets all descendants that we know to affect the possible output string.
*
* @param {Node} node - Node for which to get the descendants. Inclusive.
* @param {Node} _children - Collection of descendants. Leave null.
* @param {Node} _hasRecursed -
* Defines whether the function has recursed into inner structures.
* Leave false.
*
* @returns {Node[]} - Flat list of descendant nodes.
*/
const getDescendants = function (node: TSESTree.Node, _children?: undefined | TSESTree.Node[], _hasRecursed?: boolean): TSESTree.Node[] {
// The children array may be passed during recursion.
if (_children === undefined)
_children = [];
// Handle the special case of .join() function.
const passthrough = getPassthrough(node);
if (passthrough) {
const cn = node as TSESTree.CallExpression;
// Get the descedants from the array and the function argument.
if (passthrough.obj) {
getDescendants((cn.callee as TSESTree.MemberExpression).object, _children, _hasRecursed);
}
if (passthrough.args) {
cn.arguments.forEach(function (a) {
getDescendants(a, _children, _hasRecursed);
});
}
return _children;
}
// Check the expression type.
if (node.type === 'CallExpression' ||
node.type === 'NewExpression' ||
node.type === 'ThisExpression' ||
node.type === 'ObjectExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'UnaryExpression' ||
node.type === 'UpdateExpression' ||
node.type === 'MemberExpression' ||
node.type === 'SequenceExpression' ||
node.type === 'Literal' ||
node.type === 'Identifier' ||
(_hasRecursed && node.type === 'ArrayExpression')
) {
// Basic expressions that won't be reflected further.
_children.push(node);
} else if (node.type === 'ArrayExpression') {
// For array nodes, get the descendant nodes.
node.elements.forEach(function (e) {
getDescendants(e as never, _children, true);
});
} else if (node.type === 'BinaryExpression') {
// Binary expressions concatenate strings.
//
// Recurse to both left and right side.
getDescendants(node.left, _children, true);
getDescendants(node.right, _children, true);
} else if (node.type === 'LogicalExpression') {
// Binary expressions concatenate strings.
//
// Recurse to both left and right side.
getDescendants(node.left, _children, true);
getDescendants(node.right, _children, true);
} else if (node.type === 'AssignmentExpression') {
// There might be assignment expressions in the middle of the node.
// Use the assignment identifier as the descendant.
//
// The assignment itself will be checked with its own descendants
// check.
getDescendants(node.left, _children, _hasRecursed);
} else if (node.type === 'ConditionalExpression') {
getDescendants(node.alternate, _children, _hasRecursed);
getDescendants(node.consequent, _children, _hasRecursed);
} else if (node.type === 'TSNonNullExpression') {
_children.push(node.expression);
}
return _children;
};
/**
* Checks whether the node is safe for XSS attacks.
*
* @param {Node} node - Node to check.
*
* @returns {bool} - True, if the node is XSS safe.
*/
const isXssSafe = function (node: TSESTree.Node) {
// See if the item is commented to be safe.
if (isCommentedSafe(node))
return true;
// Literal nodes and function expressions are okay.
if (node.type === 'Literal' ||
node.type === 'FunctionExpression' ||
(node.type === 'UpdateExpression' && (node.operator === '++' || node.operator === '--'))) {
return true;
}
// Identifiers and member expressions are okay if they resolve to an
// HTML name.
if (node.type === 'Identifier' ||
node.type === 'MemberExpression') {
if (isSanitizedVariable(node)) return true;
if (node.type === 'Identifier') {
if (node.name === 'undefined' || node.name === 'null') return true;
// if the variable is initialized with a literal but not with Html, it is safe
const parent: TSESTree.VariableDeclarator | undefined = (context.getScope().variables.find(v => v.name === node.name)?.identifiers[0] as {
parent?: TSESTree.VariableDeclarator
})?.parent;
if (parent && parent.init && (
(parent.init.type === 'Literal'&& !isHTMLLiteral(parent.init)) ||
(parent.init.type === 'ConditionalExpression' &&
parent.init.consequent.type === 'Literal' &&
parent.init.alternate.type === 'Literal'))) {
return true;
}
const nodeParent = node.parent as TSESTree.VariableDeclarator;
if (nodeParent.type === 'VariableDeclarator' &&
nodeParent.init && (
nodeParent.init.type == 'UnaryExpression' || (
nodeParent.init.type == 'LogicalExpression'
&& nodeParent.init.left.type == 'UnaryExpression')
)) {
return true;
}
// ignore check condition expression
if (node.parent!.type === 'BinaryExpression' && (node.parent as TSESTree.BinaryExpression).operator.includes('=')) {
return true;
}
}
// isHtmlVariable handles both Identifiers and member expressions.
return isHtmlVariable(node);
}
if (node.type === 'ThisExpression') {
if (isSanitizedVariable(node)) return true;
return isHtmlVariable(node.parent as never);
}
// Encode calls are okay.
if (node.type === 'CallExpression') {
return isHtmlOutputFunction(node.callee);
}
// Assume unsafe.
return false;
};
/**
* Check for whether the function identifier refers to an encoding function.
*
* @param {Identifier} func - Function identifier to check.
*
* @returns {bool} True, if the function is an encoding function.
*/
const isHtmlOutputFunction = function (func: TSESTree.Node) {
return allRules.getFunctionRules(func).htmlOutput ||
re.any(tree.getFullItemName(func), htmlFunctionRules as never);
};
/**
* Checks whether the function uses raw HTML input.
*
* @param {Identifier} func - Function identifier to check.
*
* @returns {bool} True, if the function is unsafe.
*/
const functionAcceptsHtml = function (func: TSESTree.Node): boolean {
return !!allRules.getFunctionRules(func).htmlInput;
};
/**
* Checks whether the node-tree contains XSS-safe data.
*
* Reports error to ESLint.
*
* @param {Node} node - Root node to check.
* @param {Node} target
* Target node the root is used for. Affects some XSS checks.
*/
const checkForXss = function (node: TSESTree.Node, target: TSESTree.Node) {
// Skip functions.
// This stops the following from giving errors:
// > htmlEncoder = function() {}
if (node.type === 'FunctionExpression' ||
node.type === 'ObjectExpression')
return;
// Get the rules.
const targetRules = allRules.get(target);
// Get the descendants.
const nodes = getDescendants(node);
// Check each descendant.
nodes.forEach(function (childNode: TSESTree.Node) {
// Return if the parameter is marked as safe in the current context.
if (targetRules.safe === true) {
return;
} else if (Array.isArray(targetRules.safe) &&
targetRules.safe.indexOf(tree.getNodeName(childNode)) !== -1) {
return;
}
// Node is okay, if it is safe.
if (isXssSafe(childNode))
return;
// Node wasn't deemed okay. Report error.
let msg = 'Unencoded input \'{{ identifier }}\' used in HTML context';
if (childNode.type === 'CallExpression') {
msg = 'Unencoded return value from function \'{{ identifier }}\' ' +
'used in HTML context';
childNode = childNode.callee;
}
let identifier = null;
if (childNode.type === 'ObjectExpression')
identifier = '[Object]';
else if (childNode.type === 'ArrayExpression')
identifier = '[Array]';
else
identifier = context.sourceCode.getText(childNode as never);
context.report({
node: childNode as never,
message: msg,
data: { identifier: identifier }
});
});
};
/**
* Checks whether the node uses HTML.
*
* @param {Node} node - Node to check.
*
* @returns {bool} True, if the node uses HTML.
*/
const usesHtml = function (node: TSESTree.Node): boolean {
// Check the node type.
if (node.type === 'CallExpression') {
// Check the valid call expression callees.
return functionAcceptsHtml(node.callee);
} else if (node.type === 'AssignmentExpression') {
// Assignment operator.
// x = y
// HTML-name on the left indicates html expression.
return isHtmlVariable(node.left);
} else if (node.type === 'VariableDeclarator') {
// Variable declaration.
// var x = y
// HTML-name as the variable name indicates html expression.
return isHtmlVariable(node.id);
} else if (node.type === 'Property') {
// Property declaration.
// x: y
// HTML-name as the key indicates html property.
return isHtmlVariable(node.key);
} else if (node.type === 'ArrayExpression') {
// Array expression.
// [ a, b, c ]
return usesHtml(node.parent as never);
} else if (node.type === 'ReturnStatement') {
// Return statement.
const func = tree.getParentFunctionIdentifier(node);
if (!func) return false;
return isHtmlFunction(func);
} else if (node.type === 'ArrowFunctionExpression') {
// Return statement.
const func = tree.getParentFunctionIdentifier(node);
if (!func) return false;
return isHtmlFunction(func);
}
return false;
};
/**
* Checks whether the node meets the criteria of storing HTML content.
*
* Reports error to ESLint.
*
* @param {Node} node - The node to check.
*/
const checkHtmlVariable = function (node: TSESTree.Node) {
const msg = 'Non-HTML variable \'{{ identifier }}\' is used to store raw HTML';
if (!isXssSafe(node)) {
context.report({
node: node as never,
message: msg,
data: {
identifier: context.sourceCode.getText(node as never)
}
});
}
};
/**
* Checks whether the node meets the criteria of storing HTML content.
*
* Reports error to ESLint.
*
* @param {Node} node - The node to check.
* @param {Node} fault
* The node that causes the fail and should be reported as error location.
*/
const checkHtmlFunction = function (node: TSESTree.Node, fault: TSESTree.Node) {
const msg = 'Non-HTML function \'{{ identifier }}\' returns HTML content';
if (!isXssSafe(node)) {
context.report({
node: fault as never,
message: msg,
data: {
identifier: context.sourceCode.getText(node as never)
}
});
}
};
/**
* Checks whether the node meets the criteria of storing HTML content.
*
* Reports error to ESLint.
*
* @param {Node} node - The node to check.
*/
const checkFunctionAcceptsHtml = function (node: TSESTree.Node) {
if (!functionAcceptsHtml(node)) {
context.report({
node: node as never,
message: 'HTML passed in to function \'{{ identifier }}\'',
data: {
identifier: context.sourceCode.getText(node as never)
}
});
}
};
/**
* Checks whether the node name matches the variable naming rule.
*
* @param {Node} node - Node to check
*
* @returns {bool} True, if the node matches HTML variable naming.
*/
const isHtmlVariable = function (node: TSESTree.Node) {
const identifierNode = tree.getIdentifier(node);
if (!identifierNode) return false;
if (identifierNode.type === 'Identifier' && identifierNode.parent!.type === 'MemberExpression') {
const parent = identifierNode.parent as TSESTree.MemberExpression;
// ignore namespace type, for example zhtml.xxx or zul.wgt.HTML
const source = context.sourceCode.getText(parent as never);
if (source.startsWith('zhtml.') || source.startsWith('zul.') || source.startsWith('zk.')) {
return false;
}
if (htmlVariableRules.some(function (rule) {
if (typeof rule === 'object' && !(rule instanceof RegExp) && rule.object && rule.property) {
const objectMatch = (parent.object as TSESTree.Identifier).name === rule.object ||
rule.object == 'this' && parent.object.type === 'ThisExpression';
const propertyMatch = (rule.property as RegExp).test((parent.object as TSESTree.Identifier).name);
return objectMatch && propertyMatch;
}
return false;
})) {
return true;
}
}
return htmlVariableRules.some(rule => {
if (rule instanceof RegExp) {
return rule.test(identifierNode.name);
}
return false;
});
};
const isSanitizedVariable = function (node: TSESTree.Node | undefined): boolean {
node = tree.getIdentifier(node!) as unknown as TSESTree.Identifier | undefined;
if (!node) return false;
if (node.type === 'Identifier' && node.parent!.type === 'MemberExpression') {
const parent = node.parent;
if (sanitizedVariableRules.some(rule => {
if (typeof rule === 'object' && !(rule instanceof RegExp) && rule.object && rule.property) {
const objectMatch = ((parent as TSESTree.MemberExpression).object as TSESTree.Identifier).name === rule.object ||
rule.object == 'this' && (parent as TSESTree.MemberExpression).object.type === 'ThisExpression';
const propertyMatch = (rule.property as RegExp).test(((parent as TSESTree.MemberExpression).property as TSESTree.Identifier).name);
return objectMatch && propertyMatch;
}
return false;
})) {
return true;
}
}
return sanitizedVariableRules.some(rule => {
if (rule instanceof RegExp) {
return rule.test((node as TSESTree.Identifier).name);
}
return false;
});
};
/**
* Checks whether the node name matches the function naming rule.
*
* @param {Node} node - Node to check
*
* @returns {bool} True, if the node matches HTML function naming.
*/
const isHtmlFunction = function (node: TSESTree.Node | undefined): boolean {
// Ensure we can get the identifier.
node = tree.getIdentifier(node!) as TSESTree.Identifier | undefined;
if (!node) return false;
// Make the check against the function naming rule.
return re.any(node.name, htmlFunctionRules as never);
};
/**
* Checks whether the current node may infect the stack with XSS.
*
* @param {Node} node - Current node.
*
* @returns {bool} True, if the node can infect the stack.
*/
const canInfectXss = function (node: TSESTree.Node) {
// If we got nothing in the stack, there's nothing to infect.
if (exprStack.length === 0)
return false;
// Ensure the node to check is used as part of a 'parameter chain' from
// the top stack node.
//
// This 'parameter chain' is the group of nodes that directly affect the
// node result. It ignores things like function expression argument
// lists and bodies, etc.
//
// We don't want to trigger xss checks in case the identifier
// is the parent object of a function call expression for
// example:
// > html.encode( text )
const top = exprStack[exprStack.length - 1]!.node;
let parent = node;
do {
const child = parent;
parent = parent.parent!;
if (!tree.isParameter(child, parent)) {
return false;
}
} while (parent !== top);
// Assume true.
return true;
};
/**
* Pushes node to the expression stack.
*
* @param {Node} node - Node to push.
*/
const pushNode = function (node: TSESTree.Node): void {
exprStack.push({ node: node });
};
const isHTMLLiteral = function (node: TSESTree.Literal): boolean {
return !isCommentedSafe(node) && !!/<\/?[a-z]/.exec(node.value as string);
};
/**
* Pops a node from the expression stack and checks it for XSS issues.
*/
const exitNode = function () {
// Quick checks for whether the node is even vulnerable to XSS.
const expr = exprStack.pop()!;
if (!expr.xss && !usesHtml(expr.node))
return;
// Now we should know there is HTML involved somewhere.
// Check whether the node has been commented safe.
if (isCommentedSafe(expr.node))
return;
// Check the node based on its type.
if (expr.node.type === 'CallExpression') {
if (allRules.get(expr.node).sanitized)
return;
const nodes = expr.node.arguments.map((x) => getDescendants((x))).flat();
const hasUnSafeHTML = nodes.some((x) => x.type === 'Literal' && isHTMLLiteral(x));
// more check if arguments are not literal or sanitized
if (expr.sanitized) {
if (!hasUnSafeHTML || !nodes.some((x) => x.type !== 'Literal' && !allRules.get(x).sanitized)) {
return;
}
}
// Call expression.
//
// Ensure the function accepts HTML and none of the arguments have
// XSS issues.
if (hasUnSafeHTML || !nodes.some(x => x.type === 'Literal') /*if all variables, we should check*/) {
checkFunctionAcceptsHtml(expr.node.callee);
}
expr.node.arguments.forEach(function (a) {
checkForXss(a, expr.node);
});
} else if (expr.node.type === 'AssignmentExpression') {
if (allRules.get(expr.node.right).sanitized) return;
// more check if right side is not literal or sanitized
if (expr.sanitized) {
const nodes = getDescendants(expr.node.right);
const hasUnSafeHTML = nodes.some((x) => x.type === 'Literal' && isHTMLLiteral(x));
if (!hasUnSafeHTML || !nodes.some((x) => x.type !== 'Literal' && !allRules.get(x).sanitized)) {
return;
}
}
// Assignment.
//
// Ensure the target variable is HTML compatible and the assigned
// value doesn't have XSS issues.
checkHtmlVariable(expr.node.left);
checkForXss(expr.node.right, expr.node);
} else if (expr.node.type === 'VariableDeclarator') {
if ((expr.node.init && allRules.get(expr.node.init).sanitized) || expr.sanitized) return;
// New variable initialization.
//
// Ensure the target variable is HTML compatible and the assigned
// value doesn't have XSS issues.
// ignore class type
if (!expr.node.init || expr.node.init.type !== 'ClassExpression') {
checkHtmlVariable(expr.node.id);
}
if (expr.node.init && (expr.node.init.type !== 'CallExpression' || expr.node.init.callee.type !== 'FunctionExpression')) {
checkForXss(expr.node.init, expr.node);
}
} else if (expr.node.type === 'Property') {
if (expr.sanitized) return; // false alarm
// Property declaration inside an object declaration.
//
// Ensure the target property is HTML compatible and the assigned
// value doesn't have XSS issues.
checkHtmlVariable(expr.node.key);
checkForXss(expr.node.value, expr.node);
} else if (expr.node.type === 'ReturnStatement') {
// Return statement.
//
// Make sure the function we are returning from is compatible
// with a HTML return value and there are no XSS issues in the
// value returned.
// Get the closest function scope.
const func = tree.getParentFunctionIdentifier(expr.node);
if (!func) return;
if (expr.sanitized) return; // false alarm
checkHtmlFunction(func, expr.node);
checkForXss(expr.node.argument!, expr.node);
} else if (expr.node.type === 'ArrowFunctionExpression') {
// Arrow function expression.
//
// Make sure the function we are returning from is compatible
// with a HTML return value and there are no XSS issues in the
// value returned.
// Get the closest function scope.
const func = tree.getParentFunctionIdentifier(expr.node);
if (!func) return;
checkHtmlFunction(func, func);
checkForXss(expr.node.body, expr.node);
}
};
const markParentXSS = function () {
// Ensure the current node is XSS candidate.
const expr = exprStack.pop()!;
if (!expr.xss && !usesHtml(expr.node))
return;
// Mark the parent element as XSS candidate.
const candidate = getXssCandidateParent(expr.node);
if (candidate) {
candidate.xss = true;
candidate.sanitized = expr.sanitized;
}
};
/**
* Checks whether the given node is commented to be safe from HTML.
*
* @param {Node} node - The node to check for the comments.
*
* @returns {bool} True, if the node is commented safe.
*/
const isCommentedSafe = function (node: TSESTree.Node) {
while (node && (
node.type === 'ArrayExpression' ||
node.type === 'Identifier' ||
node.type === 'Literal' ||
node.type === 'CallExpression' ||
node.type === 'BinaryExpression' ||
node.type === 'MemberExpression')) {
if (nodeHasSafeComment(node))
return true;
node = getCommentParent(node)!;
}
return false;
};
/**
* Gets a parent node that might have a comment that is seemingly
* attached to the current node.
*
* This might differ from normal parent node in cases where the
* physical location of the node isn't at the start of the parent:
*
* /comment/ a + b
*
* Here the comment is attached to the binary expression node 'a+b' instead
* of the a 'a' identifier node.
*
* However 'a' should still be considered commented - but 'b' isn't.
*
* However this function also handles situation such as
* /comment/ ( a + b )
* Where the comment should count for both a and b.
*
* @param {Node} node - The node to get the parent for.
*
* @returns {Node} The practical parent node.
*/
const getCommentParent = function (node: TSESTree.Node) {
let parent = node.parent;
if (!parent) return parent;
// Call expressions don't cause comment inheritance:
// /comment/ foo( unsafe() )
//
// Shouldn't equal:
// foo( /comment/ unsafe() )
if (parent.type === 'CallExpression')
return null;
// Binary expressions are a bit confusing when it comes to comment
// parenting. /comment/ x + y belongs to the binary expression instead
// of 'x'.
if (parent.type === 'BinaryExpression') {
// If the node is left side of binary expression, return parent no
// matter what.
if (node === parent.left)
return parent;
// Get the closest parenthesized binary expression.
while (parent &&
parent.type === 'BinaryExpression' &&
!hasParentheses(parent)) {
parent = parent.parent;
}
if (parent && parent.type === 'BinaryExpression')
return parent;
return null;
}
return parent;
};
/**
* Checks whether the node is surrounded by parentheses.
*
* @param {Node} node - Node to check for parentheses.
*
* @returns {bool} True, if the node is surrounded with parentheses.
*/
const hasParentheses = function (node: TSESTree.Node) {
const prevToken = context.sourceCode.getTokenBefore(node as never)!;
return (prevToken.type === 'Punctuator' && prevToken.value === '(');
};
/**
* Checks whether the given node is commented to be safe from HTML.
*
* @param {Node} node - Node to check.
*
* @returns {bool} True, if this specific node has a /safe/ comment.
*/
const nodeHasSafeComment = function (node: TSESTree.Node) {
// Check all the comments in front of the node for comment 'safe'
let isSafe = false;
const sourceCode = context.sourceCode;
const comments = sourceCode.getCommentsBefore(node as never);
if (node.type !== 'Identifier') {
const insideComments = sourceCode.getCommentsInside(node.parent as never);
if (insideComments.length > 0) {
const left = (node.parent as TSESTree.AssignmentExpression).left;
const right = (node.parent as TSESTree.AssignmentExpression).right;
if (left && right) {
insideComments.forEach((comment, _, __) => {
if ((comment as TSESTree.Comment).range[0] >= left.range[1] && (comment as TSESTree.Comment).range[1] <= right.range[0]) {
comments.push(comment);
}
});
}
}
}
comments.forEach(function (comment) {
if (/^\s*safe\s*$/i.exec(comment.value))
isSafe = true;
});
return isSafe;
};
/**
* Gets the closest parent node that matches the given type. May return the
* node itself.
*
* @param {Node} node - The node to start the search from.
* @param {string} parentType - The node type to search.
*
* @returns {Node} The closest node of the correct type.
*/
const getPathFromParent = function (node: TSESTree.Node, parentType: string): TSESTree.Node[] | null {
const path = [node];
while (node && node.type !== parentType) {
node = node.parent!;
path.push(node);
}
if (!node)
return null;
path.reverse();
return path;
};
const getXssCandidateParent = function (node: TSESTree.Node) {
// Find the infectable node.
//
// This takes care of call expressions that might use
// passthrough functions. Here we need to check whether the
// current node is in a passthrough position.
for (let ptr = exprStack.length - 1; ptr >= 0; ptr--) {
// Only CallExpressions may pass through the parameters.
const candidate = exprStack[ptr]!;
if (candidate.node.type !== 'CallExpression')
return candidate;
// Quick check for whether this is an passthrough at all.
const functionRules = allRules.get(candidate.node);
if (!functionRules.passthrough)
return candidate;
// The function is at least a partial passthrough.
// Quickly check whether it passes everything through.
if (functionRules.passthrough.obj && functionRules.passthrough.args)
continue;
// Only obj OR args is passed through. Figure out which one the
// current node is.
const path = getPathFromParent(node, 'CallExpression')!;
const callExpr = path[0] as {callee?: TSESTree.Node};
const callImmediateChild = path[1];
const isCallee = callImmediateChild === callExpr.callee;
const isParam = !isCallee;
// Continue to next stack part if the function passes the obj through
// and the current node is the obj.
if (isCallee && functionRules.passthrough.obj)
continue;
// Continue to next stack part if the function passes the args through
// and the current node is an argument.
if (isParam && functionRules.passthrough.args)
continue;
return candidate;
}
return null;
};
const infectParentConditional = function (condition: CallableFunction, node: TSESTree.Node) {
if (exprStack.length > 0 &&
!isCommentedSafe(node) &&
canInfectXss(node) &&
condition(node)) {
// ignore pure literal HTML
if (node.type === 'Literal' && node.parent!.type === 'CallExpression' &&
(node.parent! as TSESTree.CallExpression).arguments.length === 1) {
let hasUnsafeHTML = isHTMLLiteral(node);
if (hasUnsafeHTML) {
hasUnsafeHTML = false;
let parent = node.parent as TSESTree.Node;
while (parent != null) {
if (parent.type === 'AssignmentExpression' || parent.type === 'VariableDeclarator') {
hasUnsafeHTML = true;
break;
}
parent = parent.parent!;
}
}
if (!hasUnsafeHTML) {
return;// ignore pure literal HTML with any assignment
}
}
// ignore TSTypeReference and boolean type
if (node.type === 'Identifier' && (
node.parent!.type === 'TSTypeReference' || node.parent!.type === 'UnaryExpression'
)) return;
const infectable = getXssCandidateParent(node);
if (infectable) {
infectable.xss = true;
if (node.type === 'CallExpression') {
infectable.sanitized = allRules.get(node).sanitized;
}
}
}
};
// -------------------------------------------------------------------------
// Public
// -------------------------------------------------------------------------
return {
'AssignmentExpression': pushNode,
'AssignmentExpression:exit': exitNode,
'VariableDeclarator': pushNode,
'VariableDeclarator:exit': exitNode,
'Property': pushNode,
'Property:exit': exitNode,
'ReturnStatement': pushNode,
'ReturnStatement:exit': exitNode,
'ArrowFunctionExpression': pushNode,
'ArrowFunctionExpression:exit': exitNode,
'ArrayExpression': pushNode,
'ArrayExpression:exit': markParentXSS,
// Call expressions have a dual nature. They can either infect their
// parents with XSS vulnerabilities or then they can suffer from them.
'CallExpression': function (node: TSESTree.CallExpression) {
// First check whether this expression marks the parent as dirty.
infectParentConditional(function (node: TSESTree.CallExpression) {
return isHtmlOutputFunction(node.callee);
}, node);
pushNode(node);
},
'CallExpression:exit': exitNode,
// Literals infect parents if they contain <html> tags or fragments.
'Literal': infectParentConditional.bind(null, function (node: TSESTree.Literal) {
// Skip regex and /*safe*/ strings. Remaining strings infect parent
// if they contain <html or </html tags.
return !(node as unknown as {regex?: RegExp}).regex && isHTMLLiteral(node);
}),
// Identifiers infect parents if they refer to HTML in their name.
'Identifier': infectParentConditional.bind(null, function (node: TSESTree.Identifier) {
return isHtmlVariable(node);
}),
};
};
export const schema = [
{
type: 'object',
properties: {
htmlVariableRules: { type: 'array' },
htmlFunctionRules: { type: 'array' },
sanitizedVariableRules: { type: 'array' },
functions: { type: 'object' },
},
additionalProperties: false
}
];