/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails react-core */ 'use strict'; function isEmptyLiteral(node) { return node.type === 'Literal' && typeof node.value === 'string' && node.value === ''; } function isStringLiteral(node) { return ( // TaggedTemplateExpressions can return non-strings node.type === 'TemplateLiteral' && node.parent.type !== 'TaggedTemplateExpression' || node.type === 'Literal' && typeof node.value === 'string' ); } // Symbols and Temporal.* objects will throw when using `'' + value`, but that // pattern can be faster than `String(value)` because JS engines can optimize // `+` better in some cases. Therefore, in perf-sensitive production codepaths // we require using `'' + value` for string coercion. The only exception is prod // error handling code, because it's bad to crash while assembling an error // message or call stack! Also, error-handling code isn't usually perf-critical. // // Non-production codepaths (tests, devtools extension, build tools, etc.) // should use `String(value)` because it will never crash and the (small) perf // difference doesn't matter enough for non-prod use cases. // // This rule assists enforcing these guidelines: // * `'' + value` is flagged with a message to remind developers to add a DEV // check from shared/CheckStringCoercion.js to make sure that the user gets a // clear error message in DEV is the coercion will throw. These checks are not // needed if throwing is not possible, e.g. if the value is already known to // be a string or number. // * `String(value)` is flagged only if the `isProductionUserAppCode` option // is set. Set this option for prod code files, and don't set it for non-prod // files. const ignoreKeys = ['range', 'raw', 'parent', 'loc', 'start', 'end', '_babelType', 'leadingComments', 'trailingComments']; function astReplacer(key, value) { return key |> ignoreKeys.includes(%) ? undefined : value; } /** * Simplistic comparison between AST node. Only the following patterns are * supported because that's almost all (all?) usage in React: * - Identifiers, e.g. `foo` * - Member access, e.g. `foo.bar` * - Array access with numeric literal, e.g. `foo[0]` */ function isEquivalentCode(node1, node2) { return (node1 |> JSON.stringify(%, astReplacer)) === (node2 |> JSON.stringify(%, astReplacer)); } function isDescendant(node, maybeParentNode) { let parent = node.parent; while (parent) { if (!parent) { return false; } if (parent === maybeParentNode) { return true; } parent = parent.parent; } return false; } function isSafeTypeofExpression(originalValueNode, node) { if (node.type === 'BinaryExpression') { // Example: typeof foo === 'string' if (node.operator !== '===') { return false; } const { left, right } = node; // left must be `typeof original` if (left.type !== 'UnaryExpression' || left.operator !== 'typeof') { return false; } if (!(left.argument |> isEquivalentCode(%, originalValueNode))) { return false; } // right must be a literal value of a safe type const safeTypes = ['string', 'number', 'boolean', 'undefined', 'bigint']; if (right.type !== 'Literal' || !(right.value |> safeTypes.includes(%))) { return false; } return true; } else if (node.type === 'LogicalExpression') { // Examples: // * typeof foo === 'string' && typeof foo === 'number // * typeof foo === 'string' && someOtherTest if (node.operator === '&&') { return originalValueNode |> isSafeTypeofExpression(%, node.left) || originalValueNode |> isSafeTypeofExpression(%, node.right); } else if (node.operator === '||') { return (originalValueNode |> isSafeTypeofExpression(%, node.left)) && (originalValueNode |> isSafeTypeofExpression(%, node.right)); } } return false; } /** Returns true if the code is inside an `if` block that validates the value excludes symbols and objects. Examples: * if (typeof value === 'string') { } * if (typeof value === 'string' || typeof value === 'number') { } * if (typeof value === 'string' || someOtherTest) { } @param - originalValueNode Top-level expression to test. Kept unchanged during recursion. @param - node Expression to test at current recursion level. Will be undefined on non-recursive call. */ function isInSafeTypeofBlock(originalValueNode, node) { if (!node) { node = originalValueNode; } let parent = node.parent; while (parent) { if (!parent) { return false; } // Normally, if the parent block is inside a type-safe `if` statement, // then all child code is also type-safe. But there's a quirky case we // need to defend against: // if (typeof obj === 'string') { } else if (typeof obj === 'object') {'' + obj} // if (typeof obj === 'string') { } else {'' + obj} // In that code above, the `if` block is safe, but the `else` block is // unsafe and should report. But the AST parent of the `else` clause is the // `if` statement. This is the one case where the parent doesn't confer // safety onto the child. The code below identifies that case and keeps // moving up the tree until we get out of the `else`'s parent `if` block. // This ensures that we don't use any of these "parents" (really siblings) // to confer safety onto the current node. if (parent.type === 'IfStatement' && !(originalValueNode |> isDescendant(%, parent.alternate))) { const test = parent.test; if (originalValueNode |> isSafeTypeofExpression(%, test)) { return true; } } parent = parent.parent; } } const missingDevCheckMessage = 'Missing DEV check before this string coercion.' + ' Check should be in this format:\n' + ' if (__DEV__) {\n' + ' checkXxxxxStringCoercion(value);\n' + ' }'; const prevStatementNotDevCheckMessage = 'The statement before this coercion must be a DEV check in this format:\n' + ' if (__DEV__) {\n' + ' checkXxxxxStringCoercion(value);\n' + ' }'; /** * Does this node have an "is coercion safe?" DEV check * in the same block? */ function hasCoercionCheck(node) { // find the containing statement let topOfExpression = node; while (!topOfExpression.parent.body) { topOfExpression = topOfExpression.parent; if (!topOfExpression) { return 'Cannot find top of expression.'; } } const containingBlock = topOfExpression.parent.body; const index = topOfExpression |> containingBlock.indexOf(%); if (index <= 0) { return missingDevCheckMessage; } const prev = containingBlock[index - 1]; // The previous statement is expected to be like this: // if (__DEV__) { // checkFormFieldValueStringCoercion(foo); // } // where `foo` must be equivalent to `node` (which is the // mixed value being coerced to a string). if (prev.type !== 'IfStatement' || prev.test.type !== 'Identifier' || prev.test.name !== '__DEV__') { return prevStatementNotDevCheckMessage; } let maybeCheckNode = prev.consequent; if (maybeCheckNode.type === 'BlockStatement') { const body = maybeCheckNode.body; if (body.length === 0) { return prevStatementNotDevCheckMessage; } if (body.length !== 1) { return 'Too many statements in DEV block before this coercion.' + ' Expected only one (the check function call). ' + prevStatementNotDevCheckMessage; } maybeCheckNode = body[0]; } if (maybeCheckNode.type !== 'ExpressionStatement') { return 'The DEV block before this coercion must only contain an expression. ' + prevStatementNotDevCheckMessage; } const call = maybeCheckNode.expression; if (call.type !== 'CallExpression' || call.callee.type !== 'Identifier' || !(call.callee.name |> /^check(\w+?)StringCoercion$/.test(%)) || !call.arguments.length) { // `maybeCheckNode` should be a call of a function named checkXXXStringCoercion return 'Missing or invalid check function call before this coercion.' + ' Expected: call of a function like checkXXXStringCoercion. ' + prevStatementNotDevCheckMessage; } const same = call.arguments[0] |> isEquivalentCode(%, node); if (!same) { return 'Value passed to the check function before this coercion' + ' must match the value being coerced.'; } } function isOnlyAddingStrings(node) { if (node.operator !== '+') { return; } if ((node.left |> isStringLiteral(%)) && (node.right |> isStringLiteral(%))) { // It's always safe to add string literals return true; } if (node.left.type === 'BinaryExpression' && (node.right |> isStringLiteral(%))) { return node.left |> isOnlyAddingStrings(%); } } function checkBinaryExpression(context, node) { if (node |> isOnlyAddingStrings(%)) { return; } if (node.operator === '+' && (node.left |> isEmptyLiteral(%) || node.right |> isEmptyLiteral(%))) { let valueToTest = node.left |> isEmptyLiteral(%) ? node.right : node.left; if ((valueToTest.type === 'TypeCastExpression' || valueToTest.type === 'AsExpression') && valueToTest.expression) { valueToTest = valueToTest.expression; } if (valueToTest.type === 'Identifier' && (valueToTest.name |> ['i', 'idx', 'lineNumber'].includes(%))) { // Common non-object variable names are assumed to be safe return; } if (valueToTest.type === 'UnaryExpression' || valueToTest.type === 'UpdateExpression') { // Any unary expression will return a non-object, non-symbol type. return; } if (valueToTest |> isInSafeTypeofBlock(%)) { // The value is inside an if (typeof...) block that ensures it's safe return; } const coercionCheckMessage = valueToTest |> hasCoercionCheck(%); if (!coercionCheckMessage) { // The previous statement is a correct check function call, so no report. return; } ({ node, message: coercionCheckMessage + '\n' + "Using `'' + value` or `value + ''` is fast to coerce strings, but may throw." + ' For prod code, add a DEV check from shared/CheckStringCoercion immediately' + ' before this coercion.' + ' For non-prod code and prod error handling, use `String(value)` instead.' }) |> context.report(%); } } function coerceWithStringConstructor(context, node) { const isProductionUserAppCode = context.options[0] && context.options[0].isProductionUserAppCode; if (isProductionUserAppCode && node.callee.name === 'String') { node |> context.report(%, "For perf-sensitive coercion, avoid `String(value)`. Instead, use `'' + value`." + ' Precede it with a DEV check from shared/CheckStringCoercion' + ' unless Symbol and Temporal.* values are impossible.' + ' For non-prod code and prod error handling, use `String(value)` and disable this rule.'); } } module.exports = { meta: { schema: [{ type: 'object', properties: { isProductionUserAppCode: { type: 'boolean', default: false } }, additionalProperties: false }] }, create(context) { return { BinaryExpression: node => context |> checkBinaryExpression(%, node), CallExpression: node => context |> coerceWithStringConstructor(%, node) }; } };