| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @emails react-core |
| 8 | */ |
| 9 | |
| 10 | 'use strict'; |
| 11 | |
| 12 | function isEmptyLiteral(node) { |
| 13 | return ( |
| 14 | node.type === 'Literal' && |
| 15 | typeof node.value === 'string' && |
| 16 | node.value === '' |
| 17 | ); |
| 18 | } |
| 19 | |
| 20 | function isStringLiteral(node) { |
| 21 | return ( |
| 22 | // TaggedTemplateExpressions can return non-strings |
| 23 | (node.type === 'TemplateLiteral' && |
| 24 | node.parent.type !== 'TaggedTemplateExpression') || |
| 25 | (node.type === 'Literal' && typeof node.value === 'string') |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | // Symbols and Temporal.* objects will throw when using `'' + value`, but that |
| 30 | // pattern can be faster than `String(value)` because JS engines can optimize |
| 31 | // `+` better in some cases. Therefore, in perf-sensitive production codepaths |
| 32 | // we require using `'' + value` for string coercion. The only exception is prod |
| 33 | // error handling code, because it's bad to crash while assembling an error |
| 34 | // message or call stack! Also, error-handling code isn't usually perf-critical. |
| 35 | // |
| 36 | // Non-production codepaths (tests, devtools extension, build tools, etc.) |
| 37 | // should use `String(value)` because it will never crash and the (small) perf |
| 38 | // difference doesn't matter enough for non-prod use cases. |
| 39 | // |
| 40 | // This rule assists enforcing these guidelines: |
| 41 | // * `'' + value` is flagged with a message to remind developers to add a DEV |
| 42 | // check from shared/CheckStringCoercion.js to make sure that the user gets a |
| 43 | // clear error message in DEV is the coercion will throw. These checks are not |
| 44 | // needed if throwing is not possible, e.g. if the value is already known to |
| 45 | // be a string or number. |
| 46 | // * `String(value)` is flagged only if the `isProductionUserAppCode` option |
| 47 | // is set. Set this option for prod code files, and don't set it for non-prod |
| 48 | // files. |
| 49 | |
| 50 | const ignoreKeys = [ |
| 51 | 'range', |
| 52 | 'raw', |
| 53 | 'parent', |
| 54 | 'loc', |
| 55 | 'start', |
| 56 | 'end', |
| 57 | '_babelType', |
| 58 | 'leadingComments', |
| 59 | 'trailingComments', |
| 60 | ]; |
| 61 | function astReplacer(key, value) { |
| 62 | return ignoreKeys.includes(key) ? undefined : value; |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Simplistic comparison between AST node. Only the following patterns are |
| 67 | * supported because that's almost all (all?) usage in React: |
| 68 | * - Identifiers, e.g. `foo` |
| 69 | * - Member access, e.g. `foo.bar` |
| 70 | * - Array access with numeric literal, e.g. `foo[0]` |
| 71 | */ |
| 72 | function isEquivalentCode(node1, node2) { |
| 73 | return ( |
| 74 | JSON.stringify(node1, astReplacer) === JSON.stringify(node2, astReplacer) |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | function isDescendant(node, maybeParentNode) { |
| 79 | let parent = node.parent; |
| 80 | while (parent) { |
| 81 | if (!parent) { |
| 82 | return false; |
| 83 | } |
| 84 | if (parent === maybeParentNode) { |
| 85 | return true; |
| 86 | } |
| 87 | parent = parent.parent; |
| 88 | } |
| 89 | return false; |
| 90 | } |
| 91 | |
| 92 | function isSafeTypeofExpression(originalValueNode, node) { |
| 93 | if (node.type === 'BinaryExpression') { |
| 94 | // Example: typeof foo === 'string' |
| 95 | if (node.operator !== '===') { |
| 96 | return false; |
| 97 | } |
| 98 | const {left, right} = node; |
| 99 | |
| 100 | // left must be `typeof original` |
| 101 | if (left.type !== 'UnaryExpression' || left.operator !== 'typeof') { |
| 102 | return false; |
| 103 | } |
| 104 | if (!isEquivalentCode(left.argument, originalValueNode)) { |
| 105 | return false; |
| 106 | } |
| 107 | // right must be a literal value of a safe type |
| 108 | const safeTypes = ['string', 'number', 'boolean', 'undefined', 'bigint']; |
| 109 | if (right.type !== 'Literal' || !safeTypes.includes(right.value)) { |
| 110 | return false; |
| 111 | } |
| 112 | return true; |
| 113 | } else if (node.type === 'LogicalExpression') { |
| 114 | // Examples: |
| 115 | // * typeof foo === 'string' && typeof foo === 'number |
| 116 | // * typeof foo === 'string' && someOtherTest |
| 117 | if (node.operator === '&&') { |
| 118 | return ( |
| 119 | isSafeTypeofExpression(originalValueNode, node.left) || |
| 120 | isSafeTypeofExpression(originalValueNode, node.right) |
| 121 | ); |
| 122 | } else if (node.operator === '||') { |
| 123 | return ( |
| 124 | isSafeTypeofExpression(originalValueNode, node.left) && |
| 125 | isSafeTypeofExpression(originalValueNode, node.right) |
| 126 | ); |
| 127 | } |
| 128 | } |
| 129 | return false; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | Returns true if the code is inside an `if` block that validates the value |
| 134 | excludes symbols and objects. Examples: |
| 135 | * if (typeof value === 'string') { } |
| 136 | * if (typeof value === 'string' || typeof value === 'number') { } |
| 137 | * if (typeof value === 'string' || someOtherTest) { } |
| 138 | |
| 139 | @param - originalValueNode Top-level expression to test. Kept unchanged during |
| 140 | recursion. |
| 141 | @param - node Expression to test at current recursion level. Will be undefined |
| 142 | on non-recursive call. |
| 143 | */ |
| 144 | function isInSafeTypeofBlock(originalValueNode, node) { |
| 145 | if (!node) { |
| 146 | node = originalValueNode; |
| 147 | } |
| 148 | let parent = node.parent; |
| 149 | while (parent) { |
| 150 | if (!parent) { |
| 151 | return false; |
| 152 | } |
| 153 | // Normally, if the parent block is inside a type-safe `if` statement, |
| 154 | // then all child code is also type-safe. But there's a quirky case we |
| 155 | // need to defend against: |
| 156 | // if (typeof obj === 'string') { } else if (typeof obj === 'object') {'' + obj} |
| 157 | // if (typeof obj === 'string') { } else {'' + obj} |
| 158 | // In that code above, the `if` block is safe, but the `else` block is |
| 159 | // unsafe and should report. But the AST parent of the `else` clause is the |
| 160 | // `if` statement. This is the one case where the parent doesn't confer |
| 161 | // safety onto the child. The code below identifies that case and keeps |
| 162 | // moving up the tree until we get out of the `else`'s parent `if` block. |
| 163 | // This ensures that we don't use any of these "parents" (really siblings) |
| 164 | // to confer safety onto the current node. |
| 165 | if ( |
| 166 | parent.type === 'IfStatement' && |
| 167 | !isDescendant(originalValueNode, parent.alternate) |
| 168 | ) { |
| 169 | const test = parent.test; |
| 170 | if (isSafeTypeofExpression(originalValueNode, test)) { |
| 171 | return true; |
| 172 | } |
| 173 | } |
| 174 | parent = parent.parent; |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | const missingDevCheckMessage = |
| 179 | 'Missing DEV check before this string coercion.' + |
| 180 | ' Check should be in this format:\n' + |
| 181 | ' if (__DEV__) {\n' + |
| 182 | ' checkXxxxxStringCoercion(value);\n' + |
| 183 | ' }'; |
| 184 | |
| 185 | const prevStatementNotDevCheckMessage = |
| 186 | 'The statement before this coercion must be a DEV check in this format:\n' + |
| 187 | ' if (__DEV__) {\n' + |
| 188 | ' checkXxxxxStringCoercion(value);\n' + |
| 189 | ' }'; |
| 190 | |
| 191 | /** |
| 192 | * Does this node have an "is coercion safe?" DEV check |
| 193 | * in the same block? |
| 194 | */ |
| 195 | function hasCoercionCheck(node) { |
| 196 | // find the containing statement |
| 197 | let topOfExpression = node; |
| 198 | while (!topOfExpression.parent.body) { |
| 199 | topOfExpression = topOfExpression.parent; |
| 200 | if (!topOfExpression) { |
| 201 | return 'Cannot find top of expression.'; |
| 202 | } |
| 203 | } |
| 204 | const containingBlock = topOfExpression.parent.body; |
| 205 | const index = containingBlock.indexOf(topOfExpression); |
| 206 | if (index <= 0) { |
| 207 | return missingDevCheckMessage; |
| 208 | } |
| 209 | const prev = containingBlock[index - 1]; |
| 210 | |
| 211 | // The previous statement is expected to be like this: |
| 212 | // if (__DEV__) { |
| 213 | // checkFormFieldValueStringCoercion(foo); |
| 214 | // } |
| 215 | // where `foo` must be equivalent to `node` (which is the |
| 216 | // mixed value being coerced to a string). |
| 217 | if ( |
| 218 | prev.type !== 'IfStatement' || |
| 219 | prev.test.type !== 'Identifier' || |
| 220 | prev.test.name !== '__DEV__' |
| 221 | ) { |
| 222 | return prevStatementNotDevCheckMessage; |
| 223 | } |
| 224 | let maybeCheckNode = prev.consequent; |
| 225 | if (maybeCheckNode.type === 'BlockStatement') { |
| 226 | const body = maybeCheckNode.body; |
| 227 | if (body.length === 0) { |
| 228 | return prevStatementNotDevCheckMessage; |
| 229 | } |
| 230 | if (body.length !== 1) { |
| 231 | return ( |
| 232 | 'Too many statements in DEV block before this coercion.' + |
| 233 | ' Expected only one (the check function call). ' + |
| 234 | prevStatementNotDevCheckMessage |
| 235 | ); |
| 236 | } |
| 237 | maybeCheckNode = body[0]; |
| 238 | } |
| 239 | |
| 240 | if (maybeCheckNode.type !== 'ExpressionStatement') { |
| 241 | return ( |
| 242 | 'The DEV block before this coercion must only contain an expression. ' + |
| 243 | prevStatementNotDevCheckMessage |
| 244 | ); |
| 245 | } |
| 246 | |
| 247 | const call = maybeCheckNode.expression; |
| 248 | if ( |
| 249 | call.type !== 'CallExpression' || |
| 250 | call.callee.type !== 'Identifier' || |
| 251 | !/^check(\w+?)StringCoercion$/.test(call.callee.name) || |
| 252 | !call.arguments.length |
| 253 | ) { |
| 254 | // `maybeCheckNode` should be a call of a function named checkXXXStringCoercion |
| 255 | return ( |
| 256 | 'Missing or invalid check function call before this coercion.' + |
| 257 | ' Expected: call of a function like checkXXXStringCoercion. ' + |
| 258 | prevStatementNotDevCheckMessage |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | const same = isEquivalentCode(call.arguments[0], node); |
| 263 | if (!same) { |
| 264 | return ( |
| 265 | 'Value passed to the check function before this coercion' + |
| 266 | ' must match the value being coerced.' |
| 267 | ); |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | function isOnlyAddingStrings(node) { |
| 272 | if (node.operator !== '+') { |
| 273 | return; |
| 274 | } |
| 275 | if (isStringLiteral(node.left) && isStringLiteral(node.right)) { |
| 276 | // It's always safe to add string literals |
| 277 | return true; |
| 278 | } |
| 279 | if (node.left.type === 'BinaryExpression' && isStringLiteral(node.right)) { |
| 280 | return isOnlyAddingStrings(node.left); |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | function checkBinaryExpression(context, node) { |
| 285 | if (isOnlyAddingStrings(node)) { |
| 286 | return; |
| 287 | } |
| 288 | |
| 289 | if ( |
| 290 | node.operator === '+' && |
| 291 | (isEmptyLiteral(node.left) || isEmptyLiteral(node.right)) |
| 292 | ) { |
| 293 | let valueToTest = isEmptyLiteral(node.left) ? node.right : node.left; |
| 294 | if ( |
| 295 | (valueToTest.type === 'TypeCastExpression' || |
| 296 | valueToTest.type === 'AsExpression') && |
| 297 | valueToTest.expression |
| 298 | ) { |
| 299 | valueToTest = valueToTest.expression; |
| 300 | } |
| 301 | |
| 302 | if ( |
| 303 | valueToTest.type === 'Identifier' && |
| 304 | ['i', 'idx', 'lineNumber'].includes(valueToTest.name) |
| 305 | ) { |
| 306 | // Common non-object variable names are assumed to be safe |
| 307 | return; |
| 308 | } |
| 309 | if ( |
| 310 | valueToTest.type === 'UnaryExpression' || |
| 311 | valueToTest.type === 'UpdateExpression' |
| 312 | ) { |
| 313 | // Any unary expression will return a non-object, non-symbol type. |
| 314 | return; |
| 315 | } |
| 316 | if (isInSafeTypeofBlock(valueToTest)) { |
| 317 | // The value is inside an if (typeof...) block that ensures it's safe |
| 318 | return; |
| 319 | } |
| 320 | const coercionCheckMessage = hasCoercionCheck(valueToTest); |
| 321 | if (!coercionCheckMessage) { |
| 322 | // The previous statement is a correct check function call, so no report. |
| 323 | return; |
| 324 | } |
| 325 | |
| 326 | context.report({ |
| 327 | node, |
| 328 | message: |
| 329 | coercionCheckMessage + |
| 330 | '\n' + |
| 331 | "Using `'' + value` or `value + ''` is fast to coerce strings, but may throw." + |
| 332 | ' For prod code, add a DEV check from shared/CheckStringCoercion immediately' + |
| 333 | ' before this coercion.' + |
| 334 | ' For non-prod code and prod error handling, use `String(value)` instead.', |
| 335 | }); |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | function coerceWithStringConstructor(context, node) { |
| 340 | const isProductionUserAppCode = |
| 341 | context.options[0] && context.options[0].isProductionUserAppCode; |
| 342 | if (isProductionUserAppCode && node.callee.name === 'String') { |
| 343 | context.report( |
| 344 | node, |
| 345 | "For perf-sensitive coercion, avoid `String(value)`. Instead, use `'' + value`." + |
| 346 | ' Precede it with a DEV check from shared/CheckStringCoercion' + |
| 347 | ' unless Symbol and Temporal.* values are impossible.' + |
| 348 | ' For non-prod code and prod error handling, use `String(value)` and disable this rule.' |
| 349 | ); |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | module.exports = { |
| 354 | meta: { |
| 355 | schema: [ |
| 356 | { |
| 357 | type: 'object', |
| 358 | properties: { |
| 359 | isProductionUserAppCode: { |
| 360 | type: 'boolean', |
| 361 | default: false, |
| 362 | }, |
| 363 | }, |
| 364 | additionalProperties: false, |
| 365 | }, |
| 366 | ], |
| 367 | }, |
| 368 | create(context) { |
| 369 | return { |
| 370 | BinaryExpression: node => checkBinaryExpression(context, node), |
| 371 | CallExpression: node => coerceWithStringConstructor(context, node), |
| 372 | }; |
| 373 | }, |
| 374 | }; |