eslint-plugin-unicorn 的no-unsafe-string-replacement规则:为什么String#replace()的替换值必须安全
【免费下载链接】eslint-plugin-unicornMore than 300 powerful ESLint rules项目地址: https://gitcode.com/GitHub_Trending/es/eslint-plugin-unicorn
本篇文章深入讲解 eslint-plugin-unicorn(一个提供 300+ 条 ESLint 规则的开源插件)中的no-unsafe-string-replacement规则。该规则专门拦截String#replace()与String#replaceAll()中传入"非字面量替换值"的写法,用于规避 JS 替换模式(如$&、$1、$`)展开引发的意外输出与安全漏洞。读完本文,你将理解该规则的报错机理、允许/禁止的表达式清单、源码判定的完整逻辑,以及如何在测试快照中验证修复结果。
规则要解决的核心问题:字符串替换值不是"字面插入"
在 JavaScript 中,String.prototype.replace()与String.prototype.replaceAll()的第二个参数如果传入的是字符串,并不会被原样插入结果中——替换字符串中的特殊替换模式会被引擎展开:
$&:匹配到的整个子串;$1(以及$2等):第一个捕获组的内容;$`:匹配位置之前的字符串部分;$':匹配位置之后的字符串部分。
当替换值来自一个表达式(函数调用、变量、三元表达式、对象转换等)时,表达式求值得到的字符串一旦包含上述模式,输出结果就会偏离预期。更严重的是,如果替换内容来自用户输入(例如htmlEscape(url)这样的动态值),特殊模式展开可能成为注入型安全漏洞的帮凶。
该规则的官方定位见 docs/rules/no-unsafe-string-replacement.md:
📝 Disallow non-literal replacement values in
String#replace()andString#replaceAll().
也就是说,替换值静态已知时用字面量字符串,替换值动态变化时用替换函数(replacement function),二者必居其一。
规则配置与启用范围
该规则在规则入口 rules/index.js 中以no-unsafe-string-replacement为键导出。从 rules/no-unsafe-string-replacement.js 的元信息可以看到:
type: 'problem':属于"可能引发 bug"的问题类规则;docs.recommended: true:默认在recommended配置中启用;docs.description:禁止在String#replace()/String#replaceAll()中使用非字面量替换值;languages: ['js/js']:适用于 JavaScript 文件(TypeScript 文件需配合 parser 使用,见后文)。
在项目自带的 ESLint 配置 configs/flat-config-base.js 与 configs/core-rule-replacements.js 基础上,用户只需在 ESLint 配置中启用unicorn/no-unsafe-string-replacement(或直接使用recommended预设)即可生效。规则文档也说明:该规则在unopinionated配置中是禁用的(因为它属于强观点的问题检测,而非纯粹风格偏好)。
允许与禁止的写法:从示例与快照看判定边界
官方文档示例
官方文档给出了最典型的一组对比例子:
// ❌ 动态表达式作为替换值:禁止 template.replace('{url}', htmlEscape(url)); // ✅ 改为替换函数:允许 template.replace('{url}', () => htmlEscape(url));// ❌ template.replaceAll('{url}', htmlEscape(url)); // ✅ template.replaceAll('{url}', () => htmlEscape(url));// ✅ 静态字面量:允许 template.replace('{url}', 'https://example.com'); // ✅ 无插值的模板字符串:允许 template.replace('{url}', `https://example.com`);快照测试中被判为 invalid 的 23 种写法
快照文件 test/snapshots/no-unsafe-string-replacement.js.md 完整记录了 23 个"非法"用例及其报错定位,覆盖了几乎所有非字面量替换值的形态。逐类归纳如下:
1. 普通函数调用 / 标识符引用 / 成员访问(invalid 1–5)
template.replace("{url}", htmlEscape(url)); // 函数调用 template.replaceAll("{url}", htmlEscape(url)); // replaceAll 同样拦截 template.replace("{url}", replacement); // 裸标识符 template.replace("{url}", options.replacement); // 成员表达式 template.replace("{url}", options?.replacement); // 可选链成员表达式2. 字符串化/模板化转换(invalid 6–7、11)
template.replace("{url}", String(url)); // String() 调用 template.replace("{url}", String.raw`${url}`); // 含插值的 String.raw template.replace("{url}", `${url}`); // 含插值的模板字符串注意:String.raw只有在不含插值时才被视为静态安全(见下文"允许清单");一旦${…}出现,值就是动态的,必须报错。
3. 非String.raw的 tagged template(invalid 8)
template.replace("{url}", css`safe string`);即使 tagged template 内容看起来是静态字符串,只要标签不是String.raw,就无法保证求值结果是安全的字符串字面量,因此同样报错。
4. 遮蔽全局String(invalid 9)
const String = {raw: () => replacement}; template.replace("{url}", String.raw`ignored`);当局部变量String遮蔽了全局String时,String.raw\…`不再等价于安全的静态字面量,快照中报错定位在String.raw`ignored`整个表达式上——这正是源码中sourceCode.isGlobalReference(node.tag.object)` 检查的用意(见下文源码分析)。
5. TypeScript 类型断言无法挽救(invalid 10)
template.replace("{url}", htmlEscape(url) as string);as string只是类型层面的承诺,运行时的值依然是动态表达式,规则照报不误。
6. 条件表达式 / 逗号表达式(invalid 12、19)
template.replace("{url}", url ? htmlEscape(url) : ""); template.replace("{url}", (htmlEscape(url), url));7. 带toString/valueOf/__proto__的对象字面量(invalid 13–16)
template.replace("{url}", {toString() { return url; }}); template.replace("{url}", {toString: () => url}); template.replace("{url}", {valueOf: () => url}); template.replace("{url}", {__proto__: {toString() { return url; }}});这类对象会被 JS 隐式转为字符串,转换结果依赖自定义方法,属于不可控的动态值。
8. 数组 / 数字字面量(invalid 17–18)
template.replace("{url}", [url]); // 数组转字符串 template.replace("{url}", 1); // 数字转字符串9. 带副作用/递增的调用(invalid 20)
template.replaceAll("{url}", String(++count));10. 可选链调用形态(invalid 21–22)
template?.replace("{url}", replacement); // 接收者可选链 template.replace?.("{url}", replacement); // 方法本身可选链11. 带注释的多行调用(invalid 23)
template.replace( "{url}", /* comment */ htmlEscape(url) );报错信息统一为:Do not use a non-literal replacement value with \String#replace()`.(replaceAll时方法名相应变为replaceAll),错误定位精确到替换值表达式本身(快照中^^^^^^^^^^^^^^^` 标出的位置)。
快照测试中被判为 valid 的写法
对应测试源码 test/no-unsafe-string-replacement.js 中的valid数组则给出了完整的放行清单,包括:
// 字符串字面量 template.replace("{url}", "https://example.com"); // 无插值模板字符串 template.replace("{url}", `https://example.com`); // 静态 String.raw(用于转义 $ 等特殊模式,是官方推荐的"安全转义"写法) template.replace("{url}", String.raw`https://example.com`); // 替换函数(动态替换的正确姿势) template.replace("{url}", () => htmlEscape(url)); template.replace("{url}", function () { return htmlEscape(url); }); // TypeScript 字面量断言/满足表达式/非空断言/泛型断言 template.replace("{url}", "https://example.com" as string); template.replace("{url}", "https://example.com" satisfies string); template.replace("{url}", "https://example.com"!); template.replace("{url}", <string>"https://example.com");此外,以下"形似但不属于String#replace"的场景也全部放行:
- 参数个数不是 2(
template.replace("{url}")、template.replace("{url}", replacement, extraArgument)); - 参数以展开形式传递(
template.replace(...argumentsArray)、template.replace("{url}", ...replacement)); - 非
replace/replaceAll的方法名(template.notReplace(...)、template"replace"、templatereplace); - 接收者被证明不是字符串的对象(如 Next.js 风格的路由
router.replace(pathname, {locale})、useRouter().replace(pathname, options)); - 接收者为
number类型(value.replace(...)其实调用的是其他 API)等。
TypeScript 场景下,规则还会借助@typescript-eslintparser 的类型信息判断"接收者一定不是字符串"(如declare const router: {replace(...): void}),从而避免误报——这一点正是源码中isKnownNonString的作用。
源码级实现剖析:规则到底怎么判定
规则核心实现在 rules/no-unsafe-string-replacement.js,判定分四步。
第一步:精确匹配replace/replaceAll二元调用
create中监听CallExpression,通过isMethodCall(node, {methods: ['replace', 'replaceAll'], argumentsLength: 2})过滤。该工具来自 rules/ast/method-call.js(rules/ast/index.js统一导出),它要求:
- 调用者是成员表达式(
template.replace(...)、template?.replace(...)、template.replace?.(...)均命中); - 方法名必须是
replace或replaceAll; - 参数个数必须恰为 2(第二个参数即替换值)。
argumentsLength: 2的限定解释了为何template.replace("{url}")、replace(..., extraArgument)和展开参数写法不会误报。
第二步:判断是否为"允许的安全替换值"
const isAllowedReplacement = (node, sourceCode) => { node = unwrapExpression(node); // 剥离 TS 断言、括号等包装 return isStringLiteral(node) || isStaticTemplateLiteral(node) // 无插值的模板字符串 || isStaticStringRawTaggedTemplate(node, sourceCode) // 静态 String.raw || isFunction(node); // 替换函数 };unwrapExpression来自 rules/utils/comparison.js,会剥离as断言、satisfies、!、括号等语法包装,所以"https://example.com" as string最终仍能被识别为字符串字面量。
值得细看的是isStaticStringRawTaggedTemplate:
node.type === 'TaggedTemplateExpression' && isStaticTemplateLiteral(node.quasi) // 无 ${…} 插值 && isMemberExpression(node.tag, {object: 'String', property: 'raw'}) && sourceCode.isGlobalReference(node.tag.object); // String 必须是全局引用正是最后一行isGlobalReference使const String = {raw: ...}的遮蔽写法被判为非法(快照 invalid(9)):局部String不再指向全局对象,String.raw的"静态安全"语义也就不成立。
第三步:放行"纯对象字面量"(避免路由等场景误报)
const isPlainObjectReplacement = (node, context) => { node = unwrapExpression(node); const replacement = unwrapExpression( getConstVariableInitializer(node, context) ?? node, ); // 必须是纯属性对象,且不含 __proto__/toString/valueOf 等可影响隐式转字符串的键 return replacement.type === 'ObjectExpression' && replacement.properties.every(property => /* 非计算、非方法、kind==='init'、键名不在黑名单 */); };这解释了为何router.replace(pathname, {locale})(Next.js 风格路由、{locale}作为选项对象)不会被误报:它虽然形似replace二元调用,但第二个参数是纯对象字面量,不会被当作字符串替换值。getConstVariableInitializer来自 rules/utils/get-const-variable-initializer.js,它会把const options = {locale}; router.replace(pathname, options)中的options回溯到其const初始化器,从而同样放行——前提是该const变量只被定义一次。
objectCoercionPropertyNames黑名单(__proto__、toString、valueOf)则把快照 invalid(13)–(16) 的对象排除在"纯对象"之外:这些键会改变对象隐式转字符串的行为,等价于不可控的动态替换值。
第四步:接收者已知非字符串时直接放行
if (isKnownNonString(node.callee.object, context)) { return; }isKnownNonString由 rules/utils/is-string.js 导出。它基于静态分析与 TypeScript 类型信息判断调用接收者一定不是字符串(例如declare const router: {replace(...): void}或value: number上的value.replace(...))。既然接收者不是字符串,调用就不是String#replace(),自然无需报错。这就是 test/no-unsafe-string-replacement.js 中多个 TypeScript valid 用例背后的机制。
报错输出
上述四步全部通过(即:是replace/replaceAll二元调用、替换值不在允许清单、不是纯对象、接收者可能为字符串)后,规则以替换值节点为定位,报出no-unsafe-string-replacement消息,消息文案中的{{method}}由node.callee.property.name动态填充为replace或replaceAll(见 rules/no-unsafe-string-replacement.js)。
如何用快照测试复现与验证
本规则使用 AVA 快照测试,测试源码位于 test/no-unsafe-string-replacement.js,快照结果位于 test/snapshots/no-unsafe-string-replacement.js.md(对应的二进制快照文件为test/snapshots/no-unsafe-string-replacement.js.snap)。快照文档头部注明由 AVA 自动生成,并提示"实际快照保存在no-unsafe-string-replacement.js.snap"。
快照报告完整记录了每个 invalid 用例的输入、报错行号与错误高亮范围。例如:
invalid(1):template.replace("{url}", htmlEscape(url)),报错定位于htmlEscape(url)(^^^^^^^^^^^^^^^),消息为Do not use a non-literal replacement value with \String#replace()`.`;invalid(2):同一表达式换用replaceAll,消息中的方法名相应变为String#replaceAll();invalid(23):多行带注释写法,报错精确定位到第三行/* comment */ htmlEscape(url)中的htmlEscape(url)。
TypeScript 相关用例在测试中通过typeAware()辅助函数配置@typescript-eslintparser(typescriptEslintParser,来自 scripts/parsers.js),并在languageOptions.parserOptions.projectService.allowDefaultProject: ['*.ts']下运行;普通 TS 语法用例则直接使用parsers.typescript配置。
若要本地运行该规则的全部测试,在仓库根目录执行:
npx ava test/no-unsafe-string-replacement.js当npm test或上述命令执行后,快照与test/snapshots/no-unsafe-string-replacement.js.md报告会自动保持同步,作为规则行为变更时的回归依据。
实用建议:安全的替换值应该怎么写
综合官方文档 docs/rules/no-unsafe-string-replacement.md 与源码判定逻辑,可以提炼出三条可直接落地的实践准则:
替换值静态已知→ 用字符串字面量或无插值模板字符串,必要时用
String.raw转义特殊模式:// ✅ template.replace('{url}', 'https://example.com'); template.replaceAll(/(?<symbol>`|\$(?={))/g, String.raw`\\$<symbol>`);注意:只有
String.raw标签 + 无插值才是安全写法,自定义 tagged template 一律视为动态值。替换值动态变化→ 使用替换函数,让返回值不经过特殊模式展开:
// ✅ template.replace('{url}', () => htmlEscape(url)); template.replaceAll('{url}', () => htmlEscape(url));形似
replace但实为其他 API(路由、数字方法、DOM API 等)→ 规则通过"纯对象放行"与isKnownNonString类型检查自动识别,无需手动关闭规则;若确需临时豁免,可配合 ESLint 的// eslint-disable-next-line unicorn/no-unsafe-string-replacement局部处理,但应优先重构而非豁免。
总而言之,no-unsafe-string-replacement用一套清晰、可测试的判定体系,把String#replace/replaceAll中"字符串特殊替换模式被意外展开"这一隐蔽问题转化为编译期(Lint 期)可发现的问题:静态替换用字面量、动态替换用函数,其余场景交由源码中的四层判定精确区分,避免对路由等常见误报。
【免费下载链接】eslint-plugin-unicornMore than 300 powerful ESLint rules项目地址: https://gitcode.com/GitHub_Trending/es/eslint-plugin-unicorn
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考